diff --git a/.circleci/config.yml b/.circleci/config.yml
index 00da359495..ed9ee61a35 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,24 +1,73 @@
version: 2.1
orbs:
- python: circleci/python@0.2.1
+ python: circleci/python@4.0.0
jobs:
build-and-test:
- working_directory: ~/circleci-demo-python-django
- docker:
- - image: circleci/python:3.8 # primary container for the build job
- auth:
- username: mydockerhub-user
- password: $DOCKERHUB_PASSWORD # context / project UI env-var reference
+ executor:
+ name: python/default
+ tag: '3.10' # or '3.12'
+ environment:
+ # HuggingFace: disable xet and use cache directory
+ # NOTE @deruyter92 2026-05-07: "xet" opens many simultaneous connections
+ # to different data chunks. Currently doesn't work well with CircleCI.
+ # See: https://github.com/huggingface/xet-core/issues/800
+ HF_HUB_DISABLE_XET: 1
+ HF_HOME: ~/.cache/huggingface
+
steps:
- checkout
- - python/load-cache
- - python/install-deps
- - python/save-cache
+
+ # Restore uv cache
+ - restore_cache:
+ name: Restore uv cache
+ keys:
+ - v2-uv-pip-{{ checksum "pyproject.toml" }}
+ - v2-uv-pip-
+
+ # Restore HuggingFace weights cache
+ - restore_cache:
+ name: Restore Hugging Face cache
+ keys:
+ - hf-weights-v1-{{ checksum "pyproject.toml" }}
+ - hf-weights-v1-
+
+ # Install uv
+ - run:
+ name: Install uv
+ command: |
+ pip install uv
+
+ # Install DeepLabCut runtime deps only
+ - run:
+ name: Install DeepLabCut runtime deps only
+ command: |
+ uv pip install --system -e .
+
+ # (Optional) Trim the cache for CI so uploads stay small and fast
+ - run:
+ name: Prune uv cache for CI
+ command: uv cache prune --ci || true
+
+ # Save the uv cache for next runs
+ - save_cache:
+ name: Save uv cache
+ key: v2-uv-pip-{{ checksum "pyproject.toml" }}
+ paths:
+ - ~/.cache/uv
+
+ # Test DLC
- run:
- command: python testscript_cli.py
name: TestDLC
+ command: python testscript_cli.py
+
+ # Save the HF weights cache for next runs
+ - save_cache:
+ name: Save huggingface cache
+ key: hf-weights-v1-{{ checksum "pyproject.toml" }}
+ paths:
+ - ~/.cache/huggingface
workflows:
main:
diff --git a/.codespellrc b/.codespellrc
index 5afad804bb..b46cf0e61f 100644
--- a/.codespellrc
+++ b/.codespellrc
@@ -1,5 +1,5 @@
[codespell]
-skip = .git,*.pdf,*.svg,deeplabcut/pose_estimation_tensorflow/models/pretrained
+skip = .git,*.pdf,*.svg,*.ipynb,deeplabcut/pose_estimation_tensorflow/models/pretrained
# MOT,SIE - legit acronyms
# tThe - for \tThe. codespell is not good detecting those yet
-ignore-words-list = mot,sie,tthe
+ignore-words-list = mot,sie,tthe,assertin,bu,td,ctd,wither
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index a52a2bd42a..764dfe563b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,7 +1,7 @@
name: Bug Report
description: File a bug report to help us improve
assignees:
- - n-poulsen
+ - mmathislab #temp
body:
- type: markdown
attributes:
@@ -14,28 +14,28 @@ body:
options:
- label: I have searched the existing issues
required: true
- - type: textarea
- id: what-happened
- attributes:
- label: Bug description
- description: Also tell us concisely what you expected to happen
- placeholder: What happened?
- validations:
- required: true
- type: textarea
attributes:
label: Operating System
description: What operating system are you using?
placeholder: macOS Big Sur
- value: operating system
validations:
required: true
- type: textarea
attributes:
label: DeepLabCut version
description: What version of DLC are you using? Please check with `import deeplabcut`, `deeplabcut.__version__`
- placeholder: 2.2rc3
- value: dlc version
+ placeholder: 3.0.0
+ validations:
+ required: true
+ - type: dropdown
+ id: backend-engine
+ attributes:
+ label: What engine are you using?
+ options:
+ - pytorch
+ - tensorflow
+ - both (rare!)
validations:
required: true
- type: dropdown
@@ -53,20 +53,25 @@ body:
label: Device type
description: What GPU/CPU are you using?
placeholder: GeForce 2080 RTX
- value: gpu
+ validations:
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: Bug description 🐛
+ description: Also tell us concisely what you expected to happen
+ placeholder: What happened?
validations:
required: true
- type: textarea
attributes:
label: Steps To Reproduce
- description: Steps to reproduce the behavior.
+ description: Please provide a minimal example to reproduce the behavior.
placeholder: |
1. In this environment...
2. With this config...
3. Run '...'
4. See error...
- validations:
- required: false
- type: textarea
id: logs
attributes:
@@ -80,13 +85,10 @@ body:
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images and other files by clicking this area to highlight it and then dragging files in.
- validations:
- required: false
- type: checkboxes
attributes:
label: Code of Conduct
- description: The Code of Conduct helps create a safe space for everyone. We require
- that everyone agrees to it.
+ description: The Code of Conduct helps create a safe space for everyone. We require that everyone agrees to it.
options:
- label: I agree to follow this project's [Code of Conduct](https://github.com/DeepLabCut/DeepLabCut/blob/master/CODE_OF_CONDUCT.md)
required: true
diff --git a/.github/workflows/build-book.yml b/.github/workflows/build-book.yml
new file mode 100644
index 0000000000..a1c55b9a0e
--- /dev/null
+++ b/.github/workflows/build-book.yml
@@ -0,0 +1,51 @@
+name: Build (and optionally deploy) Jupyter Book
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: "Python version used to build the docs."
+ required: false
+ default: "3.10"
+ type: string
+ build_dir:
+ required: false
+ default: "./_build/html"
+ type: string
+ upload_artifact:
+ description: "If true, upload the built site as an artifact."
+ required: false
+ default: false
+ type: boolean
+
+
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install docs dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install .[docs]
+
+ - name: Build the book
+ run: jupyter-book build .
+
+ - name: Upload built site artifact
+ if: ${{ inputs.upload_artifact }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: built-book
+ path: ${{ inputs.build_dir }}
+ if-no-files-found: error
+ retention-days: 1
diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml
index 243ba8ce5f..5c61d13720 100644
--- a/.github/workflows/codespell.yml
+++ b/.github/workflows/codespell.yml
@@ -5,7 +5,8 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
+ types: [opened, synchronize, reopened]
+ branches: [ main ]
jobs:
codespell:
@@ -14,6 +15,8 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
+ - name: Annotate locations with typos
+ uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
- uses: codespell-project/actions-codespell@v1
+ uses: codespell-project/actions-codespell@v2
diff --git a/.github/workflows/docs_and_notebooks_checks.yml b/.github/workflows/docs_and_notebooks_checks.yml
new file mode 100644
index 0000000000..b35b68eda7
--- /dev/null
+++ b/.github/workflows/docs_and_notebooks_checks.yml
@@ -0,0 +1,59 @@
+name: Docs & notebooks freshness and formatting checks
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ staleness:
+ name: Docs and notebooks scan (read-only)
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout repository (full history for git dates)
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install staleness tool dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install "pydantic>=2,<3" pyyaml "nbformat>=5"
+
+ - name: Run staleness report (read-only)
+ run: |
+ python tools/docs_and_notebooks_check.py \
+ --config tools/docs_and_notebooks_report_config.yml \
+ --out-dir tmp/docs_nb_checks \
+ report
+
+
+ # Optional: run check mode (will fail only once you populate allowlists in config)
+ - name: Run staleness policy check (optional gate)
+ continue-on-error: true
+ run: |
+ python tools/docs_and_notebooks_check.py \
+ --config tools/docs_and_notebooks_report_config.yml \
+ --out-dir tmp/docs_nb_checks \
+ --no-step-summary \
+ check
+
+ - name: Upload staleness artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: staleness-report
+ path: |
+ tmp/docs_nb_checks/*.json
+ tmp/docs_nb_checks/*.md
+ if-no-files-found: error
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
new file mode 100644
index 0000000000..9c2ced892f
--- /dev/null
+++ b/.github/workflows/format.yml
@@ -0,0 +1,119 @@
+name: pre-commit (PR only on changed files)
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+jobs:
+ detect_changes:
+ runs-on: ubuntu-latest
+ outputs:
+ changed: ${{ steps.changed_files.outputs.changed }}
+ changed_python: ${{ steps.changed_python.outputs.changed_python }}
+
+ steps:
+ - name: Checkout full history
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Detect changed files
+ id: changed_files
+ run: |
+ git fetch origin ${{ github.base_ref }}
+ CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
+
+ {
+ echo "changed<> "$GITHUB_OUTPUT"
+
+ - name: Detect changed Python files
+ id: changed_python
+ run: |
+ git fetch origin ${{ github.base_ref }}
+ CHANGED_PYTHON=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(py|pyi|ipynb)$' || true)
+
+ {
+ echo "changed_python<> "$GITHUB_OUTPUT"
+
+ - name: Show changed files
+ run: |
+ echo "Changed files:"
+ echo "${{ steps.changed_files.outputs.changed }}"
+
+ echo
+ echo "Changed Python files:"
+ echo "${{ steps.changed_python.outputs.changed_python }}"
+
+ precommit:
+ needs: detect_changes
+ runs-on: ubuntu-latest
+ if: ${{ needs.detect_changes.outputs.changed != '' }}
+
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install tooling
+ run: pip install pre-commit ruff
+
+ - name: Run pre-commit (CI check-only stage) on changed files
+ id: precommit_run
+ continue-on-error: true
+ env:
+ CHANGED_FILES: ${{ needs.detect_changes.outputs.changed }}
+ run: |
+ mapfile -t files <<< "$CHANGED_FILES"
+ pre-commit run --hook-stage manual --files "${files[@]}" --show-diff-on-failure
+
+ - name: Generate Ruff Markdown report
+ id: ruff_report
+ if: ${{ always() && needs.detect_changes.outputs.changed_python != '' }}
+ env:
+ CHANGED_PYTHON: ${{ needs.detect_changes.outputs.changed_python }}
+ run: |
+ mkdir -p tmp
+ mapfile -t pyfiles <<< "$CHANGED_PYTHON"
+ python tools/ruff_report.py "${pyfiles[@]}" --output tmp/ruff-report.md
+
+ - name: Add short Ruff report to GitHub Actions summary
+ if: ${{ always() && steps.precommit_run.outcome == 'failure' && needs.detect_changes.outputs.changed_python != '' }}
+ run: |
+ {
+ echo "# Lint summary"
+ echo
+ echo "## Ruff report (top section)"
+ echo
+ sed -n '1,80p' tmp/ruff-report.md
+ echo
+ echo "_Full report uploaded as workflow artifact: `ruff-report`_"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload Ruff report artifact
+ if: ${{ always() && needs.detect_changes.outputs.changed_python != '' }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: ruff-report
+ path: tmp/ruff-report.md
+
+ - name: Fail job if pre-commit failed
+ if: ${{ steps.precommit_run.outcome == 'failure' }}
+ run: |
+ echo "pre-commit reported failures"
+ exit 1
diff --git a/.github/workflows/intelligent-testing.yml b/.github/workflows/intelligent-testing.yml
new file mode 100644
index 0000000000..ae85292c75
--- /dev/null
+++ b/.github/workflows/intelligent-testing.yml
@@ -0,0 +1,167 @@
+name: "Intelligent Python Testing"
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ branches: [ main ]
+
+concurrency:
+ group: intelligent-${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || format('run-{0}', github.run_id) }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ intelligent-test-selection:
+ name: Select test plan
+ runs-on: ubuntu-latest
+ outputs:
+ run_skip: ${{ steps.selector.outputs.run_skip }}
+ run_docs: ${{ steps.selector.outputs.run_docs }}
+ run_fast: ${{ steps.selector.outputs.run_fast }}
+ run_full: ${{ steps.selector.outputs.run_full }}
+ selected_workflows: ${{ steps.selector.outputs.selected_workflows }}
+ lane_reasons: ${{ steps.selector.outputs.lane_reasons }}
+ pytest_paths: ${{ steps.selector.outputs.pytest_paths }}
+ functional_scripts: ${{ steps.selector.outputs.functional_scripts }}
+ reasons: ${{ steps.selector.outputs.reasons }}
+ changed_files: ${{ steps.selector.outputs.changed_files }}
+ provenance: ${{ steps.selector.outputs.provenance }}
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # needed for merge-base/diff to be reliable
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Run selector + generate report
+ id: selector
+ run: |
+ python -m pip install --upgrade pip
+ pip install "pydantic>=2,<3"
+ python tools/test_selector.py \
+ --write-github-output \
+ --write-summary \
+ --report-dir tmp/test-selection \
+ --json
+
+ - name: Upload selector report
+ uses: actions/upload-artifact@v6
+ with:
+ name: test-selection-report
+ path: |
+ tmp/test-selection/selection.json
+ tmp/test-selection/decision.md
+ retention-days: 7
+ if-no-files-found: error
+
+
+
+ skip:
+ name: Skipped (lint config change)
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_skip == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - run: |
+ echo "Only lint config files changed; skipping docs/tests."
+ echo "Pre-commit workflow is responsible for lint config validation."
+
+
+ docs:
+ name: Docs build
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_docs == 'true'
+ uses: ./.github/workflows/build-book.yml
+ with:
+ python-version: "3.10"
+ build_dir: "./_build/html"
+ upload_artifact: false
+ secrets: inherit
+
+
+ fast-tests:
+ name: Fast lane (targeted pytest + selected functional)
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_fast == 'true'
+ uses: ./.github/workflows/python-package.yml
+ with:
+ concurrency_key: ${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || '' }}
+ matrix_json: >-
+ {"include":[{"os":"ubuntu-latest","python-version":"3.12", "extras": "[tf]"}]}
+ pytest_paths_json: ${{ needs.intelligent-test-selection.outputs.pytest_paths }}
+ functional_scripts_json: ${{ needs.intelligent-test-selection.outputs.functional_scripts }}
+ full_suite: false
+
+ full-tests:
+ name: Full matrix tests
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_full == 'true'
+ uses: ./.github/workflows/python-package.yml
+ with:
+ concurrency_key: ${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || '' }}
+ matrix_json: >-
+ {
+ "include": [
+ {"os":"ubuntu-latest","python-version":"3.10", "extras": "[tf]"},
+ {"os":"ubuntu-latest","python-version":"3.11", "extras": "[tf]"},
+ {"os":"ubuntu-latest","python-version":"3.12", "extras": "[tf]"},
+ {"os":"macos-latest","python-version":"3.10", "extras": "[tf]"},
+ {"os":"macos-latest","python-version":"3.11", "extras": "[tf]"},
+ {"os":"macos-latest","python-version":"3.12", "extras": "[tf]"},
+ {"os":"windows-latest","python-version":"3.10", "extras": "[tf]"},
+ {"os":"windows-latest","python-version":"3.11", "extras": "[tf]"},
+ {"os":"windows-latest","python-version":"3.12", "extras": "[tf]"}
+ ]
+ }
+ full_suite: true
+
+ tf-install-smoke-test:
+ name: TensorFlow install smoke test
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_full == 'true'
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ['3.10', '3.11', '3.12']
+ # Run smoke test on the extras that are not tested in the full matrix tests
+ extras: ["[tf-cu11]", "[tf-cu12]"]
+ exclude:
+ - os: windows-latest
+ python-version: '3.11'
+ - os: windows-latest
+ python-version: '3.12'
+ - extras: "[tf-cu11]"
+ python-version: '3.12'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: conda-incubator/setup-miniconda@v3
+ with:
+ channels: conda-forge
+ channel-priority: strict
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ shell: bash -el {0}
+ run: |
+ python -m ensurepip --upgrade
+ python -m pip install --upgrade pip setuptools wheel
+ python -m pip install dependency-groups
+ python -m pip install --no-cache-dir -e ".${{ matrix.extras }}" --group dev
+
+ - name: Run TensorFlow install smoke test
+ shell: bash -el {0}
+ run: |
+ pytest tests/test_tf_install_smoke.py
diff --git a/.github/workflows/lockfile-check.yml b/.github/workflows/lockfile-check.yml
new file mode 100644
index 0000000000..5783029fcf
--- /dev/null
+++ b/.github/workflows/lockfile-check.yml
@@ -0,0 +1,37 @@
+name: Lockfile
+
+on:
+ pull_request:
+ paths:
+ - "pyproject.toml"
+ - "uv.lock"
+ # - ".python-version"
+ - ".github/workflows/uv-lockfile-check.yml"
+ push:
+ branches: [main]
+ paths:
+ - "pyproject.toml"
+ - "uv.lock"
+ # - ".python-version"
+ - ".github/workflows/uv-lockfile-check.yml"
+
+jobs:
+ uv-lockfile-check:
+ name: uv.lock is current
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version-file: pyproject.toml
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ version: "0.10.10"
+
+ - name: Check lockfile
+ run: uv lock --check
diff --git a/.github/workflows/publish-book.yml b/.github/workflows/publish-book.yml
index 426e87370e..7996b0672b 100644
--- a/.github/workflows/publish-book.yml
+++ b/.github/workflows/publish-book.yml
@@ -2,32 +2,34 @@ name: publish-book
on:
push:
- branches:
- - main
+ branches: [ main ]
+
+permissions:
+ contents: write
jobs:
- deploy-book:
+ build:
+ uses: ./.github/workflows/build-book.yml
+ with:
+ python-version: "3.10"
+ build_dir: "./_build/html"
+ upload_artifact: true
+ secrets: inherit
+
+ deploy:
+ needs: build
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- - uses: actions/checkout@v4
-
- - name: Set up Python 3.9
- uses: actions/setup-python@v4
- with:
- python-version: 3.9
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- python -m pip install .[tf,docs]
- pip install jupyter-book sphinxcontrib-mermaid
-
- - name: Build the book
- run: |
- jupyter-book build .
+ - name: Download built site artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: built-book
+ path: site
- - name: GitHub Pages action
- uses: peaceiris/actions-gh-pages@v3.9.3
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./_build/html
+ - name: Deploy via gh-pages branch
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: site
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index ffe28dea22..db2a30a1f4 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -1,66 +1,250 @@
name: Python package
on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
+ workflow_call:
+ # This workflow can be called by other workflows (like intelligent-testing.yml)
+ inputs:
+ concurrency_key:
+ required: false
+ type: string
+ matrix_json:
+ required: true
+ type: string
+ pytest_paths_json:
+ required: false
+ type: string
+ default: "[]"
+ functional_scripts_json:
+ required: false
+ type: string
+ default: "[]"
+ full_suite:
+ required: false
+ type: boolean
+ default: false
+
+ workflow_dispatch:
+ inputs:
+ concurrency_key:
+ description: "Optional stable key to dedupe manual runs (for example: pr-123)"
+ required: false
+ type: string
+ matrix_json:
+ description: "JSON matrix definition"
+ required: true
+ type: string
+ default: '{"include":[{"os":"ubuntu-latest","python-version":"3.12","extras": ""}]}'
+ pytest_paths_json:
+ description: "JSON array of pytest paths"
+ required: false
+ type: string
+ default: "[]"
+ functional_scripts_json:
+ description: "JSON array of functional scripts"
+ required: false
+ type: string
+ default: "[]"
+ full_suite:
+ description: "Run full pytest and default functional suite"
+ required: false
+ type: boolean
+ default: false
jobs:
build:
runs-on: ${{ matrix.os }}
+ # Cancel outdated runs on the same OS and Python version when new commits are pushed
+ # Only cancels on PRs.
+ # Use a stable concurrency key only when one is explicitly provided
+ # (e.g. PR/workflow_call/manual dedupe). Otherwise fall back to github.run_id
+ # so pushes to main never cancel each other.
+ concurrency:
+ group: >-
+ tests-${{ github.workflow }}-
+ ${{ github.event_name == 'workflow_call' && inputs.concurrency_key
+ || github.event_name == 'workflow_dispatch' && inputs.concurrency_key
+ || github.run_id }}-
+ ${{ matrix.os }}-${{ matrix.python-version }}
+ cancel-in-progress: true
strategy:
fail-fast: false
- matrix:
- os: [ubuntu-latest, macos-13, windows-latest]
- python-version: [3.9, "3.10"]
- include:
- - os: ubuntu-latest
- path: ~/.cache/pip
- - os: macos-13
- path: ~/Library/Caches/pip
- - os: windows-latest
- path: ~\AppData\Local\pip\Cache
- exclude:
- - os: macos-13
- python-version: 3.7
- - os: windows-latest
- python-version: 3.7
+ matrix: ${{ fromJson(inputs.matrix_json) }}
steps:
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
+
+ - name: Free up disk space
+ if: runner.os == 'Linux'
+ run: |
+ echo "Disk space before cleanup:"
+ df -h
+ # Remove unnecessary software to free up disk space
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /usr/local/lib/android
+ sudo rm -rf /opt/ghc
+ sudo rm -rf /opt/hostedtoolcache/CodeQL
+ sudo docker image prune --all --force
+ echo "Disk space after cleanup:"
+ df -h
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v4
+ - name: Set up Python
+ uses: conda-incubator/setup-miniconda@v3
with:
+ channels: conda-forge
+ channel-priority: strict
python-version: ${{ matrix.python-version }}
- name: Install dependencies
+ shell: bash -el {0} # Important to enable conda env
run: |
+ python -m ensurepip --upgrade
python -m pip install --upgrade pip setuptools wheel
- pip install -r requirements.txt
+ python -m pip install dependency-groups
+ python -m pip install --no-cache-dir -e ".${{ matrix.extras }}" --group dev
- - name: Install ffmpeg
+ - name: Install ffmpeg (Linux/macOS)
+ if: runner.os != 'Windows'
+ shell: bash
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
sudo apt-get update
- sudo apt-get install ffmpeg
+ sudo apt-get install -y ffmpeg
elif [ "$RUNNER_OS" == "macOS" ]; then
- brew install ffmpeg
- else
- choco install ffmpeg
+ brew install ffmpeg || true
fi
- shell: bash
+ - name: Install ffmpeg (Windows, pinned monthly BtbN build)
+ # NOTE: The pinned version should be retained for ~2 years. This WILL fail if the BtbN release is removed,
+ # so if you are two years in the future and this step fails, please check the builds. Thanks.
+ if: runner.os == 'Windows'
+ shell: pwsh
+ env:
+ FFMPEG_TAG: autobuild-2026-03-31-13-11
+ FFMPEG_ASSET: ffmpeg-N-123777-g53537f6cf5-win64-gpl-shared.zip
+ run: |
+ $ErrorActionPreference = "Stop"
+
+ $tag = $env:FFMPEG_TAG
+ $asset = $env:FFMPEG_ASSET
+
+ $baseUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/$tag"
+ $url = "$baseUrl/$asset"
+ $checksumsUrl = "$baseUrl/checksums.sha256"
+
+ $tmpRoot = Join-Path $env:RUNNER_TEMP "ffmpeg-install"
+ $zip = Join-Path $tmpRoot $asset
+ $checksums = Join-Path $tmpRoot "checksums.sha256"
+ $dest = Join-Path $tmpRoot "ffmpeg"
+
+ if (Test-Path -LiteralPath $tmpRoot) {
+ Remove-Item -LiteralPath $tmpRoot -Recurse -Force
+ }
+ New-Item -ItemType Directory -Path $tmpRoot | Out-Null
+
+ Invoke-WebRequest -Uri $url -OutFile $zip
+ Invoke-WebRequest -Uri $checksumsUrl -OutFile $checksums
+
+ $expected = Get-Content -LiteralPath $checksums |
+ ForEach-Object {
+ if ($_ -match '^(?[0-9A-Fa-f]{64})\s+\*?(?.+)$' -and $matches.name.Trim() -eq $asset) {
+ $matches.sha.ToLowerInvariant()
+ }
+ } |
+ Select-Object -First 1
- - name: Run pytest tests
+ if (-not $expected) {
+ throw "Could not find checksum for $asset in $checksums"
+ }
+
+ $actual = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $expected) {
+ throw "FFmpeg checksum mismatch. Expected $expected but got $actual"
+ }
+
+ Expand-Archive -LiteralPath $zip -DestinationPath $dest -Force
+ $ffdir = Get-ChildItem -LiteralPath $dest -Directory | Select-Object -First 1
+ if (-not $ffdir) {
+ throw "Could not find extracted FFmpeg directory."
+ }
+
+ $binDir = Join-Path $ffdir.FullName "bin"
+ $binDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
+
+ - name: Verify ffmpeg/ffprobe available
+ shell: bash -el {0}
+ run: |
+ set -e
+ ffmpeg -version
+ ffprobe -version
+
+ - name: Run pytest
+ shell: bash -el {0}
+ env:
+ FULL_SUITE: ${{ inputs.full_suite }}
+ PYTEST_PATHS_JSON: ${{ inputs.pytest_paths_json }}
run: |
- pip install pytest
- python -m pytest
+ python - << 'PY'
+ import json
+ import os
+ import subprocess
+ import sys
- - name: Run functional tests
+ full_suite = os.environ["FULL_SUITE"].lower() == "true"
+
+ if full_suite:
+ cmd = [sys.executable, "-m", "pytest"]
+ print("Running full pytest suite")
+ else:
+ paths = json.loads(os.environ.get("PYTEST_PATHS_JSON", "[]"))
+ if not paths:
+ print("No pytest paths selected; skipping pytest.")
+ raise SystemExit(0)
+ cmd = [sys.executable, "-m", "pytest", *paths]
+ print("Running targeted pytest:", " ".join(paths))
+
+ raise SystemExit(subprocess.call(cmd))
+ PY
+
+ - name: Run functional scripts
+ shell: bash -el {0}
+ env:
+ FULL_SUITE: ${{ inputs.full_suite }}
+ FUNCTIONAL_SCRIPTS_JSON: ${{ inputs.functional_scripts_json }}
run: |
- pip install git+https://github.com/${{ github.repository }}.git@${{ github.sha }}
- python examples/testscript.py
- python examples/testscript_multianimal.py
+ python - << 'PY'
+ import json
+ import os
+ import subprocess
+ import sys
+
+ def is_windows_python_3_11_or_greater() -> bool:
+ """Aligned with pyproject: .[tf*] omits TensorFlow on Windows for Python 3.11+."""
+ return sys.platform == "win32" and sys.version_info >= (3, 11)
+
+ full_suite = os.environ["FULL_SUITE"].lower() == "true"
+ if full_suite:
+ scripts = [
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_tensorflow_multi_animal.py",
+ "examples/testscript_pytorch_single_animal.py",
+ "examples/testscript_pytorch_multi_animal.py",
+ ]
+ else:
+ scripts = json.loads(os.environ.get("FUNCTIONAL_SCRIPTS_JSON", "[]"))
+ if not scripts:
+ print("No functional scripts selected; skipping functional tests.")
+ raise SystemExit(0)
+
+ for script in scripts:
+ if "tensorflow" in script and is_windows_python_3_11_or_greater():
+ ver = f"{sys.version_info.major}.{sys.version_info.minor}"
+ print(
+ f"Skipping TensorFlow example on Windows {ver} (no TF in .[tf*] for 3.11+): {script}"
+ )
+ continue
+ print("Running:", script)
+ rc = subprocess.call([sys.executable, script])
+ if rc != 0:
+ raise SystemExit(rc)
+ PY
diff --git a/.gitignore b/.gitignore
index 86a6943487..7656027ecc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,11 +5,9 @@ _build/*
#Data and examples
/examples/open*
/examples/Reac*
-/examples/TES*
-/examples/multi*
-/examples/3D*
/examples/m3*
/examples/OUT
+/examples/pretrained*
.local
.DS_Store
examples/.DS_Store
@@ -18,6 +16,15 @@ examples/.DS_Store
*.ckpt
snapshot-*
+# Modelzoo checkpoints
+deeplabcut/modelzoo/checkpoints/
+
+# PyTorch backbone weights
+deeplabcut/pose_estimation_pytorch/models/backbones/pretrained_weights/
+
+# Wandb files
+wandb/
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -115,7 +122,10 @@ ENV/
# Spyder project settings
.spyderproject
.spyproject
+
+# IDEs configurations
.vscode/*
+.idea/*
# Rope project settings
.ropeproject
@@ -125,3 +135,16 @@ ENV/
# mypy
.mypy_cache/
+
+# Tools output
+tmp/*
+
+# Test data
+tests/data/*
+
+# Automated docs checks
+**/tmp/docs_nb_checks/
+
+
+# Automatic test selection report
+**/tmp/test-selection/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000000..a5956fde1e
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,105 @@
+default_stages: [pre-commit]
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ # These are safe to run in both local & CI (they don't require "fix vs check" split)
+ - id: check-added-large-files
+ stages: [pre-commit, manual]
+ - id: check-yaml
+ stages: [pre-commit, manual]
+ - id: check-toml
+ stages: [pre-commit, manual]
+ - id: check-merge-conflict
+ stages: [pre-commit, manual]
+ - id: name-tests-test
+ args: [--pytest-test-first]
+ stages: [pre-commit, manual]
+ - id: check-json
+ stages: [pre-commit, manual]
+
+ # These modify files. Run locally only (pre-commit stage).
+ - id: end-of-file-fixer
+ stages: [pre-commit]
+ - id: trailing-whitespace
+ stages: [pre-commit]
+
+ - repo: https://github.com/tox-dev/pyproject-fmt
+ rev: v2.19.0
+ hooks:
+ - id: pyproject-fmt
+ stages: [pre-commit] # modifies -> local only
+
+ - repo: https://github.com/abravalheri/validate-pyproject
+ rev: v0.25
+ hooks:
+ - id: validate-pyproject
+ stages: [pre-commit, manual]
+
+ # NOTE: @C-Achard 2026-03-18 disabled for now
+ # It had its use in introducing and enforcing linting, especially for docstrings
+ # but now ruff should be our de-facto linter.
+ # Only re-enable if we end up requiring large-scale docstring reformatting
+ # or we need some features from this in the future
+ # - repo: https://github.com/PyCQA/docformatter
+ # rev: v1.7.7
+ # hooks:
+ # - id: docformatter
+ # name: docformatter (fix)
+ # args: [--wrap-descriptions=88, --wrap-summaries=88, --in-place, --black]
+ # stages: [pre-commit]
+
+ # - id: docformatter
+ # name: docformatter (ci)
+ # args: [--wrap-descriptions=88, --wrap-summaries=88, --check, --black]
+ # stages: [manual]
+
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.6
+ hooks:
+ # --------------------------
+ # LOCAL AUTOFIX (developers)
+ # --------------------------
+ - id: ruff-check
+ name: ruff-check (fix)
+ args: [--fix, --unsafe-fixes]
+ stages: [pre-commit]
+
+ - id: ruff-format
+ name: ruff-format (write)
+ stages: [pre-commit]
+
+ # --------------------------
+ # CI CHECK-ONLY (no writes)
+ # --------------------------
+ - id: ruff-check
+ name: ruff-check (ci)
+ args: [--output-format=github]
+ stages: [manual]
+
+ - id: ruff-format
+ name: ruff-format (ci)
+ args: [--check, --diff]
+ stages: [manual]
+
+ # check only, no modifications
+ - repo: local
+ hooks:
+ - id: dlc-docs-notebooks-check
+ name: DLC docs+notebooks staleness/check + nbformat validate + normalization
+ entry: python tools/docs_and_notebooks_check.py
+ language: python
+ pass_filenames: true
+ files: ^(docs/|examples/(JUPYTER|COLAB)/|tools/).*(\.md|\.ipynb)$
+ args:
+ - --config
+ - tools/docs_and_notebooks_report_config.yml
+ - check
+ - --targets
+ additional_dependencies:
+ - "pydantic>=2,<3"
+ - "pyyaml"
+ - "nbformat>=5"
+ stages: [pre-commit, manual]
diff --git a/AUTHORS b/AUTHORS
index 9fe42d2f98..d53068678f 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,5 +1,5 @@
DeepLabCut (www.deeplabcut.org) was initially developed by
-Alexander & Mackenzie Mathis in collaboration with Matthias Bethge.
+Alexander & Mackenzie Mathis in collaboration with Matthias Bethge in 2017.
It is actively developed by Alexander & Mackenzie Mathis (steering council and owners).
DeepLabCut is an open-source tool and has benefited from suggestions and edits by many
@@ -82,23 +82,23 @@ T Nath, https://github.com/meet10may
Preprint:
Multi-animal pose estimation and tracking with DeepLabCut
-J Lauer, M Zhou, S Ye, W Menegas, S Schneider, T Nath, MM Rahman, V Di Santo,
+J Lauer, M Zhou, S Ye, W Menegas, S Schneider, T Nath, MM Rahman, V Di Santo,
D Soberanes, G Feng, VN Murthy, G Lauder, C Dulac, M Mathis, A Mathis (2021).
https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1
-Publication:
+Publication:
Multi-animal pose estimation, identification and tracking with DeepLabCut
-Lauer, J., Zhou, M., Ye, S., Menegas, W., Schneider, S., Nath, T., Rahman, M.M.,
-Di Santo, V., Soberanes, D., Feng, G., Murthy, V.N., Lauder, G.V., Dulac, C.,
-Mathis, M.W., & Mathis, A. (2022).
+Lauer, J., Zhou, M., Ye, S., Menegas, W., Schneider, S., Nath, T., Rahman, M.M.,
+Di Santo, V., Soberanes, D., Feng, G., Murthy, V.N., Lauder, G.V., Dulac, C.,
+Mathis, M.W., & Mathis, A. (2022).
Nature Methods, 19, 496 - 504.
-Conceptualization was done by A.M. and M.W.M. Formal analysis and code were done by J.L., A.M. and M.W.M.
-New deep architectures were designed by M.Z., S.Y. and A.M. GUIs were done by J.L., M.W.M. and T.N.
-Benchmark was set by S.S., M.W.M., A.M. and J.L. Marmoset data were gathered by W.M. and G.F.
+Conceptualization was done by A.M. and M.W.M. Formal analysis and code were done by J.L., A.M. and M.W.M.
+New deep architectures were designed by M.Z., S.Y. and A.M. GUIs were done by J.L., M.W.M. and T.N.
+Benchmark was set by S.S., M.W.M., A.M. and J.L. Marmoset data were gathered by W.M. and G.F.
Marmoset behavioral analysis was carried out by W.M. Parenting data were gathered by M.M.R., A.M. and C.D.
-Tri-mouse data were gathered by D.S., A.M. and V.N.M. Fish data were gathered by V.D.S. and G.L.
-The article was written by A.M., M.W.M. and J.L. with input from all authors.
+Tri-mouse data were gathered by D.S., A.M. and V.N.M. Fish data were gathered by V.D.S. and G.L.
+The article was written by A.M., M.W.M. and J.L. with input from all authors.
M.W.M. and A.M. co-supervised the project.
############################################################################################################
@@ -108,8 +108,32 @@ A Mathis, alexander.mathis@epfl.ch | https://github.com/AlexEMG
M Mathis, mackenzie@post.harvard.edu | https://github.com/MMathisLab
J Lauer, jessy@deeplabcut.org | https://github.com/jeylau
N Poulsen, neils.poulsen@epfl.ch | https://github.com/n-poulsen
-S Ye, https://github.com/yeshaokai
+S Schneider, stes@hey.com | https://github.com/stes
+S Ye, shaokai.ye@epfl.ch | https://github.com/yeshaokai
-Preprint:
-Ye, S., Filippova, A., Lauer, J., Vidal, M., Schneider, S., Qiu, T., Mathis, A., & Mathis, M.W. (2022).
+Preprint:
+Ye, S., Filippova, A., Lauer, J., Schneider, S., Vidal, M., Qiu, T., Mathis, A., & Mathis, M.W. (2023).
SuperAnimal pretrained pose estimation models for behavioral analysis. https://arxiv.org/abs/2203.07436
+
+
+############################################################################################################
+
+DeepLabCut 3.0 Toolbox
+M Mathis, mackenzie@post.harvard.edu | https://github.com/MMathisLab
+A Mathis, alexander.mathis@epfl.ch | https://github.com/AlexEMG
+N Poulsen, neils.poulsen@epfl.ch | https://github.com/n-poulsen
+S Ye, shaokai.ye@epfl.ch | https://github.com/yeshaokai
+A Filippova, anastasiia.filippova@epfl.ch | https://github.com/nastya236
+Q Macé | https://github.com/QuentinJGMace
+J Lauer, jessy@deeplabcut.org | https://github.com/jeylau
+L Stoffl, lucas.stoffl@epfl.ch | https://github.com/LucZot
+
+We also greatly thank the 2023 DeepLabCut AI Residents who contributed:
+Anna Teruel-Sanchis | https://github.com/anna-teruel
+Riza Rae Pineda | https://github.com/rizarae-p
+Konrad Danielewski | https://github.com/KonradDanielewski
+
+Products:
+PyTorch backend for DeepLabCut
+Expanded SuperAnimal capabilities
+New model architectures (WIP: stay tuned, but includes BUCTD)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index f4396c3d16..b26ffb0fb0 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -6,7 +6,7 @@ In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
-level of experience, education, socio-economic status, nationality, personal
+level of experience, education, socioeconomic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
@@ -55,7 +55,7 @@ further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported by contacting the project team at alexander.mathis@bethgelab.org. All
+reported by contacting the project team at alexander.mathis@epfl.ch. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index daffcaa2b5..79f60b0674 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,77 +1,153 @@
-# How to Contribute to DeepLabCut
+# Contributing to DeepLabCut
-DeepLabCut is an actively developed package and we welcome community development and involvement. We are especially seeking people from underrepresented backgrounds in OSS to contribute their expertise and experience. Please get in touch if you want to discuss specific contributions you are interested in developing, and we can help shape a road-map.
+Thanks for your interest in contributing to DeepLabCut! We welcome bug fixes, new features, documentation improvements, tests, and general maintenance contributions.
-We are happy to receive code extensions, bug fixes, documentation updates, etc.
+We especially encourage contributions from people from backgrounds that are underrepresented in open-source software. If you want to discuss an idea before opening a pull request, feel free to start a discussion or open an issue.
-If you are a new user, we recommend checking out the detailed [Github Guides](https://guides.github.com).
+If you are new to GitHub, the [GitHub Guides](https://guides.github.com/) are a great place to start.
-## Setting up a development installation
+## Ways to contribute
-In order to make changes to `deeplabcut`, you will need to [fork](https://guides.github.com/activities/forking/#fork) the
-[repository](https://github.com/deeplabcut/deeplabcut).
+You can help by:
-If you are not familiar with `git`, we recommend reading up on [this guide](https://guides.github.com/introduction/git-handbook/#basic-git).
+- Fixing bugs
+- Improving documentation
+- Adding tests
+- Improving examples
+- Refactoring or cleaning up code
+- Proposing or implementing new features
-Here are guidelines for installing deeplabcut locally on your own computer, where you can make changes to the code! We often update the master deeplabcut code base on github, and then ~1 a month we push out a stable release on pypi. This is what most users turn to on a daily basis (i.e. pypi is where you get your `pip install deeplabcut` code from!
+## Development setup
-But, sometimes we add things to the repo that are not yet integrated, or you might want to edit the code yourself, or you will need to do this to contribute. Here, we show you how to do this.
+To work on DeepLabCut locally:
-**Step 1:**
+1. [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo).
+2. Clone your fork:
-- git clone the repo into a folder on your computer:
+```bash
+git clone https://github.com//DeepLabCut.git
+cd DeepLabCut
+```
-- click on this green button and copy the link:
+3. Create and activate a Python environment.
-
+We recommend using the project's development dependency group from `pyproject.toml` so you get the tools needed for local development (including formatting, linting, and testing).
-- then in the terminal type: `git clone https://github.com/DeepLabCut/DeepLabCut.git`
+For example, with `uv`:
-**Step 2:**
+```bash
+uv sync --group dev
+```
-- Now you will work from the terminal inside this cloned folder:
+With `pip` (e.g. in a `conda` environment):
-
+```bash
+pip install -e . --group dev
+```
-- Now, when you start `ipython` and `import deeplabcut` you are importing the folder "deeplabcut" - so any changes you make, or any changes we made before adding it to the pip package, are here.
+If you use a different environment manager, install the package in editable/development mode together with the `dev` dependency group defined in `pyproject.toml`.
-- You can also check which deeplabcut you are importing by running: `deeplabcut.__file__`
+## Working on the code
-
+Once your environment is ready, your local checkout is what Python will import.
-If you make changes to the code/first use the code, be sure you run `./resinstall.sh`, which you find in the main DeepLabCut folder:
+If you want to verify that you are using the local source tree, you can run:
-
+```bash
+python -c "import deeplabcut; print(deeplabcut.__file__)"
+```
+Using `ipython` or Jupyter is completely optional—use whatever workflow you prefer.
+If you change packaged resources or otherwise need to refresh the local installation, run:
-Note, before committing to DeepLabCut, please be sure your code is formatted according to `black`. To learn more,
-see [`black`'s documentation](https://black.readthedocs.io/en/stable/).
+```bash
+./reinstall.sh
+```
-Now, please make a [pull request](https://github.com/DeepLabCut/DeepLabCut/pull/new/) that includes both a **summary of and changes to**:
+> [!NOTE]
+> This script automatically uninstalls the package, builds a new wheel using `setup.py`, and installs that wheel. It is not a simple `pip install -e .` because some resources are copied during installation and need to be refreshed.
-- How you modified the code and what new functionality it has.
-- DOCSTRING update for your change
-- A working example of how it works for users.
-- If it's a function that also can be used in downstream steps (i.e. could be plotted) we ask you (1) highlight this, and (2) idealy you provide that functionality as well. If you have any questions, please reach out: admin@deeplabcut.org
+## Code style and pre-commit
-**TestScript outputs:**
+We use `pre-commit` to run formatting and other checks before code is committed.
-- The **OS it has been tested on**
-- the **output of the [testscript.py](/examples/testscript.py)** and if you are editing the **3D code the [testscript_3d.py](/examples/testscript_3d.py)**, and if you edit multi-animal code please run the [maDLC test script](https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/testscript_multianimal.py).
+Set it up once in your clone:
-**Review & Formatting:**
+```bash
+pre-commit install
+```
-- Please run black on the code to conform to our Black code style (see more at https://pypi.org/project/black/).
-- Please assign a reviewer, typically @AlexEMG, @mmathislab, or @jeylau (i/e. the [core-developers](https://github.com/orgs/DeepLabCut/teams/core-developers/members))
+Whenever you commit, `pre-commit` will run the configured checks.
-**Code headers**
+Please run `pre-commit` before opening a pull request. This helps catch formatting, import ordering, whitespace, YAML, and other common issues early and accelerates code review greatly.
-- The code headers can be standardized by running `python tools/update_license_headers.py`
-- Edit `NOTICE.yml` to update the header.
+## Tests
-**DeepLabCut is an open-source tool and has benefited from suggestions and edits by many individuals:**
+Pull requests are validated in CI, and contributors are encouraged to run tests locally using:
-- the [authors](/AUTHORS)
-- [code contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors)
+```bash
+pytest tests
+```
+in the project root before opening a pull request.
+> [!IMPORTANT]
+> Heavier tests are also run automatically on GitHub, so this is not a strict requirement,
+> but it can help catch issues early and speed up the review process.
+
+## Pull request guidelines
+
+When submitting a pull request, please:
+
+- Clearly describe what changed and why
+- Link any related issue(s)
+- Update docstrings and documentation when behavior changes
+- Add or update tests when appropriate
+- Include a small usage example when it helps reviewers understand and/or test the change
+
+Smaller, focused pull requests are usually much easier to review than very large ones.
+
+### Draft pull requests
+
+We use draft pull requests to indicate work in progress.
+You may still request reviews and feedbacks on draft pull requests, and we encourage you to do so if you would like early feedback on your work.
+Please note that the draft status is in no way related to the perceived quality of the code or its potential for merging, but is simply a way to indicate that the work is not yet ready for final review and merging.
+Most pull requests exist for the majority of their lifetime as drafts, which is expected.
+
+## Documentation
+
+Documentation improvements are always welcome.
+
+If your change affects users, please update the relevant docs, examples, or inline docstrings so the behavior is discoverable and easy to understand.
+
+## Code headers and notices
+
+If you need to standardize code headers, run:
+
+```bash
+python tools/update_license_headers.py
+```
+
+Contributors are requested not to update `NOTICE.yml` or `LICENSE` files.
+
+## Review process
+
+A maintainer will review your pull request. You do not need to supply a specific release timeline in your PR description—contributions are reviewed and merged as capacity allows.
+
+If you have questions about where a change should go or how to structure it, opening a draft pull request is completely fine.
+
+## Need help?
+
+If you are unsure whether something is in scope, open an issue or draft PR and ask.
+We'd much rather help early than have you spend time on the wrong thing.
+We also welcome "Feature requests" issues if you would like to discuss implementation details or would like preliminary feedback.
+
+## Acknowledgments
+
+DeepLabCut is an open-source project and has benefited from many contributors over time, including:
+
+- The [authors](/AUTHORS)
+- Listed [code contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors)
+- And many others over the years.
+
+We look forward to your contributions!
diff --git a/LICENSE b/LICENSE
index 341c30bda4..65c5ca88a6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -163,4 +163,3 @@ whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
-
diff --git a/NOTICE.yml b/NOTICE.yml
index 68ff6362df..d2ced97e0f 100644
--- a/NOTICE.yml
+++ b/NOTICE.yml
@@ -5,7 +5,7 @@
https://github.com/DeepLabCut/DeepLabCut
Please see AUTHORS for contributors.
- https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+ https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
Licensed under GNU Lesser General Public License v3.0
include:
@@ -17,6 +17,7 @@
# License for files adapted from DeeperCut by Eldar Insafutdinov
# https://github.com/eldar/pose-tensorflow
+
# Applies to most files in deeplabcut.pose_estimation_tensorflow
- header: |
DeepLabCut Toolbox (deeplabcut.org)
@@ -107,3 +108,8 @@
include:
- deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
- deeplabcut/pose_tracking_pytorch/model/backones/vit_pytorch.py
+
+# PyTorch license
+
+- header: |
+ See https://github.com/pytorch/pytorch/blob/main/LICENSE
diff --git a/README.md b/README.md
index ed6b63e483..5c177728ad 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,45 @@
# Welcome! 👋
-**DeepLabCut™️** is a toolbox for state-of-the-art markerless pose estimation of animals performing various behaviors. As long as you can see (label) what you want to track, you can use this toolbox, as it is animal and object agnostic. [Read a short development and application summary below](https://github.com/DeepLabCut/DeepLabCut#why-use-deeplabcut).
+**DeepLabCut™️** is a toolbox for state-of-the-art markerless pose estimation of animals performing various behaviors. As long as you can see (label) what you want to track, you can use this toolbox, as it is animal and object agnostic. [Read a short development and application summary below](https://github.com/DeepLabCut/DeepLabCut#why-use-deeplabcut).
# [Installation: how to install DeepLabCut](https://deeplabcut.github.io/DeepLabCut/docs/installation.html)
-Very quick start: `pip install "deeplabcut[gui,tf]"` that includes all functions plus GUIs, or `pip install deeplabcut[tf]` (headless version with PyTorch and TensorFlow).
-* We recommend using our conda file, see [here](https://github.com/DeepLabCut/DeepLabCut/blob/master/conda-environments/README.md) or the new [`deeplabcut-docker` package](https://github.com/DeepLabCut/DeepLabCut/tree/master/docker).
+Please click the link above for all the information you need to get started! Please note that currently we support only Python 3.10+ (see conda files for guidance).
-# [Documentation: The DeepLabCut Process](https://deeplabcut.github.io/DeepLabCut)
+## Quick start
+
+Developers Stable Release: very quick start (Python 3.10+ required) to install
+DeepLabCut with the PyTorch engine
+
+- [1] [Install PyTorch](https://pytorch.org/get-started/locally/) (**install and then select the desired
+CUDA version if you want to use a GPU**): `pip install torch torchvision`.
+Or as an example for GPU support (please check pytorch docs to get the perfect version for your CUDA):
+```bash
+conda install pytorch cudatoolkit=11.3 -c pytorch
+```
+- [2] Then, install `DeepLabCut` (with all functions + the GUI):
+
+```bash
+pip install --pre "deeplabcut[gui]"
+```
+or `pip install --pre "deeplabcut"` (headless
+version with PyTorch)!
+
+To use the TensorFlow (TF) engine (requires Python 3.10; TF up to v2.10 supported on Windows,
+up to v2.12 on other platforms): you'll need to run `pip install "deeplabcut[gui,tf]"`
+(which includes all functions plus GUIs) or `pip install "deeplabcut[tf]"` (headless
+version with PyTorch and TensorFlow). We aim to depreciate the TF part in 2027.
+
+We recommend using our conda file, see [here](https://github.com/DeepLabCut/DeepLabCut/blob/main/conda-environments/README.md) or the [`deeplabcut-docker` package](https://github.com/DeepLabCut/DeepLabCut/tree/main/docker).
+
+# [Documentation: The DeepLabCut Process](https://deeplabcut.github.io/DeepLabCut/README.html)
Our docs walk you through using DeepLabCut, and key API points. For an overview of the toolbox and workflow for project management, see our step-by-step at [Nature Protocols paper](https://doi.org/10.1038/s41596-019-0176-0).
-For a deeper understanding and more resources for you to get started with Python and DeepLabCut, please check out our free online course! http://DLCcourse.deeplabcut.org
+For a deeper understanding and more resources for you to get started with Python and DeepLabCut, please check out our free online course! https://deeplabcut.github.io/DeepLabCut/docs/course.html
-# [DEMO the code](/examples)
+# [DEMO the code](examples/README.md)
-🐭 demo [](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb)
+🐭 pose tracking of single animals demo [](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb)
-🐭🐭🐭 demo [](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_3miceDemo.ipynb)
-
-- See [more demos here](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/README.md). We provide data and several Jupyter Notebooks: one that walks you through a demo dataset to test your installation, and another Notebook to run DeepLabCut from the beginning on your own data. We also show you how to use the code in Docker, and on Google Colab.
+See [more demos here](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/README.md). We provide data and several Jupyter Notebooks: one that walks you through a demo dataset to test your installation, and another Notebook to run DeepLabCut from the beginning on your own data. We also show you how to use the code in Docker, and on Google Colab.
# Why use DeepLabCut?
-In 2018, we demonstrated the capabilities for [trail tracking](https://vnmurthylab.org/), [reaching in mice](http://www.mousemotorlab.org/) and various Drosophila behaviors during egg-laying (see [Mathis et al.](https://www.nature.com/articles/s41593-018-0209-y) for details). There is, however, nothing specific that makes the toolbox only applicable to these tasks and/or species. The toolbox has already been successfully applied (by us and others) to [rats](http://www.mousemotorlab.org/deeplabcut), humans, various fish species, bacteria, leeches, various robots, cheetahs, [mouse whiskers](http://www.mousemotorlab.org/deeplabcut) and [race horses](http://www.mousemotorlab.org/deeplabcut). DeepLabCut utilized the feature detectors (ResNets + readout layers) of one of the state-of-the-art algorithms for human pose estimation by Insafutdinov et al., called DeeperCut, which inspired the name for our toolbox (see references below). Since this time, the package has changed substantially. The code has been re-tooled and re-factored since 2.1+: We have added faster and higher performance variants with MobileNetV2s, EfficientNets, and our own DLCRNet backbones (see [Pretraining boosts out-of-domain robustness for pose estimation](https://arxiv.org/abs/1909.11229) and [Lauer et al 2022](https://www.nature.com/articles/s41592-022-01443-0)). Additionally, we have improved the inference speed and provided both additional and novel augmentation methods, added real-time, and multi-animal support. We currently provide state-of-the-art performance for animal pose estimation.
+DeepLabCut continues to be actively maintained and we strive to provide a user-friendly `GUI` and `API` for computer vision researchers and life scientists alike. This means we integrate state-of-the-art models and frameworks, while providing our "best-guess" defaults for life scientists. We highly encourage you to read our papers to get a better understanding of what to use and how to modify the models for your setting.
+
+## Performance 🔥
+
+In general, we provide all the tooling for you to train and use custom models with various high-performance backbones.
+We also provide two foundation pretrained animal models: `SuperAnimal-Quadruped`, `SuperAnimal-TopViewMouse`. To gauge their *out-of-distribution* performance, we provide the following tables.
+
+These models are trained on the [SuperAnimal-Quadruped with AP-10K held out for out-of-domain testing]([https://cocodataset.org/](https://www.nature.com/articles/s41467-024-48792-2)) and the [SuperAnimal-TopViewMouse with DLC-openfield held out for out-of-distribution testing](https://www.nature.com/articles/s41467-024-48792-2). We provide models that include AP-10K in the API (and GUI).
+Note, there are many different models to select from in DeepLabCut 3.0. We strongly recommend you check [this Guide](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/architectures.html) for more details.
+This table, and those below, give you a sense of performance in real-world complex in-the-wild and lab mouse data, respectively.
+This [link provides the model weights](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped) to reproduce the numbers; but please note, our `full` models are in our DLClibrary and released in the API.
+
+DLC 3.0 Pose Estimation (Top Down Models)
+
+| Model Name | Type | mAP SA-Q on AP-10K | mAP SA-TVM on DLC-OpenField |
+|------------------------------|------------|---------------------|-----------------------------|
+| top_down_resnet_50 | Top-Down | 54.9 | 93.5 |
+| top_down_resnet_101 | Top-Down | 55.9 | 94.1 |
+| top_down_hrnet_w32 | Top-Down | 52.5 | 92.4 |
+| top_down_hrnet_w48 | Top-Down | 55.3 | 93.8 |
+| rtmpose_s | Top-Down | 52.9 | 92.9 |
+| rtmpose_m | Top-Down | 55.4 | 94.8 |
+| rtmpose_x | Top-Down | 57.6 | 94.5 |
+
+
+## The History
+
+In 2018, we demonstrated the capabilities for [trail tracking](https://vnmurthylab.org/), [reaching in mice](http://www.mousemotorlab.org/) and various Drosophila behaviors during egg-laying (see [Mathis et al.](https://www.nature.com/articles/s41593-018-0209-y) for details). There is, however, nothing specific that makes the toolbox only applicable to these tasks and/or species. The toolbox has already been successfully applied (by us and others) to [rats](http://www.mousemotorlab.org/deeplabcut), humans, various fish species, bacteria, leeches, various robots, cheetahs, [mouse whiskers](http://www.mousemotorlab.org/deeplabcut) and [race horses](http://www.mousemotorlab.org/deeplabcut). DeepLabCut utilized the feature detectors (ResNets + readout layers) of one of the state-of-the-art algorithms for human pose estimation by Insafutdinov et al., called DeeperCut, which inspired the name for our toolbox (see references below). Since this time, the package has changed substantially. The code has been re-tooled and re-factored since 2.1+: We have added faster and higher performance variants with MobileNetV2s, EfficientNets, and our own DLCRNet backbones (see [Pretraining boosts out-of-domain robustness for pose estimation](https://arxiv.org/abs/1909.11229) and [Lauer et al 2022](https://www.nature.com/articles/s41592-022-01443-0)). Additionally, we have improved the inference speed and provided both additional and novel augmentation methods, added real-time, and multi-animal support.
+In v3.0+ we have changed the backend to support PyTorch. This brings not only an easier installation process for users, but performance gains, developer flexibility, and a lot of new tools! Importantly, the high-level API stays the same, so it will be a seamless transition for users 💜!
+We currently provide state-of-the-art performance for animal pose estimation and the labs (M. Mathis Lab and A. Mathis Group) have both top journal and computer vision conference papers.
-
+
@@ -98,101 +153,33 @@ In 2018, we demonstrated the capabilities for [trail tracking](https://vnmurthyl
## Code contributors:
-DLC code was originally developed by [Alexander Mathis](https://github.com/AlexEMG) & [Mackenzie Mathis](https://github.com/MMathisLab), and was extended in 2.0 with the core dev team consisting of [Tanmay Nath](https://github.com/meet10may) (2.0-2.1), and currently (2.1+) with [Jessy Lauer](https://github.com/jeylau) and (2.3+) [Niels Poulsen](https://github.com/n-poulsen). DeepLabCut is an open-source tool and has benefited from suggestions and edits by many individuals including Mert Yuksekgonul, Tom Biasi, Richard Warren, Ronny Eichler, Hao Wu, Federico Claudi, Gary Kane and Jonny Saunders as well as the [100+ contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors). Please see [AUTHORS](https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS) for more details!
+DLC code was originally developed by [Alexander Mathis](https://github.com/AlexEMG) & [Mackenzie Mathis](https://github.com/MMathisLab), and was extended in 2.0 with the core dev team consisting of [Tanmay Nath](https://github.com/meet10may) (2.0-2.1), [Jessy Lauer](https://github.com/jeylau) (2.1-2.4), and [Niels Poulsen](https://github.com/n-poulsen) (2.3-3.0).
+DeepLabCut is an open-source tool and has benefited from suggestions and edits by many individuals including early contributors: Mert Yuksekgonul, Tom Biasi, Richard Warren, Ronny Eichler, Hao Wu, Federico Claudi, Gary Kane and Jonny Saunders as well as the [100+ contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors). Please see [AUTHORS](https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS) for more details!
+
+🤩 This is an actively developed package and we welcome community development and involvement:
+
+[](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors)
+
+
-This is an actively developed package and we welcome community development and involvement.
# Get Assistance & be part of the DLC Community✨:
| 🚉 Platform | 🎯 Goal | ⏱️ Estimated Response Time | 📢 Support Squad |
|------------------------------------------------------------|-----------------------------------------------------------------------------|---------------------------|----------------------------------------|
-| [](https://forum.image.sc/tag/deeplabcut) 🐭Tag: DeepLabCut | To ask help and support questions👋 | Promptly🔥 | DLC Team and The DLC Community |
-| GitHub DeepLabCut/[Issues](https://github.com/DeepLabCut/DeepLabCut/issues) | To report bugs and code issues🐛 (we encourage you to search issues first) | 2-3 days | DLC Team |
-|[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) | To discuss with other users, share ideas and collaborate💡 | 2 days | The DLC Community |
-| GitHub DeepLabCut/[Contributing](https://github.com/DeepLabCut/DeepLabCut/blob/master/CONTRIBUTING.md) | To contribute your expertise and experience🙏💯 | Promptly🔥 | DLC Team |
-| 🚧 GitHub DeepLabCut/[Roadmap](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/roadmap.md) | To learn more about our journey✈️ | N/A | N/A |
-| [](https://twitter.com/DeepLabCut) | To keep up with our latest news and updates 📢 | Daily | DLC Team |
+| GitHub DeepLabCut/[Issues](https://github.com/DeepLabCut/DeepLabCut/issues) | To report bugs and code issues🐛 (we encourage you to search issues first) | 2-5 days | DLC Core Dev Team |
+| GitHub DeepLabCut/[Contributing](https://github.com/DeepLabCut/DeepLabCut/blob/master/CONTRIBUTING.md) | To contribute your expertise and experience🙏💯 | 2-5 days | DLC Core Dev Team |
+| 🚧 GitHub DeepLabCut/[Roadmap](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/roadmap.md) | To learn more about our journey✈️ | N/A | N/A
+| [](https://forum.image.sc/tag/deeplabcut) 🐭Tag: DeepLabCut | To ask help and support questions 👋 | Promptly🔥 | The DLC Community |
+|[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) | To discuss with other users, share ideas and collaborate💡 | 2-5 days | The DLC Community |
+| [BluSky🦋](https://bsky.app/profile/deeplabcut.bsky.social) | To keep up with our latest news and updates 📢 | 2-5 days | DLC Team |
+| [](https://x.com/DeepLabCut) | To keep up with our latest news and updates 📢 | 2-5 days | DLC Team |
| The DeepLabCut [AI Residency Program](https://www.deeplabcutairesidency.org/) | To come and work with us next summer👏 | Annually | DLC Team |
-## References:
-
-If you use this code or data we kindly ask that you please [cite Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y) and, if you use the Python package (DeepLabCut2.x) please also cite [Nath, Mathis et al, 2019](https://doi.org/10.1038/s41596-019-0176-0). If you utilize the MobileNetV2s or EfficientNets please cite [Mathis, Biasi et al. 2021](https://openaccess.thecvf.com/content/WACV2021/papers/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.pdf). If you use versions 2.2beta+ or 2.2rc1+, please cite [Lauer et al. 2022](https://www.nature.com/articles/s41592-022-01443-0).
-
-DOIs (#ProTip, for helping you find citations for software, check out [CiteAs.org](http://citeas.org/)!):
-
-- Mathis et al 2018: [10.1038/s41593-018-0209-y](https://doi.org/10.1038/s41593-018-0209-y)
-- Nath, Mathis et al 2019: [10.1038/s41596-019-0176-0](https://doi.org/10.1038/s41596-019-0176-0)
-- Lauer et al 2022: [10.1038/s41592-022-01443-0](https://doi.org/10.1038/s41592-022-01443-0)
-
-
-Please check out the following references for more details:
-
- @article{Mathisetal2018,
- title = {DeepLabCut: markerless pose estimation of user-defined body parts with deep learning},
- author = {Alexander Mathis and Pranav Mamidanna and Kevin M. Cury and Taiga Abe and Venkatesh N. Murthy and Mackenzie W. Mathis and Matthias Bethge},
- journal = {Nature Neuroscience},
- year = {2018},
- url = {https://www.nature.com/articles/s41593-018-0209-y}}
-
- @article{NathMathisetal2019,
- title = {Using DeepLabCut for 3D markerless pose estimation across species and behaviors},
- author = {Nath*, Tanmay and Mathis*, Alexander and Chen, An Chi and Patel, Amir and Bethge, Matthias and Mathis, Mackenzie W},
- journal = {Nature Protocols},
- year = {2019},
- url = {https://doi.org/10.1038/s41596-019-0176-0}}
-
- @InProceedings{Mathis_2021_WACV,
- author = {Mathis, Alexander and Biasi, Thomas and Schneider, Steffen and Yuksekgonul, Mert and Rogers, Byron and Bethge, Matthias and Mathis, Mackenzie W.},
- title = {Pretraining Boosts Out-of-Domain Robustness for Pose Estimation},
- booktitle = {Proceedings of the IEEE/CVF Winter Conference on Applications of Computer Vision (WACV)},
- month = {January},
- year = {2021},
- pages = {1859-1868}}
-
- @article{Lauer2022MultianimalPE,
- title={Multi-animal pose estimation, identification and tracking with DeepLabCut},
- author={Jessy Lauer and Mu Zhou and Shaokai Ye and William Menegas and Steffen Schneider and Tanmay Nath and Mohammed Mostafizur Rahman and Valentina Di Santo and Daniel Soberanes and Guoping Feng and Venkatesh N. Murthy and George Lauder and Catherine Dulac and M. Mathis and Alexander Mathis},
- journal={Nature Methods},
- year={2022},
- volume={19},
- pages={496 - 504}}
-
- @article{insafutdinov2016eccv,
- title = {DeeperCut: A Deeper, Stronger, and Faster Multi-Person Pose Estimation Model},
- author = {Eldar Insafutdinov and Leonid Pishchulin and Bjoern Andres and Mykhaylo Andriluka and Bernt Schiele},
- booktitle = {ECCV'16},
- url = {http://arxiv.org/abs/1605.03170}}
-
-Review & Educational articles:
-
- @article{Mathis2020DeepLT,
- title={Deep learning tools for the measurement of animal behavior in neuroscience},
- author={Mackenzie W. Mathis and Alexander Mathis},
- journal={Current Opinion in Neurobiology},
- year={2020},
- volume={60},
- pages={1-11}}
-
- @article{Mathis2020Primer,
- title={A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives},
- author={Alexander Mathis and Steffen Schneider and Jessy Lauer and Mackenzie W. Mathis},
- journal={Neuron},
- year={2020},
- volume={108},
- pages={44-65}}
-
-Other open-access pre-prints related to our work on DeepLabCut:
-
- @article{MathisWarren2018speed,
- author = {Mathis, Alexander and Warren, Richard A.},
- title = {On the inference speed and video-compression robustness of DeepLabCut},
- year = {2018},
- doi = {10.1101/457242},
- publisher = {Cold Spring Harbor Laboratory},
- URL = {https://www.biorxiv.org/content/early/2018/10/30/457242},
- eprint = {https://www.biorxiv.org/content/early/2018/10/30/457242.full.pdf},
- journal = {bioRxiv}}
+## References \& Citations:
+
+Please see our [dedicated page](https://deeplabcut.github.io/DeepLabCut/docs/citation.html) on how to **cite DeepLabCut** 🙏 and our suggestions for your Methods section!
## License:
@@ -202,20 +189,24 @@ SuperAnimal models are provided for research use only (non-commercial use).
## Major Versions:
-- For all versions, please see [here](https://github.com/DeepLabCut/DeepLabCut/releases).
+**For all versions, please see [here](https://github.com/DeepLabCut/DeepLabCut/releases).**
+
+VERSION 3.0: A whole new experience with PyTorch🔥. While the high-level API remains the same, the backend and developer friendliness have greatly improved, along with performance gains!
VERSION 2.3: Model Zoo SuperAnimals, and a whole new GUI experience.
VERSION 2.2: Multi-animal pose estimation, identification, and tracking with DeepLabCut is supported (as well as single-animal projects).
VERSION 2.0-2.1: This is the **Python package** of [DeepLabCut](https://www.nature.com/articles/s41593-018-0209-y) that was originally released in Oct 2018 with our [Nature Protocols](https://doi.org/10.1038/s41596-019-0176-0) paper (preprint [here](https://www.biorxiv.org/content/10.1101/476531v1)).
-This package includes graphical user interfaces to label your data, and take you from data set creation to automatic behavioral analysis. It also introduces an active learning framework to efficiently use DeepLabCut on large experimental projects, and data augmentation tools that improve network performance, especially in challenging cases (see [panel b](https://camo.githubusercontent.com/77c92f6b89d44ca758d815bdd7e801247437060b/68747470733a2f2f737461746963312e73717561726573706163652e636f6d2f7374617469632f3537663664353163396637343536366635356563663237312f742f3563336663316336373538643436393530636537656563372f313534373638323338333539352f636865657461682e706e673f666f726d61743d37353077)).
+This package includes graphical user interfaces to label your data, and take you from data set creation to automatic behavioral analysis. It also introduces an active learning framework to efficiently use DeepLabCut on large experimental projects, and data augmentation tools that improve network performance, especially in challenging cases.
VERSION 1.0: The initial, Nature Neuroscience version of [DeepLabCut](https://www.nature.com/articles/s41593-018-0209-y) can be found in the history of git, or here: https://github.com/DeepLabCut/DeepLabCut/releases/tag/1.11
# News (and in the news):
-:purple_heart: The DeepLabCut Model Zoo launches SuperAnimals, see more [here](http://www.mackenziemathislab.org/dlc-modelzoo/).
+:purple_heart: We released a major update, moving from 2.x --> 3.x with the backend change to PyTorch
+
+:purple_heart: The DeepLabCut Model Zoo launches SuperAnimals, see more [here](https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html).
:purple_heart: **DeepLabCut supports multi-animal pose estimation!** maDLC is out of beta/rc mode and beta is deprecated, thanks to the testers out there for feedback! Your labeled data will be backwards compatible, but not all other steps. Please see the [new `2.2+` releases](https://github.com/DeepLabCut/DeepLabCut/releases) for what's new & how to install it, please see our new [paper, Lauer et al 2022](https://www.nature.com/articles/s41592-022-01443-0), and the [new docs]( https://deeplabcut.github.io/DeepLabCut) on how to use it!
@@ -223,7 +214,10 @@ VERSION 1.0: The initial, Nature Neuroscience version of [DeepLabCut](https://ww
:purple_heart: We have a **real-time** package available! http://DLClive.deeplabcut.org
-- January 2024: Our original paper ['DeepLabCut: markerless pose estimation of user-defined body parts with deep learning'](https://www.nature.com/articles/s41593-018-0209-y) in Nature Neuroscience has surpassed 3,000 Google Scholar citations!
+
+- June 2024: Our second DLC paper ['Using DeepLabCut for 3D markerless pose estimation across species and behaviors'](https://www.nature.com/articles/s41596-019-0176-0) in Nature Protocols has surpassed 1,000 Google Scholar citations!
+- May 2024: DeepLabCut was featured in Nature: ['DeepLabCut: the motion-tracking tool that went viral'](https://www.nature.com/articles/d41586-024-01474-x)
+- January 2024: Our original paper ['DeepLabCut: markerless pose estimation of user-defined body parts with deep learning'](https://www.nature.com/articles/s41593-018-0209-y) in Nature Neuroscience has surpassed 3,000 Google Scholar citations!
- December 2023: DeepLabCut hit 600,000 downloads!
- October 2023: DeepLabCut celebrates a milestone with 4,000 🌟 in Github!
- July 2023: The user forum is very active with more than 1k questions and answers: [](https://forum.image.sc/tag/deeplabcut)
@@ -242,7 +236,7 @@ VERSION 1.0: The initial, Nature Neuroscience version of [DeepLabCut](https://ww
- Oct 2019: DLC 2.1 released with lots of updates. In particular, a Project Manager GUI, MobileNetsV2, and augmentation packages (Imgaug and Tensorpack). For detailed updates see [releases](https://github.com/DeepLabCut/DeepLabCut/releases)
- Sept 2019: We published two preprints. One showing that [ImageNet pretraining contributes to robustness](https://arxiv.org/abs/1909.11229) and a [review on animal pose estimation](https://arxiv.org/abs/1909.13868). Check them out!
- Jun 2019: DLC 2.0.7 released with lots of updates. For updates see [releases](https://github.com/DeepLabCut/DeepLabCut/releases)
-- Feb 2019: DeepLabCut joined [twitter](https://twitter.com/deeplabcut) [](https://twitter.com/DeepLabCut)
+- Feb 2019: DeepLabCut joined [twitter](https://x.com/deeplabcut) [](https://x.com/DeepLabCut)
- Jan 2019: We hosted workshops for DLC in Warsaw, Munich and Cambridge. The materials are available [here](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials)
- Jan 2019: We joined the Image Source Forum for user help: [](https://forum.image.sc/tag/deeplabcut)
@@ -256,3 +250,7 @@ importing a project into the new data format for DLC 2.0
- August 2018: NVIDIA AI Developer News: [AI Enables Markerless Animal Tracking](https://news.developer.nvidia.com/ai-enables-markerless-animal-tracking/)
- July 2018: Ed Yong covered DeepLabCut and interviewed several users for the [Atlantic](https://www.theatlantic.com/science/archive/2018/07/deeplabcut-tracking-animal-movements/564338).
- April 2018: first DeepLabCut preprint on [arXiv.org](https://arxiv.org/abs/1804.03142)
+
+ ## Funding
+
+ We are grateful for the follow support over the years! This software project was supported in part by the Essential Open Source Software for Science (EOSS) program at Chan Zuckerberg Initiative (cycles 1, 3, 3-DEI, 4), and jointly with the Kavli Foundation for EOSS Cycle 6! We also thank the Rowland Institute at Harvard for funding from 2017-2020, and EPFL from 2020-present.
diff --git a/_config.yml b/_config.yml
index c55ef8cebb..e1112a19ae 100644
--- a/_config.yml
+++ b/_config.yml
@@ -5,8 +5,15 @@ only_build_toc_files: true
sphinx:
config:
- autodoc_mock_imports: ["wx"]
+ autodoc_mock_imports: ["wx", "matplotlib", "qtpy", "PySide6", "napari", "shiboken6"]
mermaid_output_format: raw
+ html_static_path: ["docs/_static"]
+ html_css_files: ["custom.css"]
+ exclude_patterns:
+ - ".venv/**"
+ - "venv/**"
+ - "**/site-packages/**"
+ - "**/_build/**"
extra_extensions:
- numpydoc
- sphinxcontrib.mermaid
diff --git a/_toc.yml b/_toc.yml
index 5280e84bb4..6bb51801ff 100644
--- a/_toc.yml
+++ b/_toc.yml
@@ -1,54 +1,126 @@
format: jb-book
root: README
+
parts:
- caption: Getting Started
chapters:
- file: docs/UseOverviewGuide
+ - file: docs/course
+
- caption: Installation
chapters:
- file: docs/installation
- file: docs/recipes/installTips
- file: docs/docker
-- caption: User Guides
+
+- caption: Main User Guides
chapters:
- file: docs/standardDeepLabCut_UserGuide
- file: docs/maDLC_UserGuide
- file: docs/Overviewof3D
- file: docs/HelperFunctions
+
- caption: Graphical User Interfaces (GUIs)
chapters:
- - file: docs/PROJECT_GUI
- - file: docs/napari_GUI
- - file: docs/recipes/ClusteringNapari
-- caption: DeepLabCut-Live!
+ - file: docs/gui/PROJECT_GUI
+ - file: docs/gui/napari_GUI
+ sections:
+ - file: docs/gui/napari/basic_usage
+ - file: docs/gui/napari/advanced_usage
+
+- caption: DLC3 PyTorch Specific Docs
chapters:
- - file: docs/deeplabcutlive
-- caption: DeepLabCut Model Zoo
+ - file: docs/pytorch/user_guide.md
+ - file: docs/pytorch/pytorch_config.md
+ - file: docs/pytorch/architectures.md
+
+- caption: Quick Start Tutorials
chapters:
- - file: docs/ModelZoo
- - file: docs/recipes/UsingModelZooPupil
- - file: docs/recipes/MegaDetectorDLCLive
-- caption: DeepLabCut Benchmark
+ - file: docs/quick-start/single_animal_quick_guide
+ - file: docs/quick-start/tutorial_maDLC
+
+- caption: "🚀 Beginner's Guide to DeepLabCut"
chapters:
- - file: docs/benchmark
-- caption: Hardware
+ - file: docs/beginner-guides/beginners-guide
+ - file: docs/beginner-guides/manage-project
+ - file: docs/beginner-guides/labeling
+ - file: docs/beginner-guides/Training-Evaluation
+ - file: docs/beginner-guides/video-analysis
+
+- caption: "🚀 Main Demo Notebooks"
chapters:
- - file: docs/recipes/TechHardware
-- caption: Tutorials & Cookbook
+ - file: examples/COLAB/COLAB_DEMO_SuperAnimal
+ - file: examples/COLAB/COLAB_DEMO_mouse_openfield
+ - file: examples/COLAB/COLAB_3miceDemo
+ - file: examples/COLAB/COLAB_HumanPose_with_RTMPose
+
+- caption: "🚀 Notebooks For Your Data"
+ chapters:
+ - file: examples/COLAB/COLAB_YOURDATA_SuperAnimal
+ - file: examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis
+ - file: examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis
+
+- caption: "🚀 Special Feature Demos"
+ chapters:
+ - file: examples/COLAB/COLAB_transformer_reID
+ - file: examples/COLAB/COLAB_BUCTD_and_CTD_tracking
+ - file: examples/JUPYTER/Demo_3D_DeepLabCut
+ - file: examples/COLAB/COLAB_DLC_ModelZoo
+
+- caption: "🧑🍳 Cookbook (detailed helper guides)"
chapters:
- - file: docs/tutorial
- file: docs/convert_maDLC
+ - file: docs/recipes/OtherData
- file: docs/recipes/io
- file: docs/recipes/nn
- file: docs/recipes/post
- file: docs/recipes/BatchProcessing
- file: docs/recipes/DLCMethods
+ - file: docs/recipes/ClusteringNapari
- file: docs/recipes/OpenVINO
- file: docs/recipes/flip_and_rotate
- file: docs/recipes/pose_cfg_file_breakdown
- file: docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook
-- caption: Mission & Contribute
+
+- caption: Hardware Tips
+ chapters:
+ - file: docs/recipes/TechHardware
+
+- caption: DeepLabCut-Live!
+ chapters:
+ - file: docs/dlc-live/deeplabcutlive
+ - file: docs/dlc-live/dlc-live-gui/index
+ sections:
+ - file: docs/dlc-live/dlc-live-gui/quickstart/install
+ - file: docs/dlc-live/dlc-live-gui/user_guide/overview
+ - file: docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support
+ sections:
+ - file: docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend
+ - file: docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend
+ - file: docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend
+ - file: docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend
+ - file: docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing
+ sections:
+ - file: docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads
+ - file: docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format
+
+- caption: "🦄 DeepLabCut Model Zoo"
+ chapters:
+ - file: docs/ModelZoo
+ - file: docs/recipes/UsingModelZooPupil
+
+- caption: DeepLabCut Benchmarking
+ chapters:
+ - file: docs/benchmark
+ - file: docs/pytorch/Benchmarking_shuffle_guide
+
+- caption: "Mission & Contribute"
chapters:
- file: docs/MISSION_AND_VALUES
- file: docs/roadmap
- file: docs/Governance
+ - file: CONTRIBUTING
+
+- caption: Citations for DeepLabCut
+ chapters:
+ - file: docs/citation
diff --git a/changelog/3_0_0/images/buctd_benchmarks.png b/changelog/3_0_0/images/buctd_benchmarks.png
new file mode 100644
index 0000000000..127b1d4f1b
Binary files /dev/null and b/changelog/3_0_0/images/buctd_benchmarks.png differ
diff --git a/changelog/3_0_0/images/openfield_benchmark_pr2613.png b/changelog/3_0_0/images/openfield_benchmark_pr2613.png
new file mode 100644
index 0000000000..0ebedddb4c
Binary files /dev/null and b/changelog/3_0_0/images/openfield_benchmark_pr2613.png differ
diff --git a/changelog/3_0_0/images/speed_tensorflow.avif b/changelog/3_0_0/images/speed_tensorflow.avif
new file mode 100644
index 0000000000..43dcf74443
Binary files /dev/null and b/changelog/3_0_0/images/speed_tensorflow.avif differ
diff --git a/changelog/3_0_0/v3_0_0.md b/changelog/3_0_0/v3_0_0.md
new file mode 100644
index 0000000000..994a65958b
--- /dev/null
+++ b/changelog/3_0_0/v3_0_0.md
@@ -0,0 +1,158 @@
+# DeepLabCut 3.0: familiar workflows, modern foundations, better performance
+
+DeepLabCut 3.0 introduces a PyTorch-first training and inference stack while keeping the core DeepLabCut workflow familiar.
+Projects still follow the same labeling, training, evaluation, and video-analysis pipeline used throughout the 2.x series, but the underlying engine has been substantially modernized.
+
+For users who have already been following the release candidates, many of these changes will already feel familiar.
+DeepLabCut 3.0 consolidates these incremental changes into a stable release.
+
+## Increased model performance and speed
+
+
+
+
+
+
+Pose estimation performance of the 3.0 PyTorch models compared against previous TensorFlow models on the DeepLabCut Openfield dataset (see PR #2613 ); RMSE: root mean squared error. *Values from Mathis et al. 2018 .
+
+
+
+
+
+
+
+
+
+Speed of the current PyTorch implementation (ResNet50) compared to the TensorFlow implementation. Results were obtained using a NVIDIA GeForce RTX 2080 Ti with CUDA 12.2 on the DeepLabCut Trimice dataset.
+
+
+
+
+
+
+
+
+Comparison of the new BUCTD model architectures with DLCRNet and DEKR on the Marmoset, Fish and Trimice dataset. From Zhou et al., ICCV 2023 .
+
+
+
+## The journey to 3.0
+
+A quick recap of some of the major milestones leading to this release:
+
+- #2613 - Full initial PyTorch backend implementation
+- #2952 - New bottom-up conditional top-down (BUCTD) model architecture
+- #2795 - New RTMPose top-down architecture
+- #2868 - Updated notebooks and Colab examples for PyTorch workflows
+- #2804 - PyTorch model export
+
+And more, find the full PR reference [on GitHub](https://github.com/DeepLabCut/DeepLabCut/pulls?page=1&q=is%3Apr+label%3ADLC3.0%F0%9F%94%A5)!
+
+## Notable features in 3.0.0
+
+### PyTorch-first, TensorFlow-compatible
+
+DeepLabCut 3.0 adds a new PyTorch backend while retaining TensorFlow support for legacy workflows.
+Project management remains the same and labeled datasets remain compatible.
+PyTorch models can be trained alongside previous TensorFlow models on the same train/test splits for direct benchmarking and comparison.
+
+
+### Expanded architecture support
+
+DeepLabCut 3.0 significantly broadens the supported model ecosystem beyond the classic ResNet-based workflows. The PyTorch stack includes:
+
+- ResNet and HRNet backbones
+- Bottom-up multi-animal approaches such as DEKR and PAF/DLCRNet variants
+- Top-down detector-plus-pose pipelines including RTMPose
+- Hybrid architectures such as BUCTD and CTD variants
+- SuperAnimal-related pretrained workflows
+
+The documentation now includes dedicated [architecture guides](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/architectures.html) to help users choose models based on scene complexity and experimental needs.
+
+### Flexible PyTorch training configuration
+
+The PyTorch engine introduces a modern training stack with expanded augmentation options, training schedules, device management, and model architectures. For each training run, the settings are stored in a `pytorch_config.yaml`, enabling easy reproducibility.
+
+### Improved interoperability
+
+The new PyTorch data pipeline introduces loaders for both standard DeepLabCut projects and COCO-style datasets, making it easier to integrate DeepLabCut with broader computer-vision workflows and external annotation formats.
+
+### Model Zoo and SuperAnimal workflows
+
+DeepLabCut 3.0 continues to expand the Model Zoo and SuperAnimal ecosystem, making pretrained models and transfer learning more accessible.
+Colab notebooks and updated GUI tooling make it easier to experiment with modern architectures without extensive setup. (see the [documentation](https://deeplabcut.github.io/DeepLabCut/README.html))
+
+### Modernized installation and packaging
+
+The project has been moved to a newer packaging system, and is now based around pyproject.toml. This enables the use of modern package-managers & dependency resolvers, such as `uv` or `pdm`.
+Users can still install only the components they require, be it GUI support, TensorFlow compatibility, ModelZoo features, and optional experimental integrations.
+
+### Labeling GUI
+
+DeepLabCut 3.0 is shipped with a new release of the napari-deeplabcut plugin. Our napari-based labeling GUI has undergone a major internal re-write and modernization: while preserving familiar UI and the DeepLabCut workflow, the update substantially improves stability, data handling, usability, visualization, and annotation workflows, now with automated point tracking for faster labeling. See the [release notes](https://github.com/DeepLabCut/napari-deeplabcut/releases) to find out about all improvements.
+
+### Upcoming: refreshed documentation
+
+We have updated and streamlined the documentation, with a focus on clarity and up-to-date information in core areas (installation, getting started guides, and more).
+Expect the documentation to continue evolving soon after the release!
+
+## A major transition
+
+The jump from the final DeepLabCut 2.x releases to the current codebase is best understood as a transition to more recent Python & deep learning ecosystems rather than a routine update.
+Taken together, the PyTorch backend, broader architecture support, ModelZoo integration, packaging modernization, updated labeling GUI, and documentation improvements represent a major evolution of DeepLabCut, which we are happy to release as 3.0.
+
+
+## Closing thoughts
+
+We hope you enjoy this new version, and we aim to keep sharing many exciting improvements in the future in all areas, be it performance and speed, codebase quality improvements, foundation models integration, user experience and documentation.
+
+---
+## Changelog since 3.0.0rc14
+
+- Add up-to-date uv.lock (#3242)
+- Remove unnecessary imports (#3224)
+- Add custom styling options for docs (custom.css) (#3207)
+- Add internal helper for batched modelzoo inference from in-memory arrays (inference runner) (#3222)
+- Implement intelligent test selection in CI (#3046)
+- Revamp CONTRIBUTING.md (#3241)
+- Update FMPose3D modelzoo integration (#3221)
+- Add automated docs & notebooks freshness + normalization checks (#3228)
+- Install from PyPI pre-release; add both-backends (#3238)
+- Apply linting to entire codebase & add CI workflow to check linting (#3216)
+- Bump requests from 2.32.5 to 2.33.0 (#3259)
+- Refactor Analyze Videos tab (#3268)
+- Consolidate test workflow infrastructure in CI (#3254)
+- Move protobuf requirement to pyproject.toml (#3235)
+- Use pinned ffmpeg version in CI (#3276)
+- Docs versioning: Add glob support, better validation and reporting (#3278)
+- Bump cryptography from 46.0.5 to 46.0.7 (#3277)
+- Fix failing local Windows tests due to ruamel parsing (#3275)
+- Update & de-duplicate skeleton builder (#3258)
+- Bump pygments from 2.19.2 to 2.20.0 (#3262)
+- update conda yaml: install pyside6 via conda instead of pip (#3253)
+- Bump pyasn1 from 0.6.2 to 0.6.3 (#3249)
+- Fix SuperAnimal / pretrained load for RTMPose: implement convert_weights on RTMCCHead (#3270)
+- Use async update check in GUI (#3234)
+- Update napari-DLC docs for refactor (#3280)
+- Refactor/predict multianimal (#3220)
+- Fix incorrect MultiLevel construction in outlier_frames.compute_deviations (#3247)
+- Bump pillow from 12.1.1 to 12.2.0 (#3283)
+- Bump pytest from 9.0.2 to 9.0.3 (#3284)
+- CircleCI: disable hugginface xet (#3316)
+- Update and diversify TensorFlow optional installations. (#3292)
+- Bump urllib3 from 2.6.3 to 2.7.0 (#3325)
+- Bump gitpython from 3.1.47 to 3.1.50 (#3322)
+- make GenerativeSampler visibility-aware (#3305)
+- Add isatty method to StreamWriter + eval GUI fix (#3314)
+- Add conditional replacement of `@torch.inference_mode` for inference on AMD DirectML GPUs (#3295)
+- Robustness fix: Annotation file not dropping likelihood column if present from machine labels (#3323)
+- Remove trailing comma in models_to_framework.json (#3330)
+- update `list_videos_in_folder` (#3303)
+- Improve `TrainingDatasetMetadata` and `get_shuffle_engine` for incomplete projects (#3313)
+- update RTMPose `SimCCPredictor`: expose `apply_softmax` and fix visibility thresholding (#3306)
+- GUI: Add "Generate debug log" action (#3328)
+- [Docker 1] Simplify and modernize Dockerfile (#3290)
+- [Docker 2] Update deeplabcut-docker package (#3291)
+- Add additional drop_likelihood_columns guards (#3333)
+- Resolve inconsistent parameter names via aliasing + deprecationwarning (#3332)
+- bump dlclibrary (v0.0.12) and napari-deeplabcut (v3.1.0) (#3338)
diff --git a/conda-environments/DEEPLABCUT.yaml b/conda-environments/DEEPLABCUT.yaml
index d89b1c5be6..55da8128b3 100644
--- a/conda-environments/DEEPLABCUT.yaml
+++ b/conda-environments/DEEPLABCUT.yaml
@@ -1,17 +1,21 @@
# DEEPLABCUT.yaml
-#DeepLabCut2.0 Toolbox (deeplabcut.org)
+#DeepLabCut Toolbox (deeplabcut.org)
#© A. & M.W. Mathis Labs
#https://github.com/DeepLabCut/DeepLabCut
#Please see AUTHORS for contributors.
-#https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#Licensed under GNU Lesser General Public License v3.0
#
# DeepLabCut environment
-# FIRST: INSTALL CORRECT DRIVER for GPU, see https://stackoverflow.com/questions/30820513/what-is-the-correct-version-of-cuda-for-my-nvidia-driver/30820690
#
-# AFTER THIS FILE IS INSTALLED, if you have a GPU be sure to install `conda forge cudnn`
+# FIRST: If you have an NVIDIA GPU and want to use it, check that you have drivers installed!
+# To check if your GPUs are visible to PyTorch (and thus DeepLabCut), run:
+# >>> python -c "import torch; print(torch.cuda.is_available())"
+#
+# If "False" is printed, PyTorch (and thus DeepLabCut) cannot access your GPU. For
+# more information, see: https://pytorch.org/get-started/locally/
#
# install: conda env create -f DEEPLABCUT.yaml
# update: conda env update -f DEEPLABCUT.yaml
@@ -20,12 +24,14 @@ channels:
- conda-forge
- defaults
dependencies:
- - python=3.9
+ - python=3.10
- pip
- ipython
- jupyter
- - nb_conda
- - notebook<7.0.0
- ffmpeg
+ - pyside6
- pip:
- - "deeplabcut[gui,tf]"
+ - torch
+ - torchvision
+ - --pre
+ - deeplabcut[gui,modelzoo,wandb]
diff --git a/conda-environments/DEEPLABCUT_M1.yaml b/conda-environments/DEEPLABCUT_M1.yaml
deleted file mode 100644
index e5a8bab83d..0000000000
--- a/conda-environments/DEEPLABCUT_M1.yaml
+++ /dev/null
@@ -1,43 +0,0 @@
-# DEEPLABCUT_M1.yaml
-
-#DeepLabCut2.0 Toolbox (deeplabcut.org)
-#© A. & M.W. Mathis Labs
-#https://github.com/DeepLabCut/DeepLabCut
-#Please see AUTHORS for contributors.
-
-#https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-#Licensed under GNU Lesser General Public License v3.0
-#
-# DeepLabCut M1/M2 (Apple Silicon) environment instructions
-#
-# We'll get the miniconda M1 bash installer, as explained in
-# https://docs.conda.io/projects/conda/en/latest/user-guide/install/macos.html
-#
-# In the Terminal, run the following commands:
-# wget https://repo.anaconda.com/miniconda/Miniconda3-py39_4.12.0-MacOSX-arm64.sh -O ~/miniconda.sh
-# bash ~/miniconda.sh -b -p $HOME/miniconda
-# source ~/miniconda/bin/activate
-# conda init zsh
-#
-# Then, `git clone DeepLabCut`, and run:
-#
-# conda env create -f conda-environments/DEEPLABCUT_M1.yaml
-#
-# Next, activate the environment, and launch DLC with pythonw -m deeplabcut
-
-name: DEEPLABCUT_M1
-channels:
- - conda-forge
- - defaults
-dependencies:
- - python=3.9
- - pip
- - ipython
- - jupyter
- - nb_conda
- - notebook<7.0.0
- - python.app
- - ffmpeg
- - apple::tensorflow-deps
- - pip:
- - "deeplabcut[gui,apple_mchips]"
diff --git a/conda-environments/README.md b/conda-environments/README.md
index 9a182095a6..e7ff0fee0f 100644
--- a/conda-environments/README.md
+++ b/conda-environments/README.md
@@ -1 +1 @@
-### Please head over to [Installation](/docs/installation.md) to see how to utilize our supplied conda envs!
+# Please head over to [Installation](/docs/installation.md) to see how to utilize our supplied conda envs!
diff --git a/deeplabcut/__init__.py b/deeplabcut/__init__.py
index 2da4b6a9f5..df55aec39b 100644
--- a/deeplabcut/__init__.py
+++ b/deeplabcut/__init__.py
@@ -9,119 +9,269 @@
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+import logging
import os
+from importlib import import_module
+from typing import Any
-# Suppress tensorflow warning messages
-import tensorflow as tf
-
-tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.ERROR)
-DEBUG = True and "DEBUG" in os.environ and os.environ["DEBUG"]
-from deeplabcut.version import __version__, VERSION
-
-print(f"Loading DLC {VERSION}...")
-
-try:
- from deeplabcut.gui.tracklet_toolbox import refine_tracklets
- from deeplabcut.gui.launch_script import launch_dlc
- from deeplabcut.gui.tabs.label_frames import (
- label_frames,
- refine_labels,
- )
- from deeplabcut.gui.widgets import SkeletonBuilder
-except (ModuleNotFoundError, ImportError):
- print(
- "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)"
- )
-
-from deeplabcut.create_project import (
+logger = logging.getLogger(__name__)
+
+# DEBUG="", "0", "false", "no" -> False
+DEBUG = os.environ.get("DEBUG", "").strip().lower() not in {"", "0", "false", "no"}
+
+from .version import VERSION, __version__
+
+if DEBUG:
+ logger.debug("Loading DLC %s", VERSION)
+
+# -----------------------------------------------------------------------------
+# Always-available public API
+# -----------------------------------------------------------------------------
+
+# Train / evaluate / predict functions (compat layer)
+from .compat import (
+ analyze_images,
+ analyze_time_lapse_frames,
+ analyze_videos,
+ convert_detections2tracklets,
+ create_tracking_dataset,
+ evaluate_network,
+ export_model,
+ extract_maps,
+ extract_save_all_maps,
+ return_evaluate_network_data,
+ return_train_network_path,
+ train_network,
+ visualize_locrefs,
+ visualize_paf,
+ visualize_scoremaps,
+)
+from .core.engine import Engine
+from .create_project import (
+ add_new_videos,
create_new_project,
create_new_project_3d,
- add_new_videos,
- load_demo_data,
- create_pretrained_project,
create_pretrained_human_project,
+ create_pretrained_project,
+ load_demo_data,
)
-from deeplabcut.generate_training_dataset import (
+from .generate_training_dataset import (
+ adddatasetstovideolistandviceversa,
check_labels,
+ comparevideolistsanddatafolders,
+ create_multianimaltraining_dataset,
create_training_dataset,
- extract_frames,
- mergeandsplit,
-)
-from deeplabcut.generate_training_dataset import (
+ create_training_dataset_from_existing_split,
create_training_model_comparison,
- create_multianimaltraining_dataset,
-)
-from deeplabcut.generate_training_dataset import (
dropannotationfileentriesduetodeletedimages,
- comparevideolistsanddatafolders,
- dropimagesduetolackofannotation,
- adddatasetstovideolistandviceversa,
dropduplicatesinannotatinfiles,
+ dropimagesduetolackofannotation,
dropunlabeledframes,
+ extract_frames,
+ mergeandsplit,
)
-from deeplabcut.utils import (
- create_labeled_video,
- create_video_with_all_detections,
- plot_trajectories,
- auxiliaryfunctions,
- convert2_maDLC,
- convertcsv2h5,
+from .modelzoo.video_inference import video_inference_superanimal
+from .pose_estimation_3d import (
+ calibrate_cameras,
+ check_undistortion,
+ create_labeled_video_3d,
+ triangulate,
+)
+from .post_processing import analyzeskeleton, filterpredictions
+from .refine_training_dataset import (
+ extract_outlier_frames,
+ find_outliers_in_raw_data,
+ merge_datasets,
+)
+from .refine_training_dataset.stitch import stitch_tracklets
+from .utils import (
analyze_videos_converth5_to_csv,
analyze_videos_converth5_to_nwb,
auxfun_videos,
+ auxiliaryfunctions,
+ convert2_maDLC,
+ convertcsv2h5,
+ create_labeled_video,
+ create_video_with_all_detections,
+ plot_trajectories,
)
-
-try:
- from deeplabcut.pose_tracking_pytorch import transformer_reID
-except ModuleNotFoundError as e:
- import warnings
-
- warnings.warn(
- """
- As PyTorch is not installed, unsupervised identity learning will not be available.
- Please run `pip install torch`, or ignore this warning.
- """
- )
-
-from deeplabcut.utils.auxfun_videos import (
- ShortenVideo,
- DownSampleVideo,
+from .utils.auxfun_videos import (
CropVideo,
+ DownSampleVideo,
+ ShortenVideo,
check_video_integrity,
+ collect_video_paths,
)
-# Train, evaluate & predict functions / all require TF
-from deeplabcut.pose_estimation_tensorflow import (
- train_network,
- return_train_network_path,
- evaluate_network,
- return_evaluate_network_data,
- analyze_videos,
- create_tracking_dataset,
- analyze_time_lapse_frames,
- convert_detections2tracklets,
- extract_maps,
- visualize_scoremaps,
- visualize_locrefs,
- visualize_paf,
- extract_save_all_maps,
- export_model,
- video_inference_superanimal,
-)
+# -----------------------------------------------------------------------------
+# Optional / lazy public API
+# -----------------------------------------------------------------------------
+# These names are part of the public API, but importing them may require
+# optional GUI or torch dependencies, so we lazy load them.
+#
+# Example:
+# import deeplabcut as dlc
+# dlc.launch_dlc() # imports GUI code lazily
+# dlc.transformer_reID(...) # imports torch-dependent code lazily
+# -----------------------------------------------------------------------------
+_OPTIONAL_EXPORTS: dict[str, tuple[str, str]] = {
+ # GUI
+ "launch_dlc": (".gui.launch_script", "launch_dlc"),
+ "label_frames": (".gui.tabs.label_frames", "label_frames"),
+ "refine_labels": (".gui.tabs.label_frames", "refine_labels"),
+ "refine_tracklets": (".gui.tracklet_toolbox", "refine_tracklets"),
+ "SkeletonBuilder": (".gui.widgets", "SkeletonBuilder"),
+ # Optional torch feature
+ "transformer_reID": (".pose_tracking_pytorch", "transformer_reID"),
+}
-from deeplabcut.pose_estimation_3d import (
- calibrate_cameras,
- check_undistortion,
- triangulate,
- create_labeled_video_3d,
-)
-from deeplabcut.refine_training_dataset.stitch import stitch_tracklets
-from deeplabcut.refine_training_dataset import (
- extract_outlier_frames,
- merge_datasets,
- find_outliers_in_raw_data,
+def __getattr__(name: str) -> Any:
+ """Lazily load optional public exports."""
+ if name not in _OPTIONAL_EXPORTS:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+ module_name, attr_name = _OPTIONAL_EXPORTS[name]
+
+ try:
+ module = import_module(module_name, package=__name__)
+ value = getattr(module, attr_name)
+ except (ModuleNotFoundError, ImportError) as exc:
+ if name in {
+ "launch_dlc",
+ "label_frames",
+ "refine_labels",
+ "refine_tracklets",
+ "SkeletonBuilder",
+ }:
+ raise AttributeError(
+ f"{name!r} is unavailable because DeepLabCut was loaded without GUI dependencies."
+ ) from exc
+
+ if name == "transformer_reID":
+ raise AttributeError(
+ f"{name!r} is unavailable because the PyTorch-based tracking dependencies are not installed."
+ ) from exc
+
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
+
+ # Cache the resolved object so future access is fast
+ globals()[name] = value
+ return value
+
+
+def __dir__() -> list[str]:
+ """Improve IDE / autocomplete discoverability."""
+ return sorted(set(globals()) | set(__all__))
+
+
+# -----------------------------------------------------------------------------
+# Public API
+# -----------------------------------------------------------------------------
+
+_VERSION_EXPORTS = [
+ "__version__",
+ "VERSION",
+ "DEBUG",
+]
+
+_CORE_EXPORTS = [
+ "Engine",
+]
+
+_PROJECT_EXPORTS = [
+ "add_new_videos",
+ "create_new_project",
+ "create_new_project_3d",
+ "create_pretrained_human_project",
+ "create_pretrained_project",
+ "load_demo_data",
+]
+
+_DATASET_EXPORTS = [
+ "adddatasetstovideolistandviceversa",
+ "check_labels",
+ "comparevideolistsanddatafolders",
+ "create_multianimaltraining_dataset",
+ "create_training_dataset",
+ "create_training_dataset_from_existing_split",
+ "create_training_model_comparison",
+ "dropannotationfileentriesduetodeletedimages",
+ "dropduplicatesinannotatinfiles",
+ "dropimagesduetolackofannotation",
+ "dropunlabeledframes",
+ "extract_frames",
+ "mergeandsplit",
+]
+
+_COMPAT_EXPORTS = [
+ "analyze_images",
+ "analyze_time_lapse_frames",
+ "analyze_videos",
+ "convert_detections2tracklets",
+ "create_tracking_dataset",
+ "evaluate_network",
+ "export_model",
+ "extract_maps",
+ "extract_save_all_maps",
+ "return_evaluate_network_data",
+ "return_train_network_path",
+ "train_network",
+ "visualize_locrefs",
+ "visualize_paf",
+ "visualize_scoremaps",
+]
+
+_UTIL_EXPORTS = [
+ "analyze_videos_converth5_to_csv",
+ "analyze_videos_converth5_to_nwb",
+ "auxfun_videos",
+ "auxiliaryfunctions",
+ "convert2_maDLC",
+ "convertcsv2h5",
+ "create_labeled_video",
+ "create_video_with_all_detections",
+ "plot_trajectories",
+ "CropVideo",
+ "DownSampleVideo",
+ "ShortenVideo",
+ "check_video_integrity",
+]
+
+_POST_PROCESSING_EXPORTS = [
+ "analyzeskeleton",
+ "filterpredictions",
+ "extract_outlier_frames",
+ "find_outliers_in_raw_data",
+ "merge_datasets",
+ "stitch_tracklets",
+]
+
+_THREE_D_EXPORTS = [
+ "calibrate_cameras",
+ "check_undistortion",
+ "create_labeled_video_3d",
+ "triangulate",
+]
+
+_MODELZOO_EXPORTS = [
+ "video_inference_superanimal",
+]
+
+_OPTIONAL_API_EXPORTS = list(_OPTIONAL_EXPORTS)
+
+__all__ = (
+ _VERSION_EXPORTS
+ + _CORE_EXPORTS
+ + _PROJECT_EXPORTS
+ + _DATASET_EXPORTS
+ + _COMPAT_EXPORTS
+ + _UTIL_EXPORTS
+ + _POST_PROCESSING_EXPORTS
+ + _THREE_D_EXPORTS
+ + _MODELZOO_EXPORTS
+ + _OPTIONAL_API_EXPORTS
)
-from deeplabcut.post_processing import filterpredictions, analyzeskeleton
diff --git a/deeplabcut/__main__.py b/deeplabcut/__main__.py
index 93b3f44b64..ea7e3ca1c6 100644
--- a/deeplabcut/__main__.py
+++ b/deeplabcut/__main__.py
@@ -8,21 +8,29 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from importlib import import_module
-try:
- import PySide6
- lite = False
-except ModuleNotFoundError:
- lite = True
+def main():
+ try:
+ import_module("PySide6")
-# if module is executed directly (i.e. `python -m deeplabcut.__init__`) launch straight into the GUI
-if not lite:
- print("Starting GUI...")
- from deeplabcut.gui.launch_script import launch_dlc
+ lite = False
+ except ModuleNotFoundError:
+ lite = True
- launch_dlc()
-else:
- print(
- "You installed DLC lite, thus GUI's cannot be used. If you need GUI support please: pip install 'deeplabcut[gui]''"
- )
+ # if module is executed directly (i.e. `python -m deeplabcut.__init__`) launch straight into the GUI
+ if not lite:
+ print("Starting GUI...")
+ from deeplabcut.gui.launch_script import launch_dlc
+
+ launch_dlc()
+ else:
+ print(
+ "You installed DLC lite, thus GUI's cannot be used. If you need GUI support please: pip install"
+ "'deeplabcut[gui]''"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deeplabcut/benchmark/__init__.py b/deeplabcut/benchmark/__init__.py
index 2e70eae786..e663705b8e 100644
--- a/deeplabcut/benchmark/__init__.py
+++ b/deeplabcut/benchmark/__init__.py
@@ -12,7 +12,7 @@
import json
import os
-from typing import Container
+from collections.abc import Container
from typing import Literal
from deeplabcut.benchmark.base import Benchmark, Result, ResultCollection
@@ -38,9 +38,7 @@ class needs to be a subclass of the ``benchmark.base.Benchmark``
not a subclass of ``benchmark.base.Benchmark``.
"""
if not issubclass(cls, Benchmark):
- raise ValueError(
- f"Can only register subclasses of {type(Benchmark)}, " f"but got {cls}."
- )
+ raise ValueError(f"Can only register subclasses of {type(Benchmark)}, but got {cls}.")
__registry.append(cls)
@@ -85,7 +83,14 @@ def evaluate(
continue
benchmark = benchmark_cls()
for name in benchmark.names():
- if Result(method_name=name, benchmark_name=benchmark_cls.name) in results:
+ if (
+ Result(
+ code=benchmark.code,
+ method_name=name,
+ benchmark_name=benchmark_cls.name,
+ )
+ in results
+ ):
continue
else:
result = benchmark.evaluate(name, on_error=on_error)
@@ -102,14 +107,12 @@ def savecache(results: ResultCollection):
json.dump(results.todicts(), fh, indent=2)
-def loadcache(
- cache=CACHE, on_missing: Literal["raise", "ignore"] = "ignore"
-) -> ResultCollection:
+def loadcache(cache=CACHE, on_missing: Literal["raise", "ignore"] = "ignore") -> ResultCollection:
if not os.path.exists(cache):
if on_missing == "raise":
raise FileNotFoundError(cache)
return ResultCollection()
- with open(cache, "r") as fh:
+ with open(cache) as fh:
try:
data = json.load(fh)
except json.decoder.JSONDecodeError as e:
diff --git a/deeplabcut/benchmark/base.py b/deeplabcut/benchmark/base.py
index b9465cf07d..c19448ed9d 100644
--- a/deeplabcut/benchmark/base.py
+++ b/deeplabcut/benchmark/base.py
@@ -9,7 +9,7 @@
# Licensed under GNU Lesser General Public License v3.0
#
-"""Base classes for benchmark and result definition
+"""Base classes for benchmark and result definition.
Benchmarks subclass the abstract ``Benchmark`` class and are defined by ``name``, their
``keypoints`` names, as well as groundtruth and metadata necessary to run evaluation.
@@ -23,8 +23,8 @@
import abc
import dataclasses
-from typing import Iterable
-from typing import Tuple
+import warnings
+from collections.abc import Iterable
import pandas as pd
@@ -46,9 +46,9 @@ class Benchmark(abc.ABC):
def names(self):
"""A unique key to describe this submission, e.g. the model name.
- This is also the name that will later appear in the benchmark table.
- The name needs to be unique across the whole benchmark. Non-unique names
- will raise an error during submission of a PR.
+ This is also the name that will later appear in the benchmark table. The name
+ needs to be unique across the whole benchmark. Non-unique names will raise an
+ error during submission of a PR.
"""
raise NotImplementedError()
@@ -58,13 +58,10 @@ def get_predictions(self):
raise NotImplementedError()
def __init__(self):
- keys = ["name", "keypoints", "ground_truth", "metadata"]
+ keys = ["code", "name", "keypoints", "ground_truth", "metadata"]
for key in keys:
if not hasattr(self, key):
- raise NotImplementedError(
- f"Subclass of abstract Benchmark class need "
- f"to define the {key} property."
- )
+ raise NotImplementedError(f"Subclass of abstract Benchmark class need to define the {key} property.")
def compute_pose_rmse(self, results_objects):
return deeplabcut.benchmark.metrics.calc_rmse_from_obj(
@@ -80,15 +77,14 @@ def evaluate(self, name: str, on_error="raise"):
"""Evaluate this benchmark with all registered methods."""
if name not in self.names():
- raise ValueError(
- f"{name} is not registered. Valid names are {self.names()}"
- )
+ raise ValueError(f"{name} is not registered. Valid names are {self.names()}")
if on_error not in ("ignore", "return", "raise"):
raise ValueError(f"on_error got an undefined value: {on_error}")
mean_avg_precision = float("nan")
root_mean_squared_error = float("nan")
try:
predictions = self.get_predictions(name)
+ predictions = self._validate_predictions(name, predictions)
mean_avg_precision = self.compute_pose_map(predictions)
root_mean_squared_error = self.compute_pose_rmse(predictions)
except Exception as exception:
@@ -102,23 +98,41 @@ def evaluate(self, name: str, on_error="raise"):
pass
elif on_error == "raise":
# raise the error and stop evaluation
- raise BenchmarkEvaluationError(
- f"Error during benchmark evaluation for model {name}"
- ) from exception
+ raise BenchmarkEvaluationError(f"Error during benchmark evaluation for model {name}") from exception
else:
raise NotImplementedError() from exception
return Result(
+ code=self.code,
method_name=name,
benchmark_name=self.name,
mean_avg_precision=mean_avg_precision,
root_mean_squared_error=root_mean_squared_error,
)
+ def _validate_predictions(self, name: str, predictions: dict) -> dict:
+ """Validates the submitted predictions object Checks that there is a prediction
+ for each test image, and raises a warning if that is not the case.
+
+ Returns only predictions made for test images.
+ """
+ test_images = deeplabcut.benchmark.metrics.load_test_images(self.ground_truth, self.metadata)
+ missing_images = set(test_images) - set(predictions.keys())
+ if len(missing_images) > 0:
+ warnings.warn(
+ f"Missing {len(missing_images)} test images in the predictions for "
+ f"{name}: {list(missing_images)} Metrics will be computed as if no "
+ "individuals were detected in those images.",
+ stacklevel=2,
+ )
+
+ return {img: predictions.get(img, tuple()) for img in test_images}
+
@dataclasses.dataclass
class Result:
"""Benchmark result."""
+ code: str
method_name: str
benchmark_name: str
root_mean_squared_error: float = float("nan")
@@ -126,6 +140,7 @@ class Result:
benchmark_version: str = __version__
_export_mapping = dict(
+ code="code",
benchmark_name="benchmark",
method_name="method",
benchmark_version="version",
@@ -136,13 +151,13 @@ class Result:
_primary_key = ("benchmark_name", "method_name", "benchmark_version")
@property
- def primary_key(self) -> Tuple[str]:
+ def primary_key(self) -> tuple[str]:
"""The primary key to uniquely identify this result."""
return tuple(getattr(self, k) for k in self._primary_key)
@property
- def primary_key_names(self) -> Tuple[str]:
- """Names of the primary keys"""
+ def primary_key_names(self) -> tuple[str]:
+ """Names of the primary keys."""
return tuple(self._export_mapping.get(k) for k in self._primary_key)
def __str__(self):
@@ -172,10 +187,10 @@ def primary_key_names(self):
return next(iter(self.results.values())).primary_key_names
def toframe(self) -> pd.DataFrame:
- """Convert results to pandas dataframe"""
- return pd.DataFrame(
- [result.todict() for result in self.results.values()]
- ).set_index(list(self.primary_key_names))
+ """Convert results to pandas dataframe."""
+ return pd.DataFrame([result.todict() for result in self.results.values()]).set_index(
+ list(self.primary_key_names)
+ )
def add(self, result: Result):
"""Add a result to the collection."""
@@ -202,10 +217,7 @@ def __len__(self):
def __contains__(self, other: Result):
if not isinstance(other, Result):
- raise ValueError(
- f"{type(self)} can only store objects of type Result, "
- f"but got {type(other)}."
- )
+ raise ValueError(f"{type(self)} can only store objects of type Result, but got {type(other)}.")
return other.primary_key in self.results
def __eq__(self, other):
diff --git a/deeplabcut/benchmark/benchmarks.py b/deeplabcut/benchmark/benchmarks.py
index ee18e215c6..0701714a4a 100644
--- a/deeplabcut/benchmark/benchmarks.py
+++ b/deeplabcut/benchmark/benchmarks.py
@@ -24,9 +24,19 @@
class TriMouseBenchmark(deeplabcut.benchmark.base.Benchmark):
"""Datasets with three mice with a top-view camera.
- Three wild-type (C57BL/6J) male mice ran on a paper spool following odor trails (Mathis et al 2018). These experiments were carried out in the laboratory of Venkatesh N. Murthy at Harvard University. Data were recorded at 30 Hz with 640 x 480 pixels resolution acquired with a Point Grey Firefly FMVU-03MTM-CS. One human annotator was instructed to localize the 12 keypoints (snout, left ear, right ear, shoulder, four spine points, tail base and three tail points). All surgical and experimental procedures for mice were in accordance with the National Institutes of Health Guide for the Care and Use of Laboratory Animals and approved by the Harvard Institutional Animal Care and Use Committee. 161 frames were labeled, making this a real-world sized laboratory dataset.
-
- Introduced in Lauer et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
+ Three wild-type (C57BL/6J) male mice ran on a paper spool following odor trails
+ (Mathis et al 2018). These experiments were carried out in the laboratory of
+ Venkatesh N. Murthy at Harvard University. Data were recorded at 30 Hz with 640 x
+ 480 pixels resolution acquired with a Point Grey Firefly FMVU-03MTM-CS. One human
+ annotator was instructed to localize the 12 keypoints (snout, left ear, right ear,
+ shoulder, four spine points, tail base and three tail points). All surgical and
+ experimental procedures for mice were in accordance with the National Institutes of
+ Health Guide for the Care and Use of Laboratory Animals and approved by the Harvard
+ Institutional Animal Care and Use Committee. 161 frames were labeled, making this a
+ real-world sized laboratory dataset.
+
+ Introduced in Lauer et al. "Multi-animal pose estimation, identification and
+ tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
"""
name = "trimouse"
@@ -45,18 +55,34 @@ class TriMouseBenchmark(deeplabcut.benchmark.base.Benchmark):
"tailend",
)
ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Daniel.h5")
- metadata = deeplabcut.benchmark.get_filepath(
- "Documentation_data-MultiMouse_70shuffle1.pickle"
- )
+ metadata = deeplabcut.benchmark.get_filepath("Documentation_data-MultiMouse_70shuffle1.pickle")
num_animals = 3
class ParentingMouseBenchmark(deeplabcut.benchmark.base.Benchmark):
"""Datasets with three mice, one parenting, two pups.
- Parenting behavior is a pup directed behavior observed in adult mice involving complex motor actions directed towards the benefit of the offspring. These experiments were carried out in the laboratory of Catherine Dulac at Harvard University. The behavioral assay was performed in the homecage of singly housed adult female mice in dark/red light conditions. For these videos, the adult mice was monitored for several minutes in the cage followed by the introduction of pup (4 days old) in one corner of the cage. The behavior of the adult and pup was monitored for a duration of 15 minutes. Video was recorded at 30Hz using a Microsoft LifeCam camera (Part#: 6CH-00001) with a resolution of 1280 x 720 pixels or a Geovision camera (model no.: GV-BX4700-3V) also acquired at 30 frames per second at a resolution of 704 x 480 pixels. A human annotator labeled on the adult animal the same 12 body points as in the tri-mouse dataset, and five body points on the pup along its spine. Initially only the two ends were labeled, and intermediate points were added by interpolation and their positions was manually adjusted if necessary. All surgical and experimental procedures for mice were in accordance with the National Institutes of Health Guide for the Care and Use of Laboratory Animals and approved by the Harvard Institutional Animal Care and Use Committee. 542 frames were labeled, making this a real-world sized laboratory dataset.
-
- Introduced in Lauer et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
+ Parenting behavior is a pup directed behavior observed in adult mice involving
+ complex motor actions directed towards the benefit of the offspring. These
+ experiments were carried out in the laboratory of Catherine Dulac at Harvard
+ University. The behavioral assay was performed in the homecage of singly housed
+ adult female mice in dark/red light conditions. For these videos, the adult mice was
+ monitored for several minutes in the cage followed by the introduction of pup (4
+ days old) in one corner of the cage. The behavior of the adult and pup was monitored
+ for a duration of 15 minutes. Video was recorded at 30Hz using a Microsoft LifeCam
+ camera (Part#: 6CH-00001) with a resolution of 1280 x 720 pixels or a Geovision
+ camera (model no.: GV-BX4700-3V) also acquired at 30 frames per second at a
+ resolution of 704 x 480 pixels. A human annotator labeled on the adult animal the
+ same 12 body points as in the tri-mouse dataset, and five body points on the pup
+ along its spine. Initially only the two ends were labeled, and intermediate points
+ were added by interpolation and their positions was manually adjusted if necessary.
+ All surgical and experimental procedures for mice were in accordance with the
+ National Institutes of Health Guide for the Care and Use of Laboratory Animals and
+ approved by the Harvard Institutional Animal Care and Use Committee. 542 frames were
+ labeled, making this a real-world sized laboratory dataset.
+
+ Introduced in Lauer et al. "Multi-animal pose estimation, identification and
+ tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
"""
name = "parenting"
@@ -81,9 +107,7 @@ class ParentingMouseBenchmark(deeplabcut.benchmark.base.Benchmark):
)
ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Mostafizur.h5")
- metadata = deeplabcut.benchmark.get_filepath(
- "Documentation_data-CrackingParenting_70shuffle1.pickle"
- )
+ metadata = deeplabcut.benchmark.get_filepath("Documentation_data-CrackingParenting_70shuffle1.pickle")
num_animals = 2
def compute_pose_map(self, results_objects):
@@ -96,13 +120,32 @@ def compute_pose_map(self, results_objects):
symmetric_kpts=[(0, 4), (1, 3)],
)
+ def _validate_predictions(self, name: str, predictions: dict) -> dict:
+ """Fixes filenames for predictions made on old versions of the dataset."""
+ return super()._validate_predictions(
+ name,
+ {k.replace("Dummy", "D").replace("Dead pup", "DP"): v for k, v in predictions.items()},
+ )
+
class MarmosetBenchmark(deeplabcut.benchmark.base.Benchmark):
"""Dataset with two marmosets.
- All animal procedures are overseen by veterinary staff of the MIT and Broad Institute Department of Comparative Medicine, in compliance with the NIH guide for the care and use of laboratory animals and approved by the MIT and Broad Institute animal care and use committees. Video of common marmosets (Callithrix jacchus) was collected in the laboratory of Guoping Feng at MIT. Marmosets were recorded using Kinect V2 cameras (Microsoft) with a resolution of 1080p and frame rate of 30 Hz. After acquisition, images to be used for training the network were manually cropped to 1000 x 1000 pixels or smaller. The dataset is 7,600 labeled frames from 40 different marmosets collected from 3 different colonies (in different facilities). Each cage contains a pair of marmosets, where one marmoset had light blue dye applied to its tufts. One human annotator labeled the 15 marker points on each animal present in the frame (frames contained either 1 or 2 animals).
-
- Introduced in Lauer et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
+ All animal procedures are overseen by veterinary staff of the MIT and Broad
+ Institute Department of Comparative Medicine, in compliance with the NIH guide for
+ the care and use of laboratory animals and approved by the MIT and Broad Institute
+ animal care and use committees. Video of common marmosets (Callithrix jacchus) was
+ collected in the laboratory of Guoping Feng at MIT. Marmosets were recorded using
+ Kinect V2 cameras (Microsoft) with a resolution of 1080p and frame rate of 30 Hz.
+ After acquisition, images to be used for training the network were manually cropped
+ to 1000 x 1000 pixels or smaller. The dataset is 7,600 labeled frames from 40
+ different marmosets collected from 3 different colonies (in different facilities).
+ Each cage contains a pair of marmosets, where one marmoset had light blue dye
+ applied to its tufts. One human annotator labeled the 15 marker points on each
+ animal present in the frame (frames contained either 1 or 2 animals).
+
+ Introduced in Lauer et al. "Multi-animal pose estimation, identification and
+ tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
"""
name = "marmosets"
@@ -124,26 +167,34 @@ class MarmosetBenchmark(deeplabcut.benchmark.base.Benchmark):
"Body3",
)
ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Mackenzie.h5")
- metadata = deeplabcut.benchmark.get_filepath(
- "Documentation_data-Marmoset_70shuffle1.pickle"
- )
+ metadata = deeplabcut.benchmark.get_filepath("Documentation_data-Marmoset_70shuffle1.pickle")
num_animals = 2
class FishBenchmark(deeplabcut.benchmark.base.Benchmark):
- """Dataset with multiple fish, filmed from top-view
-
- Schools of inland silversides (Menidia beryllina, n=14 individuals per school) were recorded in the Lauder Lab at Harvard University while swimming at 15 speeds (0.5 to 8 BL/s, body length, at 0.5 BL/s intervals) in a flow tank with a total working section of 28 x 28 x 40 cm as described in previous work, at a constant temperature (18±1°C) and salinity (33 ppt), at a Reynolds number of approximately 10,000 (based on BL). Dorsal views of steady swimming across these speeds were recorded by high-speed video cameras (FASTCAM Mini AX50, Photron USA, San Diego, CA, USA) at 60-125 frames per second (feeding videos at 60 fps, swimming alone 125 fps). The dorsal view was recorded above the swim tunnel and a floating Plexiglas panel at the water surface prevented surface ripples from interfering with dorsal view videos. Five keypoints were labeled (tip, gill, peduncle, dorsal fin tip, caudal tip). 100 frames were labeled, making this a real-world sized laboratory dataset.
-
- Introduced in Lauer et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
+ """Dataset with multiple fish, filmed from top-view.
+
+ Schools of inland silversides (Menidia beryllina, n=14 individuals per school) were
+ recorded in the Lauder Lab at Harvard University while swimming at 15 speeds (0.5 to
+ 8 BL/s, body length, at 0.5 BL/s intervals) in a flow tank with a total working
+ section of 28 x 28 x 40 cm as described in previous work, at a constant temperature
+ (18±1°C) and salinity (33 ppt), at a Reynolds number of approximately 10,000 (based
+ on BL). Dorsal views of steady swimming across these speeds were recorded by high-
+ speed video cameras (FASTCAM Mini AX50, Photron USA, San Diego, CA, USA) at 60-125
+ frames per second (feeding videos at 60 fps, swimming alone 125 fps). The dorsal
+ view was recorded above the swim tunnel and a floating Plexiglas panel at the water
+ surface prevented surface ripples from interfering with dorsal view videos. Five
+ keypoints were labeled (tip, gill, peduncle, dorsal fin tip, caudal tip). 100 frames
+ were labeled, making this a real-world sized laboratory dataset.
+
+ Introduced in Lauer et al. "Multi-animal pose estimation, identification and
+ tracking with DeepLabCut." Nature Methods 19, no. 4 (2022): 496-504.
"""
name = "fish"
keypoints = ("tip", "gill", "peduncle", "caudaltip", "dfintip")
ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Valentina.h5")
- metadata = deeplabcut.benchmark.get_filepath(
- "Documentation_data-Schooling_70shuffle1.pickle"
- )
+ metadata = deeplabcut.benchmark.get_filepath("Documentation_data-Schooling_70shuffle1.pickle")
num_animals = 14
def compute_pose_rmse(self, results_objects):
diff --git a/deeplabcut/benchmark/metrics.py b/deeplabcut/benchmark/metrics.py
index a3b000ed3f..91f34a457b 100644
--- a/deeplabcut/benchmark/metrics.py
+++ b/deeplabcut/benchmark/metrics.py
@@ -11,15 +11,6 @@
"""Evaluation metrics for the DeepLabCut benchmark."""
-import sys
-import unittest.mock
-
-# TODO(stes) mocking a few modules to rely in fewer dependencies, without
-# causing import errors when using deeplabcut.
-MOCK_MODULES = ["statsmodels", "statsmodels.api", "pytables"]
-for mod_name in MOCK_MODULES:
- sys.modules[mod_name] = unittest.mock.MagicMock()
-
import os
import pickle
from collections import defaultdict
@@ -28,22 +19,17 @@
import pandas as pd
import deeplabcut.benchmark.utils
-from deeplabcut.pose_estimation_tensorflow.core import evaluate_multianimal
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils
+from deeplabcut.core import crossvalutils, inferenceutils
from deeplabcut.utils.conversioncode import guarantee_multiindex_rows
-def _format_gt_data(h5file):
+def _format_gt_data(h5file: str, test_indices: list[int] | None = None):
df = pd.read_hdf(h5file)
animals = _get_unique_level_values(df.columns, "individuals")
kpts = _get_unique_level_values(df.columns, "bodyparts")
try:
- n_unique = len(
- _get_unique_level_values(
- df.xs("single", level="individuals", axis=1).columns, "bodyparts"
- )
- )
+ n_unique = len(_get_unique_level_values(df.xs("single", level="individuals", axis=1).columns, "bodyparts"))
except KeyError:
n_unique = 0
guarantee_multiindex_rows(df)
@@ -54,9 +40,13 @@ def _format_gt_data(h5file):
.reindex(kpts, level="bodyparts", axis=1)
)
data = temp.to_numpy().reshape((len(file_paths), len(animals), -1, 2))
+ if test_indices is not None:
+ file_paths = [file_paths[i] for i in test_indices]
+ data = [data[i] for i in test_indices]
+
meta = {"animals": animals, "keypoints": kpts, "n_unique": n_unique}
return {
- "annotations": dict(zip(file_paths, data)),
+ "annotations": dict(zip(file_paths, data, strict=False)),
"metadata": meta,
}
@@ -94,9 +84,7 @@ def calc_prediction_errors(preds, gt):
if visible.size and xy_pred_.size:
# Pick the predictions closest to ground truth,
# rather than the ones the model has most confident in.
- neighbors = evaluate_multianimal._find_closest_neighbors(
- xy_gt_[visible], xy_pred_, k=3
- )
+ neighbors = crossvalutils.find_closest_neighbors(xy_gt_[visible], xy_pred_, k=3)
found = neighbors != -1
if ~np.any(found):
continue
@@ -111,10 +99,8 @@ def calc_prediction_errors(preds, gt):
def _map(strings, substrings):
- """
- Map image paths from predicted data to GT as the first are typically
- absolute whereas the latter are relative to the project path.
- """
+ """Map image paths from predicted data to GT as the first are typically absolute
+ whereas the latter are relative to the project path."""
lookup = dict()
strings_ = strings.copy()
@@ -167,16 +153,22 @@ def calc_map_from_obj(
pass
n_animals = len(df.columns.get_level_values("individuals").unique())
kpts = list(df.columns.get_level_values("bodyparts").unique())
- image_paths = list(eval_results_obj)
- ground_truth = (
- df.loc[image_paths].to_numpy().reshape((len(image_paths), n_animals, -1, 2))
- )
+
+ test_indices = _load_test_indices(metadata_file)
+ df_test = df.iloc[test_indices]
+ test_images = load_test_images(h5_file, metadata_file)
+ missing_images = set(test_images) - set(eval_results_obj.keys())
+ if len(missing_images) > 0:
+ raise ValueError(
+ f"Failed to compute the test mAP: there are test images missing from theprediction object: {missing_images}"
+ )
+
+ ground_truth = df_test.to_numpy().reshape((len(test_images), n_animals, -1, 2))
temp = np.ones((*ground_truth.shape[:3], 3))
temp[..., :2] = ground_truth
- assemblies_gt = inferenceutils._parse_ground_truth_data(temp)
- with open(metadata_file, "rb") as f:
- inds_test = set(pickle.load(f)[2])
- assemblies_gt_test = {k: v for k, v in assemblies_gt.items() if k in inds_test}
+ assemblies_gt_test = {
+ test_images[i]: assembly for i, assembly in inferenceutils._parse_ground_truth_data(temp).items()
+ }
# TODO(stes): remove/rewrite
if drop_kpts is not None:
@@ -192,9 +184,7 @@ def calc_map_from_obj(
for ind in sorted(drop_kpts, reverse=True):
kpts.pop(ind)
- assemblies_pred_ = conv_obj_to_assemblies(eval_results_obj, kpts)
- assemblies_pred = dict(enumerate(assemblies_pred_.values()))
-
+ assemblies_pred = conv_obj_to_assemblies(eval_results_obj, kpts)
with deeplabcut.benchmark.utils.DisableOutput():
oks = inferenceutils.evaluate_assembly(
assemblies_pred,
@@ -202,6 +192,7 @@ def calc_map_from_obj(
oks_sigma,
margin=margin,
symmetric_kpts=symmetric_kpts,
+ greedy_matching=True,
)
return oks["mAP"]
@@ -213,18 +204,24 @@ def calc_rmse_from_obj(
drop_kpts=None,
):
"""Calc prediction errors for submissions."""
- gt = _format_gt_data(h5_file)
+ test_indices = _load_test_indices(metadata_file)
+ gt = _format_gt_data(h5_file, test_indices=test_indices)
kpts = gt["metadata"]["keypoints"]
if drop_kpts:
for k, v in gt["annotations"].items():
gt["annotations"][k] = np.delete(v, drop_kpts, axis=1)
for ind in sorted(drop_kpts, reverse=True):
kpts.pop(ind)
- with open(metadata_file, "rb") as f:
- inds_test = set(pickle.load(f)[2])
- test_objects = {
- k: v for i, (k, v) in enumerate(eval_results_obj.items()) if i in inds_test
- }
+
+ test_objects = {k: v for k, v in eval_results_obj.items() if k in gt["annotations"].keys()}
+ if len(gt["annotations"]) != len(test_objects):
+ gt_images = list(gt["annotations"].keys())
+ missing_images = [img for img in gt_images if img not in test_objects]
+ raise ValueError(
+ "Failed to compute the test RMSE: there are test images missing from the"
+ f"prediction object: {missing_images}"
+ )
+
assemblies_pred = conv_obj_to_assemblies(test_objects, kpts)
preds = defaultdict(dict)
preds["metadata"]["keypoints"] = kpts
@@ -240,3 +237,24 @@ def calc_rmse_from_obj(
with deeplabcut.benchmark.utils.DisableOutput():
errors = calc_prediction_errors(preds, gt)
return np.nanmean(errors[..., 0])
+
+
+def load_test_images(h5file: str, metadata: str) -> list[str]:
+ """Returns the names of the test images for the benchmark, in the order
+ corresponding to the test indices."""
+ df = pd.read_hdf(h5file)
+ test_indices = _load_test_indices(metadata)
+ df_test = df.iloc[test_indices]
+ test_images = []
+ for img_path in df_test.index:
+ if not isinstance(img_path, str):
+ img_path = os.path.join(*img_path)
+ test_images.append(img_path)
+ return test_images
+
+
+def _load_test_indices(shuffle_metadata_path: str) -> list[int]:
+ """Returns the indices of test images in the training dataset dataframe."""
+ with open(shuffle_metadata_path, "rb") as f:
+ test_indices = set([int(i) for i in pickle.load(f)[2]])
+ return list(sorted(test_indices))
diff --git a/deeplabcut/benchmark/mot.py b/deeplabcut/benchmark/mot.py
new file mode 100644
index 0000000000..4c0f8e6e29
--- /dev/null
+++ b/deeplabcut/benchmark/mot.py
@@ -0,0 +1,219 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from __future__ import annotations
+
+import warnings
+
+import motmetrics as mm
+import numpy as np
+import pandas as pd
+from numpy.typing import NDArray
+
+from deeplabcut.core import trackingutils
+
+
+def convert_bboxes_to_xywh(bboxes: NDArray, inplace: bool = False) -> NDArray:
+ """Converts bounding box coordinates from [x_min, y_min, x_max, y_max] format to [x,
+ y, width, height] format.
+
+ Parameters
+ ----------
+ bbox : numpy.ndarray
+ A 2D array of shape (N, M), where N is the number of bounding boxes
+ and M >= 4. The first four columns represent the bounding box in the format
+ [x_min, y_min, x_max, y_max].
+ inplace : bool, optional
+ If True, modifies the input array in place. If False, returns a copy of
+ the array with the converted bounding box format. Defaults to False.
+
+ Returns
+ -------
+ numpy.ndarray or None
+ If `inplace` is False, returns a new array of the same shape as `bbox`
+ with the format [x, y, width, height]. If `inplace` is True, the input
+ array is modified directly, and nothing is returned.
+ """
+ w = bboxes[:, 2] - bboxes[:, 0]
+ h = bboxes[:, 3] - bboxes[:, 1]
+ if not inplace:
+ new_bboxes = bboxes.copy()
+ new_bboxes[:, 2] = w
+ new_bboxes[:, 3] = h
+ return new_bboxes
+ bboxes[:, 2] = w
+ bboxes[:, 3] = h
+
+
+_convert_bboxes_to_xywh = convert_bboxes_to_xywh
+
+
+def reconstruct_bboxes_from_bodyparts(data: pd.DataFrame, margin: float, to_xywh: bool = False) -> NDArray:
+ """Reconstructs bounding boxes from body part coordinates and likelihoods.
+
+ Parameters
+ ----------
+ data : pandas.DataFrame
+ A DataFrame containing body part data with a multi-level column index.
+ The expected levels include 'x', 'y', and 'likelihood', where:
+ - 'x' and 'y' contain the coordinates of the body parts.
+ - 'likelihood' contains the confidence scores for each body part.
+ margin : float
+ The margin to add/subtract from the minimum/maximum coordinates when defining the bounding box.
+ to_xywh : bool, optional
+ If True, converts the bounding box format from [x_min, y_min, x_max, y_max]
+ to [x, y, width, height]. Defaults to False.
+
+ Returns
+ -------
+ numpy.ndarray
+ An array of shape (N, 5), where N is the number of rows in `data`.
+ Each row represents a bounding box with the following values:
+ - [x_min, y_min, x_max, y_max, likelihood]
+ If `to_xywh` is True, the format will be [x, y, width, height, likelihood].
+
+ Notes
+ -----
+ - NaN values in the input data are ignored when computing the bounding box dimensions.
+ - Warnings related to NaN values are suppressed during calculations.
+ """
+ x = data.xs("x", axis=1, level="coords")
+ y = data.xs("y", axis=1, level="coords")
+ p = data.xs("likelihood", axis=1, level="coords")
+ xy = np.stack([x, y], axis=2)
+ bboxes = np.full((data.shape[0], 5), np.nan)
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", category=RuntimeWarning)
+ bboxes[:, :2] = np.nanmin(xy, axis=1) - margin
+ bboxes[:, 2:4] = np.nanmax(xy, axis=1) + margin
+ bboxes[:, 4] = np.nanmean(p, axis=1)
+ if to_xywh:
+ convert_bboxes_to_xywh(bboxes, inplace=True)
+ return bboxes
+
+
+def reconstruct_all_bboxes(data: pd.DataFrame, margin: float, to_xywh: bool = False) -> NDArray:
+ """Reconstructs bounding boxes for multiple individuals from body part data.
+
+ Parameters
+ ----------
+ data : pandas.DataFrame
+ A DataFrame containing body part data with a multi-level column index.
+ The expected levels include:
+ - 'individuals': Names of the individuals (e.g., animals).
+ - 'x', 'y', and 'likelihood': Coordinate and confidence data for body parts.
+ margin : float
+ The margin to add/subtract from the minimum/maximum coordinates when defining the bounding box.
+ to_xywh : bool
+ If True, converts the bounding box format from [x_min, y_min, x_max, y_max]
+ to [x, y, width, height].
+
+ Returns
+ -------
+ numpy.ndarray
+ A 3D array of shape (A, F, 5), where:
+ - A is the number of individuals (excluding 'single', if present).
+ - F is the number of frames (rows) in the input `data`.
+ - Each bounding box is represented as [x_min, y_min, x_max, y_max, likelihood].
+ If `to_xywh` is True, the format will be [x, y, width, height, likelihood].
+
+ Notes
+ -----
+ - Individuals are extracted from the 'individuals' level of the DataFrame columns.
+ - If an individual named 'single' exists, it is excluded from the bounding box computation.
+ - NaN values in the input data are ignored during calculations.
+ """
+ animals = data.columns.get_level_values("individuals").unique().tolist()
+ try:
+ animals.remove("single")
+ except ValueError:
+ pass
+ bboxes = np.full((len(animals), data.shape[0], 5), np.nan)
+ for n, animal in enumerate(animals):
+ bboxes[n] = reconstruct_bboxes_from_bodyparts(data.xs(animal, axis=1, level="individuals"), margin, to_xywh)
+ return bboxes
+
+
+def compute_mot_metrics(
+ h5_file_gt: str,
+ h5_file_pred: str,
+ tracker_type: str = "bbox",
+ **kwargs,
+) -> mm.MOTAccumulator:
+ df_gt = pd.read_hdf(h5_file_gt)
+ df = pd.read_hdf(h5_file_pred)
+ if tracker_type == "bbox":
+ func = reconstruct_all_bboxes
+ elif tracker_type == "ellipse":
+ func = trackingutils.reconstruct_all_ellipses
+ else:
+ raise ValueError(f"Unrecognized tracker type {tracker_type}.")
+
+ trackers_gt = func(df_gt, **kwargs)
+ trackers = func(df, **kwargs)
+ return _compute_mot_metrics(
+ trackers_gt,
+ trackers,
+ tracker_type,
+ )
+
+
+def _compute_mot_metrics(
+ trackers_ground_truth: NDArray,
+ trackers: NDArray,
+ tracker_type: str = "bbox",
+) -> mm.MOTAccumulator:
+ if trackers_ground_truth.shape != trackers.shape:
+ raise ValueError("Dimensions mismatch. There must be as many `trackers_ground_truth` as there are `trackers`.")
+
+ if tracker_type == "bbox":
+ sl = slice(0, 4)
+ cost_func = mm.distances.iou_matrix
+ elif tracker_type == "ellipse":
+ sl = slice(0, 5)
+
+ def cost_func(ellipses_gt, ellipses_hyp):
+ cost_matrix = np.zeros((len(ellipses_gt), len(ellipses_hyp)))
+ gt_el = [trackingutils.Ellipse(*e[:5]) for e in ellipses_gt]
+ hyp_el = [trackingutils.Ellipse(*e[:5]) for e in ellipses_hyp]
+ for i, el in enumerate(gt_el):
+ for j, tracker in enumerate(hyp_el):
+ cost_matrix[i, j] = 1 - el.calc_similarity_with(tracker)
+ return cost_matrix
+
+ else:
+ raise ValueError(f"Unrecognized tracker type {tracker_type}.")
+
+ ids = np.arange(trackers_ground_truth.shape[0])
+ acc = mm.MOTAccumulator(auto_id=True)
+ for i in range(trackers_ground_truth.shape[1]):
+ trackers_gt = trackers_ground_truth[:, i, sl]
+ trackers_hyp = trackers[:, i, sl]
+ empty_gt = np.isnan(trackers_gt).any(axis=1)
+ empty_hyp = np.isnan(trackers_hyp).any(axis=1)
+ trackers_gt = trackers_gt[~empty_gt]
+ trackers_hyp = trackers_hyp[~empty_hyp]
+ cost = cost_func(trackers_gt, trackers_hyp)
+ acc.update(ids[~empty_gt], ids[~empty_hyp], cost)
+ return acc
+
+
+def print_all_metrics(accumulators: list[mm.MOTAccumulator], all_params: list[str] | None = None):
+ if not all_params:
+ names = [f"iter{i + 1}" for i in range(len(accumulators))]
+ else:
+ s = "_".join("{}" for _ in range(len(all_params[0])))
+ names = [s.format(*params.values()) for params in all_params]
+ mh = mm.metrics.create()
+ summary = mh.compute_many(accumulators, metrics=mm.metrics.motchallenge_metrics, names=names)
+ strsummary = mm.io.render_summary(summary, formatters=mh.formatters, namemap=mm.io.motchallenge_metric_names)
+ print(strsummary)
+ return summary
diff --git a/deeplabcut/benchmark/utils.py b/deeplabcut/benchmark/utils.py
index bc1cd64d3c..9c93fcf579 100644
--- a/deeplabcut/benchmark/utils.py
+++ b/deeplabcut/benchmark/utils.py
@@ -9,16 +9,18 @@
# Licensed under GNU Lesser General Public License v3.0
#
-"""Helper functions in this file are not affected by the main repositories
-license. They are independent from the remainder of the benchmarking code.
+"""Helper functions in this file are not affected by the main repositories license.
+
+They are independent from the remainder of the benchmarking code.
"""
+
import importlib
import os
import pkgutil
import sys
-class RedirectStdStreams(object):
+class RedirectStdStreams:
"""Context manager for redirecting stdout and stderr
Reference:
https://stackoverflow.com/a/6796752
@@ -49,7 +51,7 @@ def __init__(self):
def import_submodules(package, recursive=True):
- """Import all submodules of a module, recursively, including subpackages
+ """Import all submodules of a module, recursively, including subpackages.
:param package: package (name or actual module)
:type package: str | module
@@ -62,7 +64,7 @@ def import_submodules(package, recursive=True):
if isinstance(package, str):
package = importlib.import_module(package)
results = {}
- for loader, name, is_pkg in pkgutil.walk_packages(package.__path__):
+ for _loader, name, is_pkg in pkgutil.walk_packages(package.__path__):
full_name = package.__name__ + "." + name
results[full_name] = importlib.import_module(full_name)
if recursive and is_pkg:
diff --git a/deeplabcut/cli.py b/deeplabcut/cli.py
index 5123080055..3ac5dc7de2 100644
--- a/deeplabcut/cli.py
+++ b/deeplabcut/cli.py
@@ -26,7 +26,7 @@ def main(ctx, verbose):
click.echo(main.get_help(ctx))
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("project")
@click.argument("experimenter")
@@ -49,7 +49,9 @@ def main(ctx, verbose):
# help='Directory to create project in. Default is cwd().')
@click.pass_context
def create_new_project(_, *args, **kwargs):
- """Create a new project directory, sub-directories and a basic configuration file. The configuration file is loaded with default values. Change its parameters to your projects need.\n
+ """Create a new project directory, sub-directories and a basic configuration file.
+ The configuration file is loaded with default values. Change its parameters to your
+ projects need.\n.
Options \n
---------- \n
@@ -60,28 +62,32 @@ def create_new_project(_, *args, **kwargs):
videos : list \n
\tA list of string containing the full paths of the videos to include in the project.\n
working_directory : string, optional \n
- \tThe directory where the project will be created. The default is the ``current working directory``; if provided, it must be a string\n
+ \tThe directory where the project will be created.
+ The default is the ``current working directory``; if provided, it must be a string\n
copy_videos : bool, optional \n
- If this is set to True, the symlink of the videos are copied to the project/videos directory. The default is ``True``; if provided it must be either ``True`` or ``False`` \n
+ If this is set to True, the symlink of the videos are copied to the project/videos directory.
+ The default is ``True``; if provided it must be either ``True`` or ``False`` \n
Example \n
-------- \n
To create the project in the current working directory \n
- python3 dlc.py create_new_project reaching-task Tanmay /data/videos/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi /analysis/project/
+ python3 dlc.py create_new_project reaching-task
+ Tanmay /data/videos/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi /analysis/project/
To create the project in the current working directory but do not want to create the symlinks \n
- python3 dlc.py create_new_project reaching-task Tanmay /data/videos/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi /analysis/project/ -c False
+ python3 dlc.py create_new_project reaching-task
+ Tanmay /data/videos/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi /analysis/project/ -c False
To create the project in another directory \n
- python3 dlc.py create_new_project reaching-task Tanmay /data/vies/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi analysis/project -d home/project
-
+ python3 dlc.py create_new_project reaching-task
+ Tanmay /data/vies/mouse1.avi /data/videos/mouse2.avi /data/videos/mouse3.avi analysis/project -d home/project
"""
from deeplabcut.create_project import new
new.create_new_project(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@@ -95,8 +101,7 @@ def create_new_project(_, *args, **kwargs):
)
@click.pass_context
def add_new_videos(_, *args, **kwargs):
- """
- Add new videos to the config file at any stage of the project.\n
+ """Add new videos to the config file at any stage of the project.\n.
Options\n
----------\n
@@ -113,14 +118,13 @@ def add_new_videos(_, *args, **kwargs):
Examples\n
--------\n
>>> python3 dlc.py add_new_videos /home/project/reaching-task-Tanmay-2018-08-23/config.yaml /data/videos/mouse5.avi
-
"""
from deeplabcut.create_project import add
add.add_new_videos(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.argument("mode")
@@ -139,9 +143,10 @@ def add_new_videos(_, *args, **kwargs):
)
@click.pass_context
def extract_frames(_, *args, **kwargs):
- """
- Extracts frames from the videos in the config.yaml file. Only the videos in the config.yaml will be used to select the frames.\n
- Use the function ``add_new_videos`` at any stage of the project to add new videos to the config file and extract their frames.\n
+ """Extracts frames from the videos in the config.yaml file. Only the videos in the
+ config.yaml will be used to select the frames.\n Use the function ``add_new_videos``
+ at any stage of the project to add new videos to the config file and extract their
+ frames.\n.
CONFIG : string \n
Full path of the config.yaml file as a string. \n \n \n
@@ -153,47 +158,54 @@ def extract_frames(_, *args, **kwargs):
for selecting frames automatically with 'kmeans' and do not want to crop the frames \n
>>> python3 dlc.py extract_frames /analysis/project/reaching-task/config.yaml automatic --algo kmeans \n
-------- \n
- for selecting frames automatically with 'uniform' and want to crop the frames based on the ``crop`` parameters in config.yaml \n
+ for selecting frames automatically with 'uniform' and want to
+ crop the frames based on the ``crop`` parameters in config.yaml \n
>>> python3 dlc.py extract_frames /analysis/project/reaching-task/config.yaml automatic --crop
-------- \n
for selecting frames manually, \n
>>> deeplabcut.extract_frames /analysis/project/reaching-task/config.yaml manual \n
- While selecting the frames manually, you do not need to specify the cropping parameters. Rather, you will get a prompt in the graphic user interface to choose if you need to crop or not. \n
+ While selecting the frames manually, you do not need to specify the cropping parameters.
+ Rather, you will get a prompt in the graphic user interface to choose if you need to crop or not. \n
-------- \n
-
"""
- from deeplabcut.generate_training_dataset import frameExtraction
+ from deeplabcut.generate_training_dataset.frame_extraction import extract_frames as _extract_frames
- frameExtraction.extract_frames(*args, **kwargs)
+ _extract_frames(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.pass_context
def label_frames(_, config):
- """Manually label/annotate the extracted frames. Update the list of body parts you want to localize in the config.yaml file first.\n
+ """Manually label/annotate the extracted frames. Update the list of body parts you
+ want to localize in the config.yaml file first.\n.
+
Example\n
--------\n
python3 dlc.py label_frames /analysis/project/reaching-task/config.yaml
"""
- from deeplabcut.generate_training_dataset import labelFrames
+ from deeplabcut.gui.tabs.label_frames import label_frames as _label_frames
- labelFrames.label_frames(config)
+ _label_frames(config)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.pass_context
def check_labels(_, config):
- """Check if labels were stored correctly by plotting annotations and inspect them visually. If some are wrong, then use the refine_labels to correct the labels.\n"""
- from deeplabcut.generate_training_dataset import labelFrames
+ """Check if labels were stored correctly by plotting annotations and inspect them
+ visually.
+
+ If some are wrong, then use the refine_labels to correct the labels.\n
+ """
+ from deeplabcut.generate_training_dataset.trainingsetmanipulation import check_labels as _check_labels
- labelFrames.check_labels(config)
+ _check_labels(config)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.option(
@@ -205,7 +217,8 @@ def check_labels(_, config):
)
@click.pass_context
def create_training_dataset(_, *args, **kwargs):
- """Combine frame and label information into a an array. Create training and test sets. Update parameters TrainFraction, iteration in config.yaml
+ """Combine frame and label information into a an array. Create training and test sets.
+ Update parameters TrainFraction, iteration in config.yaml
Also update parameters for pose_config.yaml as wanted.\n
CONFIG: Full path of the config.yaml file in the train directory of a project.\n
Example \n
@@ -216,12 +229,14 @@ def create_training_dataset(_, *args, **kwargs):
To create a training dataset with only 2 shuffles
python3 dlc.py create_training_dataset /analysis/project/reaching-task/config.yaml num_shuffles 2
"""
- from deeplabcut.generate_training_dataset import labelFrames
+ from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ create_training_dataset as _create_training_dataset,
+ )
- labelFrames.create_training_dataset(*args, **kwargs)
+ _create_training_dataset(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.option(
@@ -246,7 +261,7 @@ def train_network(_, *args, **kwargs):
training.train_network(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.option(
@@ -256,9 +271,7 @@ def train_network(_, *args, **kwargs):
default=[1],
help="Shuffle index of the training dataset. Default is set to 1.",
)
-@click.option(
- "-p", "--plot", "plotting", is_flag=True, help="Make plots. Default is False."
-)
+@click.option("-p", "--plot", "plotting", is_flag=True, help="Make plots. Default is False.")
@click.pass_context
def evaluate_network(_, config, **kwargs):
"""Evaluates a trained Feature detector model.\n
@@ -270,12 +283,12 @@ def evaluate_network(_, config, **kwargs):
python3 dlc.py evaluate_network /home/project/reaching/config.yaml
"""
- from deeplabcut.pose_estimation_tensorflow import evaluate
+ from deeplabcut.pose_estimation_tensorflow.core.evaluate import evaluate_network as _evaluate_network
- evaluate.evaluate_network(config, **kwargs)
+ _evaluate_network(config, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@@ -291,7 +304,7 @@ def evaluate_network(_, config, **kwargs):
@click.option(
"-vtype",
"--video_type",
- "videotype",
+ "video_extensions",
default=".avi",
help="The extension of video in case the input is a directory",
)
@@ -322,7 +335,7 @@ def analyze_videos(_, *args, **kwargs):
# predict.predict_video(config, video,**kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@@ -333,17 +346,22 @@ def analyze_videos(_, *args, **kwargs):
"--num_shuffles",
"shuffle",
default=1,
- help="The shuffle index of training dataset. The extracted frames will be stored in the labeled-dataset for the corresponding shuffle of training dataset. Default is set to 1",
+ help="The shuffle index of training dataset. The extracted frames will be stored in the "
+ "labeled-dataset for the corresponding shuffle of training dataset. Default is set to 1",
)
@click.option(
"-outlier",
"--outlier_algo",
"outlieralgorithm",
default="fitting",
- help="String specifying the algorithm used to detect the outliers. Currently, deeplabcut supports only sarimax (this will be updated). \
- This method fits a Seasonal AutoRegressive Integrated Moving Average with eXogenous regressors model to data and computes confidence interval. \
- Based on the fraction of data points outside the confidence interval and the average distance (compared to delta) \
- the user can identify potential outlier frames. The default is set to ``fitting``. Other choices: `fitting`, `jump`, `uncertain`",
+ help="String specifying the algorithm used to detect the outliers.\
+ Currently, deeplabcut supports only sarimax (this will be updated). \
+ This method fits a Seasonal AutoRegressive Integrated Moving Average with eXogenous regressors model \
+ to data and computes confidence interval. \
+ Based on the fraction of data points outside the confidence interval \
+ and the average distance (compared to delta) \
+ the user can identify potential outlier frames.\
+ The default is set to ``fitting``. Other choices: `fitting`, `jump`, `uncertain`",
)
@click.option(
"-compare",
@@ -352,7 +370,8 @@ def analyze_videos(_, *args, **kwargs):
default="all",
help="This select the body parts for which the comparisons with the outliers are carried out. Either ``all``, \
then all body parts from config.yaml are used orr a list of strings that are a subset of the full list.\
- E.g. [`hand`,`Joystick`] for the demo Reaching-Mackenzie-2018-08-30/config.yaml to select only these two body parts.",
+ E.g. [`hand`,`Joystick`]"
+ " for the demo Reaching-Mackenzie-2018-08-30/config.yaml to select only these two body parts.",
)
@click.option(
"-e",
@@ -360,15 +379,18 @@ def analyze_videos(_, *args, **kwargs):
"epsilon",
default=20,
help="Meaning depends on outlieralgoritm. The default is set to 20 pixels.For outlieralgorithm `fitting`: \
- Float bound according to which frames are picked when the (average) body part estimate deviates from model fit. \
- For outlieralgorithm `jump`: Float bound specifying the distance by which body points jump from one frame to next (Euclidean distance)",
+ Float bound according to which frames are picked when the (average)\
+ body part estimate deviates from model fit. \
+ For outlier algorithm `jump`:"
+ "Float bound specifying the distance by which body points jump from one frame to next (Euclidean distance)",
)
@click.option(
"-p",
"--p_bound",
"p_bound",
default=0.01,
- help="For outlieralgorithm `uncertain` this parameter defines the likelihood below, below which a body part will be flagged as a putative outlier.",
+ help="For outlieralgorithm `uncertain` this parameter defines the likelihood below, "
+ "below which a body part will be flagged as a putative outlier.",
)
@click.option(
"-ard",
@@ -383,7 +405,7 @@ def analyze_videos(_, *args, **kwargs):
"--ma_degree",
"MAdegree",
default=1,
- help="Int value. For outlieralgorithm `fitting`: MovingAvarage degree of Sarimax model degree.\
+ help="Int value. For outlieralgorithm `fitting`: Moving Average degree of Sarimax model degree.\
See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html",
)
@click.option(
@@ -398,15 +420,17 @@ def analyze_videos(_, *args, **kwargs):
"--extraction_algo",
"extractionalgorithm",
default="uniform",
- help="String specifying the algorithm to use for selecting the frames from the identified outliers. \
- Currently, deeplabcut supports either ``kmeans`` or ``uniform`` based selection (same logic as for extract_frames).\
- The default is set to``uniform``, if provided it must be either ``uniform`` or ``kmeans``.",
+ help="String specifying the algorithm to use for selecting the frames from the identified outliers.\
+ Currently, deeplabcut supports either ``kmeans`` or ``uniform``\
+ based selection (same logic as for extract_frames).\
+ The default is set to``uniform``,\
+ if provided it must be either ``uniform`` or ``kmeans``.",
)
@click.pass_context
def extract_outlier_frames(_, *args, **kwargs):
- """
- Extracts the outlier frames in case, the predictions are not correct for a certain video from the cropped video running from
- start to stop as defined in config.yaml.
+ """Extracts the outlier frames in case, the predictions are not correct for a
+ certain video from the cropped video running from start to stop as defined in
+ config.yaml.
Another crucial parameter in config.yaml is how many frames to extract 'numframes2extract'.
@@ -418,29 +442,32 @@ def extract_outlier_frames(_, *args, **kwargs):
Example \n
--------\n
for extracting the frames with default settings\n
- >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml /analysis/project/video/reachinvideo1.avi \n
+ >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml
+ ... /analysis/project/video/reachinvideo1.avi \n
--------\n
for extracting the frames with kmeans\n
- >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml /analysis/project/video/reachinvideo1.avi --extractionalgorithm 'kmeans' \n
+ >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml
+ ... /analysis/project/video/reachinvideo1.avi --extractionalgorithm 'kmeans' \n
--------\n
for extracting the frames with kmeans and epsilon = 5 pixels.\n
- >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml /analysis/project/video/reachinvideo1.avi --epsilon 5 --extractionalgorithm kmeans \n
+ >>> python3 dlc.py extract_outlier_frames /analysis/project/reaching-task/config.yaml
+ ... /analysis/project/video/reachinvideo1.avi --epsilon 5 --extractionalgorithm kmeans \n
--------\n
-
"""
from deeplabcut.refine_training_dataset import outlier_frames
outlier_frames.extract_outlier_frames(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.pass_context
def refine_labels(_, config):
- """Refines the labels of the outlier frames extracted from the analyzed videos.\n Helps in augmenting the training dataset.
- Use the function ``analyze_video`` to analyze a video and extracts the outlier frames using the function
- ``extract_outlier_frames`` before refining the labels.\n
+ """Refines the labels of the outlier frames extracted from the analyzed videos.\n
+ Helps in augmenting the training dataset. Use the function ``analyze_video`` to
+ analyze a video and extracts the outlier frames using the function
+ ``extract_outlier_frames`` before refining the labels.\n.
Examples \n
--------\n
@@ -452,7 +479,7 @@ def refine_labels(_, config):
outlier_frames.refine_labels(config)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.argument("videos", nargs=-1)
@@ -466,7 +493,7 @@ def refine_labels(_, config):
@click.option(
"-v",
"--video_type",
- "videotype",
+ "video_extensions",
default=".avi",
help="Checks for the extension of the video in case the input is a directory.\
Only videos with this extension are analyzed. The default is ``.avi``",
@@ -493,15 +520,16 @@ def refine_labels(_, config):
)
@click.pass_context
def create_labeled_video(_, *args, **kwargs):
- """
- Labels the bodyparts in a video. Make sure the video is already analyzed by the function 'analyze_video'
+ """Labels the bodyparts in a video.
+
+ Make sure the video is already analyzed by the function 'analyze_video'
"""
from deeplabcut.utils import make_labeled_video
make_labeled_video.create_labeled_video(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.argument("videos", nargs=-1)
@@ -515,7 +543,7 @@ def create_labeled_video(_, *args, **kwargs):
@click.option(
"-v",
"--video_type",
- "videotype",
+ "video_extensions",
default=".avi",
help="Checks for the extension of the video in case the input is a directory.\
Only videos with this extension are analyzed. The default is ``.avi``",
@@ -530,13 +558,13 @@ def create_labeled_video(_, *args, **kwargs):
)
@click.pass_context
def plot_trajectories(_, *args, **kwargs):
- """
- Plots the trajectories of various bodyparts across the video.\n
+ """Plots the trajectories of various bodyparts across the video.\n.
Example\n
--------\n
for labeling the frames\n
- >>> python3 dlc.py plot_trajectories /analysis/project/reaching-task/config.yaml /analysis/project/videos/reachingvideo1.avi \n
+ >>> python3 dlc.py plot_trajectories /analysis/project/reaching-task/config.yaml
+ /analysis/project/videos/reachingvideo1.avi \n
--------\n
"""
from deeplabcut.utils import plotting
@@ -544,7 +572,7 @@ def plot_trajectories(_, *args, **kwargs):
plotting.plot_trajectories(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("cfg-path", nargs=1, type=click.STRING)
@click.option(
@@ -606,9 +634,9 @@ def plot_trajectories(_, *args, **kwargs):
)
@click.pass_context
def export_model(_, *args, **kwargs):
- """
- Export DLC models for the model zoo or for live inference.\n
- Saves the pose configuration, snapshot files, and frozen graph of the model to a directory named exported-models within the project directory
+ """Export DLC models for the model zoo or for live inference.\n Saves the pose
+ configuration, snapshot files, and frozen graph of the model to a directory named
+ exported-models within the project directory.
Parameters
-----------
@@ -647,4 +675,4 @@ def export_model(_, *args, **kwargs):
export_model(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py
new file mode 100644
index 0000000000..cf511533bf
--- /dev/null
+++ b/deeplabcut/compat.py
@@ -0,0 +1,2006 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Compatibility file for methods available with either PyTorch or Tensorflow."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from pathlib import Path
+
+import numpy as np
+from ruamel.yaml import YAML
+
+import deeplabcut.core.visualization as visualization
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
+from deeplabcut.utils.deprecation import renamed_parameter
+
+DEFAULT_ENGINE = Engine.PYTORCH
+
+
+def get_project_engine(cfg: dict) -> Engine:
+ """
+ Args:
+ cfg: the project configuration file
+
+ Returns:
+ the engine specified for the project, or the default engine if none is specified
+ """
+ if cfg.get("engine") is not None:
+ return Engine(cfg["engine"])
+
+ return DEFAULT_ENGINE
+
+
+def get_available_aug_methods(engine: Engine) -> tuple[str, ...]:
+ """
+ Args:
+ engine: the engine for which augmentation methods should be returned
+
+ Returns:
+ the augmentations available for the given engine, where the first one is the
+ default method to use
+
+ Raises:
+ RuntimeError: if no augmentations methods are defined for the given engine
+ """
+ if engine == Engine.TF:
+ return "imgaug", "default", "deterministic", "scalecrop", "tensorpack"
+ elif engine == Engine.PYTORCH:
+ return ("albumentations",)
+
+ raise RuntimeError(f"Unknown augmentation for engine: {engine}")
+
+
+@renamed_parameter(old="maxiters", new="max_iters", since="3.0.0")
+@renamed_parameter(old="saveiters", new="save_iters", since="3.0.0")
+@renamed_parameter(old="displayiters", new="display_iters", since="3.0.0")
+def train_network(
+ config: str | Path,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ max_snapshots_to_keep: int | None = None,
+ display_iters: int | None = None,
+ save_iters: int | None = None,
+ max_iters: int | None = None,
+ epochs: int | None = None,
+ save_epochs: int | None = None,
+ allow_growth: bool = True,
+ gputouse: str | None = None,
+ autotune: bool = False,
+ keepdeconvweights: bool = True,
+ modelprefix: str = "",
+ superanimal_name: str = "",
+ superanimal_transfer_learning: bool = False,
+ engine: Engine | None = None,
+ device: str | None = None,
+ snapshot_path: str | Path | None = None,
+ detector_path: str | Path | None = None,
+ batch_size: int | None = None,
+ detector_batch_size: int | None = None,
+ detector_epochs: int | None = None,
+ detector_save_epochs: int | None = None,
+ pose_threshold: float | None = 0.1,
+ pytorch_cfg_updates: dict | None = None,
+):
+ """Trains the network with the labels in the training dataset.
+
+ Parameters
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ shuffle: int, optional, default=1
+ Integer value specifying the shuffle index to select for training.
+
+ trainingsetindex: int, optional, default=0
+ Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml.
+
+ max_snapshots_to_keep: int or None
+ Sets how many snapshots are kept, i.e. states of the trained network. Every
+ saving iteration many times a snapshot is stored, however only the last
+ ``max_snapshots_to_keep`` many are kept! If you change this to None, then all
+ are kept.
+ See: https://github.com/DeepLabCut/DeepLabCut/issues/8#issuecomment-387404835
+
+ display_iters: optional, default=None
+ This variable is actually set in ``pose_config.yaml``. However, you can
+ overwrite it with this hack. Don't use this regularly, just if you are too lazy
+ to dig out the ``pose_config.yaml`` file for the corresponding project. If
+ ``None``, the value from there is used, otherwise it is overwritten!
+
+ save_iters: optional, default=None
+ Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``:
+ you can use ``save_epochs``).
+ This variable is actually set in ``pose_config.yaml``. However, you can
+ overwrite it with this hack. Don't use this regularly, just if you are too lazy
+ to dig out the ``pose_config.yaml`` file for the corresponding project.
+ If ``None``, the value from there is used, otherwise it is overwritten!
+
+ max_iters: optional, default=None
+ Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``:
+ you can use ``epochs``).
+ This variable is actually set in ``pose_config.yaml``. However, you can
+ overwrite it with this hack. Don't use this regularly, just if you are too lazy
+ to dig out the ``pose_config.yaml`` file for the corresponding project.
+ If ``None``, the value from there is used, otherwise it is overwritten!
+
+ epochs: optional, default=None
+ Only for the PyTorch engine (equivalent to the `max_iters` parameter for the
+ TensorFlow engine). The maximum number of epochs to train the model for. If
+ None, the value will be read from the `pytorch_config.yaml` file. An epoch is a
+ single pass through the training dataset, which means your model has seen each
+ training image exactly once. So if you have 64 training images for your network,
+ an epoch is 64 iterations with batch size 1 (or 32 iterations with batch size 2,
+ 16 with batch size 4, etc.).
+
+ save_epochs: optional, default=None
+ Only for the PyTorch engine (equivalent to the `save_iters` parameter for the
+ TensorFlow engine). The number of epochs between each snapshot save. If
+ None, the value will be read from the `pytorch_config.yaml` file.
+
+ allow_growth: bool, optional, default=True.
+ Only for the TensorFlow engine.
+ For some smaller GPUs the memory issues happen. If ``True``, the memory
+ allocator does not pre-allocate the entire specified GPU memory region, instead
+ starting small and growing as needed.
+ See issue: https://forum.image.sc/t/how-to-stop-running-out-of-vram/30551/2
+
+ gputouse: optional, default=None
+ Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``:
+ you can use ``device``).
+ Natural number indicating the number of your GPU (see number in nvidia-smi).
+ If you do not have a GPU put None.
+ See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ autotune: bool, optional, default=False
+ Only for the TensorFlow engine.
+ Property of TensorFlow, somehow faster if ``False``
+ (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317).
+
+ keepdeconvweights: bool, optional, default=True
+ Also restores the weights of the deconvolution layers (and the backbone) when
+ training from a snapshot. Note that if you change the number of bodyparts, you
+ need to set this to false for re-training.
+
+ modelprefix: str, optional, default=""
+ Directory containing the deeplabcut models to use when evaluating the network.
+ By default, the models are assumed to exist in the project folder.
+
+ superanimal_name: str, optional, default =""
+ Only for the TensorFlow engine. For the PyTorch engine, you need to specify
+ this through the ``weight_init`` when creating the training dataset.
+ Specified if transfer learning with superanimal is desired
+
+ superanimal_transfer_learning: bool, optional, default = False.
+ Only for the TensorFlow engine. For the PyTorch engine, you need to specify
+ this through the ``weight_init`` when creating the training dataset.
+ If set true, the training is transfer learning (new decoding layer). If set
+ false, and superanimal_name is True, then the training is fine-tuning (reusing
+ the decoding layer)
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ device: str, optional, default = None.
+ Only for the PyTorch engine. The device to run the training on (e.g. "cuda:0")
+
+ snapshot_path: str or Path, optional, default = None.
+ Only for the PyTorch engine. The path to the pose model snapshot to resume training from.
+
+ detector_path: str or Path, optional, default = None.
+ Only for the PyTorch engine. The path to the detector model snapshot to resume training from.
+
+ batch_size: int, optional, default = None.
+ Only for the PyTorch engine. The batch size to use while training.
+
+ detector_batch_size: int, optional, default = None.
+ Only for the PyTorch engine. The batch size to use while training the detector.
+
+ detector_epochs: int, optional, default = None.
+ Only for the PyTorch engine. The number of epochs to train the detector for.
+
+ detector_save_epochs: int, optional, default = None.
+ Only for the PyTorch engine. The number of epochs between each detector snapshot save.
+
+ pose_threshold: float, optional, default = 0.1.
+ Only for the PyTorch engine. Used for memory-replay. Pseudo-predictions with confidence lower
+ than this threshold are discarded for memory-replay
+
+ pytorch_cfg_updates: dict, optional, default = None.
+ A dictionary of updates to the pytorch config. The keys are the dot-separated
+ paths to the values to update in the config.
+ For example, to update the gpus to run the training on, you can use:
+ ```
+ pytorch_cfg_updates={"runner.gpus": [0,1,2,3]}
+ ```
+
+ Returns
+ -------
+ None
+
+ Examples
+ --------
+ To train the network for first shuffle of the training dataset
+
+ >>> deeplabcut.train_network('/analysis/project/reaching-task/config.yaml')
+
+ To train the network for second shuffle of the training dataset
+
+ >>> deeplabcut.train_network(
+ '/analysis/project/reaching-task/config.yaml',
+ shuffle=2,
+ keepdeconvweights=True,
+ )
+
+ To train the network for shuffle created with a PyTorch engine, while overriding the
+ number of epochs, batch size and other parameters.
+
+ >>> deeplabcut.train_network(
+ '/analysis/project/reaching-task/config.yaml',
+ shuffle=1,
+ batch_size=8,
+ epochs=100,
+ save_epochs=10,
+ display_iters=50,
+ )
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import train_network
+
+ if max_snapshots_to_keep is None:
+ max_snapshots_to_keep = 5
+
+ return train_network(
+ str(config),
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ max_snapshots_to_keep=max_snapshots_to_keep,
+ displayiters=display_iters,
+ saveiters=save_iters,
+ maxiters=max_iters,
+ allow_growth=allow_growth,
+ gputouse=gputouse,
+ autotune=autotune,
+ keepdeconvweights=keepdeconvweights,
+ superanimal_name=superanimal_name,
+ superanimal_transfer_learning=superanimal_transfer_learning,
+ modelprefix=modelprefix,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis import train_network
+
+ return train_network(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ modelprefix=modelprefix,
+ device=device,
+ snapshot_path=snapshot_path,
+ detector_path=detector_path,
+ load_head_weights=keepdeconvweights,
+ batch_size=batch_size,
+ epochs=epochs,
+ save_epochs=save_epochs,
+ detector_batch_size=detector_batch_size,
+ detector_epochs=detector_epochs,
+ detector_save_epochs=detector_save_epochs,
+ display_iters=display_iters,
+ max_snapshots_to_keep=max_snapshots_to_keep,
+ pose_threshold=pose_threshold,
+ pytorch_cfg_updates=pytorch_cfg_updates,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def return_train_network_path(
+ config,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ modelprefix: str = "",
+ engine: Engine | None = None,
+) -> tuple[Path, Path, Path]:
+ """Returns the training and test pose config file names as well as the folder where
+ the snapshot is.
+
+ Parameters
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ shuffle: int
+ Integer value specifying the shuffle index to select for training.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use. By default the first (note
+ that TrainingFraction is a list in config.yaml).
+
+ modelprefix: str, optional
+ Directory containing the deeplabcut models to use when evaluating the network.
+ By default, the models are assumed to exist in the project folder.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Returns the triple: trainposeconfigfile, testposeconfigfile, snapshotfolder
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import return_train_network_path
+
+ return return_train_network_path(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ modelprefix=modelprefix,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ return_train_network_path,
+ )
+
+ return return_train_network_path(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ modelprefix=modelprefix,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+@renamed_parameter(old="comparisonbodyparts", new="comparison_bodyparts", since="3.0.0")
+@renamed_parameter(old="Shuffles", new="shuffles", since="3.0.0")
+def evaluate_network(
+ config: str | Path,
+ shuffles: Sequence[int] = (1,),
+ trainingsetindex: int | str = 0,
+ plotting: bool | str = False,
+ show_errors: bool = True,
+ comparison_bodyparts: str | list[str] = "all",
+ gputouse: str | None = None,
+ rescale: bool = False,
+ modelprefix: str = "",
+ per_keypoint_evaluation: bool = False,
+ snapshots_to_evaluate: list[str] | None = None,
+ pcutoff: float | list[float] | dict[str, float] | None = None,
+ engine: Engine | None = None,
+ **torch_kwargs,
+):
+ """Evaluates the network.
+
+ Evaluates the network based on the saved models at different stages of the training
+ network. The evaluation results are stored in the .h5 and .csv file under the
+ subdirectory 'evaluation_results'. Change the snapshotindex parameter in the config
+ file to 'all' in order to evaluate all the saved models.
+
+ Parameters
+ ----------
+ config : string
+ Full path of the config.yaml file.
+
+ shuffles: sequence of int, optional, default=[1]
+ List of integers specifying the shuffle indices of the training dataset.
+
+ trainingsetindex: int or str, optional, default=0
+ Integer specifying which "TrainingsetFraction" to use.
+ Note that "TrainingFraction" is a list in config.yaml. This variable can also
+ be set to "all".
+
+ plotting: bool or str, optional, default=False
+ Plots the predictions on the train and test images.
+ If provided it must be either ``True``, ``False``, ``"bodypart"``, or
+ ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for
+ multi-animal projects.
+ If a detector is used, the predicted bounding boxes will also be plotted.
+
+ show_errors: bool, optional, default=True
+ Display train and test errors.
+
+ comparison_bodyparts: str or list, optional, default="all"
+ The average error will be computed for those body parts only.
+ The provided list has to be a subset of the defined body parts.
+
+ gputouse: int or None, optional, default=None
+ Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a
+ GPU put `None``.
+ See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ rescale: bool, optional, default=False
+ Evaluate the model at the ``'global_scale'`` variable (as set in the
+ ``pose_config.yaml`` file for a particular project). I.e. every image will be
+ resized according to that scale and prediction will be compared to the resized
+ ground truth. The error will be reported in pixels at rescaled to the
+ *original* size. I.e. For a [200,200] pixel image evaluated at
+ ``global_scale=.5``, the predictions are calculated on [100,100] pixel images,
+ compared to 1/2*ground truth and this error is then multiplied by 2!.
+ The evaluation images are also shown for the original size!
+
+ modelprefix: str, optional, default=""
+ Directory containing the deeplabcut models to use when evaluating the network.
+ By default, the models are assumed to exist in the project folder.
+
+ per_keypoint_evaluation: bool, default=False
+ Compute the train and test RMSE for each keypoint, and save the results to
+ a {model_name}-keypoint-results.csv in the evaluation-results folder
+
+ snapshots_to_evaluate: List[str], optional, default=None
+ List of snapshot names to evaluate (e.g. ["snapshot-5000", "snapshot-7500"]).
+
+ pcutoff: float | list[float] | dict[str, float] | None, default=None
+ Only for the PyTorch engine. For the TensorFlow engine, please set the pcutoff
+ in the `config.yaml` file.
+ The cutoff to use for computing evaluation metrics. When `None` (default), the
+ cutoff will be loaded from the project config. If a list is provided, there
+ should be one value for each bodypart and one value for each unique bodypart
+ (if there are any). If a dict is provided, the keys should be bodyparts
+ mapping to pcutoff values for each bodypart. Bodyparts that are not defined
+ in the dict will have pcutoff set to 0.6.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ torch_kwargs:
+ You can add any keyword arguments for the deeplabcut.pose_estimation_pytorch
+ evaluate_network function here. These arguments are passed to the downstream
+ function. Available parameters are `snapshotindex`, which overrides the
+ `snapshotindex` parameter in the project configuration file. For top-down models
+ the `detector_snapshot_index` parameter can override the index of the detector
+ to use for evaluation in the project configuration file.
+
+ Returns
+ -------
+ None
+
+ Examples
+ --------
+ If you do not want to plot and evaluate with shuffle set to 1.
+
+ >>> deeplabcut.evaluate_network(
+ '/analysis/project/reaching-task/config.yaml', shuffles=[1],
+ )
+
+ If you want to plot and evaluate with shuffle set to 0 and 1.
+
+ >>> deeplabcut.evaluate_network(
+ '/analysis/project/reaching-task/config.yaml',
+ shuffles=[0, 1],
+ plotting=True,
+ )
+
+ If you want to plot assemblies for a maDLC project
+
+ >>> deeplabcut.evaluate_network(
+ '/analysis/project/reaching-task/config.yaml',
+ shuffles=[1],
+ plotting="individual",
+ )
+
+ If you have a PyTorch model for which you want to set a different p-cutoff for
+ "left_ear" and "right_ear" bodyparts, and keep the one set in the project config
+ for other bodyparts:
+
+ >>> deeplabcut.evaluate_network(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> shuffles=[0, 1],
+ >>> pcutoff={"left_ear": 0.8, "right_ear": 0.8},
+ >>> )
+
+ Note: This defaults to standard plotting for single-animal projects.
+ """
+ if engine is None:
+ cfg = _load_config(config)
+ engines = set()
+ for shuffle in shuffles:
+ engines.add(
+ get_shuffle_engine(
+ cfg,
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+ )
+ if len(engines) == 0:
+ raise ValueError(f"You must pass at least one shuffle to evaluate (had {list(shuffles)})")
+ elif len(engines) > 1:
+ raise ValueError(f"All shuffles must have the same engine (found {list(engines)})")
+ engine = engines.pop()
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import evaluate_network
+
+ return evaluate_network(
+ str(config),
+ Shuffles=shuffles,
+ trainingsetindex=trainingsetindex,
+ plotting=plotting,
+ show_errors=show_errors,
+ comparisonbodyparts=comparison_bodyparts,
+ gputouse=gputouse,
+ rescale=rescale,
+ modelprefix=modelprefix,
+ per_keypoint_evaluation=per_keypoint_evaluation,
+ snapshots_to_evaluate=snapshots_to_evaluate,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis import evaluate_network
+
+ _update_device(gputouse, torch_kwargs)
+ return evaluate_network(
+ config,
+ shuffles=shuffles,
+ trainingsetindex=trainingsetindex,
+ plotting=plotting,
+ show_errors=show_errors,
+ comparison_bodyparts=comparison_bodyparts,
+ snapshots_to_evaluate=snapshots_to_evaluate,
+ per_keypoint_evaluation=per_keypoint_evaluation,
+ modelprefix=modelprefix,
+ pcutoff=pcutoff,
+ **torch_kwargs,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+@renamed_parameter(old="comparisonbodyparts", new="comparison_bodyparts", since="3.0.0")
+@renamed_parameter(old="Snapindex", new="snapshotindex", since="3.0.0")
+def return_evaluate_network_data(
+ config: str,
+ shuffle: int = 0,
+ trainingsetindex: int = 0,
+ comparison_bodyparts: str | list[str] = "all",
+ snapshotindex: str | int | None = None,
+ rescale: bool = False,
+ fulldata: bool = False,
+ show_errors: bool = True,
+ modelprefix: str = "",
+ returnjustfns: bool = True,
+ engine: Engine | None = None,
+):
+ """Returns the results for (previously evaluated) network.
+ deeplabcut.evaluate_network(..) Returns list of (per model): [trainingsiterations,tr
+ ainfraction,shuffle,trainerror,testerror,pcutoff,trainerrorpcutoff,testerrorpcutoff,
+ Snapshots[snapshotindex],scale,net_type]
+
+ This function is only implemented for tensorflow models/shuffles, and will throw
+ an error if called with a PyTorch shuffle.
+
+ If fulldata=True, also returns (the complete annotation and prediction array)
+ Returns list of:
+ (DataMachine, Data, data, trainIndices,
+ testIndices, trainFraction, DLCscorer,
+ comparison_bodyparts, cfg, Snapshots[snapshotindex]
+ )
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ shuffle: integer
+ integers specifying shuffle index of the training dataset.
+ The default is 0.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ This variable can also be set to "all".
+
+ comparison_bodyparts: list of bodyparts, Default is "all".
+ The average error will be computed for those body parts only
+ (Has to be a subset of the body parts).
+
+ rescale: bool, default False
+ Evaluate the model at the 'global_scale' variable
+ (as set in the test/pose_config.yaml file for a particular project).
+ I.e. every image will be resized according to
+ that scale and prediction will be compared to the resized ground truth.
+ The error will be reported in pixels at rescaled to the *original* size.
+ I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
+ on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!.
+ The evaluation images are also shown for the original size!
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Examples
+ --------
+ If you do not want to plot
+ >>> deeplabcut._evaluate_network_data('/analysis/project/reaching-task/config.yaml', shuffle=[1])
+ --------
+ If you want to plot
+ >>> deeplabcut.evaluate_network('/analysis/project/reaching-task/config.yaml',shuffle=[1],plotting=True)
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import return_evaluate_network_data
+
+ return return_evaluate_network_data(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ comparisonbodyparts=comparison_bodyparts,
+ Snapindex=snapshotindex,
+ rescale=rescale,
+ fulldata=fulldata,
+ show_errors=show_errors,
+ modelprefix=modelprefix,
+ returnjustfns=returnjustfns,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+@renamed_parameter(old="batchsize", new="batch_size", since="3.0.0")
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def analyze_videos(
+ config: str,
+ videos: list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ gputouse: str | None = None,
+ save_as_csv: bool = False,
+ in_random_order: bool = True,
+ destfolder: str | None = None,
+ batch_size: int | None = None,
+ cropping: list[int] | None = None,
+ TFGPUinference: bool = True,
+ dynamic: tuple[bool, float, int] = (False, 0.5, 10),
+ modelprefix: str = "",
+ robust_nframes: bool = False,
+ allow_growth: bool = False,
+ use_shelve: bool = False,
+ auto_track: bool = True,
+ n_tracks: int | None = None,
+ animal_names: list[str] | None = None,
+ calibrate: bool = False,
+ identity_only: bool = False,
+ use_openvino: str | None = None,
+ engine: Engine | None = None,
+ **torch_kwargs,
+):
+ """Makes prediction based on a trained network.
+
+ The index of the trained network is specified by parameters in the config file
+ (in particular the variable 'snapshotindex').
+
+ The labels are stored as MultiIndex Pandas Array, which contains the name of
+ the network, body part name, (x, y) label position in pixels, and the
+ likelihood for each frame per body part. These arrays are stored in an
+ efficient Hierarchical Data Format (HDF) in the same directory where the video
+ is stored. However, if the flag save_as_csv is set to True, the data can also
+ be exported in comma-separated values format (.csv), which in turn can be
+ imported in many programs, such as MATLAB, R, Prism, etc.
+
+ Parameters
+ ----------
+ config: str
+ Full path of the config.yaml file.
+
+ videos: list[str]
+ A list of strings containing the full paths to videos for analysis or a path to
+ the directory, where all the videos with same extension are stored.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+
+ shuffle: int, optional, default=1
+ An integer specifying the shuffle index of the training dataset used for
+ training the network.
+
+ trainingsetindex: int, optional, default=0
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+
+ gputouse: int or None, optional, default=None
+ Only for the TensorFlow engine (for the PyTorch engine see the ``torch_kwargs``:
+ you can use ``device``).
+ Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a
+ GPU put ``None``.
+ See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ save_as_csv: bool, optional, default=False
+ Saves the predictions in a .csv file.
+
+ in_random_order: bool, optional (default=True)
+ Whether or not to analyze videos in a random order.
+ This is only relevant when specifying a video directory in `videos`.
+
+ destfolder: string or None, optional, default=None
+ Specifies the destination folder for analysis data. If ``None``, the path of
+ the video is used. Note that for subsequent analysis this folder also needs to
+ be passed.
+
+ batch_size: int or None, optional, default=None
+ Currently not supported by the PyTorch engine.
+ Change batch size for inference; if given overwrites value in ``pose_cfg.yaml``.
+
+ cropping: list or None, optional, default=None
+ List of cropping coordinates as [x1, x2, y1, y2].
+ Note that the same cropping parameters will then be used for all videos.
+ If different video crops are desired, run ``analyze_videos`` on individual
+ videos with the corresponding cropping coordinates.
+
+ TFGPUinference: bool, optional, default=True
+ Only for the TensorFlow engine.
+ Perform inference on GPU with TensorFlow code. Introduced in "Pretraining
+ boosts out-of-domain robustness for pose estimation" by Alexander Mathis,
+ Mert Yüksekgönül, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis.
+ Source: https://arxiv.org/abs/1909.11229
+
+ dynamic: tuple(bool, float, int) triple containing (state, det_threshold, margin)
+ If the state is true, then dynamic cropping will be performed. That means that
+ if an object is detected (i.e. any body part > detectiontreshold), then object
+ boundaries are computed according to the smallest/largest x position and
+ smallest/largest y position of all body parts. This window is expanded by the
+ margin and from then on only the posture within this crop is analyzed (until the
+ object is lost, i.e. >> deeplabcut.analyze_videos(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'],
+ )
+
+ Analyzing a single video on Linux/MacOS
+
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/reachingvideo1.avi'],
+ )
+
+ Analyze all videos of type ``avi`` in a folder
+
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos'],
+ video_extensions='.avi',
+ )
+
+ Analyze multiple videos
+
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ [
+ '/analysis/project/videos/reachingvideo1.avi',
+ '/analysis/project/videos/reachingvideo2.avi',
+ ],
+ )
+
+ Analyze multiple videos with ``shuffle=2``
+
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ [
+ '/analysis/project/videos/reachingvideo1.avi',
+ '/analysis/project/videos/reachingvideo2.avi',
+ ],
+ shuffle=2,
+ )
+
+ Analyze multiple videos with ``shuffle=2``, save results as an additional csv file
+
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ [
+ '/analysis/project/videos/reachingvideo1.avi',
+ '/analysis/project/videos/reachingvideo2.avi',
+ ],
+ shuffle=2,
+ save_as_csv=True,
+ )
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import analyze_videos
+
+ kwargs = {}
+ if use_openvino is not None: # otherwise default comes from tensorflow API
+ kwargs["use_openvino"] = use_openvino
+
+ return analyze_videos(
+ config,
+ videos,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ gputouse=gputouse,
+ save_as_csv=save_as_csv,
+ in_random_order=in_random_order,
+ destfolder=destfolder,
+ batchsize=batch_size,
+ cropping=cropping,
+ TFGPUinference=TFGPUinference,
+ dynamic=dynamic,
+ modelprefix=modelprefix,
+ robust_nframes=robust_nframes,
+ allow_growth=allow_growth,
+ use_shelve=use_shelve,
+ auto_track=auto_track,
+ n_tracks=n_tracks,
+ animal_names=animal_names,
+ calibrate=calibrate,
+ identity_only=identity_only,
+ **kwargs,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis import analyze_videos
+
+ _update_device(gputouse, torch_kwargs)
+
+ if batch_size is not None:
+ if "batch_size" in torch_kwargs:
+ print(
+ f"You called analyze_videos with parameters ``batch_size={batch_size}"
+ f"`` and batch_size={torch_kwargs['batch_size']}. Only one is "
+ f"needed/used. Using batch size {torch_kwargs['batch_size']}"
+ )
+ else:
+ torch_kwargs["batch_size"] = batch_size
+
+ return analyze_videos(
+ config,
+ videos=videos,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ save_as_csv=save_as_csv,
+ in_random_order=in_random_order,
+ destfolder=destfolder,
+ dynamic=dynamic,
+ modelprefix=modelprefix,
+ use_shelve=use_shelve,
+ robust_nframes=robust_nframes,
+ auto_track=auto_track,
+ n_tracks=n_tracks,
+ animal_names=animal_names,
+ calibrate=calibrate,
+ identity_only=identity_only,
+ overwrite=False,
+ cropping=cropping,
+ **torch_kwargs,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+@renamed_parameter(old="batchsize", new="batch_size", since="3.0.0")
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def create_tracking_dataset(
+ config: str,
+ videos: list[str],
+ track_method: str,
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ gputouse: int | None = None,
+ destfolder: str | None = None,
+ batch_size: int | None = None,
+ cropping: list[int] | None = None,
+ TFGPUinference: bool = True,
+ modelprefix: str = "",
+ robust_nframes: bool = False,
+ n_triplets: int = 1000,
+ engine: Engine | None = None,
+) -> str:
+ """Creates a tracking dataset to train a ReID tracklet stitcher.
+
+ Parameters
+ ----------
+ config: str
+ Full path of the config.yaml file.
+
+ videos: list[str]
+ A list of strings containing the full paths to videos from which to create a
+ tracking dataset, or a path to the directory where all the videos with same
+ extension are stored.
+
+ track_method: str
+ Specifies the tracker used to generate the pose estimation data. Must be either
+ 'box', 'skeleton', or 'ellipse'.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+
+ shuffle: int, optional, default=1
+ An integer specifying the shuffle index of the training dataset used for
+ training the network.
+
+ trainingsetindex: int, optional, default=0
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+
+ gputouse: int or None, optional, default=None
+ Only for the TensorFlow engine (for the PyTorch engine use ``device``).
+ Indicates the GPU to use (see number in ``nvidia-smi``). If you do not have a
+ GPU put ``None``. See:
+ https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ TFGPUinference: bool, optional, default=True
+ Only for the TensorFlow engine.
+ Perform inference on GPU with TensorFlow code. Introduced in "Pretraining
+ boosts out-of-domain robustness for pose estimation" by Alexander Mathis,
+ Mert Yüksekgönül, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis.
+ Source: https://arxiv.org/abs/1909.11229
+
+ destfolder:
+ Specifies the destination folder for analysis data. If ``None``, the path of
+ the video is used. Note that for subsequent analysis this folder also needs to
+ be passed.
+
+ modelprefix: str, optional, default=""
+ Directory containing the deeplabcut models to use when evaluating the network.
+ By default, the models are assumed to exist in the project folder.
+
+ robust_nframes: bool, optional, default=False
+ Evaluate a video's number of frames in a robust manner.
+ This option is slower (as the whole video is read frame-by-frame),
+ but does not rely on metadata, hence its robustness against file corruption.
+
+ n_triplets: int, default=1000
+ The number of triplets to extract for the dataset.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Returns
+ -------
+ DLCScorer: str
+ the scorer used to analyze the videos
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import create_tracking_dataset
+
+ return create_tracking_dataset(
+ config,
+ videos,
+ track_method,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ gputouse=gputouse,
+ destfolder=destfolder,
+ batchsize=batch_size,
+ cropping=cropping,
+ TFGPUinference=TFGPUinference,
+ modelprefix=modelprefix,
+ robust_nframes=robust_nframes,
+ n_triplets=n_triplets,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis import create_tracking_dataset
+
+ return create_tracking_dataset(
+ config,
+ videos,
+ track_method,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ destfolder=destfolder,
+ batch_size=batch_size,
+ cropping=cropping,
+ modelprefix=modelprefix,
+ robust_nframes=robust_nframes,
+ n_triplets=n_triplets,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def analyze_images(
+ config: str | Path,
+ images: str | Path | list[str] | list[Path],
+ frame_type: str | None = None,
+ destfolder: str | Path | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ max_individuals: int | None = None,
+ device: str | None = None,
+ snapshot_index: int | None = None,
+ detector_snapshot_index: int | None = None,
+ save_as_csv: bool = False,
+ modelprefix: str = "",
+ plotting: bool | str = False,
+ pcutoff: float | None = None,
+ bbox_pcutoff: float | None = None,
+ plot_skeleton: bool = False,
+ **torch_kwargs,
+) -> dict[str, dict[str, np.ndarray | np.ndarray]]:
+ """Analyzes images with a DeepLabCut model and stores the output in an H5 file.
+
+ This method is only implemented for PyTorch models.
+
+ The labels are stored as Pandas DataFrame, which contains the name of the network,
+ body part name, (x, y) label position in pixels, and the likelihood for each frame
+ per body part.
+
+ Parameters
+ ----------
+ config : str, Path
+ Full path of the project's config.yaml file.
+
+ images: str, Path, list[str], list[Path]
+ The image(s) to run inference on. Can be the path to an image, the path
+ to a directory containing images, or a list of image paths or directories
+ containing images.
+
+ frame_type: string, optional
+ Filters the images to analyze to only the ones with the given suffix (e.g.
+ setting `frame_type`=".png" will only analyze ".png" images). The default
+ behavior analyzes all ".jpg", ".jpeg" and ".png" images.
+
+ destfolder: str, Path, optional
+ The directory where the predictions will be stored. If None, the predictions
+ will be stored in the same directory as the first image given in the `images`
+ argument (if it's a directory, that directory will be used; if it's an image,
+ the directory containing the image will be used).
+
+ shuffle: int, optional
+ An integer specifying the shuffle with which to run image analysis.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use. By default, the first one
+ is used (note that TrainingFraction is a list in config.yaml).
+
+ max_individuals: int, optional
+ The maximum number of individuals to detect in each image. Set to the number of
+ individuals in the project if None.
+
+ device: str, optional
+ The CUDA device to use for training. If None, the device will be taken from the
+ ``pytorch_config.yaml`` file. Examples: {"cpu", "cuda", "cuda:0", "cuda:1"}. For
+ more information, see https://pytorch.org/docs/stable/notes/cuda.html
+
+ snapshot_index: int, optional
+ Index (starting at 0) of the snapshot to use for image analysis. To evaluate the
+ last one, use -1. Default uses the value set in the project config.
+
+ detector_snapshot_index: int, optional
+ Only for Top-Down PyTorch models. If defined, uses the detector with the given
+ index for pose estimation. To evaluate the last one, use -1. Default uses the
+ value set in the project config.
+
+ save_as_csv: bool, optional
+ Saves the predictions in a .csv file. The default is ``False``; if provided it
+ must be either ``True`` or ``False``.
+
+ modelprefix: str, optional
+ Directory containing the deeplabcut models to use when running image analysis.
+ By default, the models are assumed to exist in the project folder.
+
+ plotting: bool, str, default=False
+ Plots the predictions made by the model on the analyzed images. Results will be
+ stored in a folder named `LabeledImages_{scorer}`, where scorer is the name
+ of the model used to analyze the images. This folder will be in the same
+ directory as the file containing the predictions (either the given `destfolder`,
+ or the folder containing the first image to analyze).
+
+ If provided it must be either ``True``, ``False``, ``"bodypart"``, or
+ ``"individual"``. Setting to ``True`` defaults as ``"bodypart"`` for
+ multi-animal projects. If a detector is used, the predicted bounding boxes
+ will also be plotted.
+
+ pcutoff: float, optional, default=None
+ The cutoff score when plotting pose predictions. Must be None or in
+ (0, 1). If None, the pcutoff is read from the project configuration file.
+
+ bbox_pcutoff: float, optional, default=None
+ The cutoff score when plotting bounding box predictions. Must be
+ None or in (0, 1). If None, it is read from the project configuration file.
+
+ plot_skeleton: bool, default=False
+ If a skeleton is defined in the project's config.yaml, whether
+ to plot the skeleton connecting the predicted bodyparts on the images.
+
+ torch_kwargs:
+ Any extra parameters to pass to the PyTorch API, such as ``ctd_conditions``
+
+ Returns
+ -------
+ A dictionary mapping image paths (as strings) to model predictions.
+
+ Examples
+ --------
+ If you want to analyze all frames in /analysis/project/my_images
+ >>> import deeplabcut
+ >>> deeplabcut.analyze_images(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> "/analysis/project/my_images",
+ >>> )
+ >>>
+
+ If you want to analyze two specific images with your shuffle 3 model:
+ >>> import deeplabcut
+ >>> deeplabcut.analyze_images(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> images=["image_001.png", "img_002.jpg"],
+ >>> shuffle=3,
+ >>> )
+ >>>
+
+ If you want to analyze frames in a folder, save them and plot predictions:
+ >>> import deeplabcut
+ >>> deeplabcut.analyze_images(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> "/analysis/project/my_images",
+ >>> shuffle=3,
+ >>> destfolder="/analysis/project/my_images_analyzed",
+ >>> plotting=True,
+ >>> )
+ >>>
+ --------
+ """
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch import analyze_images
+
+ return analyze_images(
+ config=config,
+ images=images,
+ frame_type=frame_type,
+ output_dir=destfolder,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ snapshot_index=snapshot_index,
+ detector_snapshot_index=detector_snapshot_index,
+ modelprefix=modelprefix,
+ device=device,
+ save_as_csv=save_as_csv,
+ max_individuals=max_individuals,
+ plotting=plotting,
+ pcutoff=pcutoff,
+ bbox_pcutoff=bbox_pcutoff,
+ plot_skeleton=plot_skeleton,
+ **torch_kwargs,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def analyze_time_lapse_frames(
+ config: str,
+ directory: str,
+ frametype: str = ".png",
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ gputouse: int | None = None,
+ device: str | None = None,
+ save_as_csv: bool = False,
+ modelprefix: str = "",
+ engine: Engine | None = None,
+):
+ """Analyzed all images (of type = frametype) in a folder and stores the output in
+ one file.
+
+ You can crop the frames (before analysis), by changing 'cropping'=True and setting
+ 'x1','x2','y1','y2' in the config file.
+
+ Output: The labels are stored as MultiIndex Pandas Array, which contains the name
+ of the network, body part name, (x, y) label position in pixels, and the likelihood
+ for each frame per body part. These arrays are stored in an efficient Hierarchical
+ Data Format (HDF) in the same directory, where the video is stored. However, if the
+ flag save_as_csv is set to True, the data can also be exported in comma-separated
+ values format (.csv), which in turn can be imported in many programs, such as
+ MATLAB, R, Prism, etc.
+
+ Parameters
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ directory: string
+ Full path to directory containing the frames that shall be analyzed
+
+ frametype: string, optional
+ Checks for the file extension of the frames. Only images with this extension are
+ analyzed. The default is ``.png``
+
+ shuffle: int, optional
+ An integer specifying the shuffle index of the training dataset used for
+ training the network. The default is 1.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use. By default the first (note
+ that TrainingFraction is a list in config.yaml).
+
+ gputouse: int, optional.
+ Only for TensorFlow models. For PyTorch models, please use `device`. Natural
+ number indicating the number of your GPU (see number in nvidia-smi). If you do
+ not have a GPU put None. See:
+ https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ device: str, optional
+ The CUDA device to use for training. If None, the device will be taken from the
+ ``pytorch_config.yaml`` file. Examples: {"cpu", "cuda", "cuda:0", "cuda:1"}. For
+ more information, see https://pytorch.org/docs/stable/notes/cuda.html
+
+ save_as_csv: bool, optional
+ Saves the predictions in a .csv file. The default is ``False``; if provided if
+ must be either ``True`` or ``False``
+
+ Examples
+ --------
+ If you want to analyze all frames in /analysis/project/timelapseexperiment1
+ >>> import deeplabcut
+ >>> deeplabcut.analyze_time_lapse_frames(
+ >>> '/analysis/project/reaching-task/config.yaml',
+ >>> '/analysis/project/timelapseexperiment1'
+ >>> )
+
+ --------
+
+ Note: for test purposes one can extract all frames from a video with ffmeg, e.g.
+ >>> ffmpeg -i testvideo.avi "thumb%04d.png"
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import analyze_time_lapse_frames
+
+ return analyze_time_lapse_frames(
+ config,
+ directory,
+ frametype=frametype,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ gputouse=gputouse,
+ save_as_csv=save_as_csv,
+ modelprefix=modelprefix,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch import analyze_images
+
+ return analyze_images(
+ config=config,
+ images=directory,
+ output_dir=directory,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ device=_gpu_to_use_to_device(gputouse, device),
+ save_as_csv=save_as_csv,
+ modelprefix=modelprefix,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def convert_detections2tracklets(
+ config: str,
+ videos: list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ overwrite: bool = False,
+ destfolder: str | None = None,
+ ignore_bodyparts: list[str] | None = None,
+ inferencecfg: dict | None = None,
+ modelprefix: str = "",
+ greedy: bool = False,
+ calibrate: bool = False,
+ window_size: int = 0,
+ identity_only: int = False,
+ track_method: str = "",
+ engine: Engine | None = None,
+):
+ """This should be called at the end of deeplabcut.analyze_videos for multianimal
+ projects!
+
+ Parameters
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ videos : list
+ A list of strings containing the full paths to videos for analysis or a path to the directory,
+ where all the videos with same extension are stored.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+
+ shuffle: int, optional
+ An integer specifying the shuffle index of the training dataset used for training the network. T
+ he default is 1.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+
+ overwrite: bool, optional.
+ Overwrite tracks file i.e. recompute tracks from full detections and overwrite.
+
+ destfolder: string, optional
+ Specifies the destination folder for analysis data (default is the path of the video).
+ Note that for subsequent analysis this
+ folder also needs to be passed.
+
+ ignore_bodyparts: optional
+ List of body part names that should be ignored during tracking (advanced).
+ By default, all the body parts are used.
+
+ inferencecfg: Default is None.
+ Configuration file for inference (assembly of individuals). Ideally
+ should be obtained from cross validation (during evaluation). By default
+ the parameters are loaded from inference_cfg.yaml, but these get_level_values
+ can be overwritten.
+
+ calibrate: bool, optional (default=False)
+ If True, use training data to calibrate the animal assembly procedure.
+ This improves its robustness to wrong body part links,
+ but requires very little missing data.
+
+ window_size: int, optional (default=0)
+ Recurrent connections in the past `window_size` frames are
+ prioritized during assembly. By default, no temporal coherence cost
+ is added, and assembly is driven mainly by part affinity costs.
+
+ identity_only: bool, optional (default=False)
+ If True and animal identity was learned by the model,
+ assembly and tracking rely exclusively on identity prediction.
+
+ track_method: string, optional
+ Specifies the tracker used to generate the pose estimation data.
+ For multiple animals, must be either 'box', 'skeleton', or 'ellipse'
+ and will be taken from the config.yaml file if none is given.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Examples
+ --------
+ If you want to convert detections to tracklets:
+ >>> import deeplabcut
+ >>> deeplabcut.convert_detections2tracklets(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> ["/analysis/project/video1.mp4"],
+ >>> video_extensions='.mp4',
+ >>> )
+
+ If you want to convert detections to tracklets based on box_tracker:
+ >>> import deeplabcut
+ >>> deeplabcut.convert_detections2tracklets(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> ["/analysis/project/video1.mp4"],
+ >>> video_extensions=".mp4",
+ >>> track_method="box",
+ >>> )
+
+ --------
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import convert_detections2tracklets
+
+ return convert_detections2tracklets(
+ config,
+ videos,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ overwrite=overwrite,
+ destfolder=destfolder,
+ ignore_bodyparts=ignore_bodyparts,
+ inferencecfg=inferencecfg,
+ modelprefix=modelprefix,
+ greedy=greedy,
+ calibrate=calibrate,
+ window_size=window_size,
+ identity_only=identity_only,
+ track_method=track_method,
+ )
+
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis import convert_detections2tracklets
+
+ if greedy or calibrate or window_size:
+ raise NotImplementedError(
+ f"The 'greedy', 'calibrate' and 'window_size' option are not yet implemented with {engine}"
+ )
+
+ return convert_detections2tracklets(
+ config,
+ videos,
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ overwrite=overwrite,
+ destfolder=destfolder,
+ ignore_bodyparts=ignore_bodyparts,
+ inferencecfg=inferencecfg,
+ modelprefix=modelprefix,
+ identity_only=identity_only,
+ track_method=track_method,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def extract_maps(
+ config,
+ shuffle: int = 0,
+ trainingsetindex: int = 0,
+ gputouse: int | None = None,
+ device: str | None = None,
+ rescale: bool = False,
+ Indices: list[int] | None = None,
+ modelprefix: str = "",
+ engine: Engine | None = None,
+):
+ """Extracts the scoremap, locref, partaffinityfields (if available).
+
+ Returns a dictionary indexed by: trainingsetfraction, snapshotindex, and imageindex
+ for those keys, each item contains: (image, scmap, locref, paf, bpt_names,
+ partaffinity_graph, imagename, True/False if this image was in trainingset).
+
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ shuffle: integer
+ integers specifying shuffle index of the training dataset. The default is 0.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use. By default the first (note
+ that TrainingFraction is a list in config.yaml). This variable can also be set
+ to "all".
+
+ gputouse: int or None, optional, default=None
+ For the TensorFlow engine (for the PyTorch engine see ``device``). Specifies
+ the GPU to use (see number in ``nvidia-smi``). If you do not have a GPU put
+ ``None``. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ device: str or None, optional, default=None
+ The CUDA device to use for training. If None, the device will be taken from the
+ ``pytorch_config.yaml`` file. Examples: {"cpu", "cuda", "cuda:0", "cuda:1"}. See
+ https://pytorch.org/docs/stable/notes/cuda.html for more information.
+
+ rescale: bool, default False
+ Evaluate the model at the 'global_scale' variable
+ (as set in the test/pose_config.yaml file for a particular project).
+ I.e. every image will be resized according to that scale and prediction
+ will be compared to the resized ground truth.
+ The error will be reported in pixels at rescaled to the *original* size.
+ I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
+ on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!.
+ The evaluation images are also shown for the original size!
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Examples
+ --------
+ If you want to extract the data for image 0 and 103 (of the training set) for model trained with shuffle 0.
+ >>> deeplabcut.extract_maps(configfile,0,Indices=[0,103])
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import extract_maps
+
+ return extract_maps(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ gputouse=gputouse,
+ rescale=rescale,
+ Indices=Indices,
+ modelprefix=modelprefix,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch import extract_maps
+
+ return extract_maps(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ device=_gpu_to_use_to_device(gputouse, device),
+ rescale=rescale,
+ indices=Indices,
+ modelprefix=modelprefix,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def visualize_scoremaps(image: np.ndarray, scmap: np.ndarray):
+ """Plots scoremaps as an image overlay.
+
+ Args:
+ image: An image as a numpy array of shape (h, w, channels)
+ scmap: A scoremap of shape (h, w)
+
+ Returns:
+ The figure and axis on which the image scoremap was plot.
+ """
+ return visualization.visualize_scoremaps(image, scmap)
+
+
+def visualize_locrefs(
+ image: np.ndarray,
+ scmap: np.ndarray,
+ locref_x: np.ndarray,
+ locref_y: np.ndarray,
+ step: int = 5,
+ zoom_width: int = 0,
+):
+ """Plots a scoremap and the corresponding location refinement field on an image.
+
+ Args:
+ image: An image as a numpy array of shape (h, w, channels)
+ scmap: A scoremap of shape (h, w)
+ locref_x: The x-coordinate of the location refinement field, of shape (h, w)
+ locref_y: The y-coordinate of the location refinement field, of shape (h, w)
+ step: The step with which to plot the location refinement field.
+ zoom_width: The zoom width with which to plot the scoremaps.
+
+ Returns:
+ The figure and axis on which the image scoremap and locref field were plot.
+ """
+ return visualization.visualize_locrefs(image, scmap, locref_x, locref_y, step=step, zoom_width=zoom_width)
+
+
+def visualize_paf(
+ image: np.ndarray,
+ paf: np.ndarray,
+ step: int = 5,
+ colors: list | None = None,
+):
+ """Plots the PAF on top of the image.
+
+ Args:
+ image: Shape (height, width, channels). The image on which the model was run.
+ paf: Shape (height, width, 2 * len(paf_graph)). The PAF output by the model.
+ step: The step with which to plot the scoremaps.
+ colors: The colormap to use.
+
+ Returns:
+ The figure and axis on which the image PAF was plot.
+ """
+ return visualization.visualize_paf(image, paf, step=step, colors=colors)
+
+
+@renamed_parameter(old="comparisonbodyparts", new="comparison_bodyparts", since="3.0.0")
+def extract_save_all_maps(
+ config,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ comparison_bodyparts: str | list[str] = "all",
+ extract_paf: bool = True,
+ all_paf_in_one: bool = True,
+ gputouse: int | None = None,
+ device: str | None = None,
+ rescale: bool = False,
+ Indices: list[int] | None = None,
+ modelprefix: str = "",
+ dest_folder: str = None,
+ snapshot_index: int | str | None = None,
+ detector_snapshot_index: int | str | None = None,
+ engine: Engine | None = None,
+):
+ """
+ Extracts the scoremap, location refinement field and part affinity field prediction of the model. The maps
+ will be rescaled to the size of the input image and stored in the corresponding model folder in /evaluation-results.
+
+ ----------
+ config : string
+ Full path of the config.yaml file as a string.
+
+ shuffle: integer
+ integers specifying shuffle index of the training dataset. The default is 1.
+
+ trainingsetindex: int, optional
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ This variable can also be set to "all".
+
+ comparison_bodyparts: list of bodyparts, Default is "all".
+ The average error will be computed for those body parts only (Has to be a subset of the body parts).
+
+ extract_paf : bool
+ Extract part affinity fields by default.
+ Note that turning it off will make the function much faster.
+
+ all_paf_in_one : bool
+ By default, all part affinity fields are displayed on a single frame.
+ If false, individual fields are shown on separate frames.
+
+ gputouse: int or None, optional, default=None
+ For the TensorFlow engine (for the PyTorch engine see ``device``). Specifies
+ the GPU to use (see number in ``nvidia-smi``). If you do not have a GPU put
+ ``None``. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+ device: str or None, optional, default=None
+ The CUDA device to use for training. If None, the device will be taken from the
+ ``pytorch_config.yaml`` file. Examples: {"cpu", "cuda", "cuda:0", "cuda:1"}. See
+ https://pytorch.org/docs/stable/notes/cuda.html for more information.
+
+ Indices: default None
+ For which images shall the scmap/locref and paf be computed? Give a list of images
+
+ nplots_per_row: int, optional (default=None)
+ Number of plots per row in grid plots. By default, calculated to approximate a squared grid of plots
+
+ snapshot_index: Only for PyTorch models. Index (starting at 0) of the snapshot we
+ want to extract maps with. To evaluate the last one, use -1. To extract maps
+ for all snapshots, use "all". Default uses the value set in the project config.
+
+ detector_snapshot_index: Only for TD PyTorch models. If defined, uses the detector
+ with the given index for pose estimation. To extract maps for all detector
+ snapshots, use "all". Default uses the value set in the project config.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Examples
+ --------
+ Calculated maps for images 0, 1 and 33.
+ >>> deeplabcut.extract_save_all_maps('/analysis/project/reaching-task/config.yaml', shuffle=1,Indices=[0,1,33])
+
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(config),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import extract_save_all_maps
+
+ return extract_save_all_maps(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ comparisonbodyparts=comparison_bodyparts,
+ extract_paf=extract_paf,
+ all_paf_in_one=all_paf_in_one,
+ gputouse=gputouse,
+ rescale=rescale,
+ Indices=Indices,
+ modelprefix=modelprefix,
+ dest_folder=dest_folder,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch import extract_save_all_maps
+
+ return extract_save_all_maps(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ comparison_bodyparts=comparison_bodyparts,
+ extract_paf=extract_paf,
+ all_paf_in_one=all_paf_in_one,
+ device=_gpu_to_use_to_device(gputouse, device),
+ rescale=rescale,
+ indices=Indices,
+ modelprefix=modelprefix,
+ snapshot_index=snapshot_index,
+ detector_snapshot_index=detector_snapshot_index,
+ dest_folder=dest_folder,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def export_model(
+ cfg_path: str,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ snapshotindex: int | None = None,
+ iteration: int = None,
+ TFGPUinference: bool = True,
+ overwrite: bool = False,
+ make_tar: bool = True,
+ wipepaths: bool = False,
+ without_detector: bool = False,
+ modelprefix: str = "",
+ engine: Engine | None = None,
+) -> None:
+ """Export DeepLabCut models for the model zoo or for live inference.
+
+ Saves the pose configuration, snapshot files, and frozen TF graph of the model to
+ directory named exported-models within the project directory (and an
+ `exported-models-pytorch` directory for PyTorch models).
+
+ Parameters
+ -----------
+
+ cfg_path : string
+ path to the DLC Project config.yaml file
+
+ shuffle : int, optional
+ the shuffle of the model to export. default = 1
+
+ trainingsetindex : int, optional
+ the index of the training fraction for the model you wish to export. default = 1
+
+ snapshotindex : int, optional
+ the snapshot index for the weights you wish to export. If None,
+ uses the snapshotindex as defined in 'config.yaml'. Default = None
+
+ iteration : int, optional
+ The model iteration (active learning loop) you wish to export. If None,
+ the iteration listed in the config file is used.
+
+ TFGPUinference : bool, optional
+ use the tensorflow inference model? Default = True
+ For inference using DeepLabCut-live, it is recommended to set TFGPIinference=False
+
+ overwrite : bool, optional
+ if the model you wish to export has already been exported, whether to overwrite. default = False
+
+ make_tar : bool, optional
+ Do you want to compress the exported directory to a tar file? Default = True
+ This is necessary to export to the model zoo, but not for live inference.
+
+ wipepaths : bool, optional
+ Removes the actual path of your project and the init_weights from pose_cfg.
+
+ without_detector: bool, optional
+ PyTorch engine only. Exports top-down models without the detector.
+
+ engine: Engine, optional, default = None.
+ The default behavior loads the engine for the shuffle from the metadata. You can
+ overwrite this by passing the engine as an argument, but this should generally
+ not be done.
+
+ Example:
+ --------
+ Export the first stored snapshot for model trained with shuffle 3:
+ >>> deeplabcut.export_model('/analysis/project/reaching-task/config.yaml',shuffle=3, snapshotindex=-1)
+ --------
+ """
+ if engine is None:
+ engine = get_shuffle_engine(
+ _load_config(cfg_path),
+ trainingsetindex=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.TF:
+ from deeplabcut.pose_estimation_tensorflow import export_model
+
+ return export_model(
+ cfg_path=cfg_path,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ snapshotindex=snapshotindex,
+ iteration=iteration,
+ TFGPUinference=TFGPUinference,
+ overwrite=overwrite,
+ make_tar=make_tar,
+ wipepaths=wipepaths,
+ modelprefix=modelprefix,
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis.export import export_model
+
+ return export_model(
+ config=cfg_path,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ snapshotindex=snapshotindex,
+ iteration=iteration,
+ overwrite=overwrite,
+ wipe_paths=wipepaths,
+ without_detector=without_detector,
+ modelprefix=modelprefix,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def _update_device(gpu_to_use: int | None, torch_kwargs: dict) -> None:
+ if "device" not in torch_kwargs and gpu_to_use is not None:
+ device = _gpu_to_use_to_device(gpu_to_use, device=None)
+ if device is not None:
+ torch_kwargs["device"] = device
+
+
+def _gpu_to_use_to_device(gpu_to_use: int | None, device: str | None) -> str | None:
+ if device is None and gpu_to_use is not None:
+ if isinstance(gpu_to_use, int):
+ device = f"cuda:{gpu_to_use}"
+ else:
+ device = gpu_to_use
+
+ return device
+
+
+def _load_config(config: str) -> dict:
+ config_path = Path(config)
+ if not config_path.exists():
+ raise FileNotFoundError(f"Config {config} is not found. Please make sure that the file exists.")
+
+ with open(config) as f:
+ project_config = YAML(typ="safe", pure=True).load(f)
+
+ return project_config
diff --git a/deeplabcut/core/__init__.py b/deeplabcut/core/__init__.py
new file mode 100644
index 0000000000..117d127147
--- /dev/null
+++ b/deeplabcut/core/__init__.py
@@ -0,0 +1,10 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/core/config.py b/deeplabcut/core/config.py
new file mode 100644
index 0000000000..db222def3f
--- /dev/null
+++ b/deeplabcut/core/config.py
@@ -0,0 +1,73 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Simple helper methods related to configuration files stored in yaml files."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from pathlib import Path
+
+from ruamel.yaml import YAML
+
+
+def read_config_as_dict(config_path: str | Path) -> dict:
+ """
+ Args:
+ config_path: the path to the configuration file to load
+
+ Returns:
+ The configuration file with pure Python classes
+ """
+ with open(config_path) as f:
+ cfg = YAML(typ="safe", pure=True).load(f)
+
+ return cfg
+
+
+def write_config(config_path: str | Path, config: dict, overwrite: bool = True) -> None:
+ """Writes a pose configuration file to disk.
+
+ Args:
+ config_path: the path where the config should be saved
+ config: the config to save
+ overwrite: whether to overwrite the file if it already exists
+
+ Raises:
+ FileExistsError if overwrite=True and the file already exists
+ """
+ if not overwrite and Path(config_path).exists():
+ raise FileExistsError(f"Cannot write to {config_path} - set overwrite=True to force")
+
+ with open(config_path, "w") as file:
+ YAML().dump(config, file)
+
+
+def pretty_print(
+ config: dict,
+ indent: int = 0,
+ print_fn: Callable[[str], None] | None = None,
+) -> None:
+ """Prints a model configuration in a pretty and readable way.
+
+ Args:
+ config: the config to print
+ indent: the base indent on all keys
+ print_fn: custom function to call (simply calls ``print`` if None)
+ """
+ if print_fn is None:
+ print_fn = print
+
+ for k, v in config.items():
+ if isinstance(v, dict):
+ print_fn(f"{indent * ' '}{k}:")
+ pretty_print(v, indent + 2, print_fn=print_fn)
+ else:
+ print_fn(f"{indent * ' '}{k}: {v}")
diff --git a/deeplabcut/core/conversion_table.py b/deeplabcut/core/conversion_table.py
new file mode 100644
index 0000000000..d40ac940c4
--- /dev/null
+++ b/deeplabcut/core/conversion_table.py
@@ -0,0 +1,80 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Defines conversion tables mapping DeepLabCut project bodyparts to SA bodyparts."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass
+class ConversionTable:
+ """Maps DLC project bodyparts to the corresponding SuperAnimal bodyparts.
+
+ The conversion table must satisfy the following conditions (checked by validate):
+ - All SuperAnimal bodyparts must be valid (defined for the SuperAnimal model)
+ - All project bodyparts must be valid (defined for the DLC project)
+ """
+
+ super_animal: str
+ project_bodyparts: list[str]
+ super_animal_bodyparts: list[str]
+ table: dict[str, str]
+
+ def __post_init__(self):
+ """Validates the table."""
+ self.validate()
+
+ def to_array(self) -> np.ndarray:
+ """
+ Returns:
+ An array mapping the indices of SuperAnimal bodyparts
+
+ Raises:
+ ValueError: If the conversion table is misconfigured.
+ """
+ self.validate()
+ sa_indices = {sa_bpt: i for i, sa_bpt in enumerate(self.super_animal_bodyparts)}
+ sa_bpt_ordering = [self.table[bpt] for bpt in self.converted_bodyparts()]
+ return np.array([sa_indices[sa_bpt] for sa_bpt in sa_bpt_ordering])
+
+ def converted_bodyparts(self) -> list[str]:
+ """Returns: The project bodyparts included in this ordered"""
+ return [bpt for bpt in self.project_bodyparts if bpt in self.table]
+
+ def validate(self) -> None:
+ """
+ Raises:
+ ValueError: If the conversion table is misconfigured.
+ """
+ project_bpts = set(self.project_bodyparts)
+ sa_bpts = set(self.super_animal_bodyparts)
+
+ mapped_sa = set(self.table.values())
+ mapped_project = set(self.table.keys())
+
+ # check all mapped SuperAnimal bodyparts are in the config
+ if len(mapped_sa.difference(sa_bpts)) != 0:
+ extra_bodyparts = set(mapped_sa).difference(sa_bpts)
+ raise ValueError(
+ f"Some bodyparts in your mapping are not in the {self.super_animal} "
+ f"model: {extra_bodyparts}. Available bodyparts are {' '.join(sa_bpts)}"
+ )
+
+ # check all given bodyparts are in the project configuration
+ if len(mapped_project.difference(project_bpts)) != 0:
+ extra_bodyparts = mapped_project.difference(project_bpts)
+ raise ValueError(
+ "Some bodyparts in your mapping are not in your project configuration: "
+ f"{extra_bodyparts}. Defined bodyparts are {' '.join(project_bpts)}"
+ )
diff --git a/deeplabcut/core/crossvalutils.py b/deeplabcut/core/crossvalutils.py
new file mode 100644
index 0000000000..e62f19ddcc
--- /dev/null
+++ b/deeplabcut/core/crossvalutils.py
@@ -0,0 +1,457 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+
+import os
+import pickle
+import shutil
+from collections import defaultdict
+from copy import deepcopy
+
+import networkx as nx
+import numpy as np
+import pandas as pd
+from scipy.spatial import cKDTree
+from sklearn.metrics.cluster import contingency_matrix
+from tqdm import tqdm
+
+from deeplabcut.core.inferenceutils import (
+ Assembler,
+ _parse_ground_truth_data,
+ evaluate_assembly,
+)
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+
+
+def _set_up_evaluation(data):
+ params = dict()
+ params["joint_names"] = data["metadata"]["all_joints_names"]
+ params["num_joints"] = len(params["joint_names"])
+ partaffinityfield_graph = data["metadata"]["PAFgraph"]
+ params["paf"] = np.arange(len(partaffinityfield_graph))
+ params["paf_graph"] = params["paf_links"] = [partaffinityfield_graph[l] for l in params["paf"]]
+ params["bpts"] = params["ibpts"] = range(params["num_joints"])
+ params["imnames"] = [fn for fn in list(data) if fn != "metadata"]
+ return params
+
+
+def _form_original_path(path):
+ root, filename = os.path.split(path)
+ base, ext = os.path.splitext(filename)
+ return os.path.join(root, filename.split("c")[0] + ext)
+
+
+def _unsorted_unique(array):
+ _, inds = np.unique(array, return_index=True)
+ return np.asarray(array)[np.sort(inds)]
+
+
+def find_closest_neighbors(query: np.ndarray, ref: np.ndarray, k: int = 3) -> np.ndarray:
+ """Greedy matching of predicted keypoints to ground truth keypoints.
+
+ Args:
+ query: the query keypoints
+ ref: the reference keypoints
+ k: The list of k-th nearest neighbors to return.
+
+ Returns:
+ an array of shape (len(query), ) containing the index of the closest
+ reference keypoint for each query keypoint
+ """
+ n_preds = ref.shape[0]
+ tree = cKDTree(ref)
+ dist, inds = tree.query(query, k=k)
+ idx = np.argsort(dist[:, 0])
+ neighbors = np.full(len(query), -1, dtype=int)
+ picked = {tree.n}
+ for i, ind in enumerate(inds[idx]):
+ for j in ind:
+ if j not in picked:
+ picked.add(j)
+ neighbors[idx[i]] = j
+ break
+ if len(picked) == (n_preds + 1):
+ break
+ return neighbors
+
+
+def _calc_separability(vals_left, vals_right, n_bins=101, metric="jeffries", max_sensitivity=False):
+ if metric not in ("jeffries", "auc"):
+ raise ValueError("`metric` should be either 'jeffries' or 'auc'.")
+
+ bins = np.linspace(0, 1, n_bins)
+ hist_left = np.histogram(vals_left, bins=bins)[0]
+ hist_left = hist_left / hist_left.sum()
+ hist_right = np.histogram(vals_right, bins=bins)[0]
+ hist_right = hist_right / hist_right.sum()
+ tpr = np.cumsum(hist_right)
+ if metric == "jeffries":
+ sep = np.sqrt(2 * (1 - np.sum(np.sqrt(hist_left * hist_right)))) # Jeffries-Matusita distance
+ else:
+ sep = np.trapz(np.cumsum(hist_left), tpr)
+ if max_sensitivity:
+ threshold = bins[max(1, np.argmax(tpr > 0))]
+ else:
+ threshold = bins[np.argmin(1 - np.cumsum(hist_left) + tpr)]
+ return sep, threshold
+
+
+def _calc_within_between_pafs(
+ data,
+ metadata,
+ per_edge=True,
+ train_set_only=True,
+):
+ data = deepcopy(data)
+ train_inds = set(metadata["data"]["trainIndices"])
+ graph = data["metadata"]["PAFgraph"]
+ within_train = defaultdict(list)
+ within_test = defaultdict(list)
+ between_train = defaultdict(list)
+ between_test = defaultdict(list)
+ for i, (key, dict_) in enumerate(data.items()):
+ if key == "metadata":
+ continue
+
+ is_train = i in train_inds
+ if train_set_only and not is_train:
+ continue
+
+ df = dict_["groundtruth"][2]
+ try:
+ df.drop("single", level="individuals", inplace=True)
+ except KeyError:
+ pass
+ bpts = df.index.get_level_values("bodyparts").unique().to_list()
+ coords_gt = (
+ df.unstack(["individuals", "coords"])
+ .reindex(bpts, level="bodyparts")
+ .to_numpy()
+ .reshape((len(bpts), -1, 2))
+ )
+ if np.isnan(coords_gt).all():
+ continue
+
+ coords = dict_["prediction"]["coordinates"][0]
+ # Get animal IDs and corresponding indices in the arrays of detections
+ lookup = dict()
+ for i, (coord, coord_gt) in enumerate(zip(coords, coords_gt, strict=False)):
+ inds = np.flatnonzero(np.all(~np.isnan(coord), axis=1))
+ inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1))
+ if inds.size and inds_gt.size:
+ neighbors = find_closest_neighbors(coord_gt[inds_gt], coord[inds], k=3)
+ found = neighbors != -1
+ lookup[i] = dict(zip(inds_gt[found], inds[neighbors[found]], strict=False))
+
+ costs = dict_["prediction"]["costs"]
+ for k, v in costs.items():
+ paf = v["m1"]
+ mask_within = np.zeros(paf.shape, dtype=bool)
+ s, t = graph[k]
+ if s not in lookup or t not in lookup:
+ continue
+ lu_s = lookup[s]
+ lu_t = lookup[t]
+ common_id = set(lu_s).intersection(lu_t)
+ for id_ in common_id:
+ mask_within[lu_s[id_], lu_t[id_]] = True
+ within_vals = paf[mask_within]
+ between_vals = paf[~mask_within]
+ if is_train:
+ within_train[k].extend(within_vals)
+ between_train[k].extend(between_vals)
+ else:
+ within_test[k].extend(within_vals)
+ between_test[k].extend(between_vals)
+ if not per_edge:
+ within_train = np.concatenate([*within_train.values()])
+ within_test = np.concatenate([*within_test.values()])
+ between_train = np.concatenate([*between_train.values()])
+ between_test = np.concatenate([*between_test.values()])
+ return (within_train, within_test), (between_train, between_test)
+
+
+def _benchmark_paf_graphs(
+ config,
+ inference_cfg,
+ data,
+ paf_inds,
+ greedy=False,
+ add_discarded=True,
+ identity_only=False,
+ calibration_file="",
+ oks_sigma=0.1,
+ margin=0,
+ symmetric_kpts=None,
+ split_inds=None,
+):
+ metadata = data.pop("metadata")
+ multi_bpts_orig = auxfun_multianimal.extractindividualsandbodyparts(config)[2]
+ multi_bpts = [j for j in metadata["all_joints_names"] if j in multi_bpts_orig]
+ n_multi = len(multi_bpts)
+ data_ = {"metadata": metadata}
+ for k, v in data.items():
+ data_[k] = v["prediction"]
+ ass = Assembler(
+ data_,
+ max_n_individuals=inference_cfg["topktoretain"],
+ n_multibodyparts=n_multi,
+ greedy=greedy,
+ pcutoff=inference_cfg.get("pcutoff", 0.1),
+ min_affinity=inference_cfg.get("pafthreshold", 0.1),
+ add_discarded=add_discarded,
+ identity_only=identity_only,
+ )
+ if calibration_file:
+ ass.calibrate(calibration_file)
+
+ params = ass.metadata
+ image_paths = params["imnames"]
+ bodyparts = params["joint_names"]
+ idx = data[image_paths[0]]["groundtruth"][2].unstack("coords").reindex(bodyparts, level="bodyparts").index
+ mask_multi = idx.get_level_values("individuals") != "single"
+ if not mask_multi.all():
+ idx = idx.drop("single", level="individuals")
+ individuals = idx.get_level_values("individuals").unique()
+ n_individuals = len(individuals)
+ map_ = dict(zip(individuals, range(n_individuals), strict=False))
+
+ # Form ground truth beforehand
+ ground_truth = []
+ for i, imname in enumerate(image_paths):
+ temp = data[imname]["groundtruth"][2].reindex(multi_bpts, level="bodyparts")
+ ground_truth.append(temp.to_numpy().reshape((-1, 2)))
+ ground_truth = np.stack(ground_truth)
+ temp = np.ones((*ground_truth.shape[:2], 3))
+ temp[..., :2] = ground_truth
+ temp = temp.reshape((temp.shape[0], n_individuals, -1, 3))
+ ass_true_dict = _parse_ground_truth_data(temp)
+ ids = np.vectorize(map_.get)(idx.get_level_values("individuals").to_numpy())
+ ground_truth = np.insert(ground_truth, 2, ids, axis=2)
+
+ # Assemble animals on the full set of detections
+ paf_inds = sorted(paf_inds, key=len)
+ n_graphs = len(paf_inds)
+ all_scores = []
+ all_metrics = []
+ all_assemblies = []
+ for j, paf in enumerate(paf_inds, start=1):
+ print(f"Graph {j}|{n_graphs}")
+ ass.paf_inds = paf
+ ass.assemble()
+ all_assemblies.append((ass.assemblies, ass.unique, ass.metadata["imnames"]))
+ if split_inds is not None:
+ oks = []
+
+ # get the indices of the images in the training set
+ dataset_idx = [data[image_name]["index"] for image_name in image_paths]
+ for inds in split_inds:
+ ass_gt = {k: v for k, v in ass_true_dict.items() if dataset_idx[k] in inds}
+ ass_pred = {k: v for k, v in ass.assemblies.items() if dataset_idx[k] in inds}
+
+ oks.append(
+ evaluate_assembly(
+ ass_pred,
+ ass_gt,
+ oks_sigma,
+ margin=margin,
+ symmetric_kpts=symmetric_kpts,
+ greedy_matching=inference_cfg.get("greedy_oks", False),
+ )
+ )
+ else:
+ oks = evaluate_assembly(
+ ass.assemblies,
+ ass_true_dict,
+ oks_sigma,
+ margin=margin,
+ symmetric_kpts=symmetric_kpts,
+ greedy_matching=inference_cfg.get("greedy_oks", False),
+ )
+ all_metrics.append(oks)
+ scores = np.full((len(image_paths), 2), np.nan)
+ for i, imname in enumerate(tqdm(image_paths)):
+ gt = ground_truth[i]
+ gt = gt[~np.isnan(gt).any(axis=1)]
+ if len(np.unique(gt[:, 2])) < 2: # Only consider frames with 2+ animals
+ continue
+
+ # Count the number of unassembled bodyparts
+ n_dets = len(gt)
+ animals = ass.assemblies.get(i)
+ if animals is None:
+ if n_dets:
+ scores[i, 0] = 1
+ else:
+ animals = [np.c_[animal.data, np.ones(animal.data.shape[0]) * n] for n, animal in enumerate(animals)]
+ hyp = np.concatenate(animals)
+ hyp = hyp[~np.isnan(hyp).any(axis=1)]
+ scores[i, 0] = max(0, (n_dets - hyp.shape[0]) / n_dets)
+ neighbors = find_closest_neighbors(gt[:, :2], hyp[:, :2])
+ valid = neighbors != -1
+ id_gt = gt[valid, 2]
+ id_hyp = hyp[neighbors[valid], -1]
+ mat = contingency_matrix(id_gt, id_hyp)
+ purity = mat.max(axis=0).sum() / mat.sum()
+ scores[i, 1] = purity
+ all_scores.append((scores, paf))
+
+ dfs = []
+ for score, inds in all_scores:
+ df = pd.DataFrame(score, columns=["miss", "purity"])
+ df["ngraph"] = len(inds)
+ dfs.append(df)
+ big_df = pd.concat(dfs)
+ group = big_df.groupby("ngraph")
+ return (all_scores, group.agg(["mean", "std"]).T, all_metrics, all_assemblies)
+
+
+def _get_n_best_paf_graphs(
+ data,
+ metadata,
+ full_graph,
+ n_graphs=10,
+ root=None,
+ which="best",
+ ignore_inds=None,
+ metric="auc",
+):
+ if which not in ("best", "worst"):
+ raise ValueError('`which` must be either "best" or "worst"')
+
+ (within_train, _), (between_train, _) = _calc_within_between_pafs(
+ data,
+ metadata,
+ train_set_only=True,
+ )
+ # Handle unlabeled bodyparts...
+ existing_edges = set(k for k, v in within_train.items() if v)
+ if ignore_inds is not None:
+ existing_edges = existing_edges.difference(ignore_inds)
+ existing_edges = list(existing_edges)
+
+ if not any(between_train.values()):
+ # Only 1 animal, let us return the full graph indices only
+ return ([existing_edges], dict(zip(existing_edges, [0] * len(existing_edges), strict=False)))
+
+ scores, _ = zip(
+ *[_calc_separability(between_train[n], within_train[n], metric=metric) for n in existing_edges], strict=False
+ )
+
+ # Find minimal skeleton
+ G = nx.Graph()
+ for edge, score in zip(existing_edges, scores, strict=False):
+ if np.isfinite(score):
+ G.add_edge(*full_graph[edge], weight=score)
+ if which == "best":
+ order = np.asarray(existing_edges)[np.argsort(scores)[::-1]]
+ if root is None:
+ root = []
+ for edge in nx.maximum_spanning_edges(G, data=False):
+ root.append(full_graph.index(sorted(edge)))
+ else:
+ order = np.asarray(existing_edges)[np.argsort(scores)]
+ if root is None:
+ root = []
+ for edge in nx.minimum_spanning_edges(G, data=False):
+ root.append(full_graph.index(sorted(edge)))
+
+ n_edges = len(existing_edges) - len(root)
+ lengths = np.linspace(0, n_edges, min(n_graphs, n_edges + 1), dtype=int)[1:]
+ order = order[np.isin(order, root, invert=True)]
+ paf_inds = [root]
+ for length in lengths:
+ paf_inds.append(root + list(order[:length]))
+ return paf_inds, dict(zip(existing_edges, scores, strict=False))
+
+
+def cross_validate_paf_graphs(
+ config,
+ inference_config,
+ full_data_file,
+ metadata_file,
+ output_name="",
+ pcutoff=0.1,
+ oks_sigma=0.1,
+ margin=0,
+ greedy=False,
+ add_discarded=True,
+ calibrate=False,
+ overwrite_config=True,
+ n_graphs=10,
+ paf_inds=None,
+ symmetric_kpts=None,
+):
+ cfg = auxiliaryfunctions.read_config(config)
+ inf_cfg = auxiliaryfunctions.read_plainconfig(inference_config)
+ inf_cfg_temp = inf_cfg.copy()
+ inf_cfg_temp["pcutoff"] = pcutoff
+
+ with open(full_data_file, "rb") as file:
+ data = pickle.load(file)
+ with open(metadata_file, "rb") as file:
+ metadata = pickle.load(file)
+
+ params = _set_up_evaluation(data)
+ to_ignore = auxfun_multianimal.filter_unwanted_paf_connections(cfg, params["paf_graph"])
+ best_graphs = _get_n_best_paf_graphs(
+ data,
+ metadata,
+ params["paf_graph"],
+ ignore_inds=to_ignore,
+ n_graphs=n_graphs,
+ )
+ paf_scores = best_graphs[1]
+ if paf_inds is None:
+ paf_inds = best_graphs[0]
+
+ if calibrate:
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ calibration_file = os.path.join(
+ cfg["project_path"],
+ str(trainingsetfolder),
+ "CollectedData_" + cfg["scorer"] + ".h5",
+ )
+ else:
+ calibration_file = ""
+
+ results = _benchmark_paf_graphs(
+ cfg,
+ inf_cfg_temp,
+ data,
+ paf_inds,
+ greedy,
+ add_discarded,
+ oks_sigma=oks_sigma,
+ margin=margin,
+ symmetric_kpts=symmetric_kpts,
+ calibration_file=calibration_file,
+ split_inds=[
+ metadata["data"]["trainIndices"],
+ metadata["data"]["testIndices"],
+ ],
+ )
+ # Select optimal PAF graph
+ df = results[1]
+ size_opt = np.argmax((1 - df.loc["miss", "mean"]) * df.loc["purity", "mean"])
+ pose_config = inference_config.replace("inference_cfg", "pose_cfg")
+ if not overwrite_config:
+ shutil.copy(pose_config, pose_config.replace(".yaml", "_old.yaml"))
+ inds = list(paf_inds[size_opt])
+ auxiliaryfunctions.edit_config(pose_config, {"paf_best": [int(ind) for ind in inds]})
+ if output_name:
+ with open(output_name, "wb") as file:
+ pickle.dump([results], file)
+ return results[:3], paf_scores, results[3][size_opt]
+
+
+# Backwards compatibility
+_find_closest_neighbors = find_closest_neighbors
diff --git a/deeplabcut/core/debug/__init__.py b/deeplabcut/core/debug/__init__.py
new file mode 100644
index 0000000000..bbc583fe09
--- /dev/null
+++ b/deeplabcut/core/debug/__init__.py
@@ -0,0 +1,46 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from collections.abc import Sequence
+
+from .debug_logger import (
+ DLC_ALL_LIBS_SPECS,
+ DebugSection,
+ ExecutableSpec,
+ InMemoryDebugRecorder,
+ LibrarySpec,
+ RecordedLog,
+ build_debug_report,
+ collect_debug_sections,
+ collect_executable_summary,
+ collect_version_summary,
+ format_debug_report,
+ get_debug_recorder,
+ install_debug_recorder,
+ log_timing,
+)
+
+__all__: Sequence[str] = (
+ "DLC_ALL_LIBS_SPECS",
+ "ExecutableSpec",
+ "DebugSection",
+ "InMemoryDebugRecorder",
+ "LibrarySpec",
+ "RecordedLog",
+ "build_debug_report",
+ "collect_debug_sections",
+ "collect_executable_summary",
+ "collect_version_summary",
+ "format_debug_report",
+ "get_debug_recorder",
+ "install_debug_recorder",
+ "log_timing",
+)
diff --git a/deeplabcut/core/debug/_debug_utils.py b/deeplabcut/core/debug/_debug_utils.py
new file mode 100644
index 0000000000..95e8ef4713
--- /dev/null
+++ b/deeplabcut/core/debug/_debug_utils.py
@@ -0,0 +1,87 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+from collections.abc import Sequence
+from pathlib import Path
+
+
+def _env_flag(name: str, default: bool = False) -> bool:
+ """Parse a boolean environment variable.
+
+ Accepted truthy values:
+ 1, true, yes, on
+
+ Accepted falsy values:
+ 0, false, no, off
+ """
+ value = os.getenv(name)
+ if value is None:
+ return default
+
+ value = value.strip().lower()
+ if value in {"1", "true", "yes", "on"}:
+ return True
+ if value in {"0", "false", "no", "off"}:
+ return False
+ return default
+
+
+def _env_optional_float(name: str, default: float | None = None) -> float | None:
+ """Parse an optional float environment variable.
+
+ Empty strings / unset values return ``default``.
+ Invalid values also fall back to ``default``.
+ """
+ value = os.getenv(name)
+ if value is None:
+ return default
+
+ value = value.strip()
+ if not value:
+ return default
+
+ try:
+ return float(value)
+ except ValueError:
+ return default
+
+
+def _which(command: str) -> str:
+ try:
+ resolved = shutil.which(command)
+ return str(Path(resolved).resolve()) if resolved else "not-found"
+ except Exception:
+ return "not-found"
+
+
+def _command_version(command: str, version_args: Sequence[str] = ("-version",)) -> str:
+ try:
+ completed = subprocess.run(
+ [command, *version_args],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=3,
+ )
+ except Exception:
+ return "unavailable"
+
+ text = (completed.stdout or completed.stderr or "").strip()
+ if not text:
+ return "unavailable"
+
+ first_line = text.splitlines()[0].strip()
+ return first_line or "unavailable"
diff --git a/deeplabcut/core/debug/debug_logger.py b/deeplabcut/core/debug/debug_logger.py
new file mode 100644
index 0000000000..a04b72e5b4
--- /dev/null
+++ b/deeplabcut/core/debug/debug_logger.py
@@ -0,0 +1,595 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from __future__ import annotations
+
+import logging
+import platform
+import sys
+import threading
+import traceback
+from collections import deque
+from collections.abc import Iterable
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+from importlib import metadata
+from pathlib import Path
+from time import perf_counter_ns
+
+from ._debug_utils import (
+ _command_version,
+ _env_flag,
+ _env_optional_float,
+ _which,
+)
+
+_DEBUG_HANDLER_ATTR = "_dlc_debug_recorder"
+LOG_QUEUE_MAXLEN = 1000
+
+# NOTE @C-Achard 2026-05-13: we may want to centralize env vars in a config/settings module in the future
+DLC_LOG_TIMING = _env_flag("DLC_LOG_TIMING", default=False)
+DLC_LOG_TIMING_THRESHOLD_MS = _env_optional_float("DLC_LOG_TIMING_THRESHOLD_MS", default=None)
+
+
+def reload_debug_settings_from_env() -> None:
+ """Reload debug/timing settings from environment variables."""
+ global DLC_LOG_TIMING, DLC_LOG_TIMING_THRESHOLD_MS
+
+ DLC_LOG_TIMING = _env_flag("DLC_LOG_TIMING", default=False)
+ DLC_LOG_TIMING_THRESHOLD_MS = _env_optional_float(
+ "DLC_LOG_TIMING_THRESHOLD_MS",
+ default=None,
+ )
+
+
+@contextmanager
+def log_timing(
+ logger: logging.Logger,
+ label: str,
+ *,
+ level: int = logging.DEBUG,
+ threshold_ms: float | None = None,
+):
+ """Lightweight scoped timer for debug instrumentation.
+
+ Uses perf_counter_ns() for monotonic timing.
+ Logs only if logger is enabled for the requested level.
+ Optionally suppresses tiny timings below ``threshold_ms``.
+ """
+ if not logger.isEnabledFor(level) or not DLC_LOG_TIMING:
+ yield
+ return
+
+ effective_threshold_ms = threshold_ms if threshold_ms is not None else DLC_LOG_TIMING_THRESHOLD_MS
+ t0 = perf_counter_ns()
+ try:
+ yield
+ finally:
+ dt_ms = (perf_counter_ns() - t0) / 1e6
+ if effective_threshold_ms is None or dt_ms >= effective_threshold_ms:
+ logger.log(level, "%s took %.3f ms", label, dt_ms)
+
+
+@dataclass(frozen=True)
+class RecordedLog:
+ created: float
+ level: str
+ logger_name: str
+ message: str
+ exc_text: str | None = None
+
+
+class InMemoryDebugRecorder(logging.Handler):
+ """Lightweight, fail-open in-memory log recorder.
+
+ Safety properties:
+ - bounded memory via deque(maxlen=...)
+ - no file/network I/O
+ - swallow-all-errors in emit()
+ - does not log from inside itself
+ - stores only small text snapshots
+ """
+
+ def __init__(self, *, capacity: int = LOG_QUEUE_MAXLEN, level: int = logging.DEBUG):
+ super().__init__(level=level)
+ self._records: deque[RecordedLog] = deque(maxlen=max(1, int(capacity)))
+ self._lock = threading.Lock()
+ self._dropped = 0
+
+ @property
+ def dropped_count(self) -> int:
+ with self._lock:
+ return self._dropped
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ # Never call logging from here.
+ # Never inspect application objects.
+ msg = self._safe_message(record)
+ exc_text = self._safe_exception_text(record)
+
+ snap = RecordedLog(
+ created=float(getattr(record, "created", 0.0) or 0.0),
+ level=str(getattr(record, "levelname", "UNKNOWN")),
+ logger_name=str(getattr(record, "name", "")),
+ message=msg,
+ exc_text=exc_text,
+ )
+
+ with self._lock:
+ self._records.append(snap)
+
+ except Exception:
+ # Fail open: never let diagnostics interfere with runtime behavior.
+ try:
+ self._dropped += 1
+ except Exception:
+ pass
+
+ def clear(self) -> None:
+ try:
+ with self._lock:
+ self._records.clear()
+ self._dropped = 0
+ except Exception:
+ pass
+
+ def snapshot(self) -> list[RecordedLog]:
+ try:
+ with self._lock:
+ return list(self._records)
+ except Exception:
+ return []
+
+ def render_text(self, *, limit: int = 200) -> str:
+ lines: list[str] = []
+ try:
+ records = self.snapshot()[-max(1, int(limit)) :]
+ if not records:
+ if self._dropped:
+ return f"[debug-recorder] no captured logs, {self._dropped} internal failures"
+ return ""
+
+ base = records[0].created
+ for rec in records:
+ ts = datetime.fromtimestamp(rec.created).strftime("%H:%M:%S.%f")[:-3]
+ if DLC_LOG_TIMING:
+ rel_ms = (rec.created - base) * 1000.0
+ lines.append(f"{ts} (+{rel_ms:8.1f} ms) | {rec.level:<8} | {rec.logger_name} | {rec.message}")
+ else:
+ lines.append(f"{ts} | {rec.level:<8} | {rec.logger_name} | {rec.message}")
+ if rec.exc_text:
+ lines.append(rec.exc_text.rstrip())
+
+ if self._dropped:
+ lines.append(f"[debug-recorder] dropped internal failures: {self._dropped}")
+ except Exception:
+ return "[debug-recorder] failed to render logs"
+ return "\n".join(lines)
+
+ @staticmethod
+ def _safe_message(record: logging.LogRecord) -> str:
+ try:
+ return record.getMessage()
+ except Exception:
+ try:
+ return str(record.msg)
+ except Exception:
+ return ""
+
+ @staticmethod
+ def _safe_exception_text(record: logging.LogRecord) -> str | None:
+ try:
+ if not record.exc_info:
+ return None
+ return "".join(traceback.format_exception(*record.exc_info))
+ except Exception:
+ return ""
+
+
+@dataclass(frozen=True)
+class DebugSection:
+ title: str
+ items: dict[str, str]
+
+
+def install_debug_recorder(
+ *,
+ logger_name: str = "deeplabcut",
+ capacity: int = LOG_QUEUE_MAXLEN,
+ handler_level: int = logging.INFO,
+ ensure_logger_level: int | None = None,
+) -> InMemoryDebugRecorder:
+ """Attach a single in-memory recorder to the requested logger namespace.
+
+ Idempotent: repeated calls return the same recorder.
+
+ Parameters
+ ----------
+ logger_name:
+ Logger namespace to attach the recorder to.
+ capacity:
+ Maximum number of captured records. By default, uses LOG_QUEUE_MAXLEN.
+ handler_level:
+ Minimum level stored by the recorder itself.
+ ensure_logger_level:
+ Controls whether to adjust the target logger level.
+
+ - None: never modify the logger level
+ - int: lower the logger only if its effective level is more restrictive
+ """
+
+ root_logger = logging.getLogger(logger_name)
+
+ existing = getattr(root_logger, _DEBUG_HANDLER_ATTR, None)
+ if isinstance(existing, InMemoryDebugRecorder):
+ return existing
+
+ recorder = InMemoryDebugRecorder(capacity=capacity, level=handler_level)
+ recorder.set_name("deeplabcut-debug-recorder")
+
+ # Important:
+ # - attach only to a DLC-owned logger namespace, not the global root logger
+ # - keep propagation unchanged
+ # - logger level adjustment, if any, is handled below; "auto" initializes
+ # an unset logger to ``handler_level`` rather than forcing DEBUG
+ root_logger.addHandler(recorder)
+
+ if isinstance(ensure_logger_level, int):
+ # Only lower verbosity if explicitly requested.
+ if root_logger.getEffectiveLevel() > ensure_logger_level:
+ root_logger.setLevel(ensure_logger_level)
+
+ setattr(root_logger, _DEBUG_HANDLER_ATTR, recorder)
+ return recorder
+
+
+def get_debug_recorder(*, logger_name: str = "deeplabcut") -> InMemoryDebugRecorder | None:
+ logger = logging.getLogger(logger_name)
+ recorder = getattr(logger, _DEBUG_HANDLER_ATTR, None)
+ return recorder if isinstance(recorder, InMemoryDebugRecorder) else None
+
+
+# --------------------------
+# Environment / version info
+# --------------------------
+
+
+@dataclass(frozen=True)
+class LibrarySpec:
+ """Small description of a library to report."""
+
+ key: str
+ dist_name: str | None = None
+ module_name: str | None = None
+ prefer_module_version: bool = False
+
+ def resolved_dist_name(self) -> str:
+ return self.dist_name or self.key
+
+ def resolved_module_name(self) -> str:
+ return self.module_name or self.key
+
+
+DLC_CORE_LIBS: tuple[LibrarySpec, ...] = (
+ LibrarySpec("deeplabcut"),
+ LibrarySpec("torch"),
+ LibrarySpec("torchvision"),
+ LibrarySpec("numpy"),
+ LibrarySpec("pandas"),
+ LibrarySpec("scipy"),
+ LibrarySpec("h5py"),
+ LibrarySpec("tables"),
+ LibrarySpec("opencv-python", dist_name="opencv-python", module_name="cv2", prefer_module_version=True),
+)
+DLC_GUI_LIBS: tuple[LibrarySpec, ...] = (
+ LibrarySpec("PySide6"),
+ LibrarySpec("shiboken6"),
+ LibrarySpec("qtpy", dist_name="QtPy"),
+ LibrarySpec("qdarkstyle"),
+ LibrarySpec("napari"),
+ LibrarySpec("napari-deeplabcut", dist_name="napari-deeplabcut", module_name="napari_deeplabcut"),
+)
+DLC_TF_LIBS: tuple[LibrarySpec, ...] = (
+ LibrarySpec("tensorflow"),
+ LibrarySpec("tf_keras", dist_name="tf-keras"),
+ LibrarySpec("tensorpack"),
+ LibrarySpec("tf_slim", dist_name="tf-slim"),
+)
+DLC_ALL_LIBS_SPECS: tuple[LibrarySpec, ...] = DLC_CORE_LIBS + DLC_GUI_LIBS + DLC_TF_LIBS
+
+
+def _normalize_library_specs(
+ libraries: Iterable[LibrarySpec | str] | None,
+) -> tuple[LibrarySpec, ...]:
+ if libraries is None:
+ return DLC_ALL_LIBS_SPECS
+
+ normalized: list[LibrarySpec] = []
+ for item in libraries:
+ if isinstance(item, LibrarySpec):
+ normalized.append(item)
+ else:
+ normalized.append(LibrarySpec(str(item)))
+ return tuple(normalized)
+
+
+def _version(dist_name: str) -> str:
+ try:
+ return metadata.version(dist_name)
+ except Exception:
+ return "not-installed"
+
+
+def _module_path(module_name: str) -> str:
+ try:
+ mod = __import__(module_name)
+ p = getattr(mod, "__file__", None)
+ return str(Path(p).resolve()) if p else "unknown"
+ except Exception:
+ return "unknown"
+
+
+def _safe_tail(pathlike: object) -> str:
+ """Redact user-specific absolute paths.
+
+ Keeps only the last 2 path components when possible.
+ """
+ try:
+ p = Path(str(pathlike))
+ parts = p.parts
+ if len(parts) >= 2:
+ return str(Path(*parts[-2:]).as_posix())
+ return str(p.as_posix())
+ except Exception:
+ return str(pathlike)
+
+
+def _module_version(module_name: str) -> str:
+ try:
+ mod = __import__(module_name)
+ version = getattr(mod, "__version__", None)
+ if version:
+ return str(version)
+ return "unknown"
+ except Exception:
+ return "not-installed"
+
+
+def collect_version_summary(
+ *,
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ include_module_paths: bool = False,
+) -> dict[str, str]:
+ specs = _normalize_library_specs(libraries)
+ summary: dict[str, str] = {}
+
+ for spec in specs:
+ key = spec.key
+ module_name = spec.resolved_module_name()
+
+ if spec.prefer_module_version:
+ version = _module_version(module_name)
+ if version in {"not-installed", "unknown"}:
+ version = _version(spec.resolved_dist_name())
+ else:
+ version = _version(spec.resolved_dist_name())
+
+ summary[key] = version
+
+ if include_module_paths:
+ summary[f"{key}_module_path"] = _safe_tail(_module_path(module_name))
+
+ return summary
+
+
+@dataclass(frozen=True)
+class ExecutableSpec:
+ """Small description of an external executable to report.
+
+ Parameters
+ ----------
+ key:
+ Label used in the output report.
+ command:
+ Executable name or absolute path to resolve.
+ version_args:
+ Arguments used to query the executable version.
+ """
+
+ key: str
+ command: str | None = None
+ version_args: tuple[str, ...] = ("-version",)
+
+ def resolved_command(self) -> str:
+ return self.command or self.key
+
+
+DEFAULT_EXECUTABLE_SPECS: tuple[ExecutableSpec, ...] = (ExecutableSpec("ffmpeg"),)
+
+
+def _normalize_executable_specs(
+ executables: Iterable[ExecutableSpec | str] | None,
+) -> tuple[ExecutableSpec, ...]:
+ if executables is None:
+ return DEFAULT_EXECUTABLE_SPECS
+
+ normalized: list[ExecutableSpec] = []
+ for item in executables:
+ if isinstance(item, ExecutableSpec):
+ normalized.append(item)
+ else:
+ normalized.append(ExecutableSpec(str(item)))
+ return tuple(normalized)
+
+
+def collect_executable_summary(
+ *,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_paths: bool = True,
+) -> dict[str, str]:
+ specs = _normalize_executable_specs(executables)
+ summary: dict[str, str] = {}
+
+ for spec in specs:
+ key = spec.key
+ command = spec.resolved_command()
+ summary[key] = _command_version(command, spec.version_args)
+ if include_paths:
+ summary[f"{key}_path"] = _safe_tail(_which(command))
+
+ return summary
+
+
+# --------------------------
+# Report formatting
+# --------------------------
+
+
+def format_debug_report(
+ *,
+ sections: Iterable[DebugSection],
+ logs_text: str,
+) -> str:
+ lines: list[str] = []
+
+ for section in sections:
+ lines.append(f"## {section.title}")
+ if section.items:
+ for k, v in section.items.items():
+ lines.append(f"- {k}: {v}")
+ else:
+ lines.append("- ")
+ lines.append("")
+
+ lines.append("## Recent logs")
+ lines.append("```text")
+ lines.append(logs_text or "")
+ lines.append("```")
+
+ return "\n".join(lines)
+
+
+def build_debug_report(
+ *,
+ recorder: InMemoryDebugRecorder | None,
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_module_paths: bool = False,
+ include_executable_paths: bool = True,
+ log_limit: int = 300,
+) -> str:
+ logs_text = recorder.render_text(limit=log_limit) if recorder is not None else ""
+
+ sections = collect_debug_sections(
+ libraries=libraries,
+ executables=executables,
+ include_module_paths=include_module_paths,
+ include_executable_paths=include_executable_paths,
+ )
+
+ return format_debug_report(
+ sections=sections,
+ logs_text=logs_text,
+ )
+
+
+def collect_runtime_summary() -> dict[str, str]:
+ return {
+ "python": sys.version.replace("\n", " "),
+ "platform": platform.platform(),
+ "executable": _safe_tail(sys.executable),
+ }
+
+
+def _section_has_useful_values(items: dict[str, str]) -> bool:
+ for value in items.values():
+ if value not in {"not-installed", "unknown", "not-found", "unavailable"}:
+ return True
+ return False
+
+
+def collect_debug_sections(
+ *,
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_module_paths: bool = False,
+ include_executable_paths: bool = True,
+) -> list[DebugSection]:
+ sections: list[DebugSection] = []
+
+ # Always include the runtime section first
+ sections.append(
+ DebugSection(
+ title="Runtime",
+ items=collect_runtime_summary(),
+ )
+ )
+
+ # Default grouped report using your built-in constants
+ if libraries is None:
+ sections.append(
+ DebugSection(
+ title="DeepLabCut core libraries",
+ items=collect_version_summary(
+ libraries=DLC_CORE_LIBS,
+ include_module_paths=include_module_paths,
+ ),
+ )
+ )
+
+ sections.append(
+ DebugSection(
+ title="GUI libraries",
+ items=collect_version_summary(
+ libraries=DLC_GUI_LIBS,
+ include_module_paths=include_module_paths,
+ ),
+ )
+ )
+
+ tf_items = collect_version_summary(
+ libraries=DLC_TF_LIBS,
+ include_module_paths=include_module_paths,
+ )
+ if tf_items and _section_has_useful_values(tf_items):
+ sections.append(
+ DebugSection(
+ title="TensorFlow libraries",
+ items=tf_items,
+ )
+ )
+ else:
+ # Custom input
+ sections.append(
+ DebugSection(
+ title="Libraries",
+ items=collect_version_summary(
+ libraries=libraries,
+ include_module_paths=include_module_paths,
+ ),
+ )
+ )
+
+ exec_items = collect_executable_summary(
+ executables=executables,
+ include_paths=include_executable_paths,
+ )
+ if exec_items: # report if unavailable
+ sections.append(
+ DebugSection(
+ title="External tools",
+ items=exec_items,
+ ),
+ )
+
+ return sections
diff --git a/deeplabcut/core/engine.py b/deeplabcut/core/engine.py
new file mode 100644
index 0000000000..1f7a51d60b
--- /dev/null
+++ b/deeplabcut/core/engine.py
@@ -0,0 +1,50 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Defines the deep learning frameworks available."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+
+@dataclass(frozen=True)
+class EngineDataMixin:
+ aliases: tuple[str]
+ model_folder_name: str
+ pose_cfg_name: str
+ results_folder_name: str
+
+
+class Engine(EngineDataMixin, Enum):
+ PYTORCH = (
+ ("pytorch", "torch"),
+ "dlc-models-pytorch",
+ "pytorch_config.yaml",
+ "evaluation-results-pytorch",
+ )
+ TF = (
+ ("tensorflow", "tf"),
+ "dlc-models",
+ "pose_cfg.yaml",
+ "evaluation-results",
+ )
+
+ @classmethod
+ def _missing_(cls, value):
+ if isinstance(value, str):
+ for member in cls:
+ if value.lower() in member.aliases:
+ return member
+ return None
+
+ def __repr__(self) -> str:
+ return f"Engine.{self.name}"
diff --git a/deeplabcut/core/inferenceutils.py b/deeplabcut/core/inferenceutils.py
new file mode 100644
index 0000000000..b7bb82f108
--- /dev/null
+++ b/deeplabcut/core/inferenceutils.py
@@ -0,0 +1,1266 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import heapq
+import itertools
+import multiprocessing
+import operator
+import pickle
+import warnings
+from collections import defaultdict
+from collections.abc import Iterable
+from dataclasses import dataclass
+from math import erf, sqrt
+from typing import Any
+
+import networkx as nx
+import numpy as np
+import pandas as pd
+from scipy.optimize import linear_sum_assignment
+from scipy.spatial import cKDTree
+from scipy.spatial.distance import cdist, pdist
+from scipy.special import softmax
+from scipy.stats import chi2, gaussian_kde
+from tqdm import tqdm
+
+
+def _conv_square_to_condensed_indices(ind_row, ind_col, n):
+ if ind_row == ind_col:
+ raise ValueError("There are no diagonal elements in condensed matrices.")
+
+ if ind_row < ind_col:
+ ind_row, ind_col = ind_col, ind_row
+ return n * ind_col - ind_col * (ind_col + 1) // 2 + ind_row - 1 - ind_col
+
+
+Position = tuple[float, float]
+
+
+@dataclass(frozen=True)
+class Joint:
+ pos: Position
+ confidence: float = 1.0
+ label: int = None
+ idx: int = None
+ group: int = -1
+
+
+class Link:
+ def __init__(self, j1, j2, affinity=1):
+ self.j1 = j1
+ self.j2 = j2
+ self.affinity = affinity
+ self._length = sqrt((j1.pos[0] - j2.pos[0]) ** 2 + (j1.pos[1] - j2.pos[1]) ** 2)
+
+ def __repr__(self):
+ return f"Link {self.idx}, affinity={self.affinity:.2f}, length={self.length:.2f}"
+
+ @property
+ def confidence(self):
+ return self.j1.confidence * self.j2.confidence
+
+ @property
+ def idx(self):
+ return self.j1.idx, self.j2.idx
+
+ @property
+ def length(self):
+ return self._length
+
+ @length.setter
+ def length(self, length):
+ self._length = length
+
+ def to_vector(self):
+ return [*self.j1.pos, *self.j2.pos]
+
+
+class Assembly:
+ def __init__(self, size):
+ self.data = np.full((size, 4), np.nan)
+ self.confidence = 0 # 0 by default, overwritten otherwise with `add_joint`
+ self._affinity = 0
+ self._links = []
+ self._visible = set()
+ self._idx = set()
+ self._dict = dict()
+
+ def __len__(self):
+ return len(self._visible)
+
+ def __contains__(self, assembly):
+ return bool(self._visible.intersection(assembly._visible))
+
+ def __add__(self, other):
+ if other in self:
+ raise ValueError("Assemblies contain shared joints.")
+
+ assembly = Assembly(self.data.shape[0])
+ for link in self._links + other._links:
+ assembly.add_link(link)
+ return assembly
+
+ @classmethod
+ def from_array(cls, array):
+ n_bpts, n_cols = array.shape
+
+ # if a single coordinate is NaN for a bodypart, set all to NaN
+ array[np.isnan(array).any(axis=-1)] = np.nan
+
+ ass = cls(size=n_bpts)
+ ass.data[:, :n_cols] = array
+ visible = np.flatnonzero(~np.isnan(array).any(axis=1))
+ if n_cols < 3: # Only xy coordinates are being set
+ ass.data[visible, 2] = 1 # Set detection confidence to 1
+ ass._visible.update(visible)
+ return ass
+
+ @property
+ def xy(self):
+ return self.data[:, :2]
+
+ @property
+ def extent(self):
+ bbox = np.empty(4)
+ bbox[:2] = np.nanmin(self.xy, axis=0)
+ bbox[2:] = np.nanmax(self.xy, axis=0)
+ return bbox
+
+ @property
+ def area(self):
+ x1, y1, x2, y2 = self.extent
+ return (x2 - x1) * (y2 - y1)
+
+ @property
+ def confidence(self):
+ return np.nanmean(self.data[:, 2])
+
+ @confidence.setter
+ def confidence(self, confidence):
+ self.data[:, 2] = confidence
+
+ @property
+ def soft_identity(self):
+ data = self.data[~np.isnan(self.data).any(axis=1)]
+ unq, idx, cnt = np.unique(data[:, 3], return_inverse=True, return_counts=True)
+ avg = np.bincount(idx, weights=data[:, 2]) / cnt
+ soft = softmax(avg)
+ return dict(zip(unq.astype(int), soft, strict=False))
+
+ @property
+ def affinity(self):
+ n_links = self.n_links
+ if not n_links:
+ return 0
+ return self._affinity / n_links
+
+ @property
+ def n_links(self):
+ return len(self._links)
+
+ def intersection_with(self, other):
+ x11, y11, x21, y21 = self.extent
+ x12, y12, x22, y22 = other.extent
+ x1 = max(x11, x12)
+ y1 = max(y11, y12)
+ x2 = min(x21, x22)
+ y2 = min(y21, y22)
+ if x2 < x1 or y2 < y1:
+ return 0
+ ll = np.array([x1, y1])
+ ur = np.array([x2, y2])
+ xy1 = self.xy[~np.isnan(self.xy).any(axis=1)]
+ xy2 = other.xy[~np.isnan(other.xy).any(axis=1)]
+ in1 = np.all((xy1 >= ll) & (xy1 <= ur), axis=1).sum()
+ in2 = np.all((xy2 >= ll) & (xy2 <= ur), axis=1).sum()
+ return min(in1 / len(self), in2 / len(other))
+
+ def add_joint(self, joint):
+ if joint.label in self._visible or joint.label is None:
+ return False
+ self.data[joint.label] = *joint.pos, joint.confidence, joint.group
+ self._visible.add(joint.label)
+ self._idx.add(joint.idx)
+ return True
+
+ def remove_joint(self, joint):
+ if joint.label not in self._visible:
+ return False
+ self.data[joint.label] = np.nan
+ self._visible.remove(joint.label)
+ self._idx.remove(joint.idx)
+ return True
+
+ def add_link(self, link, store_dict=False):
+ if store_dict:
+ # Selective copy; deepcopy is >5x slower
+ self._dict = {
+ "data": self.data.copy(),
+ "_affinity": self._affinity,
+ "_links": self._links.copy(),
+ "_visible": self._visible.copy(),
+ "_idx": self._idx.copy(),
+ }
+ i1, i2 = link.idx
+ if i1 in self._idx and i2 in self._idx:
+ self._affinity += link.affinity
+ self._links.append(link)
+ return False
+ if link.j1.label in self._visible and link.j2.label in self._visible:
+ return False
+ self.add_joint(link.j1)
+ self.add_joint(link.j2)
+ self._affinity += link.affinity
+ self._links.append(link)
+ return True
+
+ def calc_pairwise_distances(self):
+ return pdist(self.xy, metric="sqeuclidean")
+
+
+class Assembler:
+ def __init__(
+ self,
+ data,
+ *,
+ max_n_individuals,
+ n_multibodyparts,
+ graph=None,
+ paf_inds=None,
+ greedy=False,
+ pcutoff=0.1,
+ min_affinity=0.05,
+ min_n_links=2,
+ max_overlap=0.8,
+ identity_only=False,
+ nan_policy="little",
+ force_fusion=False,
+ add_discarded=False,
+ window_size=0,
+ method="m1",
+ ):
+ self.data = data
+ self.metadata = self.parse_metadata(self.data)
+ self.max_n_individuals = max_n_individuals
+ self.n_multibodyparts = n_multibodyparts
+ self.n_uniquebodyparts = self.n_keypoints - n_multibodyparts
+ self.greedy = greedy
+ self.pcutoff = pcutoff
+ self.min_affinity = min_affinity
+ self.min_n_links = min_n_links
+ self.max_overlap = max_overlap
+ self._has_identity = "identity" in self[0]
+ if identity_only and not self._has_identity:
+ warnings.warn("The network was not trained with identity; setting `identity_only` to False.", stacklevel=2)
+ self.identity_only = identity_only & self._has_identity
+ self.nan_policy = nan_policy
+ self.force_fusion = force_fusion
+ self.add_discarded = add_discarded
+ self.window_size = window_size
+ self.method = method
+ self.graph = graph or self.metadata["paf_graph"]
+ self.paf_inds = paf_inds or self.metadata["paf"]
+ self._gamma = 0.01
+ self._trees = dict()
+ self.safe_edge = False
+ self._kde = None
+ self.assemblies = dict()
+ self.unique = dict()
+
+ def __getitem__(self, item):
+ return self.data[self.metadata["imnames"][item]]
+
+ @classmethod
+ def empty(
+ cls,
+ max_n_individuals,
+ n_multibodyparts,
+ n_uniquebodyparts,
+ graph,
+ paf_inds,
+ greedy=False,
+ pcutoff=0.1,
+ min_affinity=0.05,
+ min_n_links=2,
+ max_overlap=0.8,
+ identity_only=False,
+ nan_policy="little",
+ force_fusion=False,
+ add_discarded=False,
+ window_size=0,
+ method="m1",
+ ):
+ # Dummy data
+ n_bodyparts = n_multibodyparts + n_uniquebodyparts
+ data = {
+ "metadata": {
+ "all_joints_names": ["" for _ in range(n_bodyparts)],
+ "PAFgraph": graph,
+ "PAFinds": paf_inds,
+ },
+ "0": {},
+ }
+ return cls(
+ data,
+ max_n_individuals=max_n_individuals,
+ n_multibodyparts=n_multibodyparts,
+ graph=graph,
+ paf_inds=paf_inds,
+ greedy=greedy,
+ pcutoff=pcutoff,
+ min_affinity=min_affinity,
+ min_n_links=min_n_links,
+ max_overlap=max_overlap,
+ identity_only=identity_only,
+ nan_policy=nan_policy,
+ force_fusion=force_fusion,
+ add_discarded=add_discarded,
+ window_size=window_size,
+ method=method,
+ )
+
+ @property
+ def n_keypoints(self):
+ return self.metadata["num_joints"]
+
+ def calibrate(self, train_data_file):
+ df = pd.read_hdf(train_data_file)
+ try:
+ df.drop("single", level="individuals", axis=1, inplace=True)
+ except KeyError:
+ pass
+ n_bpts = len(df.columns.get_level_values("bodyparts").unique())
+ if n_bpts == 1:
+ warnings.warn("There is only one keypoint; skipping calibration...", stacklevel=2)
+ return
+
+ xy = df.to_numpy().reshape((-1, n_bpts, 2))
+ frac_valid = np.mean(~np.isnan(xy), axis=(1, 2))
+ # Only keeps skeletons that are more than 90% complete
+ xy = xy[frac_valid >= 0.9]
+ if not xy.size:
+ warnings.warn("No complete poses were found. Skipping calibration...", stacklevel=2)
+ return
+
+ # TODO Normalize dists by longest length?
+ # TODO Smarter imputation technique (Bayesian? Grassmann averages?)
+ dists = np.vstack([pdist(data, "sqeuclidean") for data in xy])
+ mu = np.nanmean(dists, axis=0)
+ missing = np.isnan(dists)
+ dists = np.where(missing, mu, dists)
+ try:
+ kde = gaussian_kde(dists.T)
+ kde.mean = mu
+ self._kde = kde
+ self.safe_edge = True
+ except np.linalg.LinAlgError:
+ # Covariance matrix estimation fails due to numerical singularities
+ warnings.warn("The assembler could not be robustly calibrated. Continuing without it...", stacklevel=2)
+
+ def calc_assembly_mahalanobis_dist(self, assembly, return_proba=False, nan_policy="little"):
+ if self._kde is None:
+ raise ValueError("Assembler should be calibrated first with training data.")
+
+ dists = assembly.calc_pairwise_distances() - self._kde.mean
+ mask = np.isnan(dists)
+ # Distance is undefined if the assembly is empty
+ if not len(assembly) or mask.all():
+ if return_proba:
+ return np.inf, 0
+ return np.inf
+
+ if nan_policy == "little":
+ inds = np.flatnonzero(~mask)
+ dists = dists[inds]
+ inv_cov = self._kde.inv_cov[np.ix_(inds, inds)]
+ # Correct distance to account for missing observations
+ factor = self._kde.d / len(inds)
+ else:
+ # Alternatively, reduce contribution of missing values to the Mahalanobis
+ # distance to zero by substituting the corresponding means.
+ dists[mask] = 0
+ mask.fill(False)
+ inv_cov = self._kde.inv_cov
+ factor = 1
+ dot = dists @ inv_cov
+ mahal = factor * sqrt(np.sum((dot * dists), axis=-1))
+ if return_proba:
+ proba = 1 - chi2.cdf(mahal, np.sum(~mask))
+ return mahal, proba
+ return mahal
+
+ def calc_link_probability(self, link):
+ if self._kde is None:
+ raise ValueError("Assembler should be calibrated first with training data.")
+
+ i = link.j1.label
+ j = link.j2.label
+ ind = _conv_square_to_condensed_indices(i, j, self.n_multibodyparts)
+ mu = self._kde.mean[ind]
+ sigma = self._kde.covariance[ind, ind]
+ z = (link.length**2 - mu) / sigma
+ return 2 * (1 - 0.5 * (1 + erf(abs(z) / sqrt(2))))
+
+ @staticmethod
+ def _flatten_detections(data_dict):
+ ind = 0
+ coordinates = data_dict["coordinates"][0]
+ confidence = data_dict["confidence"]
+ ids = data_dict.get("identity", None)
+ if ids is None:
+ ids = [np.ones(len(arr), dtype=int) * -1 for arr in confidence]
+ else:
+ ids = [arr.argmax(axis=1) for arr in ids]
+ for i, (coords, conf, id_) in enumerate(zip(coordinates, confidence, ids, strict=False)):
+ if not np.any(coords):
+ continue
+ for xy, p, g in zip(coords, conf, id_, strict=False):
+ joint = Joint(tuple(xy), p.item(), i, ind, g)
+ ind += 1
+ yield joint
+
+ def extract_best_links(self, joints_dict, costs, trees=None):
+ links = []
+ for ind in self.paf_inds:
+ s, t = self.graph[ind]
+ dets_s = joints_dict.get(s, None)
+ dets_t = joints_dict.get(t, None)
+ if dets_s is None or dets_t is None:
+ continue
+ if ind not in costs:
+ continue
+ lengths = costs[ind]["distance"]
+ if np.isinf(lengths).all():
+ continue
+ aff = costs[ind][self.method].copy()
+ aff[np.isnan(aff)] = 0
+
+ if trees:
+ vecs = np.vstack([[*det_s.pos, *det_t.pos] for det_s in dets_s for det_t in dets_t])
+ dists = []
+ for n, tree in enumerate(trees, start=1):
+ d, _ = tree.query(vecs)
+ dists.append(np.exp(-self._gamma * n * d))
+ w = np.mean(dists, axis=0)
+ aff *= w.reshape(aff.shape)
+
+ if self.greedy:
+ conf = np.asarray([[det_s.confidence * det_t.confidence for det_t in dets_t] for det_s in dets_s])
+ rows, cols = np.where((conf >= self.pcutoff * self.pcutoff) & (aff >= self.min_affinity))
+ candidates = sorted(
+ zip(rows, cols, aff[rows, cols], lengths[rows, cols], strict=False),
+ key=lambda x: x[2],
+ reverse=True,
+ )
+ i_seen = set()
+ j_seen = set()
+ for i, j, w, _l in candidates:
+ if i not in i_seen and j not in j_seen:
+ i_seen.add(i)
+ j_seen.add(j)
+ links.append(Link(dets_s[i], dets_t[j], w))
+ if len(i_seen) == self.max_n_individuals:
+ break
+ else: # Optimal keypoint pairing
+ inds_s = sorted(range(len(dets_s)), key=lambda x: dets_s[x].confidence, reverse=True)[
+ : self.max_n_individuals
+ ]
+ inds_t = sorted(range(len(dets_t)), key=lambda x: dets_t[x].confidence, reverse=True)[
+ : self.max_n_individuals
+ ]
+ keep_s = [ind for ind in inds_s if dets_s[ind].confidence >= self.pcutoff]
+ keep_t = [ind for ind in inds_t if dets_t[ind].confidence >= self.pcutoff]
+ aff = aff[np.ix_(keep_s, keep_t)]
+ rows, cols = linear_sum_assignment(aff, maximize=True)
+ for row, col in zip(rows, cols, strict=False):
+ w = aff[row, col]
+ if w >= self.min_affinity:
+ links.append(Link(dets_s[keep_s[row]], dets_t[keep_t[col]], w))
+ return links
+
+ def _fill_assembly(self, assembly, lookup, assembled, safe_edge, nan_policy):
+ stack = []
+ visited = set()
+ tabu = []
+ counter = itertools.count()
+
+ def push_to_stack(i):
+ for j, link in lookup[i].items():
+ if j in assembly._idx:
+ continue
+ if link.idx in visited:
+ continue
+ heapq.heappush(stack, (-link.affinity, next(counter), link))
+ visited.add(link.idx)
+
+ for idx in assembly._idx:
+ push_to_stack(idx)
+
+ while stack and len(assembly) < self.n_multibodyparts:
+ _, _, best = heapq.heappop(stack)
+ i, j = best.idx
+ if i in assembly._idx:
+ new_ind = j
+ elif j in assembly._idx:
+ new_ind = i
+ else:
+ continue
+ if new_ind in assembled:
+ continue
+ if safe_edge:
+ d_old = self.calc_assembly_mahalanobis_dist(assembly, nan_policy=nan_policy)
+ success = assembly.add_link(best, store_dict=True)
+ if not success:
+ assembly._dict = dict()
+ continue
+ d = self.calc_assembly_mahalanobis_dist(assembly, nan_policy=nan_policy)
+ if d < d_old:
+ push_to_stack(new_ind)
+ try:
+ _, _, link = heapq.heappop(tabu)
+ heapq.heappush(stack, (-link.affinity, next(counter), link))
+ except IndexError:
+ pass
+ else:
+ heapq.heappush(tabu, (d - d_old, next(counter), best))
+ assembly.__dict__.update(assembly._dict)
+ assembly._dict = dict()
+ else:
+ assembly.add_link(best)
+ push_to_stack(new_ind)
+
+ def build_assemblies(self, links):
+ lookup = defaultdict(dict)
+ for link in links:
+ i, j = link.idx
+ lookup[i][j] = link
+ lookup[j][i] = link
+
+ assemblies = []
+ assembled = set()
+
+ # Fill the subsets with unambiguous, complete individuals
+ G = nx.Graph([link.idx for link in links])
+ for chain in nx.connected_components(G):
+ if len(chain) == self.n_multibodyparts:
+ edges = [tuple(sorted(edge)) for edge in G.edges(chain)]
+ assembly = Assembly(self.n_multibodyparts)
+ for link in links:
+ i, j = link.idx
+ if (i, j) in edges:
+ success = assembly.add_link(link)
+ if success:
+ lookup[i].pop(j)
+ lookup[j].pop(i)
+ assembled.update(assembly._idx)
+ assemblies.append(assembly)
+
+ if len(assemblies) == self.max_n_individuals:
+ return assemblies, assembled
+
+ for link in sorted(links, key=lambda x: x.affinity, reverse=True):
+ if any(i in assembled for i in link.idx):
+ continue
+ assembly = Assembly(self.n_multibodyparts)
+ assembly.add_link(link)
+ self._fill_assembly(assembly, lookup, assembled, self.safe_edge, self.nan_policy)
+ for link in assembly._links:
+ i, j = link.idx
+ lookup[i].pop(j)
+ lookup[j].pop(i)
+ assembled.update(assembly._idx)
+ assemblies.append(assembly)
+
+ # Fuse superfluous assemblies
+ n_extra = len(assemblies) - self.max_n_individuals
+ if n_extra > 0:
+ if self.safe_edge:
+ ds_old = [self.calc_assembly_mahalanobis_dist(assembly) for assembly in assemblies]
+ while len(assemblies) > self.max_n_individuals:
+ ds = []
+ for i, j in itertools.combinations(range(len(assemblies)), 2):
+ if assemblies[j] not in assemblies[i]:
+ temp = assemblies[i] + assemblies[j]
+ d = self.calc_assembly_mahalanobis_dist(temp)
+ delta = d - max(ds_old[i], ds_old[j])
+ ds.append((i, j, delta, d, temp))
+ if not ds:
+ break
+ min_ = sorted(ds, key=lambda x: x[2])
+ i, j, delta, d, new = min_[0]
+ if delta < 0 or len(min_) == 1:
+ assemblies[i] = new
+ assemblies.pop(j)
+ ds_old[i] = d
+ ds_old.pop(j)
+ else:
+ break
+ elif self.force_fusion:
+ assemblies = sorted(assemblies, key=len)
+ for nrow in range(n_extra):
+ assembly = assemblies[nrow]
+ candidates = [a for a in assemblies[nrow:] if assembly not in a]
+ if not candidates:
+ continue
+ if len(candidates) == 1:
+ candidate = candidates[0]
+ else:
+ dists = []
+ for cand in candidates:
+ d = cdist(assembly.xy, cand.xy)
+ dists.append(np.nanmin(d))
+ candidate = candidates[np.argmin(dists)]
+ ind = assemblies.index(candidate)
+ assemblies[ind] += assembly
+ else:
+ store = dict()
+ for assembly in assemblies:
+ if len(assembly) != self.n_multibodyparts:
+ for i in assembly._idx:
+ store[i] = assembly
+ used = [link for assembly in assemblies for link in assembly._links]
+ unconnected = [link for link in links if link not in used]
+ for link in unconnected:
+ i, j = link.idx
+ try:
+ if store[j] not in store[i]:
+ temp = store[i] + store[j]
+ store[i].__dict__.update(temp.__dict__)
+ assemblies.remove(store[j])
+ for idx in store[j]._idx:
+ store[idx] = store[i]
+ except KeyError:
+ pass
+
+ # Second pass without edge safety
+ for assembly in assemblies:
+ if len(assembly) != self.n_multibodyparts:
+ self._fill_assembly(assembly, lookup, assembled, False, "")
+ assembled.update(assembly._idx)
+
+ return assemblies, assembled
+
+ def _assemble(self, data_dict, ind_frame):
+ joints = list(self._flatten_detections(data_dict))
+ if not joints:
+ return None, None
+
+ bag = defaultdict(list)
+ for joint in joints:
+ bag[joint.label].append(joint)
+
+ assembled = set()
+
+ if self.n_uniquebodyparts:
+ unique = np.full((self.n_uniquebodyparts, 3), np.nan)
+ for n, ind in enumerate(range(self.n_multibodyparts, self.n_keypoints)):
+ dets = bag[ind]
+ if not dets:
+ continue
+ if len(dets) > 1:
+ det = max(dets, key=lambda x: x.confidence)
+ else:
+ det = dets[0]
+ # Mark the unique body parts as assembled anyway so
+ # they are not used later on to fill assemblies.
+ assembled.update(d.idx for d in dets)
+ if det.confidence <= self.pcutoff and not self.add_discarded:
+ continue
+ unique[n] = *det.pos, det.confidence
+ if np.isnan(unique).all():
+ unique = None
+ else:
+ unique = None
+
+ if not any(i in bag for i in range(self.n_multibodyparts)):
+ return None, unique
+
+ if self.n_multibodyparts == 1:
+ assemblies = []
+ for joint in bag[0]:
+ if joint.confidence >= self.pcutoff:
+ ass = Assembly(self.n_multibodyparts)
+ ass.add_joint(joint)
+ assemblies.append(ass)
+ return assemblies, unique
+
+ if self.max_n_individuals == 1:
+ get_attr = operator.attrgetter("confidence")
+ ass = Assembly(self.n_multibodyparts)
+ for ind in range(self.n_multibodyparts):
+ joints = bag[ind]
+ if not joints:
+ continue
+ ass.add_joint(max(joints, key=get_attr))
+ return [ass], unique
+
+ if self.identity_only:
+ assemblies = []
+ get_attr = operator.attrgetter("group")
+ temp = sorted(
+ (joint for joint in joints if np.isfinite(joint.confidence)),
+ key=get_attr,
+ )
+ groups = itertools.groupby(temp, get_attr)
+ for _, group in groups:
+ ass = Assembly(self.n_multibodyparts)
+ for joint in sorted(group, key=lambda x: x.confidence, reverse=True):
+ if joint.confidence >= self.pcutoff and joint.label < self.n_multibodyparts:
+ ass.add_joint(joint)
+ if len(ass):
+ assemblies.append(ass)
+ assembled.update(ass._idx)
+ else:
+ trees = []
+ for j in range(1, self.window_size + 1):
+ tree = self._trees.get(ind_frame - j, None)
+ if tree is not None:
+ trees.append(tree)
+
+ links = self.extract_best_links(bag, data_dict["costs"], trees)
+ if self._kde:
+ for link in links[::-1]:
+ p = max(self.calc_link_probability(link), 0.001)
+ link.affinity *= p
+ if link.affinity < self.min_affinity:
+ links.remove(link)
+
+ if self.window_size >= 1 and links:
+ # Store selected edges for subsequent frames
+ vecs = np.vstack([link.to_vector() for link in links])
+ self._trees[ind_frame] = cKDTree(vecs)
+
+ assemblies, assembled_ = self.build_assemblies(links)
+ assembled.update(assembled_)
+
+ # Remove invalid assemblies
+ discarded = set(joint for joint in joints if joint.idx not in assembled and np.isfinite(joint.confidence))
+ for assembly in assemblies[::-1]:
+ if 0 < assembly.n_links < self.min_n_links or not len(assembly):
+ for link in assembly._links:
+ discarded.update((link.j1, link.j2))
+ assemblies.remove(assembly)
+ if 0 < self.max_overlap < 1: # Non-maximum pose suppression
+ if self._kde is not None:
+ scores = [-self.calc_assembly_mahalanobis_dist(ass) for ass in assemblies]
+ else:
+ scores = [ass._affinity for ass in assemblies]
+ lst = list(zip(scores, assemblies, strict=False))
+ assemblies = []
+ while lst:
+ temp = max(lst, key=lambda x: x[0])
+ lst.remove(temp)
+ assemblies.append(temp[1])
+ for pair in lst[::-1]:
+ if temp[1].intersection_with(pair[1]) >= self.max_overlap:
+ lst.remove(pair)
+ if len(assemblies) > self.max_n_individuals:
+ assemblies = sorted(assemblies, key=len, reverse=True)
+ for assembly in assemblies[self.max_n_individuals :]:
+ for link in assembly._links:
+ discarded.update((link.j1, link.j2))
+ assemblies = assemblies[: self.max_n_individuals]
+
+ if self.add_discarded and discarded:
+ # Fill assemblies with unconnected body parts
+ for joint in sorted(discarded, key=lambda x: x.confidence, reverse=True):
+ if self.safe_edge:
+ for assembly in assemblies:
+ if joint.label in assembly._visible:
+ continue
+ d_old = self.calc_assembly_mahalanobis_dist(assembly)
+ assembly.add_joint(joint)
+ d = self.calc_assembly_mahalanobis_dist(assembly)
+ if d < d_old:
+ break
+ assembly.remove_joint(joint)
+ else:
+ dists = []
+ for i, assembly in enumerate(assemblies):
+ if joint.label in assembly._visible:
+ continue
+ d = cdist(assembly.xy, np.atleast_2d(joint.pos))
+ dists.append((i, np.nanmin(d)))
+ if not dists:
+ continue
+ min_ = sorted(dists, key=lambda x: x[1])
+ ind, _ = min_[0]
+ assemblies[ind].add_joint(joint)
+
+ return assemblies, unique
+
+ def assemble(self, chunk_size=1, n_processes=None):
+ self.assemblies = dict()
+ self.unique = dict()
+ # Spawning (rather than forking) multiple processes does not
+ # work nicely with the GUI or interactive sessions.
+ # In that case, we fall back to the serial assembly.
+ if chunk_size == 0 or multiprocessing.get_start_method() == "spawn":
+ for i, data_dict in enumerate(tqdm(self)):
+ assemblies, unique = self._assemble(data_dict, i)
+ if assemblies:
+ self.assemblies[i] = assemblies
+ if unique is not None:
+ self.unique[i] = unique
+ else:
+ global wrapped # Hack to make the function pickable
+
+ def wrapped(i):
+ return i, self._assemble(self[i], i)
+
+ n_frames = len(self.metadata["imnames"])
+ with multiprocessing.Pool(n_processes) as p:
+ with tqdm(total=n_frames) as pbar:
+ for i, (assemblies, unique) in p.imap_unordered(wrapped, range(n_frames), chunksize=chunk_size):
+ if assemblies:
+ self.assemblies[i] = assemblies
+ if unique is not None:
+ self.unique[i] = unique
+ pbar.update()
+
+ def from_pickle(self, pickle_path):
+ with open(pickle_path, "rb") as file:
+ data = pickle.load(file)
+ self.unique = data.pop("single", {})
+ self.assemblies = data
+
+ @staticmethod
+ def parse_metadata(data):
+ params = dict()
+ params["joint_names"] = data["metadata"]["all_joints_names"]
+ params["num_joints"] = len(params["joint_names"])
+ params["paf_graph"] = data["metadata"]["PAFgraph"]
+ params["paf"] = data["metadata"].get("PAFinds", np.arange(len(params["joint_names"])))
+ params["bpts"] = params["ibpts"] = range(params["num_joints"])
+ params["imnames"] = [fn for fn in list(data) if fn != "metadata"]
+ return params
+
+ def to_h5(self, output_name):
+ data = np.full(
+ (
+ len(self.metadata["imnames"]),
+ self.max_n_individuals,
+ self.n_multibodyparts,
+ 4,
+ ),
+ fill_value=np.nan,
+ )
+ for ind, assemblies in self.assemblies.items():
+ for n, assembly in enumerate(assemblies):
+ data[ind, n] = assembly.data
+ index = pd.MultiIndex.from_product(
+ [
+ ["scorer"],
+ map(str, range(self.max_n_individuals)),
+ map(str, range(self.n_multibodyparts)),
+ ["x", "y", "likelihood"],
+ ],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ temp = data[..., :3].reshape((data.shape[0], -1))
+ df = pd.DataFrame(temp, columns=index)
+ df.to_hdf(output_name, key="ass")
+
+ def to_pickle(self, output_name):
+ data = dict()
+ for ind, assemblies in self.assemblies.items():
+ data[ind] = [ass.data for ass in assemblies]
+ if self.unique:
+ data["single"] = self.unique
+ with open(output_name, "wb") as file:
+ pickle.dump(data, file, pickle.HIGHEST_PROTOCOL)
+
+
+@dataclass
+class MatchedPrediction:
+ """A match between a prediction and a ground truth assembly.
+
+ The ground truth assembly should be None f the prediction was not matched to any GT,
+ and the OKS should be 0.
+
+ Attributes:
+ prediction: A prediction made by a pose model.
+ score: The confidence score for the prediction.
+ ground_truth: If None, then this prediction is not matched to any ground truth
+ (this can happen when there are more predicted individuals than GT).
+ Otherwise, the ground truth assembly to which this prediction is matched.
+ oks: The OKS score between the prediction and the ground truth pose.
+ """
+
+ prediction: Assembly
+ score: float
+ ground_truth: Assembly | None
+ oks: float
+
+
+def calc_object_keypoint_similarity(
+ xy_pred,
+ xy_true,
+ sigma,
+ margin=0,
+ symmetric_kpts=None,
+):
+ visible_gt = ~np.isnan(xy_true).all(axis=1)
+ if visible_gt.sum() < 2: # At least 2 points needed to calculate scale
+ return np.nan
+
+ true = xy_true[visible_gt]
+ scale_squared = np.prod(np.ptp(true, axis=0) + np.spacing(1) + margin * 2)
+ if np.isclose(scale_squared, 0):
+ return np.nan
+
+ k_squared = (2 * sigma) ** 2
+ denom = 2 * scale_squared * k_squared
+ if isinstance(sigma, np.ndarray):
+ denom = denom[visible_gt]
+
+ if symmetric_kpts is None:
+ pred = xy_pred[visible_gt]
+ pred[np.isnan(pred)] = np.inf
+ dist_squared = np.sum((pred - true) ** 2, axis=1)
+ oks = np.exp(-dist_squared / denom)
+ return np.mean(oks)
+ else:
+ oks = []
+ xy_preds = [xy_pred]
+ combos = (pair for l in range(len(symmetric_kpts)) for pair in itertools.combinations(symmetric_kpts, l + 1))
+ for pairs in combos:
+ # Swap corresponding keypoints
+ tmp = xy_pred.copy()
+ for pair in pairs:
+ tmp[pair, :] = tmp[pair[::-1], :]
+ xy_preds.append(tmp)
+ for xy_pred in xy_preds:
+ pred = xy_pred[visible_gt]
+ pred[np.isnan(pred)] = np.inf
+ dist_squared = np.sum((pred - true) ** 2, axis=1)
+ oks.append(np.mean(np.exp(-dist_squared / denom)))
+ return max(oks)
+
+
+def match_assemblies(
+ predictions: list[Assembly],
+ ground_truth: list[Assembly],
+ sigma: float,
+ margin: int = 0,
+ symmetric_kpts: list[tuple[int, int]] | None = None,
+ greedy_matching: bool = False,
+ greedy_oks_threshold: float = 0.0,
+) -> tuple[int, list[MatchedPrediction]]:
+ """Matches assemblies to ground truth predictions.
+
+ Returns:
+ int: the total number of valid ground truth assemblies
+ list[MatchedPrediction]: a list containing all valid predictions, potentially
+ matched to ground truth assemblies.
+ """
+ # Only consider assemblies of at least two keypoints
+ predictions = [a for a in predictions if len(a) > 1]
+ ground_truth = [a for a in ground_truth if len(a) > 1]
+ num_ground_truth = len(ground_truth)
+
+ # Sort predictions by score
+ inds_pred = np.argsort([ins.affinity if ins.n_links else ins.confidence for ins in predictions])[::-1]
+ predictions = np.asarray(predictions)[inds_pred]
+
+ # indices of unmatched ground truth assemblies
+ matched = [
+ MatchedPrediction(
+ prediction=p,
+ score=(p.affinity if p.n_links else p.confidence),
+ ground_truth=None,
+ oks=0.0,
+ )
+ for p in predictions
+ ]
+
+ # Greedy assembly matching like in pycocotools
+ if greedy_matching:
+ matched_gt_indices = set()
+ for idx, pred in enumerate(predictions):
+ oks = [
+ calc_object_keypoint_similarity(
+ pred.xy,
+ gt.xy,
+ sigma,
+ margin,
+ symmetric_kpts,
+ )
+ for gt in ground_truth
+ ]
+ if np.all(np.isnan(oks)):
+ continue
+
+ ind_best = np.nanargmax(oks)
+
+ # if this gt already matched, and not a crowd, continue
+ if ind_best in matched_gt_indices:
+ continue
+
+ # Only match the pred to the GT if the OKS value is above a given threshold
+ if oks[ind_best] < greedy_oks_threshold:
+ continue
+
+ matched_gt_indices.add(ind_best)
+ matched[idx].ground_truth = ground_truth[ind_best]
+ matched[idx].oks = oks[ind_best]
+
+ # Global rather than greedy assembly matching
+ else:
+ inds_true = list(range(len(ground_truth)))
+ mat = np.zeros((len(predictions), len(ground_truth)))
+ for i, a_pred in enumerate(predictions):
+ for j, a_true in enumerate(ground_truth):
+ oks = calc_object_keypoint_similarity(
+ a_pred.xy,
+ a_true.xy,
+ sigma,
+ margin,
+ symmetric_kpts,
+ )
+ if ~np.isnan(oks):
+ mat[i, j] = oks
+ rows, cols = linear_sum_assignment(mat, maximize=True)
+ for row, col in zip(rows, cols, strict=False):
+ matched[row].ground_truth = ground_truth[col]
+ matched[row].oks = mat[row, col]
+ _ = inds_true.remove(col)
+
+ return num_ground_truth, matched
+
+
+def parse_ground_truth_data_file(h5_file):
+ df = pd.read_hdf(h5_file)
+ try:
+ df.drop("single", axis=1, level="individuals", inplace=True)
+ except KeyError:
+ pass
+ # Cast columns of dtype 'object' to float to avoid TypeError
+ # further down in _parse_ground_truth_data.
+ cols = df.select_dtypes(include="object").columns
+ if cols.to_list():
+ df[cols] = df[cols].astype("float")
+ n_individuals = len(df.columns.get_level_values("individuals").unique())
+ n_bodyparts = len(df.columns.get_level_values("bodyparts").unique())
+ data = df.to_numpy().reshape((df.shape[0], n_individuals, n_bodyparts, -1))
+ return _parse_ground_truth_data(data)
+
+
+def _parse_ground_truth_data(data):
+ gt = dict()
+ for i, arr in enumerate(data):
+ temp = []
+ for row in arr:
+ if np.isnan(row[:, :2]).all():
+ continue
+ ass = Assembly.from_array(row)
+ temp.append(ass)
+ if not temp:
+ continue
+ gt[i] = temp
+ return gt
+
+
+def find_outlier_assemblies(dict_of_assemblies, criterion="area", qs=(5, 95)):
+ if not hasattr(Assembly, criterion):
+ raise ValueError(f"Invalid criterion {criterion}.")
+
+ if len(qs) != 2:
+ raise ValueError("Two percentiles (for lower and upper bounds) should be given.")
+
+ tuples = []
+ for frame_ind, assemblies in dict_of_assemblies.items():
+ for assembly in assemblies:
+ tuples.append((frame_ind, getattr(assembly, criterion)))
+ frame_inds, vals = zip(*tuples, strict=False)
+ vals = np.asarray(vals)
+ lo, up = np.percentile(vals, qs, interpolation="nearest")
+ inds = np.flatnonzero((vals < lo) | (vals > up)).tolist()
+ return list(set(frame_inds[i] for i in inds))
+
+
+def _compute_precision_and_recall(
+ num_gt_assemblies: int,
+ oks_values: np.ndarray,
+ oks_threshold: float,
+ recall_thresholds: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+ """Computes the precision and recall scores at a given OKS threshold.
+
+ Args:
+ num_gt_assemblies: the number of ground truth assemblies (used to compute false
+ negatives + true positives).
+ oks_values: the OKS value to the matched GT assembly for each prediction
+ oks_threshold: the OKS threshold at which recall and precision are being
+ computed
+ recall_thresholds: the recall thresholds to use to compute scores
+
+ Returns:
+ The precision and recall arrays at each recall threshold
+ """
+ tp = np.cumsum(oks_values >= oks_threshold)
+ fp = np.cumsum(oks_values < oks_threshold)
+ rc = tp / num_gt_assemblies
+ pr = tp / (fp + tp + np.spacing(1))
+ recall = rc[-1]
+
+ # Guarantee precision decreases monotonically, see
+ # https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173
+ for i in range(len(pr) - 1, 0, -1):
+ if pr[i] > pr[i - 1]:
+ pr[i - 1] = pr[i]
+
+ inds_rc = np.searchsorted(rc, recall_thresholds, side="left")
+ precision = np.zeros(inds_rc.shape)
+ valid = inds_rc < len(pr)
+ precision[valid] = pr[inds_rc[valid]]
+ return precision, recall
+
+
+def evaluate_assembly_greedy(
+ assemblies_gt: dict[Any, list[Assembly]],
+ assemblies_pred: dict[Any, list[Assembly]],
+ oks_sigma: float,
+ oks_thresholds: Iterable[float],
+ margin: int | float = 0,
+ symmetric_kpts: list[tuple[int, int]] | None = None,
+) -> dict:
+ """Runs greedy mAP evaluation, as done by pycocotools.
+
+ Args:
+ assemblies_gt: A dictionary mapping image ID (e.g. filepath) to ground truth
+ assemblies. Should contain all the same keys as ``assemblies_pred``.
+ assemblies_pred: A dictionary mapping image ID (e.g. filepath) to predicted
+ assemblies. Should contain all the same keys as ``assemblies_gt``.
+ oks_sigma: The sigma to use to compute OKS values for keypoints .
+ oks_thresholds: The OKS thresholds at which to compute precision & recall.
+ margin: The margin to use to compute bounding boxes from keypoints.
+ symmetric_kpts: The symmetric keypoints in the dataset.
+ """
+ recall_thresholds = np.linspace( # np.linspace(0, 1, 101)
+ start=0.0, stop=1.00, num=int(np.round((1.00 - 0.0) / 0.01)) + 1, endpoint=True
+ )
+ precisions = []
+ recalls = []
+ for oks_t in oks_thresholds:
+ all_matched = []
+ total_gt_assemblies = 0
+ for ind, gt_assembly in assemblies_gt.items():
+ pred_assemblies = assemblies_pred.get(ind, [])
+ num_gt_assemblies, matched = match_assemblies(
+ pred_assemblies,
+ gt_assembly,
+ oks_sigma,
+ margin,
+ symmetric_kpts,
+ greedy_matching=True,
+ greedy_oks_threshold=oks_t,
+ )
+ all_matched.extend(matched)
+ total_gt_assemblies += num_gt_assemblies
+
+ if len(all_matched) == 0:
+ precisions.append(0.0)
+ recalls.append(0.0)
+ continue
+
+ # Global sort of assemblies (across all images) by score
+ scores = np.asarray([-m.score for m in all_matched])
+ sorted_pred_indices = np.argsort(scores, kind="mergesort")
+ oks = np.asarray([match.oks for match in all_matched])[sorted_pred_indices]
+
+ # Compute prediction and recall
+ p, r = _compute_precision_and_recall(total_gt_assemblies, oks, oks_t, recall_thresholds)
+ precisions.append(p)
+ recalls.append(r)
+
+ precisions = np.asarray(precisions)
+ recalls = np.asarray(recalls)
+ return {
+ "precisions": precisions,
+ "recalls": recalls,
+ "mAP": precisions.mean(),
+ "mAR": recalls.mean(),
+ }
+
+
+def evaluate_assembly(
+ ass_pred_dict,
+ ass_true_dict,
+ oks_sigma=0.072,
+ oks_thresholds=None,
+ margin=0,
+ symmetric_kpts=None,
+ greedy_matching=False,
+ with_tqdm: bool = True,
+):
+ if oks_thresholds is None:
+ oks_thresholds = np.linspace(0.5, 0.95, 10)
+ if greedy_matching:
+ return evaluate_assembly_greedy(
+ ass_true_dict,
+ ass_pred_dict,
+ oks_sigma=oks_sigma,
+ oks_thresholds=oks_thresholds,
+ margin=margin,
+ symmetric_kpts=symmetric_kpts,
+ )
+
+ # sigma is taken as the median of all COCO keypoint standard deviations
+ all_matched = []
+ total_gt_assemblies = 0
+
+ gt_assemblies = ass_true_dict.items()
+ if with_tqdm:
+ gt_assemblies = tqdm(gt_assemblies)
+
+ for ind, gt_assembly in gt_assemblies:
+ pred_assemblies = ass_pred_dict.get(ind, [])
+ num_gt, matched = match_assemblies(
+ pred_assemblies,
+ gt_assembly,
+ oks_sigma,
+ margin,
+ symmetric_kpts,
+ greedy_matching,
+ )
+ all_matched.extend(matched)
+ total_gt_assemblies += num_gt
+
+ if not all_matched:
+ return {
+ "precisions": np.array([]),
+ "recalls": np.array([]),
+ "mAP": 0.0,
+ "mAR": 0.0,
+ }
+
+ conf_pred = np.asarray([match.score for match in all_matched])
+ idx = np.argsort(-conf_pred, kind="mergesort")
+ # Sort matching score (OKS) in descending order of assembly affinity
+ oks = np.asarray([match.oks for match in all_matched])[idx]
+ recall_thresholds = np.linspace(0, 1, 101)
+ precisions = []
+ recalls = []
+ for t in oks_thresholds:
+ p, r = _compute_precision_and_recall(total_gt_assemblies, oks, t, recall_thresholds)
+ precisions.append(p)
+ recalls.append(r)
+
+ precisions = np.asarray(precisions)
+ recalls = np.asarray(recalls)
+ return {
+ "precisions": precisions,
+ "recalls": recalls,
+ "mAP": precisions.mean(),
+ "mAR": recalls.mean(),
+ }
diff --git a/deeplabcut/core/metrics/__init__.py b/deeplabcut/core/metrics/__init__.py
new file mode 100644
index 0000000000..94397de57a
--- /dev/null
+++ b/deeplabcut/core/metrics/__init__.py
@@ -0,0 +1,13 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from .api import compute_metrics, prepare_evaluation_data
+from .bbox import compute_bbox_metrics
+from .identity import compute_identity_scores
diff --git a/deeplabcut/core/metrics/api.py b/deeplabcut/core/metrics/api.py
new file mode 100644
index 0000000000..a00fc617b9
--- /dev/null
+++ b/deeplabcut/core/metrics/api.py
@@ -0,0 +1,179 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""API methods to get metrics for deep learning models."""
+
+from __future__ import annotations
+
+import numpy as np
+
+import deeplabcut.core.metrics.distance_metrics as distance_metrics
+
+
+def compute_metrics(
+ ground_truth: dict[str, np.ndarray],
+ predictions: dict[str, np.ndarray],
+ single_animal: bool = False,
+ unique_bodypart_gt: dict[str, np.ndarray] | None = None,
+ unique_bodypart_poses: dict[str, np.ndarray] | None = None,
+ pcutoff: float = -1,
+ oks_bbox_margin: int = 0,
+ oks_sigma: float | np.ndarray = 0.1,
+ per_keypoint_rmse: bool = False,
+ compute_detection_rmse: bool = True,
+) -> dict:
+ """Computes pose estimation performance metrics.
+
+ Given ground truth pose labels and predictions on a dataset, computes RMSE and pose
+ mAP/mAR using OKS.
+
+ The image paths in the ground_truth dict must be the same as the ones in the
+ predictions dict.
+
+ Single animal RMSE is computed by simply calculating the Euclidean distance between
+ each ground truth keypoint and the corresponding prediction.
+
+ Multi-animal RMSE is computed differently: predictions are first matched to ground
+ truth individuals using greedy OKS matching. OKS (or object keypoint similarity) is
+ a similarity metric for keypoints (you can read more about it and its definition
+ here: https://cocodataset.org/#keypoints-eval). RMSE is then computed only between
+ predictions and the ground truth pose they are matched to, only when the OKS is
+ greater than a small threshold. Predictions that cannot be matched to any ground
+ truth with non-zero OKS are not used to compute RMSE.
+
+ Args:
+ ground_truth: The ground truth pose for which to compute metrics in the dataset.
+ This should be a dictionary mapping strings (image UIDs, such as image
+ paths) to ground truth pose for the image. The pose arrays should be
+ in the format (num_individuals, num_bodyparts, 3), where the 3 values are
+ x, y and visibility. The ``num_individuals`` corresponds to the number of
+ individuals labeled in each image.
+ predictions: The predicted poses for which to compute metrics in the dataset.
+ This should be a dictionary mapping strings (image UIDs, such as image
+ paths) to pose predictions for the image. The pose arrays should be
+ in the format (num_predictions, num_bodyparts, 3), where the 3 values are
+ x, y and score. The number of predictions can be different to the number of
+ ground truth individuals labeled for an image.
+ single_animal: Whether the metrics are being computed on a single-animal or
+ multi-animal dataset. This has an impact on RMSE computation.
+ unique_bodypart_gt: If unique bodyparts are defined for the dataset, they should
+ be contained in this dict in the same format as the ``ground_truth`` dict.
+ unique_bodypart_poses: If unique bodyparts are defined for the dataset, the
+ predictions should be contained in this dict in the same format as the
+ ``predictions`` dict.
+ pcutoff: The threshold to compute the "rmse_cutoff" score (RMSE of all
+ predictions with score above the cutoff).
+ oks_bbox_margin: The margin to add around keypoints to compute the area for OKS
+ computation.
+ oks_sigma: The OKS sigma to use to compute pose.
+ per_keypoint_rmse: Compute per-keypoint RMSE values.
+ compute_detection_rmse: Computes detection RMSE (without animal assembly) if the
+ predictions are from a multi-animal model.
+
+ Returns:
+ A dictionary containing keys "rmse", "rmse_cutoff", "mAP" and "mAR" mapping
+ to those metrics on the given dataset.
+
+ If unique bodyparts are given, two extra keys "rmse_unique_bodyparts" and
+ "rmse_pcutoff_unique_bodyparts" are also returned, containing the metrics for
+ the unique bodyparts head.
+
+ If `per_keypoint_evaluation=True`, "keypoint_rmse", "keypoint_rmse_cutoff" (and
+ optionally "unique_keypoint_rmse" and "unique_keypoint_rmse_cutoff") keys are
+ added, containing a list of floats representing the RMSE for each keypoint.
+
+ Examples:
+ >>> # Define the p-cutoff, prediction, and target DataFrames
+ >>> pcutoff = 0.5
+ >>> ground_truth = {"img0": np.array([[[1.0, 1.0, 2.0], ...], ...]), ...}
+ >>> predictions = {"img0": np.array([[[2.0, 1.0, 0.4], ...], ...]), ...}
+ >>> scores = compute_metrics(ground_truth, predictions, pcutoff=pcutoff)
+ >>> print(scores)
+ {
+ "rmse": 1.0,
+ "rmse_pcutoff": 0.0,
+ 'mAP': 84.2,
+ 'mAR': 74.5
+ } # Sample output scores
+ """
+ data = prepare_evaluation_data(ground_truth, predictions)
+ oks_scores = distance_metrics.compute_oks(
+ data=data,
+ oks_sigma=oks_sigma,
+ oks_bbox_margin=oks_bbox_margin,
+ )
+
+ data_unique = None
+ if unique_bodypart_gt is not None:
+ assert unique_bodypart_poses is not None
+ data_unique = prepare_evaluation_data(unique_bodypart_gt, unique_bodypart_poses)
+
+ rmse_scores = distance_metrics.compute_rmse(
+ data,
+ single_animal,
+ pcutoff,
+ data_unique=data_unique,
+ per_keypoint_results=per_keypoint_rmse,
+ )
+ results = dict(**rmse_scores, **oks_scores)
+
+ if compute_detection_rmse and not single_animal:
+ det_rmse, det_rmse_p = distance_metrics.compute_detection_rmse(
+ data,
+ pcutoff,
+ data_unique=data_unique,
+ )
+ results["rmse_detections"] = det_rmse
+ results["rmse_detections_pcutoff"] = det_rmse_p
+
+ return results
+
+
+def prepare_evaluation_data(
+ ground_truth: dict[str, np.ndarray],
+ predictions: dict[str, np.ndarray],
+) -> list[tuple[np.ndarray, np.ndarray]]:
+ """Prepares predictions and ground truth pose to compute metrics.
+
+ Only keeps ground truth and predicted assemblies with at least 2 valid keypoints.
+ Sets the coordinates for all keypoints that aren't visible (for ground truth,
+ visibility <= 0 and for predictions score <= 0) to ``np.nan``.
+
+ Sorts valid predictions by score.
+
+ Args:
+ ground_truth: For each image, the GT of shape (n_idv, n_bpt, 3).
+ predictions: For each image, the pose predictions of shape (n_pred, n_bpt, 3).
+
+ Returns:
+ A list containing (ground truth pose, predicted pose) for each image in the
+ dataset, where the predicted pose is sorted from highest to lowest score.
+ """
+ pose_data = []
+ for image, gt in ground_truth.items():
+ gt = gt.copy()
+ gt[gt[..., 2] <= 0] = np.nan
+
+ # only keep ground truth pose with at least one keypoint
+ gt_mask = np.any(np.all(~np.isnan(gt), axis=-1), axis=-1)
+ gt = gt[gt_mask]
+
+ pred = predictions[image][..., :3].copy() # PAF have 5 values; keep xy + score
+ pred[pred[..., 2] < 0] = np.nan
+
+ # only keep predicted pose with at least two keypoints
+ pred_mask = np.any(np.all(~np.isnan(pred), axis=-1), axis=-1)
+ pred = pred[pred_mask]
+
+ scores = np.nanmean(pred[:, :, 2], axis=-1)
+ pred_order = np.argsort(-scores, kind="mergesort")
+ pose_data.append((gt, pred[pred_order]))
+
+ return pose_data
diff --git a/deeplabcut/core/metrics/bbox.py b/deeplabcut/core/metrics/bbox.py
new file mode 100644
index 0000000000..5cc7a85e76
--- /dev/null
+++ b/deeplabcut/core/metrics/bbox.py
@@ -0,0 +1,166 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Bounding box metrics.
+
+Metrics are currently computed using pycocotools, which can be installed with `pypi`
+(see https://github.com/ppwwyyxx/cocoapi/tree/master).
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from unittest.mock import Mock, patch
+
+import numpy as np
+
+try:
+ from pycocotools.coco import COCO
+ from pycocotools.cocoeval import COCOeval
+
+ with_pycocotools = True
+except ModuleNotFoundError:
+ with_pycocotools = False
+
+
+@patch("pycocotools.coco.print", Mock())
+@patch("pycocotools.cocoeval.print", Mock())
+def compute_bbox_metrics(
+ ground_truth: dict[str, dict],
+ detections: dict[str, dict],
+) -> dict[str, float]:
+ """Computes bbox mAP and mAR metrics for bounding boxes.
+
+ Args:
+ ground_truth: A dictionary mapping image UIDs (such as image paths or filenames)
+ to a ground truth labels dict. The labels dict should contain the keys
+ "width" (image width), "height" (image height) and "bboxes" (a numpy array
+ of shape (num_gt_bboxes, 4) containing the ground truth bounding boxes in
+ format xywh).
+ detections: A dictionary mapping image UIDs (such as image paths or filenames)
+ to a predicted bounding box dict. The detections dict should contain the
+ keys "bboxes" (a numpy array of shape (num_detected_bboxes, 4) containing
+ the predicted bounding boxes in format xywh) and "scores" (a numpy array of
+ length num_detected_bboxes containing the confidence score for each
+ predicted bounding box).
+
+ Returns:
+ The bounding box mAP/mAR metrics in a dictionary.
+
+ Raises:
+ ModuleNotFoundError: if ``pycocotools`` is not installed
+ ValueError: if there are mismatches in the keys of ground_truth and detections
+ """
+ if not with_pycocotools:
+ raise ModuleNotFoundError("pycocotools not installed! can't compute bbox mAP")
+
+ if len(detections) != len(ground_truth):
+ raise ValueError()
+
+ coco = COCO()
+ coco.dataset["annotations"] = []
+ coco.dataset["categories"] = [{"id": 1, "name": "animals", "supercategory": "obj"}]
+ coco.dataset["images"] = []
+ coco.dataset["info"] = {
+ "description": "Generated by DeepLabCut",
+ "year": datetime.now().year,
+ "date_created": datetime.now().strftime("%Y-%m-%d"),
+ }
+ predictions = []
+ for idx, (img, gt) in enumerate(ground_truth.items()):
+ img_id = idx + 1
+ coco.dataset["images"].append(
+ {
+ "id": img_id,
+ "file_name": img,
+ "width": gt["width"],
+ "height": gt["height"],
+ }
+ )
+ for bbox in gt["bboxes"][:, :4]:
+ ann_id = len(coco.dataset["annotations"]) + 1
+ coco.dataset["annotations"].append(
+ {
+ "id": ann_id,
+ "image_id": img_id,
+ "category_id": 1,
+ "area": max(1, (bbox[2] * bbox[3]).item()),
+ "bbox": bbox,
+ "iscrowd": 0,
+ }
+ )
+
+ for bbox, score in zip(detections[img]["bboxes"], detections[img]["scores"], strict=False):
+ predictions.append(np.array([img_id, *bbox, score, 1]))
+
+ if len(predictions) == 0:
+ return {
+ "mAP@50:95": 0.0,
+ "mAP@50": 0.0,
+ "mAP@75": 0.0,
+ "mAR@50:95": 0.0,
+ "mAR@50": 0.0,
+ "mAR@75": 0.0,
+ }
+
+ predictions = np.stack(predictions, axis=0)
+ coco.createIndex()
+ coco_det = coco.loadRes(predictions)
+ coco_eval = COCOeval(coco, coco_det, iouType="bbox")
+ coco_eval.evaluate()
+ coco_eval.accumulate()
+ return {
+ name: val
+ for name, val in [
+ _get_metric(coco_eval, recall=False),
+ _get_metric(coco_eval, recall=False, iou_threshold=0.5),
+ _get_metric(coco_eval, recall=False, iou_threshold=0.75),
+ _get_metric(coco_eval, recall=True),
+ _get_metric(coco_eval, recall=True, iou_threshold=0.5),
+ _get_metric(coco_eval, recall=True, iou_threshold=0.75),
+ ]
+ }
+
+
+def _get_metric(
+ coco_eval: COCOeval,
+ recall: bool = False,
+ iou_threshold: float | None = None,
+ area_rng: str = "all",
+ max_dets: int = 100,
+) -> tuple[str, float]:
+ metric_name = "mAR" if recall else "mAP"
+ if iou_threshold is not None:
+ thresh = f"{int(100 * iou_threshold)}"
+ else:
+ low, high = coco_eval.params.iouThrs[0], coco_eval.params.iouThrs[-1]
+ thresh = f"{int(100 * low)}:{int(100 * high)}"
+
+ aind = [i for i, aRng in enumerate(coco_eval.params.areaRngLbl) if aRng == area_rng]
+ mind = [i for i, mDet in enumerate(coco_eval.params.maxDets) if mDet == max_dets]
+ if recall:
+ s = coco_eval.eval["recall"]
+ if iou_threshold is not None:
+ t = np.where(iou_threshold == coco_eval.params.iouThrs)[0]
+ s = s[t]
+ s = s[:, :, aind, mind]
+ else:
+ s = coco_eval.eval["precision"]
+ if iou_threshold is not None:
+ t = np.where(iou_threshold == coco_eval.params.iouThrs)[0]
+ s = s[t]
+ s = s[:, :, :, aind, mind]
+
+ if len(s[s > -1]) == 0:
+ mean_s = -1
+ else:
+ mean_s = 100 * np.mean(s[s > -1]).item()
+
+ return f"{metric_name}@{thresh}", mean_s
diff --git a/deeplabcut/core/metrics/distance_metrics.py b/deeplabcut/core/metrics/distance_metrics.py
new file mode 100644
index 0000000000..78d3f00573
--- /dev/null
+++ b/deeplabcut/core/metrics/distance_metrics.py
@@ -0,0 +1,463 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Implementations of methods to compute distance metrics such as RMSE or OKS."""
+
+from __future__ import annotations
+
+import numpy as np
+
+import deeplabcut.core.metrics.matching as matching
+from deeplabcut.core.crossvalutils import find_closest_neighbors
+from deeplabcut.core.inferenceutils import calc_object_keypoint_similarity
+
+
+def compute_oks_matrix(
+ ground_truth: np.ndarray,
+ predictions: np.ndarray,
+ oks_sigma: float | np.ndarray,
+ oks_bbox_margin: float = 0.0,
+) -> np.ndarray:
+ """Computes the OKS score for each (prediction, gt) pair in an image.
+
+ Args:
+ ground_truth: The GT poses for an image, shape (n_individuals, n_kpts, 2)
+ predictions: The predicted poses in the image, shape (n_pred, n_kpts, 2)
+ oks_sigma: The sigma value to use to compute OKS
+ oks_bbox_margin: The margin to add around keypoints when computing the area.
+ FIXME(niels) We should allow the use of ground truth bboxes to get area
+
+ Returns:
+ A matrix of shape (n_pred, n_kpts) where entry (i, j) is the OKS between
+ prediction i and ground truth j.
+ """
+ oks_matrix = np.zeros((len(predictions), len(ground_truth)))
+ for pred_idx, pred in enumerate(predictions):
+ for gt_idx, gt in enumerate(ground_truth):
+ oks_matrix[pred_idx, gt_idx] = calc_object_keypoint_similarity(
+ pred[:, :2],
+ gt[:, :2],
+ sigma=oks_sigma,
+ margin=oks_bbox_margin,
+ )
+
+ return oks_matrix
+
+
+def compute_oks(
+ data: list[tuple[np.ndarray, np.ndarray]],
+ oks_bbox_margin: float = 0.0,
+ oks_sigma: float | np.ndarray = 0.1,
+ oks_thresholds: np.ndarray | None = None,
+ oks_recall_thresholds: np.ndarray | None = None,
+) -> dict[str, float]:
+ """Computes the OKS for pose at different thresholds.
+
+ Args:
+ data: The data for which to compute OKS mAP: a list containing (gt_poses,
+ predicted_poses) tuples, where gt_pose is an array of shape
+ (num_gt_individuals, num_bpts, 3) and predicted_poses is an array of shape
+ (num_predictions, num_bpts, 3). For the GT, the 3 coordinates are (x, y,
+ visibility) while for the pose they are (x, y, confidence score).
+ oks_sigma: The OKS sigma to use to compute pose.
+ oks_bbox_margin: The margin to add around keypoints to compute the area for OKS
+ computation.
+ oks_thresholds: The OKS thresholds at which to compute AP. If None, defaults to
+ (0.5, 0.55, 0.6, ..., 0.9, 0.95).
+ oks_recall_thresholds: The recall thresholds to use to compute mAP. If None,
+ defaults to the same default values used in pycocotools.
+
+ Returns:
+ A dictionary containing mAP and mAR scores.
+ """
+ if oks_thresholds is None:
+ oks_thresholds = np.linspace(0.5, 0.95, 10)
+
+ if oks_recall_thresholds is None:
+ oks_recall_thresholds = np.linspace(
+ start=0.0,
+ stop=1.00,
+ num=int(np.round((1.00 - 0.0) / 0.01)) + 1,
+ endpoint=True,
+ )
+
+ total_gt = 0
+ pose_data = []
+ for gt, pred in data:
+ # filter data to only keep individuals with at least 2 valid keypoints
+ gt = gt[np.sum(np.all(~np.isnan(gt), axis=-1), axis=-1) > 1]
+ pred = pred[np.sum(np.all(~np.isnan(pred), axis=-1), axis=-1) > 1]
+
+ oks_matrix = compute_oks_matrix(
+ gt[:, :, :2],
+ pred[:, :, :2],
+ oks_sigma=oks_sigma,
+ oks_bbox_margin=oks_bbox_margin,
+ )
+
+ total_gt += len(gt)
+ pose_data.append((gt, pred, oks_matrix))
+
+ precisions, recalls = [], []
+ for oks_threshold in oks_thresholds:
+ matches = []
+ for gt, pred, oks_matrix in pose_data:
+ image_matches = matching.match_greedy_oks(
+ gt,
+ pred,
+ oks_matrix=oks_matrix,
+ oks_threshold=oks_threshold,
+ )
+ matches.extend(image_matches)
+
+ if len(matches) == 0: # no predictions -> precision 0, recall 0
+ return {"mAP": 0, "mAR": 0}
+
+ scores = np.asarray([m.score for m in matches])
+ match_order = np.argsort(-scores, kind="mergesort")
+ oks_values = np.asarray([m.oks for m in matches])
+ oks_values = oks_values[match_order]
+
+ tp = np.cumsum(oks_values >= oks_threshold)
+ fp = np.cumsum(oks_values < oks_threshold)
+ rc = tp / total_gt
+ pr = tp / (fp + tp + np.spacing(1))
+ recall = rc[-1]
+
+ # Guarantee precision decreases monotonically, see
+ # https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173
+ for i in range(len(pr) - 1, 0, -1):
+ if pr[i] > pr[i - 1]:
+ pr[i - 1] = pr[i]
+
+ inds_rc = np.searchsorted(rc, oks_recall_thresholds, side="left")
+ precision = np.zeros(inds_rc.shape)
+ valid = inds_rc < len(pr)
+ precision[valid] = pr[inds_rc[valid]]
+
+ precisions.append(precision)
+ recalls.append(recall)
+
+ precisions = np.asarray(precisions)
+ recalls = np.asarray(recalls)
+ return {
+ "mAP": 100 * precisions.mean().item(),
+ "mAR": 100 * recalls.mean().item(),
+ }
+
+
+def match_predictions_for_rmse(
+ data: list[tuple[np.ndarray, np.ndarray]],
+ single_animal: bool,
+ oks_bbox_margin: float = 0.0,
+) -> list[matching.PotentialMatch]:
+ """Matches GT keypoints to predictions to compute RMSE.
+
+ Single animal RMSE is computed by simply calculating the distance between each
+ ground truth keypoint and the corresponding prediction.
+
+ Multi-animal RMSE is computed differently: predictions are first matched to ground
+ truth individuals using greedy OKS matching. RMSE is then computed only between
+ predictions and the ground truth pose they are matched to, only when the OKS is
+ non-zero (greater than a small threshold). Predictions that cannot be matched to
+ any ground truth with non-zero OKS are not used to compute RMSE.
+
+ Args:
+ data: The data for which to compute RMSE. This is a list containing (gt_poses,
+ predicted_poses), where gt_pose is an array of shape (num_gt_individuals,
+ num_bpts, 3) and predicted_poses is an array of shape (num_predictions,
+ num_bpts, 3). For the GT, the 3 coordinates are (x, y, visibility) while for
+ the pose they are (x, y, confidence score).
+ single_animal: Whether this is a single animal dataset.
+ oks_bbox_margin: When single_animal is False, predictions are matched to GT
+ using OKS. This is the margin used to apply when computing the bbox from
+ the pose to compute OKS.
+
+ Returns:
+ A list containing the predictions matched to ground truth.
+
+ Raises:
+ ValueError: If `single_animal=True` but more than one ground truth/predicted
+ keypoint is found for an entry
+ """
+ matches = []
+ for gt, pred in data:
+ if single_animal:
+ if gt.shape[0] > 1 or pred.shape[0] > 1:
+ raise ValueError(
+ "At most 1 individual and 1 prediction can be given when computing "
+ f"single animal RMSE. Found gt={gt.shape}, pred={pred.shape}"
+ )
+
+ image_matches = []
+ if gt.shape[0] == 1 and pred.shape[0] == 1:
+ match = matching.PotentialMatch.from_pose(pred[0])
+ match.match(gt[0], oks=float("nan")) # OKS not needed for RMSE
+ image_matches.append(match)
+ else:
+ oks_matrix = compute_oks_matrix(
+ gt[:, :, :2],
+ pred[:, :, :2],
+ oks_sigma=0.1,
+ oks_bbox_margin=oks_bbox_margin,
+ )
+ image_matches = matching.match_greedy_oks(
+ gt,
+ pred,
+ oks_matrix=oks_matrix,
+ oks_threshold=1e-6,
+ )
+
+ matches.extend(image_matches)
+
+ return matches
+
+
+def compute_rmse(
+ data: list[tuple[np.ndarray, np.ndarray]],
+ single_animal: bool,
+ pcutoff: float | list[float],
+ data_unique: list[tuple[np.ndarray, np.ndarray]] | None = None,
+ per_keypoint_results: bool = False,
+ oks_bbox_margin: float = 0.0,
+) -> dict[str, float]:
+ """Computes the RMSE for pose predictions.
+
+ Single animal RMSE is computed by simply calculating the distance between each
+ ground truth keypoint and the corresponding prediction.
+
+ Multi-animal RMSE is computed differently: predictions are first matched to ground
+ truth individuals using greedy OKS matching. RMSE is then computed only between
+ predictions and the ground truth pose they are matched to, only when the OKS is
+ non-zero (greater than a small threshold). Predictions that cannot be matched to
+ any ground truth with non-zero OKS are not used to compute RMSE.
+
+ Args:
+ data: The data for which to compute RMSE. This is a list containing (gt_poses,
+ predicted_poses), where gt_pose is an array of shape (num_gt_individuals,
+ num_bpts, 3) and predicted_poses is an array of shape (num_predictions,
+ num_bpts, 3). For the GT, the 3 coordinates are (x, y, visibility) while for
+ the pose they are (x, y, confidence score).
+ single_animal: Whether this is a single animal dataset.
+ pcutoff: The p-cutoff to use to compute RMSE. If a list, the cutoff for each
+ bodypart is set individually. The list must have length num_bodyparts +
+ num_unique_bodyparts.
+ data_unique: Unique bodypart ground truth and predictions to include in RMSE
+ computations, if there are any such bodyparts.
+ per_keypoint_results: Whether to compute the RMSE for each individual keypoint.
+ oks_bbox_margin: When single_animal is False, predictions are matched to GT
+ using OKS. This is the margin used to apply when computing the bbox from
+ the pose to compute OKS.
+
+ Returns:
+ A dictionary matching metric names to values. It will at least have "rmse" and
+ "rmse_cutoff" keys. If `per_keypoint_results=True` and there is at least one
+ non-NaN pixel error it will also contain "rmse_keypoint_X" and
+ "rmse_cutoff_keypoint_X" keys for each bodypart, where X is the index of the
+ bodypart.
+
+ Raises:
+ ValueError: If `single_animal=True` but more than one ground truth/predicted
+ keypoint is found for an entry
+ """
+ matches = match_predictions_for_rmse(data, single_animal, oks_bbox_margin)
+ pixel_errors, keypoint_scores = None, None
+ if len(matches) > 0:
+ pixel_errors = np.stack([m.pixel_errors() for m in matches])
+ keypoint_scores = np.stack([m.keypoint_scores() for m in matches])
+
+ error, support, cutoff_error, cutoff_support = 0, 0, 0, 0
+ if pixel_errors is not None:
+ bpt_cutoffs = pcutoff
+ if not isinstance(pcutoff, (int, float)):
+ bpt_cutoffs = pcutoff[: pixel_errors.shape[1]]
+
+ error, support, cutoff_error, cutoff_support = collect_pixel_errors(
+ pixel_errors,
+ keypoint_scores,
+ bpt_cutoffs,
+ )
+
+ unique_pixel_errors, unique_keypoint_scores = None, None
+ if data_unique is not None:
+ u_matches = match_predictions_for_rmse(data_unique, single_animal=True)
+ if len(u_matches) > 0:
+ unique_pixel_errors = np.stack([m.pixel_errors() for m in u_matches])
+ unique_keypoint_scores = np.stack([m.keypoint_scores() for m in u_matches])
+
+ bpt_cutoffs = pcutoff
+ if not isinstance(pcutoff, (int, float)):
+ bpt_cutoffs = pcutoff[-unique_pixel_errors.shape[1] :]
+ u_error, u_support, u_cutoff_error, u_cutoff_support = collect_pixel_errors(
+ unique_pixel_errors,
+ unique_keypoint_scores,
+ bpt_cutoffs,
+ )
+ error += u_error
+ support += u_support
+ cutoff_error += u_cutoff_error
+ cutoff_support += u_cutoff_support
+
+ results = dict(rmse=float("nan"), rmse_pcutoff=float("nan"))
+ if support > 0:
+ results["rmse"] = float(error / support)
+ if cutoff_support > 0:
+ results["rmse_pcutoff"] = float(cutoff_error / cutoff_support)
+
+ if per_keypoint_results:
+ bodypart_errors = [("rmse_keypoint", pixel_errors)]
+ if unique_pixel_errors is not None:
+ bodypart_errors.append(("rmse_unique_keypoint", unique_pixel_errors))
+
+ for key_prefix, bpt_errors in bodypart_errors:
+ for idx, keypoint_error in enumerate(bpt_errors.T):
+ rmse = float("nan")
+ if np.any(~np.isnan(keypoint_error)):
+ rmse = np.nanmean(keypoint_error).item()
+ results[f"{key_prefix}_{idx}"] = float(rmse)
+
+ return results
+
+
+def compute_detection_rmse(
+ data: list[tuple[np.ndarray, np.ndarray]],
+ pcutoff: float | list[float],
+ data_unique: list[tuple[np.ndarray, np.ndarray]] | None = None,
+) -> tuple[float, float]:
+ """Computes the detection RMSE for pose predictions.
+
+ The detection RMSE score does not take individual assemblies into account. It only
+ judges the performance of the detections, matching each predicted keypoint to the
+ closest ground truth for each bodypart.
+
+ This is the same way multi-animal RMSE was computed in DeepLabCut 2.X.
+
+ Args:
+ data: The data for which to compute RMSE. This is a list containing (gt_poses,
+ predicted_poses), where gt_pose is an array of shape (num_gt_individuals,
+ num_bpts, 3) and predicted_poses is an array of shape (num_predictions,
+ num_bpts, 3). For the GT, the 3 coordinates are (x, y, visibility) while for
+ the pose they are (x, y, confidence score).
+ pcutoff: The p-cutoff to use to compute RMSE. If a list, the cutoff for each
+ bodypart is set individually. The list must have length num_bodyparts +
+ num_unique_bodyparts.
+ data_unique: Unique bodypart ground truth and predictions to include in RMSE
+ computations, if there are any such bodyparts.
+
+ Returns:
+ The detection RMSE and detection RMSE after removing all detections with a
+ score below the pcutoff.
+ """
+ distances = []
+ distances_cutoff = []
+ for image_gt, image_pred in data:
+ image_gt = image_gt.transpose((1, 0, 2)) # to (num_bpts, num_gt_individuals, 3)
+ image_pred = image_pred.transpose((1, 0, 2)) # to (num_bpts, num_pred, 3)
+
+ for bpt_index, (bpt_gt, bpt_pred) in enumerate(zip(image_gt, image_pred, strict=False)):
+ # filter NaNs and invalid values
+ bpt_gt = bpt_gt[~np.any(np.isnan(bpt_gt), axis=1)]
+ bpt_pred = bpt_pred[~np.any(np.isnan(bpt_pred), axis=1)]
+ if len(bpt_gt) == 0 or len(bpt_pred) == 0:
+ continue
+
+ if isinstance(pcutoff, (int, float)):
+ bpt_pcutoff = pcutoff
+ else:
+ bpt_pcutoff = pcutoff[bpt_index]
+
+ # assignment of predicted bodyparts to ground truth
+ neighbors = find_closest_neighbors(bpt_gt, bpt_pred, k=3)
+ for gt_index, pred_index in enumerate(neighbors):
+ if pred_index != -1:
+ gt = bpt_gt[gt_index]
+ pred = bpt_pred[pred_index]
+ dist = np.linalg.norm(gt[:2] - pred[:2])
+ distances.append(dist)
+
+ score = bpt_pred[pred_index, 2]
+ if score >= bpt_pcutoff:
+ distances_cutoff.append(dist)
+
+ if data_unique is not None:
+ for image_gt, image_pred in data_unique:
+ assert len(image_gt) <= 1 and len(image_pred) <= 1, (
+ f"Unique GT an predictions must have length 0 or 1! Found {image_gt.shape}, {image_pred.shape}."
+ )
+
+ if len(image_gt) == 1 and len(image_pred) == 1:
+ unique_gt, unique_pred = image_gt[0], image_pred[0]
+ num_unique = unique_gt.shape[0]
+ unique_cutoffs = pcutoff
+ if not isinstance(pcutoff, (int, float)):
+ unique_cutoffs = pcutoff[-num_unique:]
+
+ for bpt_index, (gt, pred) in enumerate(zip(unique_gt, unique_pred, strict=False)):
+ dist = np.linalg.norm(gt[:2] - pred[:2])
+ distances.append(dist)
+
+ score = pred[2]
+ if isinstance(pcutoff, (int, float)):
+ bpt_pcutoff = unique_cutoffs
+ else:
+ bpt_pcutoff = unique_cutoffs[bpt_index]
+
+ if score >= bpt_pcutoff:
+ distances_cutoff.append(dist)
+
+ rmse, rmse_cutoff = float("nan"), float("nan")
+ if len(distances) == 0:
+ return rmse, rmse_cutoff
+
+ distances = np.stack(distances)
+ if np.any(~np.isnan(distances)):
+ rmse = float(np.nanmean(distances).item())
+
+ if len(distances_cutoff) > 0:
+ distances_cutoff = np.stack(distances_cutoff)
+ if np.any(~np.isnan(distances_cutoff)):
+ rmse_cutoff = float(np.nanmean(distances_cutoff).item())
+
+ return rmse, rmse_cutoff
+
+
+def collect_pixel_errors(
+ pixel_errors: np.ndarray,
+ keypoint_scores: np.ndarray,
+ pcutoff: float,
+) -> tuple[float, int, float, int]:
+ """Collects pixel errors for RMSE computation.
+
+ Args:
+ pixel_errors: The pixel errors to collect, of shape (num_matches, num_bodyparts)
+ keypoint_scores: The scores corresponding to the pixel errors, of shape
+ (num_matches, num_bodyparts).
+ pcutoff: The pcutoff to use when computing cutoff RMSE.
+
+ Returns: error, support, cutoff_error, support_cutoff
+ error: The sum of all pixel errors.
+ support: The number of valid pixel errors.
+ cutoff_error: The sum of all pixel errors with score > pcutoff.
+ support_cutoff: The number of valid pixel errors with score > pcutoff.
+ """
+ error = 0.0
+ cutoff_error = 0.0
+ support = np.sum(~np.isnan(pixel_errors)).item()
+ support_cutoff = 0
+ if support > 0:
+ error += np.nansum(pixel_errors).item()
+
+ cutoff_mask = keypoint_scores >= pcutoff
+ cutoff_pixel_errors = pixel_errors[cutoff_mask]
+ support_cutoff = np.sum(~np.isnan(cutoff_pixel_errors)).item()
+ if support_cutoff > 0:
+ cutoff_error = np.nansum(cutoff_pixel_errors).item()
+
+ return error, support, cutoff_error, support_cutoff
diff --git a/deeplabcut/core/metrics/identity.py b/deeplabcut/core/metrics/identity.py
new file mode 100644
index 0000000000..684b797213
--- /dev/null
+++ b/deeplabcut/core/metrics/identity.py
@@ -0,0 +1,91 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Implementations of methods to compute identity prediction accuracy."""
+
+from __future__ import annotations
+
+import numpy as np
+from sklearn.metrics import accuracy_score
+
+from deeplabcut.core.crossvalutils import find_closest_neighbors
+
+
+def compute_identity_scores(
+ individuals: list[str],
+ bodyparts: list[str],
+ predictions: dict[str, np.ndarray],
+ identity_scores: dict[str, np.ndarray],
+ ground_truth: dict[str, np.ndarray],
+) -> dict[str, float]:
+ """
+ FIXME: With DLCRNet all heatmap "peaks" above 0.01 were kept, with 1 keypoint and
+ 1 identity score map per peak. Then, for each ground truth keypoint, we selected
+ the prediction closest to it, and evaluated the identity score in that position.
+ This is no longer the case, as we're now evaluating after assembly. So we only
+ have num_individuals assemblies.
+
+ Args:
+ individuals:
+ bodyparts:
+ predictions: (num_assemblies, num_bodyparts, 3)
+ identity_scores: (num_assemblies, num_bodyparts, num_individuals)
+ ground_truth: (num_individuals, num_bodyparts, 3)
+
+ Returns:
+
+ """
+ if not len(predictions) == len(ground_truth):
+ raise ValueError("Mismatch between number of predictions and ground truth")
+
+ all_bpts = np.asarray(len(individuals) * bodyparts)
+ ids = np.full((len(predictions), len(all_bpts), 2), np.nan)
+ for i, (image, pred) in enumerate(predictions.items()):
+ for j in range(len(individuals)):
+ for k in range(len(bodyparts)):
+ bpt_idx = len(bodyparts) * j + k
+ ids[i, bpt_idx, 0] = j
+
+ # set keypoints that aren't visible to NaN
+ gt = ground_truth[image].copy()
+ gt[gt[..., 2] <= 0, :2] = np.nan
+ gt = gt[..., :2]
+
+ id_scores = identity_scores[image]
+
+ # reorder to (bodypart, individual, ...)
+ gt = gt.transpose((1, 0, 2))
+ pred = pred.transpose((1, 0, 2))[..., :2]
+ id_scores = id_scores.transpose((1, 0, 2))
+ for bpt, bpt_gt, bpt_pred, bpt_id_scores in zip(bodyparts, gt, pred, id_scores, strict=True):
+ # assign ground truth keypoints to the closest prediction, so the ID score
+ # is the closest possible to the ID score computed with "ground truth"
+ indices_gt = np.flatnonzero(np.all(~np.isnan(bpt_gt), axis=1))
+
+ # Remove NaN predictions from the bodypart predictions
+ indices_pred = np.all(np.isfinite(bpt_pred), axis=1)
+ bpt_pred = bpt_pred[indices_pred]
+ bpt_id_scores = bpt_id_scores[indices_pred]
+
+ neighbors = find_closest_neighbors(bpt_gt[indices_gt], bpt_pred, k=3)
+ found = neighbors != -1
+ indices = np.flatnonzero(all_bpts == bpt)
+ # Get the predicted identity of each bodypart by taking the argmax
+ ids[i, indices[indices_gt[found]], 1] = np.argmax(bpt_id_scores[neighbors[found]], axis=1)
+
+ ids = ids.reshape((len(predictions), len(individuals), len(bodyparts), 2))
+ results = {}
+ for i, bpt in enumerate(bodyparts):
+ temp = ids[:, :, i].reshape((-1, 2))
+ valid = np.isfinite(temp).all(axis=1)
+ y_true, y_pred = temp[valid].T
+ results[f"{bpt}_accuracy"] = accuracy_score(y_true, y_pred)
+
+ return results
diff --git a/deeplabcut/core/metrics/matching.py b/deeplabcut/core/metrics/matching.py
new file mode 100644
index 0000000000..791bcae97f
--- /dev/null
+++ b/deeplabcut/core/metrics/matching.py
@@ -0,0 +1,167 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Algorithms to match predictions to ground truth labels."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass
+class PotentialMatch:
+ """A potential match between predicted pose and ground truth pose.
+
+ Args:
+ pose: An array of shape (num_bodyparts, 3)
+ score: The score for the prediction. This could be the mean of the confidence
+ score for each bodypart, or another value representing how confident the
+ model is that this assembly is correct.
+ gt: None if no ground truth pose was matched to the prediction. If defined, the
+ ground truth to which the prediction is matched. It should be of shape
+ (num_bodyparts, 3), where the 3 values are x, y and visibility.
+ oks: The OKS score between the pose and the ground truth.
+ """
+
+ pose: np.ndarray
+ score: float
+ gt: np.ndarray | None = None
+ oks: float = 0.0
+
+ def keypoint_scores(self) -> np.ndarray:
+ """Returns: The confidence score for each bodypart in the predicted pose."""
+ return self.pose[:, 2].copy()
+
+ def pixel_errors(self) -> np.ndarray:
+ """
+ Returns:
+ The distance (in pixels) between each predicted and ground truth bodypart.
+ If this prediction is unmatched, returns an array of length num_bodyparts
+ containing all NaNs.
+ """
+ if self.gt is None:
+ return np.full(len(self.pose), np.nan)
+
+ return np.linalg.norm(self.pose[:, :2] - self.gt[:, :2], axis=1)
+
+ def match(self, gt: np.ndarray, oks: float) -> None:
+ """Adds a ground truth match to this PotentialMatch.
+
+ Args:
+ gt: The ground truth to which the prediction is matched. The ground truth
+ pose should be of shape (num_bodyparts, 3), where the 3 values are x, y
+ and visibility.
+ oks: The OKS similarity between the ground truth and this.
+ """
+ self.gt = gt
+ self.oks = oks
+
+ @classmethod
+ def from_pose(cls, pose: np.ndarray) -> PotentialMatch:
+ assert len(pose.shape) == 2 # Must be pose for a single individual
+ scores = pose[:, 2]
+ if np.all(np.isnan(scores)):
+ raise ValueError(f"Cannot create a Match from a pose prediction where all scores are nan (pose={pose})")
+
+ return PotentialMatch(pose=pose, score=np.nanmean(scores).item())
+
+
+def match_greedy_oks(
+ ground_truth: np.ndarray,
+ predictions: np.ndarray,
+ oks_matrix: np.ndarray,
+ oks_threshold: float = 0.0,
+) -> list[PotentialMatch]:
+ """Greedy matching of ground truth individuals to predicted individuals using OKS.
+
+ This is done in the same way as done in pycocotools. The predictions must be sorted
+ by score before being passed to this function.
+
+ Args:
+ ground_truth: The ground truth labels for an image, of shape (n_idv, n_bpt, 2)
+ predictions: The predictions for an image, of shape (n_idv, n_bpt, 2)
+ oks_matrix: A matrix of shape (n_pred, n_kpts) where entry (i, j) is the OKS
+ between prediction i and ground truth j.
+ oks_threshold: The min. OKS for a prediction to be matched to a GT pose
+
+ Returns:
+ A list containing a PotentialMatch for each predicted pose in the given
+ predictions.
+ """
+ matches = [PotentialMatch.from_pose(pose=pred) for pred in predictions]
+ matched_gt_indices = set()
+ for idx, _pred in enumerate(predictions):
+ oks = oks_matrix[idx]
+ if np.all(np.isnan(oks)):
+ continue
+
+ ind_best = np.nanargmax(oks)
+
+ # if this gt already matched, continue
+ if ind_best in matched_gt_indices:
+ continue
+
+ # Only match the pred to the GT if the OKS value is above a given threshold
+ if oks[ind_best] < oks_threshold:
+ continue
+
+ matched_gt_indices.add(ind_best)
+ matches[idx].match(gt=ground_truth[ind_best], oks=oks[ind_best])
+
+ return matches
+
+
+def match_greedy_rmse(
+ ground_truth: np.ndarray,
+ predictions: np.ndarray,
+ keep_assemblies: bool = True,
+) -> list[PotentialMatch]:
+ """Greedy matching of ground truth individuals to predicted individuals using RMSE.
+
+ The predictions must be sorted by score before being passed to this function.
+
+ Args:
+ ground_truth: The ground truth labels for an image, of shape (n_idv, n_bpt, 2)
+ predictions: The predictions for an image, of shape (n_idv, n_bpt, 2)
+ keep_assemblies: Whether to match predicted keypoints to ground truth keypoints
+ while enforcing that all bodyparts for a predicted individual are matched
+ to bodyparts from the same ground truth assembly. When set to False, this
+ corresponds to detection RMSE score.
+
+ Returns:
+ A list containing a PotentialMatch for each predicted pose in the given
+ predictions.
+ """
+ if not keep_assemblies:
+ raise NotImplementedError()
+
+ matches = [PotentialMatch.from_pose(pose=pred) for pred in predictions]
+ matched_gt_indices = set()
+ for idx, pred in enumerate(predictions):
+ bpt_distances = np.linalg.norm(pred[:, :2] - ground_truth[:, :, :2], axis=-1)
+ if np.all(np.isnan(bpt_distances)):
+ continue
+
+ distances = np.nanmean(bpt_distances, axis=-1)
+ ind_best = np.nanargmin(distances)
+
+ # if this gt already matched, continue
+ if ind_best in matched_gt_indices:
+ continue
+
+ matched_gt_indices.add(ind_best)
+ matches[idx].match(
+ gt=ground_truth[ind_best],
+ oks=float("nan"), # don't compute OKS here
+ )
+
+ return matches
diff --git a/deeplabcut/core/trackingutils.py b/deeplabcut/core/trackingutils.py
new file mode 100644
index 0000000000..75934dc26b
--- /dev/null
+++ b/deeplabcut/core/trackingutils.py
@@ -0,0 +1,819 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import abc
+import math
+import warnings
+from collections import defaultdict
+
+import numpy as np
+from filterpy.common import kinematic_kf
+from filterpy.kalman import KalmanFilter
+from matplotlib import patches
+from numba import jit
+from numba.core.errors import NumbaPerformanceWarning
+from scipy.optimize import linear_sum_assignment
+from scipy.stats import mode
+from tqdm import tqdm
+
+warnings.simplefilter("ignore", category=NumbaPerformanceWarning)
+
+TRACK_METHODS = {
+ "box": "_bx",
+ "ctd": "_ctd",
+ "skeleton": "_sk",
+ "ellipse": "_el",
+ "transformer": "_tr",
+}
+
+
+def calc_iou(bbox1, bbox2):
+ x1 = max(bbox1[0], bbox2[0])
+ y1 = max(bbox1[1], bbox2[1])
+ x2 = min(bbox1[2], bbox2[2])
+ y2 = min(bbox1[3], bbox2[3])
+ w = max(0, x2 - x1)
+ h = max(0, y2 - y1)
+ wh = w * h
+ return wh / ((bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) + (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) - wh)
+
+
+class BaseTracker:
+ """Base class for a constant-velocity Kalman filter-based tracker."""
+
+ n_trackers = 0
+
+ def __init__(self, dim, dim_z):
+ self.kf = kinematic_kf(
+ dim,
+ 1,
+ dim_z=dim_z,
+ order_by_dim=False,
+ )
+ self.id = self.__class__.n_trackers
+ self.__class__.n_trackers += 1
+ self.time_since_update = 0
+ self.age = 0
+ self.hits = 0
+ self.hit_streak = 0
+
+ def update(self, z):
+ self.time_since_update = 0
+ self.hits += 1
+ self.hit_streak += 1
+ self.kf.update(z)
+
+ def predict(self):
+ self.kf.predict()
+ self.age += 1
+ if self.time_since_update > 0:
+ self.hit_streak = 0
+ self.time_since_update += 1
+ return self.state
+
+ @property
+ def state(self):
+ return self.kf.x.squeeze()[: self.kf.dim_z]
+
+ @state.setter
+ def state(self, state):
+ self.kf.x[: self.kf.dim_z] = state
+
+
+class Ellipse:
+ def __init__(self, x, y, width, height, theta):
+ self.x = x
+ self.y = y
+ self.width = width
+ self.height = height
+ self.theta = theta # in radians
+ self._geometry = None
+
+ @property
+ def parameters(self):
+ return self.x, self.y, self.width, self.height, self.theta
+
+ @property
+ def aspect_ratio(self):
+ return max(self.width, self.height) / min(self.width, self.height)
+
+ def calc_similarity_with(self, other_ellipse):
+ max_dist = max(self.height, self.width, other_ellipse.height, other_ellipse.width)
+ dist = math.sqrt((self.x - other_ellipse.x) ** 2 + (self.y - other_ellipse.y) ** 2)
+
+ if max_dist == 0:
+ max_dist = 1
+
+ cost1 = 1 - min(dist / max_dist, 1)
+ cost2 = abs(math.cos(self.theta - other_ellipse.theta))
+ return 0.8 * cost1 + 0.2 * cost2 * cost1
+
+ def contains_points(self, xy, tol=0.1):
+ ca = math.cos(self.theta)
+ sa = math.sin(self.theta)
+ x_demean = xy[:, 0] - self.x
+ y_demean = xy[:, 1] - self.y
+ return (
+ ((ca * x_demean + sa * y_demean) ** 2 / (0.5 * self.width) ** 2)
+ + ((sa * x_demean - ca * y_demean) ** 2 / (0.5 * self.height) ** 2)
+ ) <= 1 + tol
+
+ def draw(self, show_axes=True, ax=None, **kwargs):
+ import matplotlib.pyplot as plt
+ from matplotlib.lines import Line2D
+ from matplotlib.transforms import Affine2D
+
+ if ax is None:
+ ax = plt.subplot(111, aspect="equal")
+ el = patches.Ellipse(
+ xy=(self.x, self.y),
+ width=self.width,
+ height=self.height,
+ angle=np.rad2deg(self.theta),
+ **kwargs,
+ )
+ ax.add_patch(el)
+ if show_axes:
+ major = Line2D([-self.width / 2, self.width / 2], [0, 0], lw=3, zorder=3)
+ minor = Line2D([0, 0], [-self.height / 2, self.height / 2], lw=3, zorder=3)
+ trans = Affine2D().rotate(self.theta).translate(self.x, self.y) + ax.transData
+ major.set_transform(trans)
+ minor.set_transform(trans)
+ ax.add_artist(major)
+ ax.add_artist(minor)
+
+
+class EllipseFitter:
+ def __init__(self, sd=2):
+ self.sd = sd
+ self.x = None
+ self.y = None
+ self.params = None
+ self._coeffs = None
+
+ def fit(self, xy):
+ self.x, self.y = xy[np.isfinite(xy).all(axis=1)].T
+ if len(self.x) < 3:
+ return None
+ if self.sd:
+ self.params = self._fit_error(self.x, self.y, self.sd)
+ else:
+ self._coeffs = self._fit(self.x, self.y)
+ self.params = self.calc_parameters(self._coeffs)
+ if not np.isnan(self.params).any():
+ return Ellipse(*self.params)
+ return None
+
+ @staticmethod
+ @jit(nopython=True)
+ def _fit(x, y):
+ """Least Squares ellipse fitting algorithm Fit an ellipse to a set of X- and
+ Y-coordinates. See Halir and Flusser, 1998 for implementation details.
+
+ :param x: ndarray, 1D trajectory
+ :param y: ndarray, 1D trajectory
+ :return: 1D ndarray of 6 coefficients of the general quadratic curve: ax^2 +
+ 2bxy + cy^2 + 2dx + 2fy + g = 0
+ """
+ D1 = np.vstack((x * x, x * y, y * y))
+ D2 = np.vstack((x, y, np.ones_like(x)))
+ S1 = D1 @ D1.T
+ S2 = D1 @ D2.T
+ S3 = D2 @ D2.T
+ T = -np.linalg.inv(S3) @ S2.T
+ temp = S1 + S2 @ T
+ M = np.zeros_like(temp)
+ M[0] = temp[2] * 0.5
+ M[1] = -temp[1]
+ M[2] = temp[0] * 0.5
+ E, V = np.linalg.eig(M)
+ cond = 4 * V[0] * V[2] - V[1] ** 2
+ a1 = V[:, cond > 0][:, 0]
+ a2 = T @ a1
+ return np.hstack((a1, a2))
+
+ @staticmethod
+ @jit(nopython=True)
+ def _fit_error(x, y, sd):
+ """Fit a sd-sigma covariance error ellipse to the data.
+
+ :param x: ndarray, 1D input of X coordinates
+ :param y: ndarray, 1D input of Y coordinates
+ :param sd: int, size of the error ellipse in 'standard deviation'
+ :return: ellipse center, semi-axes length, angle to the X-axis
+ """
+ cov = np.cov(x, y)
+ E, V = np.linalg.eigh(cov) # Returns the eigenvalues in ascending order
+ # r2 = chi2.ppf(2 * norm.cdf(sd) - 1, 2)
+ # height, width = np.sqrt(E * r2)
+ height, width = 2 * sd * np.sqrt(E)
+ a, b = V[:, 1]
+ rotation = math.atan2(b, a) % np.pi
+ return [np.mean(x), np.mean(y), width, height, rotation]
+
+ @staticmethod
+ @jit(nopython=True)
+ def calc_parameters(coeffs):
+ """
+ Calculate ellipse center coordinates, semi-axes lengths, and
+ the counterclockwise angle of rotation from the x-axis to the ellipse major axis.
+ Visit http://mathworld.wolfram.com/Ellipse.html
+ for how to estimate ellipse parameters.
+
+ :param coeffs: list of fitting coefficients
+ :return: center: 1D ndarray, semi-axes: 1D ndarray, angle: float
+ """
+ # The general quadratic curve has the form:
+ # ax^2 + 2bxy + cy^2 + 2dx + 2fy + g = 0
+ a, b, c, d, f, g = coeffs
+ b *= 0.5
+ d *= 0.5
+ f *= 0.5
+
+ # Ellipse center coordinates
+ x0 = (c * d - b * f) / (b * b - a * c)
+ y0 = (a * f - b * d) / (b * b - a * c)
+
+ # Semi-axes lengths
+ num = 2 * (a * f * f + c * d * d + g * b * b - 2 * b * d * f - a * c * g)
+ den1 = (b * b - a * c) * (np.sqrt((a - c) ** 2 + 4 * b * b) - (a + c))
+ den2 = (b * b - a * c) * (-np.sqrt((a - c) ** 2 + 4 * b * b) - (a + c))
+ major = np.sqrt(num / den1)
+ minor = np.sqrt(num / den2)
+
+ # Angle to the horizontal
+ if b == 0:
+ if a < c:
+ phi = 0
+ else:
+ phi = np.pi / 2
+ else:
+ if a < c:
+ phi = np.arctan(2 * b / (a - c)) / 2
+ else:
+ phi = np.pi / 2 + np.arctan(2 * b / (a - c)) / 2
+
+ return [x0, y0, 2 * major, 2 * minor, phi]
+
+
+class EllipseTracker(BaseTracker):
+ def __init__(self, params):
+ super().__init__(dim=5, dim_z=5)
+ self.kf.R[2:, 2:] *= 10.0
+ # High uncertainty to the unobservable initial velocities
+ self.kf.P[5:, 5:] *= 1000.0
+ self.kf.P *= 10.0
+ self.kf.Q[5:, 5:] *= 0.01
+ self.state = params
+
+ @BaseTracker.state.setter
+ def state(self, params):
+ state = np.asarray(params).reshape((-1, 1))
+ super(EllipseTracker, type(self)).state.fset(self, state)
+
+
+class SkeletonTracker(BaseTracker):
+ def __init__(self, n_bodyparts):
+ super().__init__(dim=n_bodyparts * 2, dim_z=n_bodyparts)
+ self.kf.Q[self.kf.dim_z :, self.kf.dim_z :] *= 10
+ self.kf.R[self.kf.dim_z :, self.kf.dim_z :] *= 0.01
+ self.kf.P[self.kf.dim_z :, self.kf.dim_z :] *= 1000
+
+ def update(self, pose):
+ flat = pose.reshape((-1, 1))
+ empty = np.isnan(flat).squeeze()
+ if empty.any():
+ H = self.kf.H.copy()
+ H[empty] = 0
+ flat[empty] = 0
+ self.kf.update(flat, H=H)
+ else:
+ super().update(flat)
+
+ @BaseTracker.state.setter
+ def state(self, pose):
+ curr_pose = pose.copy()
+ empty = np.isnan(curr_pose).all(axis=1)
+ if empty.any():
+ fill = np.nanmean(pose, axis=0)
+ curr_pose[empty] = fill
+ super(SkeletonTracker, type(self)).state.fset(self, curr_pose.reshape((-1, 1)))
+
+
+class BoxTracker(BaseTracker):
+ def __init__(self, bbox):
+ super().__init__(dim=4, dim_z=4)
+ self.kf = KalmanFilter(dim_x=7, dim_z=4)
+ self.kf.F = np.array(
+ [
+ [1, 0, 0, 0, 1, 0, 0],
+ [0, 1, 0, 0, 0, 1, 0],
+ [0, 0, 1, 0, 0, 0, 1],
+ [0, 0, 0, 1, 0, 0, 0],
+ [0, 0, 0, 0, 1, 0, 0],
+ [0, 0, 0, 0, 0, 1, 0],
+ [0, 0, 0, 0, 0, 0, 1],
+ ]
+ )
+ self.kf.H = np.array(
+ [
+ [1, 0, 0, 0, 0, 0, 0],
+ [0, 1, 0, 0, 0, 0, 0],
+ [0, 0, 1, 0, 0, 0, 0],
+ [0, 0, 0, 1, 0, 0, 0],
+ ]
+ )
+ self.kf.R[2:, 2:] *= 10.0
+ # Give high uncertainty to the unobservable initial velocities
+ self.kf.P[4:, 4:] *= 1000.0
+ self.kf.P *= 10.0
+ self.kf.Q[-1, -1] *= 0.01
+ self.kf.Q[4:, 4:] *= 0.01
+ self.state = bbox
+
+ def update(self, bbox):
+ super().update(self.convert_bbox_to_z(bbox))
+
+ def predict(self):
+ if (self.kf.x[6] + self.kf.x[2]) <= 0:
+ self.kf.x[6] *= 0.0
+ return super().predict()
+
+ @property
+ def state(self):
+ return self.convert_x_to_bbox(self.kf.x)[0]
+
+ @state.setter
+ def state(self, bbox):
+ state = self.convert_bbox_to_z(bbox)
+ super(BoxTracker, type(self)).state.fset(self, state)
+
+ @staticmethod
+ def convert_x_to_bbox(x, score=None):
+ """Takes a bounding box in the centre form [x,y,s,r] and returns it in the form
+ [x1,y1,x2,y2] where x1,y1 is the top left and x2,y2 is the bottom right."""
+ w = np.sqrt(x[2] * x[3])
+ h = x[2] / w
+ if score is None:
+ return np.array([x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0]).reshape((1, 4))
+ else:
+ return np.array([x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0, score]).reshape((1, 5))
+
+ @staticmethod
+ def convert_bbox_to_z(bbox):
+ """Takes a bounding box in the form [x1,y1,x2,y2] and returns z in the form
+ [x,y,s,r] where x,y is the centre of the box and s is the scale/area and r is
+ the aspect ratio."""
+ w = bbox[2] - bbox[0]
+ h = bbox[3] - bbox[1]
+ x = bbox[0] + w / 2.0
+ y = bbox[1] + h / 2.0
+ s = w * h # scale is just area
+ r = w / float(h)
+ return np.array([x, y, s, r]).reshape((4, 1))
+
+
+class SORTBase(metaclass=abc.ABCMeta):
+ def __init__(self):
+ self.n_frames = 0
+ self.trackers = []
+
+ @abc.abstractmethod
+ def track(self):
+ pass
+
+
+class SORTEllipse(SORTBase):
+ def __init__(self, max_age, min_hits, iou_threshold, sd=2):
+ self.max_age = max_age
+ self.min_hits = min_hits
+ self.iou_threshold = iou_threshold
+ self.fitter = EllipseFitter(sd)
+ EllipseTracker.n_trackers = 0
+ super().__init__()
+
+ def track(self, poses, identities=None):
+ self.n_frames += 1
+
+ trackers = np.zeros((len(self.trackers), 6))
+ for i in range(len(trackers)):
+ trackers[i, :5] = self.trackers[i].predict()
+ empty = np.isnan(trackers).any(axis=1)
+ trackers = trackers[~empty]
+ for ind in np.flatnonzero(empty)[::-1]:
+ self.trackers.pop(ind)
+
+ ellipses = []
+ pred_ids = []
+ for i, pose in enumerate(poses):
+ el = self.fitter.fit(pose)
+ if el is not None:
+ ellipses.append(el)
+ if identities is not None:
+ pred_ids.append(mode(identities[i])[0][0])
+ if not len(trackers):
+ matches = np.empty((0, 2), dtype=int)
+ unmatched_detections = np.arange(len(ellipses))
+ unmatched_trackers = np.empty((0, 6), dtype=int)
+ else:
+ ellipses_trackers = [Ellipse(*t[:5]) for t in trackers]
+ cost_matrix = np.zeros((len(ellipses), len(ellipses_trackers)))
+ for i, el in enumerate(ellipses):
+ for j, el_track in enumerate(ellipses_trackers):
+ cost = el.calc_similarity_with(el_track)
+ if identities is not None:
+ match = 2 if pred_ids[i] == self.trackers[j].id_ else 1
+ cost *= match
+ cost_matrix[i, j] = cost
+ row_indices, col_indices = linear_sum_assignment(cost_matrix, maximize=True)
+ unmatched_detections = [i for i, _ in enumerate(ellipses) if i not in row_indices]
+ unmatched_trackers = [j for j, _ in enumerate(trackers) if j not in col_indices]
+ matches = []
+ for row, col in zip(row_indices, col_indices, strict=False):
+ val = cost_matrix[row, col]
+ # diff = val - cost_matrix
+ # diff[row, col] += val
+ # if (
+ # val < self.iou_threshold
+ # or np.any(diff[row] <= 0.2)
+ # or np.any(diff[:, col] <= 0.2)
+ # ):
+ if val < self.iou_threshold:
+ unmatched_detections.append(row)
+ unmatched_trackers.append(col)
+ else:
+ matches.append([row, col])
+ if not len(matches):
+ matches = np.empty((0, 2), dtype=int)
+ else:
+ matches = np.stack(matches)
+ unmatched_trackers = np.asarray(unmatched_trackers)
+ unmatched_detections = np.asarray(unmatched_detections)
+
+ animalindex = []
+ for t, tracker in enumerate(self.trackers):
+ if t not in unmatched_trackers:
+ ind = matches[matches[:, 1] == t, 0][0]
+ animalindex.append(ind)
+ tracker.update(ellipses[ind].parameters)
+ else:
+ animalindex.append(-1)
+
+ for i in unmatched_detections:
+ trk = EllipseTracker(ellipses[i].parameters)
+ if identities is not None:
+ trk.id_ = mode(identities[i])[0][0]
+ self.trackers.append(trk)
+ animalindex.append(i)
+
+ i = len(self.trackers)
+ ret = []
+ for trk in reversed(self.trackers):
+ d = trk.state
+ if (trk.time_since_update < 1) and (trk.hit_streak >= self.min_hits or self.n_frames <= self.min_hits):
+ ret.append(
+ np.concatenate((d, [trk.id, int(animalindex[i - 1])])).reshape(1, -1)
+ ) # for DLC we also return the original animalid
+ # +1 as MOT benchmark requires positive >> this is removed for DLC!
+ i -= 1
+ # remove dead tracklet
+ if trk.time_since_update > self.max_age:
+ self.trackers.pop(i)
+
+ if len(ret) > 0:
+ return np.concatenate(ret)
+ return np.empty((0, 7))
+
+
+class SORTSkeleton(SORTBase):
+ def __init__(self, n_bodyparts, max_age=20, min_hits=3, oks_threshold=0.5):
+ self.n_bodyparts = n_bodyparts
+ self.max_age = max_age
+ self.min_hits = min_hits
+ self.oks_threshold = oks_threshold
+ SkeletonTracker.n_trackers = 0
+ super().__init__()
+
+ @staticmethod
+ def weighted_hausdorff(x, y):
+ # Modified from scipy source code:
+ # - to restrict its use to 2D
+ # - to get rid of shuffling (since arrays are only (nbodyparts * 3) element long)
+ # TODO - factor in keypoint confidence (and weight by # of observations??)
+ cmax = 0
+ for i in range(x.shape[0]):
+ no_break_occurred = True
+ cmin = np.inf
+ for j in range(y.shape[0]):
+ d = (x[i, 0] - y[j, 0]) ** 2 + (x[i, 1] - y[j, 1]) ** 2
+ if d < cmax:
+ no_break_occurred = False
+ break
+ if d < cmin:
+ cmin = d
+ if cmin != np.inf and cmin > cmax and no_break_occurred:
+ cmax = cmin
+ return np.sqrt(cmax)
+
+ @staticmethod
+ def object_keypoint_similarity(x, y):
+ mask = ~np.isnan(x * y).all(axis=1) # Intersection visible keypoints
+ xx = x[mask]
+ yy = y[mask]
+ dist = np.linalg.norm(xx - yy, axis=1)
+ scale = np.sqrt(np.product(np.ptp(yy, axis=0))) # square root of bounding box area
+ oks = np.exp(-0.5 * (dist / (0.05 * scale)) ** 2)
+ return np.mean(oks)
+
+ def calc_pairwise_hausdorff_dist(self, poses, poses_ref):
+ mat = np.zeros((len(poses), len(poses_ref)))
+ for i, pose in enumerate(poses):
+ for j, pose_ref in enumerate(poses_ref):
+ mat[i, j] = self.weighted_hausdorff(pose, pose_ref)
+ return mat
+
+ def calc_pairwise_oks(self, poses, poses_ref):
+ mat = np.zeros((len(poses), len(poses_ref)))
+ for i, pose in enumerate(poses):
+ for j, pose_ref in enumerate(poses_ref):
+ mat[i, j] = self.object_keypoint_similarity(pose, pose_ref)
+ return mat
+
+ def track(self, poses):
+ self.n_frames += 1
+
+ if not len(self.trackers):
+ for pose in poses:
+ tracker = SkeletonTracker(self.n_bodyparts)
+ tracker.state = pose
+ self.trackers.append(tracker)
+
+ poses_ref = []
+ for _, tracker in enumerate(self.trackers):
+ pose_ref = tracker.predict()
+ poses_ref.append(pose_ref.reshape((-1, 2)))
+
+ # mat = self.calc_pairwise_oks(poses, poses_ref)
+ mat = self.calc_pairwise_hausdorff_dist(poses, poses_ref)
+ row_indices, col_indices = linear_sum_assignment(mat, maximize=False)
+
+ unmatched_poses = [p for p, _ in enumerate(poses) if p not in row_indices]
+ unmatched_trackers = [t for t, _ in enumerate(poses_ref) if t not in col_indices]
+ # Remove matched detections with low OKS
+ # matches = []
+ # for row, col in zip(row_indices, col_indices):
+ # if mat[row, col] < self.oks_threshold:
+ # unmatched_poses.append(row)
+ # unmatched_trackers.append(col)
+ # else:
+ # matches.append([row, col])
+ # if not len(matches):
+ # matches = np.empty((0, 2), dtype=int)
+ # else:
+ # matches = np.stack(matches)
+ matches = np.c_[row_indices, col_indices]
+
+ animalindex = []
+ for t, tracker in enumerate(self.trackers):
+ if t not in unmatched_trackers:
+ ind = matches[matches[:, 1] == t, 0][0]
+ animalindex.append(ind)
+ tracker.update(poses[ind])
+ else:
+ animalindex.append(-1)
+
+ for i in unmatched_poses:
+ tracker = SkeletonTracker(self.n_bodyparts)
+ tracker.state = poses[i]
+ self.trackers.append(tracker)
+ animalindex.append(i)
+
+ states = []
+ i = len(self.trackers)
+ for tracker in reversed(self.trackers):
+ i -= 1
+ if tracker.time_since_update > self.max_age:
+ self.trackers.pop()
+ continue
+ state = tracker.predict()
+ states.append(np.r_[state, [tracker.id, int(animalindex[i])]])
+ if len(states) > 0:
+ return np.stack(states)
+ return np.empty((0, self.n_bodyparts * 2 + 2))
+
+
+class SORTBox(SORTBase):
+ def __init__(self, max_age, min_hits, iou_threshold):
+ self.max_age = max_age
+ self.min_hits = min_hits
+ self.iou_threshold = iou_threshold
+ BoxTracker.n_trackers = 0
+ super().__init__()
+
+ def track(self, dets):
+ self.n_frames += 1
+
+ trackers = np.zeros((len(self.trackers), 5))
+ for i in range(len(trackers)):
+ trackers[i, :4] = self.trackers[i].predict()
+ empty = np.isnan(trackers).any(axis=1)
+ trackers = trackers[~empty]
+ for ind in np.flatnonzero(empty)[::-1]:
+ self.trackers.pop(ind)
+
+ matched, unmatched_dets, unmatched_trks = self.match_detections_to_trackers(dets, trackers, self.iou_threshold)
+
+ # update matched trackers with assigned detections
+ animalindex = []
+ for t, trk in enumerate(self.trackers):
+ if t not in unmatched_trks:
+ d = matched[np.where(matched[:, 1] == t)[0], 0]
+ animalindex.append(d[0])
+ trk.update(dets[d, :][0]) # update coordinates
+ else:
+ animalindex.append("nix") # lost trk!
+
+ # create and initialise new trackers for unmatched detections
+ for i in unmatched_dets:
+ trk = BoxTracker(dets[i, :])
+ self.trackers.append(trk)
+ animalindex.append(i)
+
+ i = len(self.trackers)
+ ret = []
+ for trk in reversed(self.trackers):
+ d = trk.state
+ if (trk.time_since_update < 1) and (trk.hit_streak >= self.min_hits or self.n_frames <= self.min_hits):
+ ret.append(
+ np.concatenate((d, [trk.id, int(animalindex[i - 1])])).reshape(1, -1)
+ ) # for DLC we also return the original animalid
+ # +1 as MOT benchmark requires positive >> this is removed for DLC!
+ i -= 1
+ # remove dead tracklet
+ if trk.time_since_update > self.max_age:
+ self.trackers.pop(i)
+
+ if len(ret) > 0:
+ return np.concatenate(ret)
+ return np.empty((0, 5))
+
+ @staticmethod
+ def match_detections_to_trackers(detections, trackers, iou_threshold):
+ """Assigns detections to tracked object (both represented as bounding boxes)
+
+ Returns 3 lists of matches, unmatched_detections and unmatched_trackers
+ """
+ if not len(trackers):
+ return (
+ np.empty((0, 2), dtype=int),
+ np.arange(len(detections)),
+ np.empty((0, 5), dtype=int),
+ )
+ iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float32)
+
+ for d, det in enumerate(detections):
+ for t, trk in enumerate(trackers):
+ iou_matrix[d, t] = calc_iou(det, trk)
+ row_indices, col_indices = linear_sum_assignment(-iou_matrix)
+
+ unmatched_detections = []
+ for d, _ in enumerate(detections):
+ if d not in row_indices:
+ unmatched_detections.append(d)
+ unmatched_trackers = []
+ for t, _ in enumerate(trackers):
+ if t not in col_indices:
+ unmatched_trackers.append(t)
+
+ # filter out matched with low IOU
+ matches = []
+ for row, col in zip(row_indices, col_indices, strict=False):
+ if iou_matrix[row, col] < iou_threshold:
+ unmatched_detections.append(row)
+ unmatched_trackers.append(col)
+ else:
+ matches.append([row, col])
+ if not len(matches):
+ matches = np.empty((0, 2), dtype=int)
+ else:
+ matches = np.stack(matches)
+ return matches, np.array(unmatched_detections), np.array(unmatched_trackers)
+
+
+def fill_tracklets(tracklets, trackers, animals, imname):
+ for content in trackers:
+ tracklet_id, pred_id = content[-2:].astype(int)
+ if tracklet_id not in tracklets:
+ tracklets[tracklet_id] = {}
+ if pred_id != -1:
+ tracklets[tracklet_id][imname] = np.asarray(animals[pred_id])
+ else: # Resort to the tracker prediction
+ xy = np.asarray(content[:-2])
+ pred = np.insert(xy, range(2, len(xy) + 1, 2), 1)
+ tracklets[tracklet_id][imname] = np.asarray(pred)
+
+
+def calc_bboxes_from_keypoints(data, slack=0, offset=0):
+ data = np.asarray(data)
+ if data.shape[-1] < 3:
+ raise ValueError("Data should be of shape (n_animals, n_bodyparts, 3)")
+
+ if data.ndim != 3:
+ data = np.expand_dims(data, axis=0)
+ bboxes = np.full((data.shape[0], 5), np.nan)
+ bboxes[:, :2] = np.nanmin(data[..., :2], axis=1) - slack # X1, Y1
+ bboxes[:, 2:4] = np.nanmax(data[..., :2], axis=1) + slack # X2, Y2
+ bboxes[:, -1] = np.nanmean(data[..., 2], axis=1) # Average confidence
+ bboxes[:, [0, 2]] += offset
+ return bboxes
+
+
+def reconstruct_all_ellipses(data, sd):
+ """Reconstructs ellipses for multiple individuals based on their body part
+ coordinates across multiple frames. Each ellipse is fitted to the coordinates using
+ an `EllipseFitter`.
+
+ Parameters
+ ----------
+ data : pandas.DataFrame
+ A multi-level DataFrame containing body part coordinates and likelihood values.
+ The index represents frames, and the columns follow a multi-level structure:
+ - Level 0: Scorer
+ - Level 1: Individuals
+ - Level 2: Body parts
+ - Level 3: Coordinates ("x" and "y") and "likelihood".
+ sd : float
+ The standard deviation used by the `EllipseFitter` for fitting ellipses.
+
+ Returns
+ -------
+ numpy.ndarray
+ A 3D array of shape (A, F, 5), where:
+ - A is the number of individuals (excluding "single" if present).
+ - F is the number of frames.
+ - Each row contains ellipse parameters [cx, cy, width, height, angle].
+
+ Notes
+ -----
+ - The method drops the "likelihood" column from the input DataFrame as it is not
+ relevant for ellipse fitting.
+ - If the "single" individual is present, it is excluded from the reconstruction process.
+ - The `EllipseFitter` is used to fit ellipses to the body part coordinates for each
+ individual in each frame.
+ - NaN values are assigned when no valid ellipse can be fitted.
+ """
+ xy = data.droplevel("scorer", axis=1).drop("likelihood", axis=1, level=-1)
+ if "single" in xy:
+ xy.drop("single", axis=1, level="individuals", inplace=True)
+ animals = xy.columns.get_level_values("individuals").unique()
+ nrows = xy.shape[0]
+ ellipses = np.full((len(animals), nrows, 5), np.nan)
+ fitter = EllipseFitter(sd)
+ for n, animal in enumerate(animals):
+ data = xy.xs(animal, axis=1, level="individuals").values.reshape((nrows, -1, 2))
+ for i, coords in enumerate(tqdm(data)):
+ el = fitter.fit(coords.astype(np.float64))
+ if el is not None:
+ ellipses[n, i] = el.parameters
+ return ellipses
+
+
+def _track_individuals(individuals, min_hits=1, max_age=5, similarity_threshold=0.6, track_method="ellipse"):
+ if track_method not in TRACK_METHODS:
+ raise ValueError(f"Unknown {track_method} tracker.")
+
+ if track_method == "ellipse":
+ tracker = SORTEllipse(max_age, min_hits, similarity_threshold)
+ elif track_method == "box":
+ tracker = SORTBox(max_age, min_hits, similarity_threshold)
+ else:
+ n_bodyparts = individuals[0][0].shape[0]
+ tracker = SORTSkeleton(n_bodyparts, max_age, min_hits, similarity_threshold)
+
+ tracklets = defaultdict(dict)
+ all_hyps = dict()
+ for i, (multi, single) in enumerate(tqdm(individuals)):
+ if single is not None:
+ tracklets["single"][i] = single
+ if multi is None:
+ continue
+ if track_method == "box":
+ # TODO: get cropping parameters and utilize!
+ xy = calc_bboxes_from_keypoints(multi)
+ else:
+ xy = multi[..., :2]
+ hyps = tracker.track(xy)
+ all_hyps[i] = hyps
+ for hyp in hyps:
+ tracklet_id, pred_id = hyp[-2:].astype(int)
+ if pred_id != -1:
+ tracklets[tracklet_id][i] = multi[pred_id]
+ return tracklets, all_hyps
diff --git a/deeplabcut/core/visualization.py b/deeplabcut/core/visualization.py
new file mode 100644
index 0000000000..d9796f1c14
--- /dev/null
+++ b/deeplabcut/core/visualization.py
@@ -0,0 +1,234 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Visualization methods for."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+
+def form_figure(nx, ny) -> tuple[plt.Figure, plt.Axes]:
+ """Forms a figure on which to plot images."""
+ fig, ax = plt.subplots(frameon=False)
+ ax.set_xlim(0, nx)
+ ax.set_ylim(0, ny)
+ ax.axis("off")
+ ax.invert_yaxis()
+ fig.tight_layout()
+ return fig, ax
+
+
+def visualize_scoremaps(
+ image: np.ndarray,
+ scmap: np.ndarray,
+) -> tuple[plt.Figure, plt.Axes]:
+ """Plots scoremaps as an image overlay.
+
+ Args:
+ image: An image as a numpy array of shape (h, w, channels)
+ scmap: A scoremap of shape (h, w)
+
+ Returns:
+ The figure and axis on which the image scoremap was plot.
+ """
+ ny, nx = np.shape(image)[:2]
+ fig, ax = form_figure(nx, ny)
+ ax.imshow(image)
+ ax.imshow(scmap, alpha=0.5)
+ return fig, ax
+
+
+def visualize_locrefs(
+ image: np.ndarray,
+ scmap: np.ndarray,
+ locref_x: np.ndarray,
+ locref_y: np.ndarray,
+ step: int = 5,
+ zoom_width: int = 0,
+) -> tuple[plt.Figure, plt.Axes]:
+ """Plots a scoremap and the corresponding location refinement field on an image.
+
+ Args:
+ image: An image as a numpy array of shape (h, w, channels)
+ scmap: A scoremap of shape (h, w)
+ locref_x: The x-coordinate of the location refinement field, of shape (h, w)
+ locref_y: The y-coordinate of the location refinement field, of shape (h, w)
+ step: The step with which to plot the location refinement field.
+ zoom_width: The zoom width with which to plot the scoremaps.
+
+ Returns:
+ The figure and axis on which the image scoremap and locref field were plot.
+ """
+ fig, ax = visualize_scoremaps(image, scmap)
+ X, Y = np.meshgrid(np.arange(locref_x.shape[1]), np.arange(locref_x.shape[0]))
+ M = np.zeros(locref_x.shape, dtype=bool)
+ M[scmap < 0.5] = True
+ U = np.ma.masked_array(locref_x, mask=M)
+ V = np.ma.masked_array(locref_y, mask=M)
+ ax.quiver(
+ X[::step, ::step],
+ Y[::step, ::step],
+ U[::step, ::step],
+ V[::step, ::step],
+ color="r",
+ units="x",
+ scale_units="xy",
+ scale=1,
+ angles="xy",
+ )
+ if zoom_width > 0:
+ maxloc = np.unravel_index(np.argmax(scmap), scmap.shape)
+ ax.set_xlim(maxloc[1] - zoom_width, maxloc[1] + zoom_width)
+ ax.set_ylim(maxloc[0] + zoom_width, maxloc[0] - zoom_width)
+ return fig, ax
+
+
+def visualize_paf(
+ image: np.ndarray,
+ paf: np.ndarray,
+ step: int = 5,
+ colors: list | None = None,
+) -> tuple[plt.Figure, plt.Axes]:
+ """Plots the PAF on top of the image.
+
+ Args:
+ image: Shape (height, width, channels). The image on which the model was run.
+ paf: Shape (height, width, 2 * len(paf_graph)). The PAF output by the model.
+ step: The step with which to plot the scoremaps.
+ colors: The colormap to use.
+
+ Returns:
+ The figure and axis on which the image PAF was plot.
+ """
+ ny, nx = np.shape(image)[:2]
+ fig, ax = form_figure(nx, ny)
+ ax.imshow(image)
+ n_fields = paf.shape[2]
+ if colors is None:
+ colors = ["r"] * n_fields
+ for n in range(n_fields):
+ U = paf[:, :, n, 0]
+ V = paf[:, :, n, 1]
+ X, Y = np.meshgrid(np.arange(U.shape[1]), np.arange(U.shape[0]))
+ M = np.zeros(U.shape, dtype=bool)
+ M[U**2 + V**2 < 0.5 * 0.5**2] = True
+ U = np.ma.masked_array(U, mask=M)
+ V = np.ma.masked_array(V, mask=M)
+ ax.quiver(
+ X[::step, ::step],
+ Y[::step, ::step],
+ U[::step, ::step],
+ V[::step, ::step],
+ scale=50,
+ headaxislength=4,
+ alpha=1,
+ width=0.002,
+ color=colors[n],
+ angles="xy",
+ )
+ return fig, ax
+
+
+def generate_model_output_plots(
+ output_folder: Path,
+ image_name: str,
+ bodypart_names: list[str],
+ bodyparts_to_plot: list[str],
+ image: np.ndarray,
+ scmap: np.ndarray,
+ locref: np.ndarray | None = None,
+ paf: np.ndarray | None = None,
+ paf_graph: list[tuple[int, int]] | None = None,
+ paf_all_in_one: bool = True,
+ paf_colormap: str = "rainbow",
+ output_suffix: str = "",
+) -> None:
+ """Generates model output plots (maps) for an image and saves them to disk.
+
+ Args:
+ output_folder: The folder in which the plots should be saved.
+ image_name: The name of the image for which the plots were generated.
+ bodypart_names: The names of bodyparts the model outputs.
+ bodyparts_to_plot: The names of bodyparts that should be plot.
+ image: Shape (height, width, channels). The image on which the model was run.
+ scmap: Shape (height, width, num_bodyparts). The scoremaps output by the model.
+ locref: Shape (height, width, num_bodyparts, 2). Optionally, the location
+ refinement fields output by the model.
+ paf: Shape (height, width, 2 * len(paf_graph)). Optionally, the part-affinity
+ fields output by the model.
+ paf_graph: Must be set if paf is not None. The PAF graph used to assemble.
+ paf_all_in_one: Whether to plot all PAFs in a single image.
+ paf_colormap: The colormap to use for the PAF maps.
+ output_suffix: The filename suffix for the maps to output.
+ """
+
+ def _filename(map_name) -> str:
+ return f"{image_name}_{map_name}_{output_suffix}.png"
+
+ to_plot = [i for i, bpt in enumerate(bodypart_names) if bpt in bodyparts_to_plot]
+ if len(to_plot) > 1:
+ map_ = scmap[:, :, to_plot].sum(axis=2)
+ elif len(to_plot) == 1 and len(bodypart_names) > 1:
+ map_ = scmap[:, :, to_plot[0]]
+ else:
+ map_ = scmap[..., 0]
+
+ fig1, _ = visualize_scoremaps(image, map_)
+ fig1.savefig(output_folder / _filename("scmap"))
+
+ if locref is not None:
+ if len(to_plot) > 1:
+ map_ = scmap[:, :, to_plot]
+ locref_x_ = locref[:, :, to_plot, 0]
+ locref_y_ = locref[:, :, to_plot, 1]
+ # only get the locref fields around their respective detections
+ locref_x_[map_ < 0.5] = 0
+ locref_y_[map_ < 0.5] = 0
+ # combine locrefs
+ map_ = map_.sum(axis=2)
+ locref_x_ = locref_x_.sum(axis=2)
+ locref_y_ = locref_y_.sum(axis=2)
+ elif len(to_plot) == 1 and len(bodypart_names) > 1:
+ locref_x_ = locref[:, :, to_plot[0], 0]
+ locref_y_ = locref[:, :, to_plot[0], 1]
+ else:
+ locref_x_ = locref[..., 0]
+ locref_y_ = locref[..., 1]
+
+ fig2, _ = visualize_locrefs(image, map_, locref_x_, locref_y_)
+ fig2.savefig(output_folder / _filename("locref"))
+
+ if paf is not None:
+ if paf_graph is None:
+ raise ValueError("When plotting the PAF, you must pass the ``paf_graph``")
+
+ edge_list = []
+ for n, edge in enumerate(paf_graph):
+ if any(ind in to_plot for ind in edge):
+ e0, e1 = edge
+ edge_list.append([(2 * n, 2 * n + 1), (bodypart_names[e0], bodypart_names[e1])])
+
+ if paf_all_in_one:
+ inds = [elem[0] for elem in edge_list]
+ n_inds = len(inds)
+ cmap = plt.cm.get_cmap(paf_colormap, n_inds)
+ colors = cmap(range(n_inds))
+ fig3, _ = visualize_paf(image, paf[:, :, inds], colors=colors)
+ fig3.savefig(output_folder / _filename("paf"))
+ else:
+ for inds, names in edge_list:
+ fig3, _ = visualize_paf(image, paf[:, :, [inds]])
+ fig3.savefig(output_folder / _filename(f"paf_{'_'.join(names)}"))
+
+ plt.close("all")
diff --git a/deeplabcut/core/weight_init.py b/deeplabcut/core/weight_init.py
new file mode 100644
index 0000000000..ae755223cf
--- /dev/null
+++ b/deeplabcut/core/weight_init.py
@@ -0,0 +1,207 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Classes to configure how to initialize model weights."""
+
+from __future__ import annotations
+
+import warnings
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+
+
+@dataclass
+class WeightInitialization:
+ """Configures weights initialization when transfer learning or fine-tuning models.
+
+ Args:
+ snapshot_path: The path to the snapshot used to initialize pose model weights
+ when training a model.
+ detector_snapshot_path: The path to the snapshot used to initialize detector
+ weights when training a model.
+ dataset: Optionally, the dataset on which the snapshots were trained. Required
+ when fine-tuning SuperAnimal models.
+ with_decoder: Whether to load the decoder weights as well.
+ memory_replay: Only when ``with_decoder=True``. Whether to train the model with
+ memory replay, so that it predicts all SuperAnimal (or previous project)
+ bodyparts.
+ conversion_array: The mapping from SuperAnimal (or other project, on which the
+ weights were trained) to project bodyparts. Required when
+ `with_decoder=True`.
+ An array [7, 0, 1] means the project has 3 bodyparts, where the 1st bodypart
+ corresponds to the 8th bodypart in the pretrained model, the 2nd to the 1st
+ and the 3rd to the 2nd (as arrays are 0-indexed).
+ bodyparts: Optionally, the name of each bodypart entry in the conversion array.
+ """
+
+ snapshot_path: Path
+ detector_snapshot_path: Path | None = None
+ dataset: str | None = None
+ with_decoder: bool = False
+ memory_replay: bool = False
+ conversion_array: np.ndarray | None = None
+ bodyparts: list[str] | None = None
+
+ def __post_init__(self):
+ if self.memory_replay and not self.with_decoder:
+ raise ValueError(
+ "You cannot train a model with memory replay if you do not keep the "
+ "decoder layers (``with_decoder=True``), but you passed "
+ "`memory_replay=True` and `with_decoder=False`. Please change your "
+ "WeightInitialization parameters."
+ )
+
+ if self.with_decoder and self.conversion_array is None:
+ raise ValueError(
+ "You must specify a conversion_array to initialize decoder weights (``with_decoder=True``)."
+ )
+
+ if self.bodyparts is not None and self.conversion_array is None:
+ raise ValueError(
+ "Specifying bodyparts should only be done when `with_decoder=True` and"
+ " the conversion array is specified."
+ )
+
+ if self.conversion_array is not None and self.bodyparts is not None:
+ if not len(self.conversion_array) == len(self.bodyparts):
+ raise ValueError(
+ "There must be the same number of elements in the bodyparts list "
+ "and conv. array; found {self.bodyparts}, {self.conversion_array}"
+ )
+
+ def to_dict(self) -> dict:
+ """Returns: the weight initialization as a dict"""
+ data = dict()
+ if self.dataset is not None:
+ data["dataset"] = self.dataset
+
+ data["snapshot_path"] = str(self.snapshot_path)
+ if self.detector_snapshot_path is not None:
+ data["detector_snapshot_path"] = str(self.detector_snapshot_path)
+
+ data["with_decoder"] = self.with_decoder
+ data["memory_replay"] = self.memory_replay
+
+ if self.conversion_array is not None:
+ data["conversion_array"] = self.conversion_array.tolist()
+
+ if self.bodyparts is not None:
+ data["bodyparts"] = self.bodyparts
+
+ return data
+
+ @staticmethod
+ def from_dict(data: dict) -> WeightInitialization:
+ if "snapshot_path" not in data:
+ return WeightInitialization.from_dict_legacy(data)
+
+ detector_snapshot_path = data.get("detector_snapshot_path")
+ if detector_snapshot_path is not None:
+ detector_snapshot_path = Path(detector_snapshot_path)
+
+ conversion_array = data.get("conversion_array")
+ if conversion_array is not None:
+ conversion_array = np.array(conversion_array, dtype=int)
+
+ return WeightInitialization(
+ snapshot_path=Path(data["snapshot_path"]),
+ detector_snapshot_path=detector_snapshot_path,
+ dataset=data.get("dataset"),
+ with_decoder=data["with_decoder"],
+ memory_replay=data["memory_replay"],
+ conversion_array=conversion_array,
+ bodyparts=data.get("bodyparts"),
+ )
+
+ @staticmethod
+ def from_dict_legacy(data: dict) -> WeightInitialization:
+ """Deals with weight initialization that were created before 3.0.0rc5."""
+ import deeplabcut.pose_estimation_pytorch.modelzoo.utils as utils
+
+ conversion_array = data.get("conversion_array")
+ if conversion_array is not None:
+ conversion_array = np.array(conversion_array, dtype=int)
+
+ return WeightInitialization(
+ snapshot_path=utils.get_super_animal_snapshot_path(
+ dataset=data["dataset"],
+ model_name="hrnet_w32",
+ ),
+ detector_snapshot_path=utils.get_super_animal_snapshot_path(
+ dataset=data["dataset"],
+ model_name="fasterrcnn_resnet50_fpn_v2",
+ ),
+ with_decoder=data["with_decoder"],
+ memory_replay=data["memory_replay"],
+ conversion_array=conversion_array,
+ bodyparts=data.get("bodyparts"),
+ )
+
+ @staticmethod
+ def build(
+ cfg: dict,
+ super_animal: str,
+ model_name: str = "hrnet_w32",
+ detector_name: str = "fasterrcnn_resnet50_fpn_v2",
+ with_decoder: bool = False,
+ memory_replay: bool = False,
+ customized_pose_checkpoint: str | None = None,
+ customized_detector_checkpoint: str | None = None,
+ ) -> WeightInitialization:
+ """Builds a WeightInitialization for a project.
+
+ `WeightInitialization.build` is deprecated and will be removed in a future
+ version of DeepLabCut. Please use `build_weight_init` from `deeplabcut.modelzoo`
+ instead.
+
+ Args:
+ cfg: The project's configuration.
+ super_animal: The SuperAnimal model with which to initialize weights.
+ model_name: The name of the model architecture for which to load the weights
+ (defaults to "hrnet_w32" for backwards compatibility).
+ detector_name: The name of the detector architecture for which to load the
+ weights (defaults to "fasterrcnn_resnet50_fpn_v2" for backwards
+ compatibility).
+ with_decoder: Whether to load the decoder weights as well. If this is true,
+ a conversion table must be specified for the given SuperAnimal in the
+ project configuration file. See
+ ``deeplabcut.modelzoo.utils.create_conversion_table`` to create a
+ conversion table.
+ memory_replay: Only when ``with_decoder=True``. Whether to train the model
+ with memory replay, so that it predicts all SuperAnimal bodyparts.
+ customized_pose_checkpoint: A customized SuperAnimal pose checkpoint, as an
+ alternative to the Hugging Face one
+ customized_detector_checkpoint: A customized SuperAnimal detector
+ checkpoint, as an alternative to the Hugging Face one
+
+ Returns:
+ The built WeightInitialization.
+ """
+ from deeplabcut.modelzoo import build_weight_init
+
+ deprecation_warning = (
+ "The `WeightInitialization.build` is deprecated and will be removed in a "
+ "future version of DeepLabCut. Please use `build_weight_init` from "
+ "`deeplabcut.modelzoo` instead."
+ )
+ warnings.warn(deprecation_warning, DeprecationWarning, stacklevel=2)
+
+ return build_weight_init(
+ cfg,
+ super_animal,
+ model_name,
+ detector_name,
+ with_decoder,
+ memory_replay,
+ customized_pose_checkpoint,
+ customized_detector_checkpoint,
+ )
diff --git a/deeplabcut/create_project/add.py b/deeplabcut/create_project/add.py
index 9b83b12444..550c5a91e7 100644
--- a/deeplabcut/create_project/add.py
+++ b/deeplabcut/create_project/add.py
@@ -10,11 +10,8 @@
#
-def add_new_videos(
- config, videos, copy_videos=False, coords=None, extract_frames=False
-):
- """
- Add new videos to the config file at any stage of the project.
+def add_new_videos(config, videos, copy_videos=False, coords=None, extract_frames=False):
+ """Add new videos to the config file at any stage of the project.
Parameters
----------
@@ -38,22 +35,29 @@ def add_new_videos(
Examples
--------
Video will be added, with cropping dimensions according to the frame dimensions of mouse5.avi
- >>> deeplabcut.add_new_videos('/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',['/data/videos/mouse5.avi'])
+ >>> deeplabcut.add_new_videos(
+ '/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',['/data/videos/mouse5.avi']
+ )
Video will be added, with cropping dimensions [0,100,0,200]
- >>> deeplabcut.add_new_videos('/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',['/data/videos/mouse5.avi'],copy_videos=False,coords=[[0,100,0,200]])
+ >>> deeplabcut.add_new_videos(
+ '/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',
+ ['/data/videos/mouse5.avi'],copy_videos=False,coords=[[0,100,0,200]]
+ )
Two videos will be added, with cropping dimensions [0,100,0,200] and [0,100,0,250], respectively.
- >>> deeplabcut.add_new_videos('/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',['/data/videos/mouse5.avi','/data/videos/mouse6.avi'],copy_videos=False,coords=[[0,100,0,200],[0,100,0,250]])
-
+ >>> deeplabcut.add_new_videos(
+ '/home/project/reaching-task-Tanmay-2018-08-23/config.yaml',
+ ['/data/videos/mouse5.avi','/data/videos/mouse6.avi'],
+ copy_videos=False,coords=[[0,100,0,200],[0,100,0,250]])
"""
import os
import shutil
from pathlib import Path
- from deeplabcut.utils import auxiliaryfunctions
- from deeplabcut.utils.auxfun_videos import VideoReader
- from deeplabcut.generate_training_dataset import frame_extraction
+ from ..generate_training_dataset import frame_extraction
+ from ..utils import auxiliaryfunctions
+ from ..utils.auxfun_videos import VideoReader
# Read the config file
cfg = auxiliaryfunctions.read_config(config)
@@ -69,14 +73,12 @@ def add_new_videos(
dirs = [data_path / Path(i.stem) for i in videos]
for p in dirs:
- """
- Creates directory under data & perhaps copies videos (to /video)
- """
+ """Creates directory under data & perhaps copies videos (to /video)"""
p.mkdir(parents=True, exist_ok=True)
destinations = [video_path.joinpath(vp.name) for vp in videos]
if copy_videos:
- for src, dst in zip(videos, destinations):
+ for src, dst in zip(videos, destinations, strict=False):
if dst.exists():
pass
else:
@@ -86,7 +88,7 @@ def add_new_videos(
else:
# creates the symlinks of the video and puts it in the videos directory.
print("Attempting to create a symbolic link of the video ...")
- for src, dst in zip(videos, destinations):
+ for src, dst in zip(videos, destinations, strict=False):
if dst.exists():
print(f"Video {dst} already exists. Skipping...")
continue
@@ -94,19 +96,16 @@ def add_new_videos(
src = str(src)
dst = str(dst)
os.symlink(src, dst)
- print("Created the symlink of {} to {}".format(src, dst))
+ print(f"Created the symlink of {src} to {dst}")
except OSError:
try:
import subprocess
- subprocess.check_call("mklink %s %s" % (dst, src), shell=True)
+ subprocess.check_call(f"mklink {dst} {src}", shell=True)
except (OSError, subprocess.CalledProcessError):
- print(
- "Symlink creation impossible (exFat architecture?): "
- "copying the video instead."
- )
+ print("Symlink creation impossible (exFat architecture?): copying the video instead.")
shutil.copy(os.fspath(src), os.fspath(dst))
- print("{} copied to {}".format(src, dst))
+ print(f"{src} copied to {dst}")
videos = destinations
if copy_videos:
@@ -117,7 +116,7 @@ def add_new_videos(
# For windows os.path.realpath does not work and does not link to the real video.
video_path = str(Path.resolve(Path(video)))
# video_path = os.path.realpath(video)
- except:
+ except Exception:
video_path = os.readlink(video)
vid = VideoReader(video_path)
@@ -133,13 +132,7 @@ def add_new_videos(
videos_str = [str(video) for video in videos]
auxiliaryfunctions.write_config(config, cfg)
if extract_frames:
- frame_extraction.extract_frames(
- config, userfeedback=False, videos_list=videos_str
- )
- print(
- "New videos were added to the project and frames have been extracted for labeling!"
- )
+ frame_extraction.extract_frames(config, userfeedback=False, videos_list=videos_str)
+ print("New videos were added to the project and frames have been extracted for labeling!")
else:
- print(
- "New videos were added to the project! Use the function 'extract_frames' to select frames for labeling."
- )
+ print("New videos were added to the project! Use the function 'extract_frames' to select frames for labeling.")
diff --git a/deeplabcut/create_project/demo_data.py b/deeplabcut/create_project/demo_data.py
index c495c89ba6..30b58390f0 100644
--- a/deeplabcut/create_project/demo_data.py
+++ b/deeplabcut/create_project/demo_data.py
@@ -13,13 +13,17 @@
from pathlib import Path
import deeplabcut
+from deeplabcut.core.engine import Engine
from deeplabcut.utils import auxiliaryfunctions
-def load_demo_data(config, createtrainingset=True):
- """
- Loads the demo data -- subset from trail-tracking data in Mathis et al. 2018.
- When loading, it sets paths correctly to run this project on your system
+def load_demo_data(
+ config: str,
+ createtrainingset: bool = True,
+ engine: Engine = Engine.PYTORCH,
+):
+ """Loads the demo data -- subset from trail-tracking data in Mathis et al. 2018.
+ When loading, it sets paths correctly to run this project on your system.
Parameter
----------
@@ -29,6 +33,9 @@ def load_demo_data(config, createtrainingset=True):
createtrainingset : bool
Boolean variable indicating if a training set shall be created.
+ engine: Engine
+ The Engine to create the training set for if a training set shall be created.
+
Example
--------
>>> deeplabcut.load_demo_data('config.yaml')
@@ -40,12 +47,12 @@ def load_demo_data(config, createtrainingset=True):
transform_data(config)
if createtrainingset:
print("Loaded, now creating training data...")
- deeplabcut.create_training_dataset(config, num_shuffles=1)
+ deeplabcut.create_training_dataset(config, num_shuffles=1, engine=engine)
def transform_data(config):
- """
- This function adds the full path to labeling dataset.
+ """This function adds the full path to labeling dataset.
+
It also adds the correct path to the video file in the config file.
"""
@@ -61,8 +68,6 @@ def transform_data(config):
print("This is not an official demo dataset.")
if "WILL BE AUTOMATICALLY UPDATED BY DEMO CODE" in cfg["video_sets"].keys():
- cfg["video_sets"][str(video_file)] = cfg["video_sets"].pop(
- "WILL BE AUTOMATICALLY UPDATED BY DEMO CODE"
- )
+ cfg["video_sets"][str(video_file)] = cfg["video_sets"].pop("WILL BE AUTOMATICALLY UPDATED BY DEMO CODE")
auxiliaryfunctions.write_config(config, cfg)
diff --git a/deeplabcut/create_project/modelzoo.py b/deeplabcut/create_project/modelzoo.py
index 76679f1867..b4d615803f 100644
--- a/deeplabcut/create_project/modelzoo.py
+++ b/deeplabcut/create_project/modelzoo.py
@@ -10,17 +10,38 @@
#
import os
+from collections.abc import Sequence
from pathlib import Path
import yaml
-
-import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions
+from dlclibrary import get_available_detectors
from dlclibrary.dlcmodelzoo.modelzoo_download import (
- download_huggingface_model,
MODELOPTIONS,
+ download_huggingface_model,
+ get_available_datasets,
+ get_available_models,
)
+import deeplabcut
+from deeplabcut.core.config import read_config_as_dict, write_config
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.metadata import (
+ DataSplit,
+ ShuffleMetadata,
+ TrainingDatasetMetadata,
+)
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ MakeInference_yaml,
+)
+from deeplabcut.modelzoo.utils import get_super_animal_project_cfg
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ add_metadata,
+ make_pytorch_test_config,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import load_super_animal_config
+from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils.deprecation import renamed_parameter
+
Modeloptions = MODELOPTIONS # backwards compatibility for COLAB NOTEBOOK
@@ -58,18 +79,18 @@ def MakeTest_pose_yaml(dictionary, keys2save, saveasfile):
# yaml.dump(dict_test, f)
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_pretrained_human_project(
project,
experimenter,
videos,
working_directory=None,
copy_videos=False,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
createlabeledvideo=True,
analyzevideo=True,
):
- """
- LEGACY FUNCTION will be deprecated.
+ """LEGACY FUNCTION will be deprecated.
Use deeplabcut.create_pretrained_project(project, experimenter, videos, model='full_human', ..)
@@ -80,7 +101,9 @@ def create_pretrained_human_project(
Please make sure to cite it too if you use this code!
"""
print(
- "LEGACY FUNCTION will be deprecated.... use deeplabcut.create_pretrained_project(project, experimenter, videos, model='full_human', ..) in the future!"
+ "LEGACY FUNCTION will be deprecated.... "
+ "use deeplabcut.create_pretrained_project(project, experimenter, videos, model='full_human', ..) "
+ "in the future!"
)
create_pretrained_project(
project,
@@ -89,26 +112,169 @@ def create_pretrained_human_project(
model="full_human",
working_directory=working_directory,
copy_videos=copy_videos,
- videotype=videotype,
+ video_extensions=video_extensions,
createlabeledvideo=createlabeledvideo,
analyzevideo=analyzevideo,
+ engine=Engine.TF,
)
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_pretrained_project(
- project,
- experimenter,
- videos,
- model="full_human",
- working_directory=None,
- copy_videos=False,
- videotype="",
- analyzevideo=True,
- filtered=True,
- createlabeledvideo=True,
- trainFraction=None,
+ project: str,
+ experimenter: str,
+ videos: list[str],
+ model: str | None = None,
+ working_directory: str | None = None,
+ copy_videos: bool = False,
+ video_extensions: str | Sequence[str] | None = None,
+ analyzevideo: bool = True,
+ filtered: bool = True,
+ createlabeledvideo: bool = True,
+ trainFraction: float | None = None,
+ engine: Engine = Engine.PYTORCH,
+ multi_animal: bool = False,
+ individuals: list[str] | None = None,
+ net_name: str | None = None,
+ detector_name: str | None = None,
):
+ r"""Creates a new project directory, sub-directories and a basic configuration file.
+ Change its parameters to your projects need.
+
+ The project will also be initialized with a pre-trained model from the DeepLabCut model zoo!
+
+ http://modelzoo.deeplabcut.org
+
+ Parameters
+ ----------
+ project : string
+ String containing the name of the project.
+
+ experimenter : string
+ String containing the name of the experimenter.
+
+ model: string | None, default = None,
+ The model / dataset to use as basis for the project.
+ If None, the default model / dataset for the selected engine will be used.
+
+ videos : list[string]
+ A list of string containing the full paths of the videos to include in the project.
+
+ working_directory : string, optional, default = None
+ The directory where the project will be created. If None - the current working directory will be used.
+
+ copy_videos : bool, optional, default = False,
+ If this is set to True, the videos are copied to the ``videos`` directory.
+ If it is False, symlink of the videos are copied to the project/videos directory.
+ Note: on Windows: True is often necessary!
+
+ analyzevideo: bool, optional
+ If true, then the video is analyzed and a labeled video is created.
+ If false, then only the project will be created and the weights downloaded.
+
+ filtered: bool, default True
+ Indicates if filtered pose data output should be plotted rather than frame-by-frame predictions.
+ Filtered version can be calculated with deeplabcut.filterpredictions()
+
+ createlabeledvideo: bool, default True,
+ Specifies if a labeled video needs to be created.
+
+ trainFraction: float|None, default = None.
+ Fraction that will be used in dlc-model/trainingset folder name.
+ If None - default value (0.95) from new projects will be used.
+
+ engine: Engine, default Engine.PYTORCH,
+ engine on which the pretrained weights are based
+
+ multi_animal: bool = False,
+ Specifies if the project is single or multi-animal.
+ Implemented only for Pytorch-based models.
+
+ individuals: list[str] | None = None,
+ Only if multianimal is True.
+ Defines the names of the individuals.
+
+ net_name: str | None, default = None,
+ Valid only if using Pytorch engine.
+ Name of the pose model on which the superanimal dataset has been trained on.
+ If None - "hrnet_w32" will be used as default.
+
+ detector_name: str | None, default = None,
+ Valid only if using Pytorch engine.
+ Name of the detector model on which the superanimal dataset has been trained on.
+ If None - "fasterrcnn_resnet50_fpn_v2" will be used as default.
+
+ Example
+ --------
+ Linux/MacOs loading full_human model and analyzing video /homosapiens1.avi
+ >>> deeplabcut.create_pretrained_project("humanstrokestudy", "Linus",
+ ... ["/data/videos/homosapiens1.avi"], copy_videos=False)
+
+ Loading full_cat model and analyzing video "felixfeliscatus3.avi"
+ >>> deeplabcut.create_pretrained_project("humanstrokestudy", "Linus",
+ ... ["/data/videos/felixfeliscatus3.avi"], model="full_cat", engine=Engine.TF)
+
+ Windows:
+ >>> deeplabcut.create_pretrained_project("humanstrokestudy", "Bill",
+ ... [r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],
+ ... r'C:\yourusername\analysis\project', copy_videos=True)
+ Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
"""
+ if engine == Engine.TF:
+ return create_pretrained_project_tensorflow(
+ project=project,
+ experimenter=experimenter,
+ videos=videos,
+ model=model,
+ working_directory=working_directory,
+ copy_videos=copy_videos,
+ video_extensions=video_extensions,
+ analyzevideo=analyzevideo,
+ filtered=filtered,
+ createlabeledvideo=createlabeledvideo,
+ trainFraction=trainFraction,
+ )
+ elif engine == Engine.PYTORCH:
+ return create_pretrained_project_pytorch(
+ project=project,
+ experimenter=experimenter,
+ videos=videos,
+ dataset=model,
+ working_directory=working_directory,
+ copy_videos=copy_videos,
+ video_extensions=video_extensions,
+ analyze_video=analyzevideo,
+ filtered=filtered,
+ create_labeled_video=createlabeledvideo,
+ train_fraction=trainFraction,
+ multi_animal=multi_animal,
+ individuals=individuals,
+ net_name=net_name,
+ detector_name=detector_name,
+ )
+
+ raise NotImplementedError(f"This function is not implemented for {engine}")
+
+
+def create_pretrained_project_pytorch(
+ project: str,
+ experimenter: str,
+ videos: list[str],
+ dataset: str | None = None,
+ working_directory: str | None = None,
+ copy_videos: bool = False,
+ video_extensions: str | None = None,
+ analyze_video: bool = True,
+ filtered: bool = True,
+ create_labeled_video: bool = True,
+ train_fraction: float | None = None,
+ multi_animal: bool = False,
+ individuals: list[str] | None = None,
+ net_name: str | None = None,
+ detector_name: str | None = None,
+):
+ r"""Method used specifically for Pytorch-based ModelZoo models.
+
Creates a new project directory, sub-directories and a basic configuration file.
Change its parameters to your projects need.
@@ -124,48 +290,281 @@ def create_pretrained_project(
experimenter : string
String containing the name of the experimenter.
- model: string, options see http://www.mousemotorlab.org/dlc-modelzoo
- Current option and default: 'full_human' Creates a demo human project and analyzes a video with ResNet 101 weights pretrained on MPII Human Pose. This is from the DeeperCut paper
- by Insafutdinov et al. https://arxiv.org/abs/1605.03170 Please make sure to cite it too if you use this code!
+ dataset: string|None, default = None,
+ The superanimal dataset to use as basis for the project.
+ If not specified - superanimal_quadruped will be used by default.
- videos : list
+ videos : list[string]
A list of string containing the full paths of the videos to include in the project.
- working_directory : string, optional
- The directory where the project will be created. The default is the ``current working directory``; if provided, it must be a string.
+ working_directory : string, optional, default = None
+ The directory where the project will be created. If None - the current working directory will be used.
+
+ copy_videos : bool, optional, default = False,
+ If this is set to True, the videos are copied to the ``videos`` directory.
+ If it is False, symlink of the videos are copied to the project/videos directory.
+ Note: on Windows: True is often necessary!
- copy_videos : bool, optional ON WINDOWS: TRUE is often necessary!
- If this is set to True, the videos are copied to the ``videos`` directory. If it is False,symlink of the videos are copied to the project/videos directory. The default is ``False``; if provided it must be either
- ``True`` or ``False``.
+ analyze_video: bool, optional
+ If true, then the video is analyzed and a labeled video is created.
+ If false, then only the project will be created and the weights downloaded.
- analyzevideo " bool, optional
- If true, then the video is analyzed and a labeled video is created. If false, then only the project will be created and the weights downloaded. You can then access them
+ filtered: bool, default True
+ Indicates if filtered pose data output should be plotted rather than frame-by-frame predictions.
+ Filtered version can be calculated with deeplabcut.filterpredictions()
- filtered: bool, default false
- Boolean variable indicating if filtered pose data output should be plotted rather than frame-by-frame predictions.
- Filtered version can be calculated with deeplabcut.filterpredictions
+ create_labeled_video: bool, default True
+ Specifies if a labeled video needs to be created.
- trainFraction: By default value from *new* projects. (0.95)
+ train_fraction: float|None, default = None.
Fraction that will be used in dlc-model/trainingset folder name.
+ If None - default value (0.95) from new projects will be used.
+
+ multi_animal: bool = False,
+ Specifies if the project is single or multi-animal
+
+ individuals: list[str]|None = None,
+ Only if multianimal is True.
+ Defines the names of the individuals.
+
+ net_name: str | None, default = None,
+ Valid only if using Pytorch engine.
+ Name of the pose model on which the superanimal dataset has been trained on.
+ If None - "hrnet_w32" will be used as default.
+
+ detector_name: str | None, default = None,
+ Valid only if using Pytorch engine.
+ Name of the detector model on which the superanimal dataset has been trained on.
+ If None - "fasterrcnn_resnet50_fpn_v2" will be used as default.
Example
--------
Linux/MacOs loading full_human model and analyzing video /homosapiens1.avi
- >>> deeplabcut.create_pretrained_project('humanstrokestudy','Linus',['/data/videos/homosapiens1.avi'], copy_videos=False)
+ >>> deeplabcut.create_pretrained_project_pytorch("humanstrokestudy", "Linus",
+ ... ["/data/videos/homosapiens1.avi"], copy_videos=False)
Loading full_cat model and analyzing video "felixfeliscatus3.avi"
- >>> deeplabcut.create_pretrained_project('humanstrokestudy','Linus',['/data/videos/felixfeliscatus3.avi'], model='full_cat')
+ >>> deeplabcut.create_pretrained_project_pytorch("humanstrokestudy", "Linus",
+ ... ["/data/videos/felixfeliscatus3.avi"], model="full_cat", engine=Engine.TF)
Windows:
- >>> deeplabcut.create_pretrained_project('humanstrokestudy','Bill',[r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],r'C:\yourusername\analysis\project' copy_videos=True)
+ >>> deeplabcut.create_pretrained_project_pytorch("humanstrokestudy",
+ ... "Bill", [r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],
+ ... r'C:\yourusername\analysis\project', copy_videos=True)
Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
+ """
+ # Check arguments
+ if not dataset:
+ dataset = "superanimal_quadruped"
+
+ if not net_name:
+ net_name = "hrnet_w32"
+
+ # Currently, all Pytorch Superanimal models are Top-Down.
+ if not detector_name:
+ detector_name = "fasterrcnn_resnet50_fpn_v2"
+
+ if dataset not in get_available_datasets():
+ raise ValueError(f"Invalid dataset '{dataset}'. Available datasets are: {get_available_datasets()}")
+
+ if net_name not in get_available_models(dataset):
+ raise ValueError(
+ f"Invalid net_name '{net_name}' for dataset {dataset}. "
+ f"The following net types are available: {get_available_models(dataset)}"
+ )
+
+ if detector_name not in get_available_detectors(dataset):
+ raise ValueError(
+ f"Invalid detector_name '{detector_name}' for dataset {dataset}. "
+ f"The following detectors are available: {get_available_detectors(dataset)}"
+ )
+
+ # Create project
+ cfg_path = deeplabcut.create_new_project(
+ project=project,
+ experimenter=experimenter,
+ videos=videos,
+ working_directory=working_directory,
+ copy_videos=copy_videos,
+ video_extensions=video_extensions,
+ multianimal=multi_animal,
+ individuals=individuals,
+ )
+
+ # Edits to do to the project config
+ cfg_edits = {}
+ if train_fraction is not None:
+ cfg_edits["TrainingFraction"] = [train_fraction]
+ super_animal_project_cfg = get_super_animal_project_cfg(dataset)
+ super_animal_bodyparts = super_animal_project_cfg.get("bodyparts")
+ super_animal_skeleton = super_animal_project_cfg.get("skeleton")
+ cfg_edits["skeleton"] = super_animal_skeleton
+ if multi_animal:
+ cfg_edits["multianimalbodyparts"] = super_animal_bodyparts
+ else:
+ cfg_edits["bodyparts"] = super_animal_bodyparts
+ auxiliaryfunctions.edit_config(cfg_path, edits=cfg_edits)
+
+ # Create the shuffle train and test directories
+ config = read_config_as_dict(cfg_path)
+ shuffle_dir = Path(cfg_path).parent / auxiliaryfunctions.get_model_folder(
+ trainFraction=config["TrainingFraction"][0],
+ shuffle=1,
+ cfg=config,
+ engine=Engine.PYTORCH,
+ )
+ train_dir = shuffle_dir / "train"
+ test_dir = shuffle_dir / "test"
+ train_dir.mkdir(parents=True, exist_ok=True)
+ test_dir.mkdir(parents=True, exist_ok=True)
+
+ # Download the weights and put them into appropriate directory
+ print("Downloading weights...")
+ super_animal_detector_name = f"{dataset}_{detector_name}"
+ new_detector_name = "snapshot-detector-000.pt"
+ download_huggingface_model(
+ model_name=super_animal_detector_name,
+ target_dir=str(train_dir),
+ rename_mapping={f"{super_animal_detector_name}.pt": new_detector_name},
+ )
+ super_animal_model_name = f"{dataset}_{net_name}"
+ new_snapshot_name = "snapshot-000.pt"
+ download_huggingface_model(
+ model_name=super_animal_model_name,
+ target_dir=str(train_dir),
+ rename_mapping={f"{super_animal_model_name}.pt": new_snapshot_name},
+ )
+
+ # Create pytorch_config.yaml
+ train_cfg_path = train_dir / "pytorch_config.yaml"
+ pytorch_config = load_super_animal_config(
+ super_animal=dataset,
+ model_name=net_name,
+ detector_name=detector_name,
+ )
+ pytorch_config = add_metadata(config, pytorch_config, train_cfg_path)
+ pytorch_config["resume_training_from"] = str(train_dir / new_snapshot_name)
+ pytorch_config["detector"]["resume_training_from"] = str(train_dir / new_detector_name)
+ write_config(train_cfg_path, pytorch_config)
+
+ # Create test pose_cfg.yaml
+ test_cfg_path = test_dir / "pose_cfg.yaml"
+ make_pytorch_test_config(model_config=pytorch_config, test_config_path=test_cfg_path, save=True)
+
+ # Create inference_cfg.yaml if needed
+ if multi_animal:
+ inference_cfg_path = test_dir / "inference_cfg.yaml"
+ _create_inference_config(inference_cfg_path, config)
+
+ # Create metadata.yaml with shuffle info in training-data directory
+ _create_training_datasets_metadata(config, shuffle_dir.name, Engine.PYTORCH)
+
+ # Process the videos
+ _process_videos(
+ cfg_path=cfg_path,
+ video_extensions=video_extensions,
+ analyze_video=analyze_video,
+ filtered=filtered,
+ create_labeled_video=create_labeled_video,
+ )
+ return cfg_path, str(train_cfg_path)
+
+
+def _create_inference_config(inference_cfg_path: str | Path, project_cfg: dict):
+ inf_updates = dict(
+ minimalnumberofconnections=int(len(project_cfg["multianimalbodyparts"]) / 2),
+ topktoretain=len(project_cfg["individuals"]),
+ withid=project_cfg.get("identity", False),
+ )
+ default_inf_path = Path(auxiliaryfunctions.get_deeplabcut_path()) / "inference_cfg.yaml"
+ MakeInference_yaml(inf_updates, inference_cfg_path, default_inf_path)
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def create_pretrained_project_tensorflow(
+ project: str,
+ experimenter: str,
+ videos: list[str],
+ model: str | None = None,
+ working_directory: str | None = None,
+ copy_videos: bool = False,
+ video_extensions: str | Sequence[str] | None = None,
+ analyzevideo: bool = True,
+ filtered: bool = True,
+ createlabeledvideo: bool = True,
+ trainFraction: float | None = None,
+):
+ r"""Method used specifically for Tensorflow-based ModelZoo models.
+
+ Creates a new project directory, sub-directories and a basic configuration file.
+ Change its parameters to your projects need.
+
+ The project will also be initialized with a pre-trained model from the DeepLabCut model zoo!
+
+ http://modelzoo.deeplabcut.org
+
+ Parameters
+ ----------
+ project : string
+ String containing the name of the project.
+
+ experimenter : string
+ String containing the name of the experimenter.
+
+ model: string|None, default = None,
+ The model / dataset to use as basis for the project.
+ If not specified - full_human will be used by default.
+
+ videos : list[string]
+ A list of string containing the full paths of the videos to include in the project.
+
+ working_directory : string, optional, default = None
+ The directory where the project will be created. If None - the current working directory will be used.
+
+ copy_videos : bool, optional, default = False,
+ If this is set to True, the videos are copied to the ``videos`` directory.
+ If it is False, symlink of the videos are copied to the project/videos directory.
+ Note: on Windows: True is often necessary!
+ analyzevideo: bool, optional
+ If true, then the video is analyzed and a labeled video is created.
+ If false, then only the project will be created and the weights downloaded.
+
+ filtered: bool, default True
+ Indicates if filtered pose data output should be plotted rather than frame-by-frame predictions.
+ Filtered version can be calculated with deeplabcut.filterpredictions()
+
+ createlabeledvideo: bool, default True
+ Specifies if a labeled video needs to be created.
+
+ trainFraction: float|None, default = None.
+ Fraction that will be used in dlc-model/trainingset folder name.
+ If None - default value (0.95) from new projects will be used.
+
+ Example
+ --------
+ Linux/MacOs loading full_human model and analyzing video /homosapiens1.avi
+ >>> deeplabcut.create_pretrained_project_tensorflow("humanstrokestudy",
+ ... "Linus", ["/data/videos/homosapiens1.avi"], copy_videos=False)
+
+ Loading full_cat model and analyzing video "felixfeliscatus3.avi"
+ >>> deeplabcut.create_pretrained_project_tensorflow("humanstrokestudy",
+ ... "Linus", ["/data/videos/felixfeliscatus3.avi"], model="full_cat", engine=Engine.TF)
+
+ Windows:
+ >>> deeplabcut.create_pretrained_project_tensorflow("humanstrokestudy",
+ ... "Bill", [r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],
+ ... r'C:\yourusername\analysis\project', copy_videos=True)
+ Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
"""
+ if not model:
+ model = "full_human"
+
if model in MODELOPTIONS:
cwd = os.getcwd()
cfg = deeplabcut.create_new_project(
- project, experimenter, videos, working_directory, copy_videos, videotype
+ project, experimenter, videos, working_directory, copy_videos, video_extensions=video_extensions
)
if trainFraction is not None:
auxiliaryfunctions.edit_config(cfg, {"TrainingFraction": [trainFraction]})
@@ -245,16 +644,8 @@ def create_pretrained_project(
modelfoldername = auxiliaryfunctions.get_model_folder(
trainFraction=config["TrainingFraction"][0], shuffle=1, cfg=config
)
- path_train_config = str(
- os.path.join(
- config["project_path"], Path(modelfoldername), "train", "pose_cfg.yaml"
- )
- )
- path_test_config = str(
- os.path.join(
- config["project_path"], Path(modelfoldername), "test", "pose_cfg.yaml"
- )
- )
+ path_train_config = str(os.path.join(config["project_path"], Path(modelfoldername), "train", "pose_cfg.yaml"))
+ path_test_config = str(os.path.join(config["project_path"], Path(modelfoldername), "test", "pose_cfg.yaml"))
# Download the weights and put then in appropriate directory
print("Downloading weights...")
@@ -272,13 +663,12 @@ def create_pretrained_project(
}
auxiliaryfunctions.edit_config(cfg, dict_)
- # downloading base encoder / not required unless on re-trains (but when a training set is created this happens anyway)
+ # downloading base encoder / not required unless on re-trains
+ # (but when a training set is created this happens anyway)
# model_path = auxfun_models.check_for_weights(pose_cfg['net_type'], parent_path)
# Updating training and test pose_cfg:
- snapshotname = [fn for fn in os.listdir(train_dir) if ".meta" in fn][0].split(
- ".meta"
- )[0]
+ snapshotname = [fn for fn in os.listdir(train_dir) if ".meta" in fn][0].split(".meta")[0]
dict2change = {
"init_weights": str(os.path.join(train_dir, snapshotname)),
"project_path": str(config["project_path"]),
@@ -300,23 +690,63 @@ def create_pretrained_project(
MakeTest_pose_yaml(pose_cfg, keys2save, path_test_config)
- video_dir = os.path.join(config["project_path"], "videos")
- if analyzevideo == True:
- print("Analyzing video...")
- deeplabcut.analyze_videos(cfg, [video_dir], videotype, save_as_csv=True)
-
- if createlabeledvideo == True:
- if filtered:
- deeplabcut.filterpredictions(cfg, [video_dir], videotype)
+ _create_training_datasets_metadata(config, modelfoldername.name, Engine.TF)
- print("Plotting results...")
- deeplabcut.create_labeled_video(
- cfg, [video_dir], videotype, draw_skeleton=True, filtered=filtered
- )
- deeplabcut.plot_trajectories(cfg, [video_dir], videotype, filtered=filtered)
+ _process_videos(
+ cfg_path=cfg,
+ video_extensions=video_extensions,
+ analyze_video=analyzevideo,
+ filtered=filtered,
+ create_labeled_video=createlabeledvideo,
+ )
os.chdir(cwd)
return cfg, path_train_config
else:
return "N/A", "N/A"
+
+
+def _create_training_datasets_metadata(config: dict, shuffle_dir_name: str, engine: Engine):
+ # First create the metadata object
+ metadata = TrainingDatasetMetadata.create(config)
+
+ # Create a new shuffle with TensorFlow engine
+ new_shuffle = ShuffleMetadata(
+ name=shuffle_dir_name,
+ train_fraction=config["TrainingFraction"][0],
+ index=1,
+ engine=engine,
+ split=DataSplit(train_indices=(), test_indices=()),
+ )
+
+ # Add the shuffle to metadata
+ metadata = metadata.add(new_shuffle)
+
+ # Save the metadata
+ metadata.save()
+
+ return metadata
+
+
+def _process_videos(
+ cfg_path: str | Path,
+ video_extensions: str | Sequence[str] | None = None,
+ analyze_video: bool = True,
+ filtered: bool = True,
+ create_labeled_video: bool = True,
+):
+ cfg_path = str(cfg_path)
+ video_dir = Path(cfg_path).parent / "videos"
+
+ if analyze_video:
+ print("Analyzing video...")
+ deeplabcut.analyze_videos(cfg_path, [video_dir], video_extensions=video_extensions, save_as_csv=True)
+
+ if create_labeled_video:
+ if filtered:
+ deeplabcut.filterpredictions(cfg_path, [video_dir], video_extensions)
+
+ print("Plotting results...")
+ deeplabcut.create_labeled_video(cfg_path, [video_dir], video_extensions, draw_skeleton=True, filtered=filtered)
+ deeplabcut.plot_trajectories(cfg_path, [video_dir], video_extensions, filtered=filtered)
diff --git a/deeplabcut/create_project/new.py b/deeplabcut/create_project/new.py
index c6194e9882..11e1f26ba4 100644
--- a/deeplabcut/create_project/new.py
+++ b/deeplabcut/create_project/new.py
@@ -13,19 +13,25 @@
import os
import shutil
import warnings
+from collections.abc import Sequence
from pathlib import Path
+
from deeplabcut import DEBUG
-from deeplabcut.utils.auxfun_videos import VideoReader
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils.auxfun_videos import VideoReader, collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_new_project(
- project,
- experimenter,
- videos,
- working_directory=None,
- copy_videos=False,
- videotype="",
- multianimal=False,
+ project: str,
+ experimenter: str,
+ videos: list[str],
+ working_directory: str | None = None,
+ copy_videos: bool = False,
+ video_extensions: str | Sequence[str] | None = None,
+ multianimal: bool = False,
+ individuals: list[str] | None = None,
):
r"""Create the necessary folders and files for a new project.
@@ -42,9 +48,17 @@ def create_new_project(
The name of the experimenter.
videos : list[str]
- A list of strings representing the full paths of the videos to include in the
- project. If the strings represent a directory instead of a file, all videos of
- ``videotype`` will be imported.
+ A list of strings representing the full paths of the videos or video-directories
+ to include in the project.
+
+ video_extensions (str | Sequence[str] | None, default=None):
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
working_directory : string, optional
The directory where the project will be created. The default is the
@@ -58,11 +72,21 @@ def create_new_project(
multianimal: bool, optional. Default: False.
For creating a multi-animal project (introduced in DLC 2.2)
+ individuals: list[str]|None = None,
+ Relevant only if multianimal is True.
+ list of individuals to be used in the project configuration.
+ If None - defaults to ['individual1', 'individual2', 'individual3']
+
Returns
-------
str
Path to the new project configuration file.
+ Raises
+ ------
+ FileNotFoundError
+ If a non-existent path is passed to ``videos``.
+
Examples
--------
@@ -82,7 +106,7 @@ def create_new_project(
project='reaching-task',
experimenter='Linus',
videos=['/data/videos'],
- videotype='.mp4',
+ video_extensions='.mp4',
)
Windows:
@@ -94,9 +118,11 @@ def create_new_project(
copy_videos=True,
)
- Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
+ Users must format paths with either:
+ r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
"""
from datetime import datetime as dt
+
from deeplabcut.utils import auxiliaryfunctions
months_3letter = {
@@ -122,12 +148,12 @@ def create_new_project(
if working_directory is None:
working_directory = "."
wd = Path(working_directory).resolve()
- project_name = "{pn}-{exp}-{date}".format(pn=project, exp=experimenter, date=date)
+ project_name = f"{project}-{experimenter}-{date}"
project_path = wd / project_name
# Create project and sub-directories
if not DEBUG and project_path.exists():
- print('Project "{}" already exists!'.format(project_path))
+ print(f'Project "{project_path}" already exists!')
return os.path.join(str(project_path), "config.yaml")
video_path = project_path / "videos"
data_path = project_path / "labeled-data"
@@ -135,74 +161,54 @@ def create_new_project(
results_path = project_path / "dlc-models"
for p in [video_path, data_path, shuffles_path, results_path]:
p.mkdir(parents=True, exist_ok=DEBUG)
- print('Created "{}"'.format(p))
-
- # Add all videos in the folder. Multiple folders can be passed in a list, similar to the video files. Folders and video files can also be passed!
- vids = []
- for i in videos:
- # Check if it is a folder
- if os.path.isdir(i):
- vids_in_dir = [
- os.path.join(i, vp) for vp in os.listdir(i) if vp.endswith(videotype)
- ]
- vids = vids + vids_in_dir
- if len(vids_in_dir) == 0:
- print("No videos found in", i)
- print(
- "Perhaps change the videotype, which is currently set to:",
- videotype,
- )
- else:
- videos = vids
- print(
- len(vids_in_dir),
- " videos from the directory",
- i,
- "were added to the project.",
- )
- else:
- if os.path.isfile(i):
- vids = vids + [i]
- videos = vids
-
- videos = [Path(vp) for vp in videos]
- dirs = [data_path / Path(i.stem) for i in videos]
+ print(f'Created "{p}"')
+
+ # Add all videos in the folder. Multiple folders can be passed in a list,
+ # similar to the video files. Folders and video files can also be passed!
+ collected_videos: list[Path] = collect_video_paths(videos, extensions=video_extensions)
+
+ # TODO @deruyter92 2026-05-20: Move this verbosity block to `collect_video_paths` instead
+ files_per_dir: dict[Path, int] = {}
+ for f in collected_videos:
+ files_per_dir[f.parent] = files_per_dir.get(f.parent, 0) + 1
+ for dir, count in files_per_dir.items():
+ print(f"Found {count} videos in {dir}")
+ for p in (Path(v) for v in videos if Path(v).is_dir()):
+ if p.resolve() not in {d.resolve() for d in files_per_dir}:
+ print(f"No videos found in {p}")
+ print(f"Perhaps change the video_extensions, which is currently set to: {video_extensions}")
+
+ videos = collected_videos
+ dirs = [data_path / i.stem for i in videos]
for p in dirs:
- """
- Creates directory under data
- """
+ """Creates directory under data."""
p.mkdir(parents=True, exist_ok=True)
destinations = [video_path.joinpath(vp.name) for vp in videos]
if copy_videos:
print("Copying the videos")
- for src, dst in zip(videos, destinations):
- shutil.copy(
- os.fspath(src), os.fspath(dst)
- ) # https://www.python.org/dev/peps/pep-0519/
+ for src, dst in zip(videos, destinations, strict=False):
+ shutil.copy(os.fspath(src), os.fspath(dst)) # https://www.python.org/dev/peps/pep-0519/
else:
# creates the symlinks of the video and puts it in the videos directory.
print("Attempting to create a symbolic link of the video ...")
- for src, dst in zip(videos, destinations):
+ for src, dst in zip(videos, destinations, strict=False):
if dst.exists() and not DEBUG:
- raise FileExistsError("Video {} exists already!".format(dst))
+ raise FileExistsError(f"Video {dst} exists already!")
try:
src = str(src)
dst = str(dst)
os.symlink(src, dst)
- print("Created the symlink of {} to {}".format(src, dst))
+ print(f"Created the symlink of {src} to {dst}")
except OSError:
try:
import subprocess
- subprocess.check_call("mklink %s %s" % (dst, src), shell=True)
+ subprocess.check_call(f"mklink {dst} {src}", shell=True)
except (OSError, subprocess.CalledProcessError):
- print(
- "Symlink creation impossible (exFat architecture?): "
- "copying the video instead."
- )
+ print("Symlink creation impossible (exFat architecture?): copying the video instead.")
shutil.copy(os.fspath(src), os.fspath(dst))
- print("{} copied to {}".format(src, dst))
+ print(f"{src} copied to {dst}")
videos = destinations
if copy_videos:
@@ -213,16 +219,17 @@ def create_new_project(
for video in videos:
print(video)
try:
- # For windows os.path.realpath does not work and does not link to the real video. [old: rel_video_path = os.path.realpath(video)]
+ # For windows os.path.realpath does not work and does not link to the real
+ # video. [old: rel_video_path = os.path.realpath(video)]
rel_video_path = str(Path.resolve(Path(video)))
- except:
+ except Exception:
rel_video_path = os.readlink(str(video))
try:
vid = VideoReader(rel_video_path)
video_sets[rel_video_path] = {"crop": ", ".join(map(str, vid.get_bbox()))}
- except IOError:
- warnings.warn("Cannot open the video file! Skipping to the next one...")
+ except OSError:
+ warnings.warn("Cannot open the video file! Skipping to the next one...", stacklevel=2)
os.remove(video) # Removing the video or link from the project
if not len(video_sets):
@@ -230,7 +237,8 @@ def create_new_project(
shutil.rmtree(project_path, ignore_errors=True)
warnings.warn(
"No valid videos were found. The project was not created... "
- "Verify the video files and re-create the project."
+ "Verify the video files and re-create the project.",
+ stacklevel=2,
)
return "nothingcreated"
@@ -239,7 +247,7 @@ def create_new_project(
cfg_file, ruamelFile = auxiliaryfunctions.create_config_template(multianimal)
cfg_file["multianimalproject"] = multianimal
cfg_file["identity"] = False
- cfg_file["individuals"] = ["individual1", "individual2", "individual3"]
+ cfg_file["individuals"] = individuals if individuals else ["individual1", "individual2", "individual3"]
cfg_file["multianimalbodyparts"] = ["bodypart1", "bodypart2", "bodypart3"]
cfg_file["uniquebodyparts"] = []
cfg_file["bodyparts"] = "MULTI!"
@@ -248,8 +256,15 @@ def create_new_project(
["bodypart2", "bodypart3"],
["bodypart1", "bodypart3"],
]
- cfg_file["default_augmenter"] = "multi-animal-imgaug"
- cfg_file["default_net_type"] = "dlcrnet_ms5"
+ engine = cfg_file.get("engine")
+ if engine in Engine.PYTORCH.aliases:
+ cfg_file["default_augmenter"] = "albumentations"
+ cfg_file["default_net_type"] = "resnet_50"
+ elif engine in Engine.TF.aliases:
+ cfg_file["default_augmenter"] = "multi-animal-imgaug"
+ cfg_file["default_net_type"] = "dlcrnet_ms5"
+ else:
+ raise ValueError(f"Unknown or undefined engine {engine}")
cfg_file["default_track_method"] = "ellipse"
else:
cfg_file, ruamelFile = auxiliaryfunctions.create_config_template()
@@ -272,13 +287,15 @@ def create_new_project(
cfg_file["TrainingFraction"] = [0.95]
cfg_file["iteration"] = 0
cfg_file["snapshotindex"] = -1
+ cfg_file["detector_snapshotindex"] = -1
cfg_file["x1"] = 0
cfg_file["x2"] = 640
cfg_file["y1"] = 277
cfg_file["y2"] = 624
- cfg_file[
- "batch_size"
- ] = 8 # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242
+ cfg_file["batch_size"] = (
+ 8 # batch size during inference (video - analysis); see https://www.biorxiv.org/content/early/2018/10/30/457242
+ )
+ cfg_file["detector_batch_size"] = 1
cfg_file["corner2move2"] = (50, 50)
cfg_file["move2corner"] = True
cfg_file["skeleton_color"] = "black"
@@ -293,7 +310,11 @@ def create_new_project(
print('Generated "{}"'.format(project_path / "config.yaml"))
print(
- "\nA new project with name %s is created at %s and a configurable file (config.yaml) is stored there. Change the parameters in this file to adapt to your project's needs.\n Once you have changed the configuration file, use the function 'extract_frames' to select frames for labeling.\n. [OPTIONAL] Use the function 'add_new_videos' to add new videos to your project (at any stage)."
- % (project_name, str(wd))
+ f"\nA new project with name {project_name} is created at {str(wd)} "
+ "and a configurable file (config.yaml) is stored there. "
+ "Change the parameters in this file to adapt to your project's needs.\n "
+ "Once you have changed the configuration file, "
+ "use the function 'extract_frames' to select frames for labeling.\n. "
+ "[OPTIONAL] Use the function 'add_new_videos' to add new videos to your project (at any stage)."
)
return projconfigfile
diff --git a/deeplabcut/create_project/new_3d.py b/deeplabcut/create_project/new_3d.py
index f95344b3e2..24be797a2d 100644
--- a/deeplabcut/create_project/new_3d.py
+++ b/deeplabcut/create_project/new_3d.py
@@ -17,8 +17,9 @@
def create_new_project_3d(project, experimenter, num_cameras=2, working_directory=None):
- """Creates a new project directory, sub-directories and a basic configuration file for 3d project.
- The configuration file is loaded with the default values. Adjust the parameters to your project's needs.
+ r"""Creates a new project directory, sub-directories and a basic configuration file
+ for 3d project. The configuration file is loaded with the default values. Adjust the
+ parameters to your project's needs.
Parameters
----------
@@ -32,7 +33,8 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
An integer value specifying the number of cameras.
working_directory : string, optional
- The directory where the project will be created. The default is the ``current working directory``; if provided, it must be a string.
+ The directory where the project will be created. The default is the ``current working directory``; if provided,
+ it must be a string.
Example
@@ -42,10 +44,10 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
Windows:
>>> deeplabcut.create_new_project('reaching-task','Bill',2)
- Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
-
+ Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \\ )
"""
from datetime import datetime as dt
+
from deeplabcut.utils import auxiliaryfunctions
date = dt.today()
@@ -58,13 +60,11 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
working_directory = "."
wd = Path(working_directory).resolve()
- project_name = "{pn}-{exp}-{date}-{triangulate}".format(
- pn=project, exp=experimenter, date=date, triangulate="3d"
- )
+ project_name = "{pn}-{exp}-{date}-{triangulate}".format(pn=project, exp=experimenter, date=date, triangulate="3d")
project_path = wd / project_name
# Create project and sub-directories
if not DEBUG and project_path.exists():
- print('Project "{}" already exists!'.format(project_path))
+ print(f'Project "{project_path}" already exists!')
return
camera_matrix_path = project_path / "camera_matrix"
@@ -81,7 +81,7 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
path_removed_images,
]:
p.mkdir(parents=True, exist_ok=DEBUG)
- print('Created "{}"'.format(p))
+ print(f'Created "{p}"')
# Create config file
cfg_file_3d, ruamelFile_3d = auxiliaryfunctions.create_config_template_3d()
@@ -89,7 +89,10 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
cfg_file_3d["scorer"] = experimenter
cfg_file_3d["date"] = d
cfg_file_3d["project_path"] = str(project_path)
- # cfg_file_3d['config_files']= [str('Enter the path of the config file ')+str(i)+ ' to include' for i in range(1,3)]
+ # cfg_file_3d['config_files']= [
+ # str('Enter the path of the config file ') + str(i) + ' to include'
+ # for i in range(1, 3)
+ # ]
# cfg_file_3d['config_files']= ['Enter the path of the config file 1']
cfg_file_3d["colormap"] = "jet"
cfg_file_3d["dotsize"] = 15
@@ -98,9 +101,7 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
cfg_file_3d["markerColor"] = "r"
cfg_file_3d["pcutoff"] = 0.4
cfg_file_3d["num_cameras"] = num_cameras
- cfg_file_3d["camera_names"] = [
- str("camera-" + str(i)) for i in range(1, num_cameras + 1)
- ]
+ cfg_file_3d["camera_names"] = [str("camera-" + str(i)) for i in range(1, num_cameras + 1)]
cfg_file_3d["scorername_3d"] = "DLC_3D"
cfg_file_3d["skeleton"] = [
@@ -113,26 +114,21 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
for i in range(num_cameras):
path = str(
- "/home/mackenzie/DEEPLABCUT/DeepLabCut/2DprojectCam"
- + str(i + 1)
- + "-Mackenzie-2019-06-05/config.yaml"
- )
- cfg_file_3d.insert(
- len(cfg_file_3d), str("config_file_camera-" + str(i + 1)), path
+ "/home/mackenzie/DEEPLABCUT/DeepLabCut/2DprojectCam" + str(i + 1) + "-Mackenzie-2019-06-05/config.yaml"
)
+ cfg_file_3d.insert(len(cfg_file_3d), str("config_file_camera-" + str(i + 1)), path)
for i in range(num_cameras):
cfg_file_3d.insert(len(cfg_file_3d), str("shuffle_camera-" + str(i + 1)), 1)
- cfg_file_3d.insert(
- len(cfg_file_3d), str("trainingsetindex_camera-" + str(i + 1)), 0
- )
+ cfg_file_3d.insert(len(cfg_file_3d), str("trainingsetindex_camera-" + str(i + 1)), 0)
projconfigfile = os.path.join(str(project_path), "config.yaml")
auxiliaryfunctions.write_config_3d(projconfigfile, cfg_file_3d)
print('Generated "{}"'.format(project_path / "config.yaml"))
print(
- "\nA new project with name %s is created at %s and a configurable file (config.yaml) is stored there. If you have not calibrated the cameras, then use the function 'calibrate_camera' to start calibrating the camera otherwise use the function ``triangulate`` to triangulate the dataframe"
- % (project_name, wd)
+ f"\nA new project with name {project_name} is created at {wd} and a configurable file (config.yaml) is stored"
+ f"there. If you have not calibrated the cameras, then use the function 'calibrate_camera' to start calibrating"
+ f"the camera otherwise use the function ``triangulate`` to triangulate the dataframe"
)
return projconfigfile
diff --git a/deeplabcut/generate_training_dataset/__init__.py b/deeplabcut/generate_training_dataset/__init__.py
index 05b0092d49..7729536aba 100644
--- a/deeplabcut/generate_training_dataset/__init__.py
+++ b/deeplabcut/generate_training_dataset/__init__.py
@@ -11,5 +11,10 @@
from deeplabcut.generate_training_dataset.frame_extraction import *
-from deeplabcut.generate_training_dataset.trainingsetmanipulation import *
+from deeplabcut.generate_training_dataset.metadata import (
+ DataSplit,
+ ShuffleMetadata,
+ TrainingDatasetMetadata,
+)
from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import *
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import *
diff --git a/deeplabcut/generate_training_dataset/frame_extraction.py b/deeplabcut/generate_training_dataset/frame_extraction.py
index 6264e01157..3605b9aca7 100755
--- a/deeplabcut/generate_training_dataset/frame_extraction.py
+++ b/deeplabcut/generate_training_dataset/frame_extraction.py
@@ -11,11 +11,10 @@
def select_cropping_area(config, videos=None):
- """
- Interactively select the cropping area of all videos in the config.
- A user interface pops up with a frame to select the cropping parameters.
- Use the left click to draw a box and hit the button 'set cropping parameters'
- to store the cropping parameters for a video in the config.yaml file.
+ """Interactively select the cropping area of all videos in the config. A user
+ interface pops up with a frame to select the cropping parameters. Use the left click
+ to draw a box and hit the button 'set cropping parameters' to store the cropping
+ parameters for a video in the config.yaml file.
Parameters
----------
@@ -31,7 +30,7 @@ def select_cropping_area(config, videos=None):
cfg : dict
Updated project configuration
"""
- from deeplabcut.utils import auxiliaryfunctions, auxfun_videos
+ from deeplabcut.utils import auxfun_videos, auxiliaryfunctions
cfg = auxiliaryfunctions.read_config(config)
if videos is None:
@@ -251,23 +250,24 @@ def extract_frames(
extracted_cam=0,
)
"""
+ import glob
import os
- import sys
import re
- import glob
- import numpy as np
+ import sys
from pathlib import Path
+
+ import numpy as np
from skimage import io
from skimage.util import img_as_ubyte
- from deeplabcut.utils import frameselectiontools
- from deeplabcut.utils import auxiliaryfunctions
+
+ from deeplabcut.utils import auxiliaryfunctions, frameselectiontools
config_file = Path(config).resolve()
cfg = auxiliaryfunctions.read_config(config_file)
print("Config file read successfully.")
if videos_list is None:
- videos = cfg.get("video_sets_original") or cfg["video_sets"]
+ videos = list(cfg.get("video_sets_original") or cfg["video_sets"])
else: # filter video_list by the ones in the config file
videos = [v for v in cfg["video_sets"] if v in videos_list]
@@ -284,13 +284,9 @@ def extract_frames(
# Check for variable correctness
if start > 1 or stop > 1 or start < 0 or stop < 0 or start >= stop:
- raise Exception(
- "Erroneous start or stop values. Please correct it in the config file."
- )
+ raise Exception("Erroneous start or stop values. Please correct it in the config file.")
if numframes2pick < 1 and not int(numframes2pick):
- raise Exception(
- "Perhaps consider extracting more, or a natural number of frames."
- )
+ raise Exception("Perhaps consider extracting more, or a natural number of frames.")
if opencv:
from deeplabcut.utils.auxfun_videos import VideoWriter
@@ -340,12 +336,7 @@ def extract_frames(
askuser = input(
"The directory already contains some frames. Do you want to add to it?(yes/no): "
)
- if not (
- askuser == "y"
- or askuser == "yes"
- or askuser == "Y"
- or askuser == "Yes"
- ):
+ if not (askuser == "y" or askuser == "yes" or askuser == "Y" or askuser == "Yes"):
sys.exit("Delete the frames and try again later!")
if crop == "GUI":
@@ -368,16 +359,12 @@ def extract_frames(
else:
coords = None
- print("Extracting frames based on %s ..." % algo)
+ print(f"Extracting frames based on {algo} ...")
if algo == "uniform":
if opencv:
- frames2pick = frameselectiontools.UniformFramescv2(
- cap, numframes2pick, start, stop
- )
+ frames2pick = frameselectiontools.UniformFramescv2(cap, numframes2pick, start, stop)
else:
- frames2pick = frameselectiontools.UniformFrames(
- clip, numframes2pick, start, stop
- )
+ frames2pick = frameselectiontools.UniformFrames(clip, numframes2pick, start, stop)
elif algo == "kmeans":
if opencv:
frames2pick = frameselectiontools.KmeansbasedFrameselectioncv2(
@@ -401,17 +388,16 @@ def extract_frames(
)
else:
print(
- "Please implement this method yourself and send us a pull request! Otherwise, choose 'uniform' or 'kmeans'."
+ "Please implement this method yourself and send us a pull "
+ "request! Otherwise, choose 'uniform' or 'kmeans'."
)
frames2pick = []
if not len(frames2pick):
print("Frame selection failed...")
- return
+ return []
- output_path = (
- Path(config).parents[0] / "labeled-data" / Path(video).stem
- )
+ output_path = Path(config).parents[0] / "labeled-data" / Path(video).stem
output_path.mkdir(parents=True, exist_ok=True)
is_valid = []
if opencv:
@@ -420,12 +406,7 @@ def extract_frames(
frame = cap.read_frame(crop=True)
if frame is not None:
image = img_as_ubyte(frame)
- img_name = (
- str(output_path)
- + "/img"
- + str(index).zfill(indexlength)
- + ".png"
- )
+ img_name = str(output_path) + "/img" + str(index).zfill(indexlength) + ".png"
io.imsave(img_name, image)
is_valid.append(True)
else:
@@ -436,16 +417,12 @@ def extract_frames(
for index in frames2pick:
try:
image = img_as_ubyte(clip.get_frame(index * 1.0 / clip.fps))
- img_name = (
- str(output_path)
- + "/img"
- + str(index).zfill(indexlength)
- + ".png"
- )
+ img_name = str(output_path) + "/img" + str(index).zfill(indexlength) + ".png"
io.imsave(img_name, image)
if np.var(image) == 0: # constant image
print(
- "Seems like black/constant images are extracted from your video. Perhaps consider using opencv under the hood, by setting: opencv=True"
+ "Seems like black/constant images are extracted from your video."
+ "Perhaps consider using opencv under the hood, by setting: opencv=True"
)
is_valid.append(True)
except FileNotFoundError:
@@ -464,17 +441,17 @@ def extract_frames(
if all(has_failed):
print("Frame extraction failed. Video files must be corrupted.")
- return
+ return has_failed
elif any(has_failed):
print("Although most frames were extracted, some were invalid.")
else:
- print(
- "Frames were successfully extracted, for the videos listed in the config.yaml file."
- )
+ print("Frames were successfully extracted, for the videos listed in the config.yaml file.")
print(
"\nYou can now label the frames using the function 'label_frames' "
- "(Note, you should label frames extracted from diverse videos (and many videos; we do not recommend training on single videos!))."
+ "(Note, you should label frames extracted from diverse videos "
+ "(and many videos; we do not recommend training on single videos!))."
)
+ return has_failed
elif mode == "match":
import cv2
@@ -487,19 +464,17 @@ def extract_frames(
videos = [v for v in videos if v in videos_list]
project_path = Path(config).parents[0]
labels_path = os.path.join(project_path, "labeled-data/")
- video_dir = os.path.join(project_path, "videos/")
+ os.path.join(project_path, "videos/")
try:
cfg_3d = auxiliaryfunctions.read_config(config3d)
- except:
+ except Exception as e:
raise Exception(
"You must create a 3D project and edit the 3D config file before extracting matched frames. \n"
- )
+ ) from e
cams = cfg_3d["camera_names"]
extCam_name = cams[extracted_cam]
del cams[extracted_cam]
- label_dirs = sorted(
- glob.glob(os.path.join(labels_path, "*" + extCam_name + "*"))
- )
+ label_dirs = sorted(glob.glob(os.path.join(labels_path, "*" + extCam_name + "*")))
# select crop method
crop_list = []
@@ -521,7 +496,7 @@ def extract_frames(
coords = None
crop_list.append(coords)
- for coords, dirPath in zip(crop_list, label_dirs):
+ for coords, dirPath in zip(crop_list, label_dirs, strict=False):
extracted_images = glob.glob(os.path.join(dirPath, "*png"))
imgPattern = re.compile("[0-9]{1,10}")
@@ -563,12 +538,11 @@ def extract_frames(
)
else:
io.imsave(img_name, image)
- print(
- "\n Done extracting matched frames. You can now begin labeling frames using the function label_frames\n"
- )
+ print("\n Done extracting matched frames. You can now begin labeling frames using the function label_frames\n")
else:
print(
- "Invalid MODE. Choose either 'manual', 'automatic' or 'match'. Check ``help(deeplabcut.extract_frames)`` on python and ``deeplabcut.extract_frames?`` \
- for ipython/jupyter notebook for more details."
+ "Invalid MODE. Choose either 'manual', 'automatic' or 'match'. "
+ "Check ``help(deeplabcut.extract_frames)`` on python and ``deeplabcut.extract_frames?``"
+ " for ipython/jupyter notebook for more details."
)
diff --git a/deeplabcut/generate_training_dataset/metadata.py b/deeplabcut/generate_training_dataset/metadata.py
new file mode 100644
index 0000000000..f854a962a5
--- /dev/null
+++ b/deeplabcut/generate_training_dataset/metadata.py
@@ -0,0 +1,492 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""File containing methods to load and parse shuffle metadata."""
+
+from __future__ import annotations
+
+import logging
+import pickle
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+from ruamel.yaml import YAML
+
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils import auxiliaryfunctions
+
+
+@dataclass(frozen=True)
+class DataSplit:
+ """Class representing the metadata for a shuffle."""
+
+ train_indices: tuple[int, ...]
+ test_indices: tuple[int, ...]
+
+ def __post_init__(self) -> None:
+ """
+ Raises:
+ ValueError if the indices are not sorted in increasing
+ """
+ for indices in [self.train_indices, self.test_indices]:
+ idx = np.array(indices)
+ if not np.all(idx[:-1] < idx[1:]):
+ raise RuntimeError(
+ "The training and test indices in a data split must be sorted in strictly ascending order."
+ )
+
+
+@dataclass(frozen=True)
+class ShuffleMetadata:
+ """Class representing the metadata for a shuffle."""
+
+ name: str
+ train_fraction: float
+ index: int
+ engine: Engine
+ split: DataSplit | None
+
+ def load_split(self, cfg: dict, trainset_path: Path) -> ShuffleMetadata:
+ """Loads the data split for this shuffle.
+
+ Args:
+ cfg: the config for the DeepLabCut project
+ trainset_path: the path to the training dataset folder
+
+ Returns:
+ a new instance with the data split defined
+ """
+ _, doc_path = auxiliaryfunctions.get_data_and_metadata_filenames(
+ trainset_path, self.train_fraction, self.index, cfg
+ )
+ if not Path(doc_path).exists():
+ raise ValueError(
+ f"Could not load the metadata file for {self} as {doc_path} does not "
+ f"exist. If you deleted the shuffle, you also need to delete the "
+ f"shuffle from metadata.yaml or recreate the metadata.yaml file."
+ )
+
+ with open(doc_path, "rb") as f:
+ _, train_idx, test_idx, _ = pickle.load(f)
+ return ShuffleMetadata(
+ name=self.name,
+ train_fraction=self.train_fraction,
+ index=self.index,
+ engine=self.engine,
+ split=DataSplit(
+ train_indices=tuple(sorted([int(idx) for idx in train_idx])),
+ test_indices=tuple(sorted([int(idx) for idx in test_idx])),
+ ),
+ )
+
+
+@dataclass(frozen=True)
+class TrainingDatasetMetadata:
+ """An immutable class containing the metadata for a dataset.
+
+ When creating a new "training-datasets" folder (e.g., when creating the first
+ training set for a project, or when creating the first training for a given
+ iteration of a project), TrainingDatasetMetadata.create(cfg) should be called when
+ the "training-datasets" folder is still empty.
+
+ For existing projects (created with DeepLabCut < 3.0), calling
+ TrainingDatasetMetadata.create(cfg) will go over documentation data for all existing
+ shuffles in the training-datasets folder and add them to a new metadata instance.
+ All shuffles will be given Engine.TF as an engine.
+
+ Examples:
+ # Creating the metadata file for an existing project
+ config = "/data/my-dlc-project/config.yaml"
+ trainset_metadata = TrainingDatasetMetadata.create(config)
+ trainset_metadata.save()
+
+ # Adding a new shuffle to the metadata file
+ config = "/data/my-dlc-project-2008-06-17/config.yaml"
+ trainset_metadata = TrainingDatasetMetadata.load(config)
+ new_shuffle = ShuffleMetadata(
+ name="my-dlc-projectJun17-trainset60shuffle5",
+ train_fraction=0.6,
+ index=5,
+ engine=compat.Engine.PYTORCH,
+ split=DataSplit(train_indices=(1, 3, 4), test_indices=(0, 2)),
+ )
+ trainset_metadata = trainset_metadata.add(new_shuffle)
+ trainset_metadata.save() # saves to disk
+ """
+
+ project_config: dict
+ shuffles: tuple[ShuffleMetadata, ...]
+ file_header: tuple[str] = (
+ "# This file is automatically generated - DO NOT EDIT",
+ "# It contains the information about the shuffles created for the dataset",
+ "---",
+ )
+
+ def __post_init__(self) -> None:
+ """
+ Raises:
+ ValueError if the indices are not sorted in increasing order
+ """
+ indices = [[s.train_fraction, s.index] for s in self.shuffles]
+ for (frac1, idx1), (frac2, idx2) in zip(indices[:-1], indices[1:], strict=False):
+ if not (frac1 < frac2 or (frac1 == frac2 and idx1 < idx2)):
+ raise RuntimeError(
+ "The shuffles given must be sorted in order of ascending training "
+ f"fraction and index. Found {self.shuffles}"
+ )
+
+ def add(
+ self,
+ shuffle: ShuffleMetadata,
+ overwrite: bool = False,
+ ) -> TrainingDatasetMetadata:
+ """Adds a new shuffle to the metadata file.
+
+ Args:
+ shuffle: the shuffle to add
+ overwrite: if a shuffle with the same index is already stored in the
+ metadata file, whether to overwrite it
+
+ Returns:
+ A new instance of TrainingDatasetMetadata with updated shuffles
+
+ Raises:
+ ValueError: if overwrite=False and there is already a shuffle with the given
+ index in the metadata file.
+ """
+ existing_indices = [s.index for s in self.shuffles if s.train_fraction == shuffle.train_fraction]
+ if shuffle.index in existing_indices:
+ if not overwrite:
+ raise RuntimeError(
+ f"Cannot add {shuffle} to the meta: a shuffle with index "
+ f"{shuffle.index} and train_fraction {shuffle.train_fraction} "
+ f"already exists: {self.shuffles}."
+ )
+
+ existing_shuffles = [
+ s for s in self.shuffles if (s.index != shuffle.index or s.train_fraction != shuffle.train_fraction)
+ ]
+ shuffles = existing_shuffles + [shuffle]
+ return TrainingDatasetMetadata(
+ project_config=self.project_config,
+ shuffles=tuple(sorted(shuffles, key=lambda s: (s.train_fraction, s.index))),
+ )
+
+ def get(self, trainset_index: int = 0, index: int = 0) -> ShuffleMetadata:
+ """
+ Args:
+ trainset_index: the index of the trainset fraction as defined in config.yaml
+ index: the index of the shuffle
+
+ Returns:
+ the shuffle with the given trainset index and shuffle index
+
+ Raises:
+ ValueError if trainset_index is out of bounds or the shuffle is not present
+ """
+ fractions = self.project_config["TrainingFraction"]
+ if trainset_index >= len(fractions):
+ raise ValueError(
+ f"trainset_index={trainset_index} is out of bounds for "
+ f"TrainingFraction={fractions} (length {len(fractions)})."
+ )
+ train_fraction = fractions[trainset_index]
+ for shuffle in self.shuffles:
+ if shuffle.train_fraction == train_fraction and shuffle.index == index:
+ return shuffle
+
+ known = [(s.train_fraction, s.index) for s in self.shuffles] or "none"
+ raise ValueError(
+ f"Could not find a shuffle with train_fraction={train_fraction} and "
+ f"index={index}. Known shuffles (fraction, index): {known}."
+ )
+
+ def save(self) -> None:
+ """Saves the training dataset metadata to disk."""
+ metadata = {"shuffles": {}}
+ data_splits: dict[DataSplit, int] = {}
+ trainset_path = self.path(self.project_config).parent
+ for s in self.shuffles:
+ if s.split is None:
+ s = s.load_split(cfg=self.project_config, trainset_path=trainset_path)
+
+ split_index = data_splits.get(s.split)
+ if split_index is None:
+ split_index = len(data_splits) + 1
+ data_splits[s.split] = split_index
+
+ metadata["shuffles"][s.name] = {
+ "train_fraction": s.train_fraction,
+ "index": s.index,
+ "split": split_index,
+ "engine": s.engine.aliases[0],
+ }
+
+ with open(self.path(self.project_config), "w") as file:
+ file.write("\n".join(self.file_header) + "\n")
+ YAML().dump(metadata, file)
+
+ @staticmethod
+ def load(
+ config: str | Path | dict,
+ load_splits: bool = False,
+ ) -> TrainingDatasetMetadata:
+ """Loads the metadata from disk.
+
+ Args:
+ config: the config for the DeepLabCut project (or its path)
+ load_splits: whether to load the data split for each shuffle
+ """
+ if isinstance(config, (str, Path)):
+ cfg = auxiliaryfunctions.read_config(config)
+ else:
+ cfg = config
+
+ metadata_path = TrainingDatasetMetadata.path(cfg)
+ if not metadata_path.exists():
+ raise FileNotFoundError(f"No metadata.yaml found at {metadata_path}.")
+ with open(metadata_path) as file:
+ metadata = YAML(typ="safe", pure=True).load(file)
+
+ shuffles = []
+ for shuffle_name, shuffle_metadata in metadata["shuffles"].items():
+ shuffle = ShuffleMetadata(
+ name=shuffle_name,
+ train_fraction=shuffle_metadata["train_fraction"],
+ index=shuffle_metadata["index"],
+ engine=Engine(shuffle_metadata["engine"]),
+ split=None,
+ )
+ if load_splits:
+ shuffle = shuffle.load_split(cfg, metadata_path.parent)
+
+ shuffles.append(shuffle)
+
+ shuffles.sort(key=lambda s: (s.train_fraction, s.index))
+ return TrainingDatasetMetadata(project_config=cfg, shuffles=tuple(shuffles))
+
+ @staticmethod
+ def create(config: str | Path | dict) -> TrainingDatasetMetadata:
+ """Function to create the metadata file.
+
+ Assumes that all existing shuffles use the TensorFlow engine, as this file
+ should have already been created for PyTorch shuffles.
+
+ Args;
+ config: the config for the DeepLabCut project (or its path)
+ default_engine: the default engine to set for shuffles in the project
+
+ Returns:
+ the metadata for the existing shuffles in the project
+ """
+ if isinstance(config, (str, Path)):
+ cfg = auxiliaryfunctions.read_config(config)
+ else:
+ cfg = config
+
+ trainset_path = TrainingDatasetMetadata.path(cfg).parent
+ if trainset_path.exists():
+ shuffle_docs = [
+ f for f in trainset_path.iterdir() if re.match(r"Documentation_data-.+shuffle[0-9]+\.pickle", f.name)
+ ]
+ else:
+ trainset_path.mkdir(parents=True)
+ shuffle_docs = []
+
+ prefix = cfg["Task"] + cfg["date"]
+ shuffles = []
+ existing_splits: dict[tuple[tuple[int, ...], tuple[int, ...]], int] = {}
+ for doc_path in shuffle_docs:
+ index = int(doc_path.stem.split("shuffle")[-1])
+ with open(doc_path, "rb") as f:
+ _, train_idx, test_idx, train_frac = pickle.load(f)
+
+ engine = Engine.TF
+ train_idx = tuple(sorted([int(idx) for idx in train_idx]))
+ test_idx = tuple(sorted([int(idx) for idx in test_idx]))
+ split_idx = existing_splits.get((train_idx, test_idx))
+ if split_idx is None:
+ split_idx = len(existing_splits) + 1
+ existing_splits[(train_idx, test_idx)] = split_idx
+
+ shuffles.append(
+ ShuffleMetadata(
+ name=f"{prefix}-trainset{int(100 * train_frac)}shuffle{index}",
+ train_fraction=train_frac,
+ index=index,
+ engine=engine,
+ split=DataSplit(train_indices=train_idx, test_indices=test_idx),
+ )
+ )
+
+ shuffles = tuple(sorted(shuffles, key=lambda s: (s.train_fraction, s.index)))
+ return TrainingDatasetMetadata(
+ project_config=cfg,
+ shuffles=shuffles,
+ )
+
+ @staticmethod
+ def path(cfg: dict) -> Path:
+ """
+ Args:
+ cfg: the config for the DeepLabCut project
+
+ Returns:
+ the path to the training dataset metadata file
+ """
+ meta_path = auxiliaryfunctions.get_training_set_folder(cfg) / "metadata.yaml"
+ return Path(cfg["project_path"]) / meta_path
+
+
+def update_metadata(
+ cfg: dict,
+ train_fraction: float,
+ shuffle: int,
+ engine: Engine,
+ train_indices: list[int],
+ test_indices: list[int],
+ overwrite: bool = False,
+) -> None:
+ """Updates the metadata for a training-dataset.
+
+ Args:
+ cfg: the config for the DeepLabCut project
+ train_fraction: the train_fraction of the new shuffle
+ shuffle: the index of the shuffle to add
+ engine: the engine for the shuffle
+ train_indices: the indices of images in the training set
+ test_indices: the indices of images in the test set
+ overwrite: whether to overwrite a shuffle with the same index and train fraction
+ if one exists
+
+ Raises:
+ ValueError: if overwrite=False and there is already a shuffle with the given
+ index in the metadata file.
+ """
+ prefix = cfg["Task"] + cfg["date"]
+ metadata = TrainingDatasetMetadata.load(cfg, load_splits=True)
+ new_shuffle = ShuffleMetadata(
+ name=f"{prefix}-trainset{int(100 * train_fraction)}shuffle{shuffle}",
+ train_fraction=train_fraction,
+ index=shuffle,
+ engine=engine,
+ split=DataSplit(
+ train_indices=tuple(sorted([int(i) for i in train_indices])),
+ test_indices=tuple(sorted([int(i) for i in test_indices])),
+ ),
+ )
+ metadata = metadata.add(shuffle=new_shuffle, overwrite=overwrite)
+ metadata.save()
+
+
+def get_shuffle_engine(
+ cfg: dict,
+ trainingsetindex: int,
+ shuffle: int,
+ modelprefix: str = "",
+) -> Engine:
+ """
+ Args:
+ cfg: the config for the DeepLabCut project
+ trainingsetindex: the training set index used
+ shuffle: the shuffle for which to get the engine
+ modelprefix: the model prefix, if there is one
+
+ Returns:
+ the engine that the shuffle was created with
+
+ Raises:
+ ValueError if the engine for the shuffle cannot be determined or the shuffle
+ doesn't exist
+ """
+ if not TrainingDatasetMetadata.path(cfg).exists():
+ metadata = TrainingDatasetMetadata.create(cfg)
+ if metadata.shuffles:
+ # only persist when there is actual content to avoid writing empty files
+ metadata.save()
+ else:
+ metadata = TrainingDatasetMetadata.load(cfg)
+
+ # Try to resolve the shuffle from metadata; fall through to model-folder detection
+ # on failure so that inference works even when metadata is incomplete.
+ shuffle_metadata = None
+ try:
+ shuffle_metadata = metadata.get(trainingsetindex, shuffle)
+ except ValueError as e:
+ logging.warning(
+ "Could not read shuffle metadata for trainingsetindex=%s, shuffle=%s: %s. "
+ "Falling back to detecting the engine from model folders.",
+ trainingsetindex,
+ shuffle,
+ e,
+ )
+
+ if shuffle_metadata is not None:
+ return shuffle_metadata.engine
+
+ engines = find_engines_from_model_folders(cfg, trainingsetindex, shuffle, modelprefix)
+ if len(engines) == 0:
+ prefix_str = f" and modelprefix={modelprefix}" if modelprefix else ""
+ raise ValueError(
+ f"Couldn't find any shuffles with trainingsetindex={trainingsetindex}, "
+ f"shuffle={shuffle}{prefix_str}. The shuffle was not found "
+ "in metadata.yaml and no model folder exists for it. Please check that "
+ "such a shuffle is defined."
+ )
+
+ engine = list(engines)[0] # Get any engine from the set
+ if len(engines) > 1:
+ logging.warning(
+ f"Found multiple engines for trainingsetindex={trainingsetindex}, "
+ f"shuffle={shuffle} and modelprefix={modelprefix}. Using engine={engine}. "
+ f"To select another engine, please specify it in your API call."
+ )
+ return engine
+
+
+def find_engines_from_model_folders(
+ cfg: dict,
+ trainingsetindex: int,
+ shuffle: int,
+ modelprefix: str = "",
+) -> set[Engine]:
+ """Determines which engines are used with a given shuffle.
+
+ This method can be useful when using modelprefix, as the engine for a shuffle stored
+ under a "modelprefix" might not be the same as the base shuffle (for which the
+ engine is stored in the training-datasets folder).
+
+ Args:
+ cfg: the config for the DeepLabCut project
+ trainingsetindex: the training set index used
+ shuffle: the shuffle for which to get the engine
+ modelprefix: the model prefix, if there is one
+
+ Returns:
+ the engines for which a model folder exists for the given shuffle
+ """
+ project_path = Path(cfg["project_path"])
+ train_fraction = cfg["TrainingFraction"][trainingsetindex]
+
+ existing_engines = set()
+ for engine in Engine:
+ expected_model_folder = project_path / auxiliaryfunctions.get_model_folder(
+ trainFraction=train_fraction,
+ shuffle=shuffle,
+ cfg=cfg,
+ engine=engine,
+ modelprefix=modelprefix,
+ )
+ if expected_model_folder.exists():
+ existing_engines.add(engine)
+
+ return existing_engines
diff --git a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py
index 196eb216d9..19b0fedc30 100755
--- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py
+++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py
@@ -8,6 +8,7 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
import os
import os.path
@@ -19,19 +20,25 @@
import numpy as np
from tqdm import tqdm
-from deeplabcut.generate_training_dataset import (
- merge_annotateddatasets,
- read_image_shape_fast,
- SplitTrials,
- MakeTrain_pose_yaml,
- MakeTest_pose_yaml,
- MakeInference_yaml,
- pad_train_test_indices,
-)
+import deeplabcut.compat as compat
+import deeplabcut.generate_training_dataset.metadata as metadata
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.weight_init import WeightInitialization
from deeplabcut.utils import (
- auxiliaryfunctions,
auxfun_models,
auxfun_multianimal,
+ auxiliaryfunctions,
+)
+
+from .trainingsetmanipulation import (
+ MakeInference_yaml,
+ MakeTest_pose_yaml,
+ MakeTrain_pose_yaml,
+ SplitTrials,
+ merge_annotateddatasets,
+ pad_train_test_indices,
+ read_image_shape_fast,
+ validate_shuffles,
)
@@ -49,9 +56,7 @@ def format_multianimal_training_data(
n_individuals = individuals.unique().size
mask_single = individuals.str.contains("single")
n_animals = n_individuals - 1 if np.any(mask_single) else n_individuals
- array = np.full(
- (nrows, n_individuals, n_bodyparts, 3), fill_value=np.nan, dtype=np.float32
- )
+ array = np.full((nrows, n_individuals, n_bodyparts, 3), fill_value=np.nan, dtype=np.float32)
array[..., 0] = np.arange(n_bodyparts)
temp = df.to_numpy()
temp_multi = temp[:, ~mask_single].reshape((nrows, n_animals, -1, 2))
@@ -101,6 +106,7 @@ def create_multianimaltraining_dataset(
Shuffles=None,
windows2linux=False,
net_type=None,
+ detector_type=None,
numdigits=2,
crop_size=(400, 400),
crop_sampling="hybrid",
@@ -109,15 +115,19 @@ def create_multianimaltraining_dataset(
testIndices=None,
n_edges_threshold=105,
paf_graph_degree=6,
+ userfeedback: bool = True,
+ weight_init: WeightInitialization | None = None,
+ engine: Engine | None = None,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
):
- """
- Creates a training dataset for multi-animal datasets. Labels from all the extracted frames are merged into a single .h5 file.\n
- Only the videos included in the config file are used to create this dataset.\n
- [OPTIONAL] Use the function 'add_new_videos' at any stage of the project to add more videos to the project.
+ """Creates a training dataset for multi-animal datasets. Labels from all the
+ extracted frames are merged into a single .h5 file.\n Only the videos included in
+ the config file are used to create this dataset.\n [OPTIONAL] Use the function
+ 'add_new_videos' at any stage of the project to add more videos to the project.
Important differences to standard:
- stores coordinates with numdigits as many digits
- - creates
+
Parameter
----------
config : string
@@ -130,17 +140,74 @@ def create_multianimaltraining_dataset(
Alternatively the user can also give a list of shuffles (integers!).
net_type: string
- Type of networks. Currently resnet_50, resnet_101, and resnet_152, efficientnet-b0, efficientnet-b1, efficientnet-b2, efficientnet-b3,
- efficientnet-b4, efficientnet-b5, and efficientnet-b6 as well as dlcrnet_ms5 are supported (not the MobileNets!).
- See Lauer et al. 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1
+ Type of networks. The options available depend on which engine is used. See
+ Lauer et al. 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1
+ Currently supported options are:
+ TensorFlow
+ * ``resnet_50``
+ * ``resnet_101``
+ * ``resnet_152``
+ * ``efficientnet-b0``
+ * ``efficientnet-b1``
+ * ``efficientnet-b2``
+ * ``efficientnet-b3``
+ * ``efficientnet-b4``
+ * ``efficientnet-b5``
+ * ``efficientnet-b6``
+ PyTorch (call ``deeplabcut.pose_estimation_pytorch.available_models()`` for
+ a complete list)
+ * ``animaltokenpose_base``
+ * ``cspnext_m``
+ * ``cspnext_s``
+ * ``cspnext_x``
+ * ``ctd_coam_w32``
+ * ``ctd_coam_w48``
+ * ``ctd_prenet_hrnet_w32``
+ * ``ctd_prenet_hrnet_w48``
+ * ``ctd_prenet_rtmpose_m``
+ * ``ctd_prenet_rtmpose_x``
+ * ``ctd_prenet_rtmpose_x_human``
+ * ``dekr_w18``
+ * ``dekr_w32``
+ * ``dekr_w48``
+ * ``dlcrnet_stride16_ms5``
+ * ``dlcrnet_stride32_ms5``
+ * ``hrnet_w18``
+ * ``hrnet_w32``
+ * ``hrnet_w48``
+ * ``resnet_101``
+ * ``resnet_50``
+ * ``rtmpose_m``
+ * ``rtmpose_s``
+ * ``rtmpose_x``
+ * ``top_down_cspnext_m``
+ * ``top_down_cspnext_s``
+ * ``top_down_cspnext_x``
+ * ``top_down_hrnet_w18``
+ * ``top_down_hrnet_w32``
+ * ``top_down_hrnet_w48``
+ * ``top_down_resnet_101``
+ * ``top_down_resnet_50``
+
+ detector_type: string, optional, default=None
+ Only for the PyTorch engine.
+ When passing creating shuffles for top-down models, you can specify which
+ detector you want. If the detector_type is None, the ```ssdlite``` will be used.
+ The list of all available detectors can be obtained by calling
+ ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported options:
+ * ``ssdlite``
+ * ``fasterrcnn_mobilenet_v3_large_fpn``
+ * ``fasterrcnn_resnet50_fpn_v2``
numdigits: int, optional
crop_size: tuple of int, optional
+ Only for the TensorFlow engine.
Dimensions (width, height) of the crops for data augmentation.
Default is 400x400.
crop_sampling: str, optional
+ Only for the TensorFlow engine.
Crop centers sampling method. Must be either:
"uniform" (randomly over the image),
"keypoints" (randomly over the annotated keypoints),
@@ -149,6 +216,7 @@ def create_multianimaltraining_dataset(
Default is "hybrid".
paf_graph: list of lists, or "config" optional (default=None)
+ Only for the TensorFlow engine.
If not None, overwrite the default complete graph. This is useful for advanced users who
already know a good graph, or simply want to use a specific one. Note that, in that case,
the data-driven selection procedure upon model evaluation will be skipped.
@@ -163,16 +231,43 @@ def create_multianimaltraining_dataset(
List of one or multiple lists containing test indexes.
n_edges_threshold: int, optional (default=105)
+ Only for the TensorFlow engine.
Number of edges above which the graph is automatically pruned.
paf_graph_degree: int, optional (default=6)
+ Only for the TensorFlow engine.
Degree of paf_graph when automatically pruning it (before training).
+ userfeedback: bool, optional, default=True
+ If ``False``, all requested train/test splits are created (no matter if they
+ already exist). If you want to assure that previous splits etc. are not
+ overwritten, set this to ``True`` and you will be asked for each split.
+
+ weight_init: WeightInitialisation, optional, default=None
+ PyTorch engine only. Specify how model weights should be initialized. The
+ default mode uses transfer learning from ImageNet weights.
+
+ engine: Engine, optional
+ Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to
+ the value specified in the project configuration file. If no engine is specified
+ for the project, defaults to ``deeplabcut.compat.DEFAULT_ENGINE``.
+
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] , optional, default = None,
+ If using a conditional-top-down (CTD) net_type, this argument needs to be specified.
+ It defines the conditions that will be used with the CTD model.
+ It can be either:
+ * A shuffle number (ctd_conditions: int), which must correspond to a bottom-up (BU) network type.
+ * A predictions file path (ctd_conditions: string | Path), which must correspond to a .json or .h5
+ predictions file.
+ * A shuffle number and a particular snapshot (ctd_conditions: tuple[int, str] | tuple[int, int]), which
+ respectively correspond to a bottom-up (BU) network type and a particular snapshot name or index.
+
Example
--------
>>> deeplabcut.create_multianimaltraining_dataset('/analysis/project/reaching-task/config.yaml',num_shuffles=1)
- >>> deeplabcut.create_multianimaltraining_dataset('/analysis/project/reaching-task/config.yaml', Shuffles=[0,1,2], trainIndices=[trainInd1, trainInd2, trainInd3], testIndices=[testInd1, testInd2, testInd3])
+ >>> deeplabcut.create_multianimaltraining_dataset('/analysis/project/reaching-task/config.yaml', Shuffles=[0,1,2],
+ trainIndices=[trainInd1, trainInd2, trainInd3], testIndices=[testInd1, testInd2, testInd3])
Windows:
>>> deeplabcut.create_multianimaltraining_dataset(r'C:\\Users\\Ulf\\looming-task\\config.yaml',Shuffles=[3,17,5])
@@ -182,6 +277,7 @@ def create_multianimaltraining_dataset(
warnings.warn(
"`windows2linux` has no effect since 2.2.0.4 and will be removed in 2.2.1.",
FutureWarning,
+ stacklevel=2,
)
if len(crop_size) != 2 or not all(isinstance(v, int) for v in crop_size):
@@ -189,8 +285,7 @@ def create_multianimaltraining_dataset(
if crop_sampling not in ("uniform", "keypoints", "density", "hybrid"):
raise ValueError(
- f"Invalid sampling {crop_sampling}. Must be "
- f"either 'uniform', 'keypoints', 'density', or 'hybrid."
+ f"Invalid sampling {crop_sampling}. Must be either 'uniform', 'keypoints', 'density', or 'hybrid."
)
# Loading metadata from config file:
@@ -202,6 +297,11 @@ def create_multianimaltraining_dataset(
full_training_path = Path(project_path, trainingsetfolder)
auxiliaryfunctions.attempt_to_make_folder(full_training_path, recursive=True)
+ # Create the trainset metadata file, if it doesn't yet exist
+ if not metadata.TrainingDatasetMetadata.path(cfg).exists():
+ trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg)
+ trainset_metadata.save()
+
Data = merge_annotateddatasets(cfg, full_training_path)
if Data is None:
return
@@ -209,17 +309,22 @@ def create_multianimaltraining_dataset(
if net_type is None: # loading & linking pretrained models
net_type = cfg.get("default_net_type", "dlcrnet_ms5")
- elif not any(net in net_type for net in ("resnet", "eff", "dlc", "mob")):
- raise ValueError(f"Unsupported network {net_type}.")
+
+ # load the engine to use to create the shuffle
+ if engine is None:
+ engine = compat.get_project_engine(cfg)
+
+ if not (any(net in net_type for net in ("resnet", "eff", "dlc", "mob")) or engine == Engine.PYTORCH):
+ raise ValueError(f"Unsupported network {net_type} for engine {engine}.")
multi_stage = False
### dlcnet_ms5: backbone resnet50 + multi-fusion & multi-stage module
### dlcr101_ms5/dlcr152_ms5: backbone resnet101/152 + multi-fusion & multi-stage module
- if all(net in net_type for net in ("dlcr", "_ms5")):
+ if all(net in net_type for net in ("dlcr", "_ms5")) and engine != Engine.PYTORCH:
num_layers = re.findall("dlcr([0-9]*)", net_type)[0]
if num_layers == "":
num_layers = 50
- net_type = "resnet_{}".format(num_layers)
+ net_type = f"resnet_{num_layers}"
multi_stage = True
dataset_type = "multi-animal-imgaug"
@@ -231,9 +336,7 @@ def create_multianimaltraining_dataset(
if paf_graph is None: # Automatically form a complete PAF graph
n_bpts = len(multianimalbodyparts)
- partaffinityfield_graph = [
- list(edge) for edge in combinations(range(n_bpts), 2)
- ]
+ partaffinityfield_graph = [list(edge) for edge in combinations(range(n_bpts), 2)]
n_edges_orig = len(partaffinityfield_graph)
# If the graph is unnecessarily large (with 15+ keypoints by default),
# we randomly prune it to a size guaranteeing an average node degree of 6;
@@ -248,21 +351,14 @@ def create_multianimaltraining_dataset(
# Use the skeleton defined in the config file
skeleton = cfg["skeleton"]
paf_graph = [
- sorted(
- (multianimalbodyparts.index(bpt1), multianimalbodyparts.index(bpt2))
- )
- for bpt1, bpt2 in skeleton
+ sorted((multianimalbodyparts.index(bpt1), multianimalbodyparts.index(bpt2))) for bpt1, bpt2 in skeleton
]
- print(
- "Using `skeleton` from the config file as a paf_graph. Data-driven skeleton will not be computed."
- )
+ print("Using `skeleton` from the config file as a paf_graph. Data-driven skeleton will not be computed.")
# Ignore possible connections between 'multi' and 'unique' body parts;
# one can never be too careful...
to_ignore = auxfun_multianimal.filter_unwanted_paf_connections(cfg, paf_graph)
- partaffinityfield_graph = [
- edge for i, edge in enumerate(paf_graph) if i not in to_ignore
- ]
+ partaffinityfield_graph = [edge for i, edge in enumerate(paf_graph) if i not in to_ignore]
auxfun_multianimal.validate_paf_graph(cfg, partaffinityfield_graph)
print("Utilizing the following graph:", partaffinityfield_graph)
@@ -272,14 +368,13 @@ def create_multianimaltraining_dataset(
# Loading the encoder (if necessary downloading from TF)
dlcparent_path = auxiliaryfunctions.get_deeplabcut_path()
defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml")
- model_path = auxfun_models.check_for_weights(
- net_type, Path(dlcparent_path)
- )
- if Shuffles is None:
- Shuffles = range(1, num_shuffles + 1, 1)
+ if engine == Engine.PYTORCH:
+ model_path = dlcparent_path
else:
- Shuffles = [i for i in Shuffles if isinstance(i, int)]
+ model_path = auxfun_models.check_for_weights(net_type, Path(dlcparent_path))
+
+ Shuffles = validate_shuffles(cfg, Shuffles, num_shuffles, userfeedback)
# print(trainIndices,testIndices, Shuffles, augmenter_type,net_type)
if trainIndices is None and testIndices is None:
@@ -290,19 +385,11 @@ def create_multianimaltraining_dataset(
splits.append((train_frac, shuffle, (train_inds, test_inds)))
else:
if len(trainIndices) != len(testIndices) != len(Shuffles):
- raise ValueError(
- "Number of Shuffles and train and test indexes should be equal."
- )
+ raise ValueError("Number of Shuffles and train and test indexes should be equal.")
splits = []
- for shuffle, (train_inds, test_inds) in enumerate(
- zip(trainIndices, testIndices)
- ):
- trainFraction = round(
- len(train_inds) * 1.0 / (len(train_inds) + len(test_inds)), 2
- )
- print(
- f"You passed a split with the following fraction: {int(100 * trainFraction)}%"
- )
+ for shuffle, (train_inds, test_inds) in enumerate(zip(trainIndices, testIndices, strict=False)):
+ trainFraction = round(len(train_inds) * 1.0 / (len(train_inds) + len(test_inds)), 2)
+ print(f"You passed a split with the following fraction: {int(100 * trainFraction)}%")
# Now that the training fraction is guaranteed to be correct,
# the values added to pad the indices are removed.
train_inds = np.asarray(train_inds)
@@ -311,6 +398,11 @@ def create_multianimaltraining_dataset(
test_inds = test_inds[test_inds != -1]
splits.append((trainFraction, Shuffles[shuffle], (train_inds, test_inds)))
+ top_down = False
+ if engine == Engine.PYTORCH and net_type.startswith("top_down_"):
+ top_down = True
+ net_type = net_type[len("top_down_") :]
+
for trainFraction, shuffle, (trainIndices, testIndices) in splits:
####################################################
# Generating data structure with labeled information & frame metadata (for deep cut)
@@ -334,9 +426,7 @@ def create_multianimaltraining_dataset(
(
datafilename,
metadatafilename,
- ) = auxiliaryfunctions.get_data_and_metadata_filenames(
- trainingsetfolder, trainFraction, shuffle, cfg
- )
+ ) = auxiliaryfunctions.get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, cfg)
################################################################################
# Saving metadata and data file (Pickle file)
################################################################################
@@ -347,6 +437,15 @@ def create_multianimaltraining_dataset(
testIndices,
trainFraction,
)
+ metadata.update_metadata(
+ cfg=cfg,
+ train_fraction=trainFraction,
+ shuffle=shuffle,
+ engine=engine,
+ train_indices=trainIndices,
+ test_indices=testIndices,
+ overwrite=not userfeedback,
+ )
datafilename = datafilename.split(".mat")[0] + ".pickle"
import pickle
@@ -361,17 +460,14 @@ def create_multianimaltraining_dataset(
#################################################################################
modelfoldername = auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg
- )
- auxiliaryfunctions.attempt_to_make_folder(
- Path(config).parents[0] / modelfoldername, recursive=True
- )
- auxiliaryfunctions.attempt_to_make_folder(
- str(Path(config).parents[0] / modelfoldername / "train")
- )
- auxiliaryfunctions.attempt_to_make_folder(
- str(Path(config).parents[0] / modelfoldername / "test")
+ trainFraction,
+ shuffle,
+ cfg,
+ engine=engine,
)
+ auxiliaryfunctions.attempt_to_make_folder(Path(config).parents[0] / modelfoldername, recursive=True)
+ auxiliaryfunctions.attempt_to_make_folder(str(Path(config).parents[0] / modelfoldername / "train"))
+ auxiliaryfunctions.attempt_to_make_folder(str(Path(config).parents[0] / modelfoldername / "test"))
path_train_config = str(
os.path.join(
@@ -398,88 +494,123 @@ def create_multianimaltraining_dataset(
)
)
- jointnames = [str(bpt) for bpt in multianimalbodyparts]
- jointnames.extend([str(bpt) for bpt in uniquebodyparts])
- items2change = {
- "dataset": datafilename,
- "metadataset": metadatafilename,
- "num_joints": len(multianimalbodyparts)
- + len(uniquebodyparts), # cfg["uniquebodyparts"]),
- "all_joints": [
- [i] for i in range(len(multianimalbodyparts) + len(uniquebodyparts))
- ], # cfg["uniquebodyparts"]))],
- "all_joints_names": jointnames,
- "init_weights": model_path,
- "project_path": str(cfg["project_path"]),
- "net_type": net_type,
- "multi_stage": multi_stage,
- "pairwise_loss_weight": 0.1,
- "pafwidth": 20,
- "partaffinityfield_graph": partaffinityfield_graph,
- "partaffinityfield_predict": partaffinityfield_predict,
- "weigh_only_present_joints": False,
- "num_limbs": len(partaffinityfield_graph),
- "dataset_type": dataset_type,
- "optimizer": "adam",
- "batch_size": 8,
- "multi_step": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 200000]],
- "save_iters": 10000,
- "display_iters": 500,
- "num_idchannel": len(cfg["individuals"])
- if cfg.get("identity", False)
- else 0,
- "crop_size": list(crop_size),
- "crop_sampling": crop_sampling,
- }
+ if engine == Engine.TF:
+ jointnames = [str(bpt) for bpt in multianimalbodyparts]
+ jointnames.extend([str(bpt) for bpt in uniquebodyparts])
+ items2change = {
+ "dataset": datafilename,
+ "engine": engine.aliases[0],
+ "metadataset": metadatafilename,
+ "num_joints": len(multianimalbodyparts) + len(uniquebodyparts), # cfg["uniquebodyparts"]),
+ "all_joints": [
+ [i] for i in range(len(multianimalbodyparts) + len(uniquebodyparts))
+ ], # cfg["uniquebodyparts"]))],
+ "all_joints_names": jointnames,
+ "init_weights": str(model_path),
+ "project_path": str(cfg["project_path"]),
+ "net_type": net_type,
+ "multi_stage": multi_stage,
+ "pairwise_loss_weight": 0.1,
+ "pafwidth": 20,
+ "partaffinityfield_graph": partaffinityfield_graph,
+ "partaffinityfield_predict": partaffinityfield_predict,
+ "weigh_only_present_joints": False,
+ "num_limbs": len(partaffinityfield_graph),
+ "dataset_type": dataset_type,
+ "optimizer": "adam",
+ "batch_size": 8,
+ "multi_step": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 200000]],
+ "save_iters": 10000,
+ "display_iters": 500,
+ "num_idchannel": (len(cfg["individuals"]) if cfg.get("identity", False) else 0),
+ "crop_size": list(crop_size),
+ "crop_sampling": crop_sampling,
+ }
+
+ trainingdata = MakeTrain_pose_yaml(
+ items2change,
+ path_train_config,
+ defaultconfigfile,
+ save=(engine == Engine.TF),
+ )
+ keys2save = [
+ "dataset",
+ "num_joints",
+ "all_joints",
+ "all_joints_names",
+ "net_type",
+ "multi_stage",
+ "init_weights",
+ "global_scale",
+ "location_refinement",
+ "locref_stdev",
+ "dataset_type",
+ "partaffinityfield_predict",
+ "pairwise_predict",
+ "partaffinityfield_graph",
+ "num_limbs",
+ "dataset_type",
+ "num_idchannel",
+ ]
+
+ MakeTest_pose_yaml(
+ trainingdata,
+ keys2save,
+ path_test_config,
+ nmsradius=5.0,
+ minconfidence=0.01,
+ sigma=1,
+ locref_smooth=False,
+ ) # setting important def. values for inference
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ make_pytorch_pose_config,
+ make_pytorch_test_config,
+ )
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import (
+ make_super_animal_finetune_config,
+ )
- trainingdata = MakeTrain_pose_yaml(
- items2change, path_train_config, defaultconfigfile
- )
- keys2save = [
- "dataset",
- "num_joints",
- "all_joints",
- "all_joints_names",
- "net_type",
- "multi_stage",
- "init_weights",
- "global_scale",
- "location_refinement",
- "locref_stdev",
- "dataset_type",
- "partaffinityfield_predict",
- "pairwise_predict",
- "partaffinityfield_graph",
- "num_limbs",
- "dataset_type",
- "num_idchannel",
- ]
+ # backwards compatibility with version 2.X
+ if net_type == "dlcrnet_ms5":
+ net_type = "dlcrnet_stride16_ms5"
+
+ config_path = Path(path_train_config).with_name(engine.pose_cfg_name)
+ if weight_init is not None and weight_init.with_decoder:
+ pytorch_cfg = make_super_animal_finetune_config(
+ project_config=cfg,
+ pose_config_path=config_path,
+ model_name=net_type,
+ detector_name=detector_type,
+ weight_init=weight_init,
+ save=True,
+ )
+ else:
+ pytorch_cfg = make_pytorch_pose_config(
+ project_config=cfg,
+ pose_config_path=config_path,
+ net_type=net_type,
+ top_down=top_down,
+ detector_type=detector_type,
+ weight_init=weight_init,
+ save=True,
+ ctd_conditions=ctd_conditions,
+ )
- MakeTest_pose_yaml(
- trainingdata,
- keys2save,
- path_test_config,
- nmsradius=5.0,
- minconfidence=0.01,
- sigma=1,
- locref_smooth=False,
- ) # setting important def. values for inference
+ make_pytorch_test_config(pytorch_cfg, path_test_config, save=True)
# Setting inference cfg file:
- defaultinference_configfile = os.path.join(
- dlcparent_path, "inference_cfg.yaml"
- )
- items2change = {
- "minimalnumberofconnections": int(len(cfg["multianimalbodyparts"]) / 2),
- "topktoretain": len(cfg["individuals"]),
- "withid": cfg.get("identity", False),
- }
- MakeInference_yaml(
- items2change, path_inference_config, defaultinference_configfile
+ default_inf_path = Path(dlcparent_path) / "inference_cfg.yaml"
+ inf_updates = dict(
+ minimalnumberofconnections=int(len(cfg["multianimalbodyparts"]) / 2),
+ topktoretain=len(cfg["individuals"]),
+ withid=cfg.get("identity", False),
)
+ MakeInference_yaml(inf_updates, path_inference_config, default_inf_path)
print(
- "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!"
+ "The training dataset is successfully created. Use the function "
+ "'train_network' to start training. Happy training!"
)
else:
pass
@@ -491,20 +622,20 @@ def convert_cropped_to_standard_dataset(
delete_crops=True,
back_up=True,
):
- import pandas as pd
import pickle
import shutil
- from deeplabcut.generate_training_dataset import trainingsetmanipulation
+
+ import pandas as pd
+
from deeplabcut.utils import read_plainconfig, write_config
+ from . import trainingsetmanipulation
+
cfg = auxiliaryfunctions.read_config(config_path)
videos_orig = cfg.pop("video_sets_original")
is_cropped = cfg.pop("croppedtraining")
if videos_orig is None or not is_cropped:
- print(
- "Labeled data do not appear to be cropped. "
- "Project will remain unchanged..."
- )
+ print("Labeled data do not appear to be cropped. Project will remain unchanged...")
return
project_path = cfg["project_path"]
@@ -542,9 +673,7 @@ def strip_cropped_image_name(path):
file = file.split("c")[0]
return os.path.join(head, file + "." + ext)
- img_names_old = np.asarray(
- [strip_cropped_image_name(img) for img in df_old.index.to_list()]
- )
+ img_names_old = np.asarray([strip_cropped_image_name(img) for img in df_old.index.to_list()])
df = merge_annotateddatasets(cfg, datasets_folder)
img_names = df.index.to_numpy()
train_idx = []
@@ -557,15 +686,9 @@ def strip_cropped_image_name(path):
if filename.startswith("Docu"):
with open(pickle_file, "rb") as f:
_, train_inds, test_inds, train_frac = pickle.load(f)
- train_inds_temp = np.flatnonzero(
- np.isin(img_names, img_names_old[train_inds])
- )
- test_inds_temp = np.flatnonzero(
- np.isin(img_names, img_names_old[test_inds])
- )
- train_inds, test_inds = pad_train_test_indices(
- train_inds_temp, test_inds_temp, train_frac
- )
+ train_inds_temp = np.flatnonzero(np.isin(img_names, img_names_old[train_inds]))
+ test_inds_temp = np.flatnonzero(np.isin(img_names, img_names_old[test_inds]))
+ train_inds, test_inds = pad_train_test_indices(train_inds_temp, test_inds_temp, train_frac)
train_idx.append(train_inds)
test_idx.append(test_inds)
diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
index 6d237c27b3..e6fa7c913f 100755
--- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
+++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
@@ -8,51 +8,47 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
-import math
import logging
+import math
import os
import os.path
import warnings
-
-from functools import lru_cache
+from functools import cache
from pathlib import Path
-from PIL import Image
import numpy as np
import pandas as pd
import yaml
+from PIL import Image
-from deeplabcut.pose_estimation_tensorflow import training
+import deeplabcut.compat as compat
+import deeplabcut.generate_training_dataset.metadata as metadata
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.weight_init import WeightInitialization
from deeplabcut.utils import (
- auxiliaryfunctions,
- conversioncode,
auxfun_models,
auxfun_multianimal,
+ auxiliaryfunctions,
+ conversioncode,
)
from deeplabcut.utils.auxfun_videos import VideoReader
-from deeplabcut.pose_estimation_tensorflow.config import load_config
-from deeplabcut.modelzoo.utils import parse_available_supermodels
def comparevideolistsanddatafolders(config):
- """
- Auxiliary function that compares the folders in labeled-data and the ones listed under video_sets (in the config file).
+ """Auxiliary function that compares the folders in labeled-data and the ones listed
+ under video_sets (in the config file).
Parameter
----------
config : string
String containing the full path of the config file in the project.
-
"""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
video_names = [Path(i).stem for i in videos]
- alldatafolders = [
- fn
- for fn in os.listdir(Path(config).parent / "labeled-data")
- if "_labeled" not in fn
- ]
+ alldatafolders = [fn for fn in os.listdir(Path(config).parent / "labeled-data") if "_labeled" not in fn]
print("Config file contains:", len(video_names))
print("Labeled-data contains:", len(alldatafolders))
@@ -67,14 +63,16 @@ def comparevideolistsanddatafolders(config):
def adddatasetstovideolistandviceversa(config):
- """
- First run comparevideolistsanddatafolders(config) to compare the folders in labeled-data and the ones listed under video_sets (in the config file).
- If you detect differences this function can be used to maker sure each folder has a video entry & vice versa.
+ """First run comparevideolistsanddatafolders(config) to compare the folders in
+ labeled-data and the ones listed under video_sets (in the config file). If you
+ detect differences this function can be used to maker sure each folder has a video
+ entry & vice versa.
It corrects this problem in the following way:
If a video entry in the config file does not contain a folder in labeled-data, then the entry is removed.
- If a folder in labeled-data does not contain a video entry in the config file then the prefix path will be added in front of the name of the labeled-data folder and combined
+ If a folder in labeled-data does not contain a video entry in the config file then
+ the prefix path will be added in front of the name of the labeled-data folder and combined
with the suffix variable as an ending. Width and height will be added as cropping variables as passed on.
Handle with care!
@@ -89,9 +87,7 @@ def adddatasetstovideolistandviceversa(config):
video_names = [Path(i).stem for i in videos]
alldatafolders = [
- fn
- for fn in os.listdir(Path(config).parent / "labeled-data")
- if "_labeled" not in fn and not fn.startswith(".")
+ fn for fn in os.listdir(Path(config).parent / "labeled-data") if "_labeled" not in fn and not fn.startswith(".")
]
print("Config file contains:", len(video_names))
@@ -122,23 +118,19 @@ def adddatasetstovideolistandviceversa(config):
if found:
video_path = os.path.join(cfg["project_path"], "videos", file)
clip = VideoReader(video_path)
- videos.update(
- {video_path: {"crop": ", ".join(map(str, clip.get_bbox()))}}
- )
+ videos.update({video_path: {"crop": ", ".join(map(str, clip.get_bbox()))}})
auxiliaryfunctions.write_config(config, cfg)
def dropduplicatesinannotatinfiles(config):
- """
-
- Drop duplicate entries (of images) in annotation files (this should no longer happen, but might be useful).
+ """Drop duplicate entries (of images) in annotation files (this should no longer
+ happen, but might be useful).
Parameter
----------
config : string
String containing the full path of the config file in the project.
-
"""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
@@ -154,24 +146,21 @@ def dropduplicatesinannotatinfiles(config):
if len(DC.index) < numimages:
print("Dropped", numimages - len(DC.index))
DC.to_hdf(fn, key="df_with_missing", mode="w")
- DC.to_csv(
- os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv")
- )
+ DC.to_csv(os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv"))
except FileNotFoundError:
print("Attention:", folder, "does not appear to have labeled data!")
def dropannotationfileentriesduetodeletedimages(config):
- """
- Drop entries for all deleted images in annotation files, i.e. for folders of the type: /labeled-data/*folder*/CollectedData_*scorer*.h5
- Will be carried out iteratively for all *folders* in labeled-data.
+ """Drop entries for all deleted images in annotation files, i.e. for folders of the
+ type: /labeled-data/*folder*/CollectedData_*scorer*.h5 Will be carried out
+ iteratively for all *folders* in labeled-data.
Parameter
----------
config : string
String containing the full path of the config file in the project.
-
"""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
@@ -193,11 +182,9 @@ def dropannotationfileentriesduetodeletedimages(config):
print("Dropping...", imagename)
DC = DC.drop(imagename)
dropped = True
- if dropped == True:
+ if dropped:
DC.to_hdf(fn, key="df_with_missing", mode="w")
- DC.to_csv(
- os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv")
- )
+ DC.to_csv(os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv"))
def dropimagesduetolackofannotation(config):
@@ -222,6 +209,7 @@ def dropimagesduetolackofannotation(config):
except FileNotFoundError:
print("Attention:", folder, "does not appear to have labeled data!")
continue
+ conversioncode.guarantee_multiindex_rows(DC)
annotatedimages = [fn[-1] for fn in DC.index]
imagelist = [fns for fns in os.listdir(str(folder)) if ".png" in fns]
print("Annotated images: ", len(annotatedimages), " In folder:", len(imagelist))
@@ -229,9 +217,7 @@ def dropimagesduetolackofannotation(config):
if imagename in annotatedimages:
pass
else:
- fullpath = os.path.join(
- cfg["project_path"], "labeled-data", folder, imagename
- )
+ fullpath = os.path.join(cfg["project_path"], "labeled-data", folder, imagename)
if os.path.isfile(fullpath):
print("Deleting", fullpath)
os.remove(fullpath)
@@ -249,15 +235,14 @@ def dropimagesduetolackofannotation(config):
def dropunlabeledframes(config):
- """
- Drop entries such that all the bodyparts are not labeled from the annotation files, i.e. h5 and csv files
- Will be carried out iteratively for all *folders* in labeled-data.
+ """Drop entries such that all the bodyparts are not labeled from the annotation
+ files, i.e. h5 and csv files Will be carried out iteratively for all *folders* in
+ labeled-data.
Parameter
----------
config : string
String containing the full path of the config file in the project.
-
"""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
@@ -277,9 +262,7 @@ def dropunlabeledframes(config):
dropped = before_len - after_len
if dropped:
DC.to_hdf(h5file, key="df_with_missing", mode="w")
- DC.to_csv(
- os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv")
- )
+ DC.to_csv(os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv"))
print("Dropped ", dropped, "entries in ", folder)
@@ -288,7 +271,7 @@ def dropunlabeledframes(config):
def check_labels(
config,
- Labels=["+", ".", "x"],
+ Labels=None,
scale=1,
dpi=100,
draw_skeleton=True,
@@ -338,20 +321,17 @@ def check_labels(
from deeplabcut.utils import visualization
+ if Labels is None:
+ Labels = ["+", ".", "x"]
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
video_names = [_robust_path_split(video)[1] for video in videos]
- folders = [
- os.path.join(cfg["project_path"], "labeled-data", str(Path(i)))
- for i in video_names
- ]
- print("Creating images with labels by %s." % cfg["scorer"])
+ folders = [os.path.join(cfg["project_path"], "labeled-data", str(Path(i))) for i in video_names]
+ print("Creating images with labels by {}.".format(cfg["scorer"]))
for folder in folders:
try:
- DataCombined = pd.read_hdf(
- os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
- )
+ DataCombined = pd.read_hdf(os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5"))
conversioncode.guarantee_multiindex_rows(DataCombined)
if cfg.get("multianimalproject", False):
color_by = "individual" if visualizeindividuals else "bodypart"
@@ -371,9 +351,7 @@ def check_labels(
except FileNotFoundError:
print("Attention:", folder, "does not appear to have labeled data!")
- print(
- "If all the labels are ok, then use the function 'create_training_dataset' to create the training dataset!"
- )
+ print("If all the labels are ok, then use the function 'create_training_dataset' to create the training dataset!")
def boxitintoacell(joints):
@@ -395,19 +373,26 @@ def ParseYaml(configfile):
def MakeTrain_pose_yaml(
- itemstochange, saveasconfigfile, defaultconfigfile, items2drop={}
+ itemstochange,
+ saveasconfigfile,
+ defaultconfigfile,
+ items2drop: dict | None = None,
+ save: bool = True,
):
+ if items2drop is None:
+ items2drop = {}
+
docs = ParseYaml(defaultconfigfile)
for key in items2drop.keys():
- # print(key, "dropping?")
if key in docs[0].keys():
docs[0].pop(key)
for key in itemstochange.keys():
docs[0][key] = itemstochange[key]
- with open(saveasconfigfile, "w") as f:
- yaml.dump(docs[0], f)
+ if save:
+ with open(saveasconfigfile, "w") as f:
+ yaml.dump(docs[0], f)
return docs[0]
@@ -459,33 +444,104 @@ def _robust_path_split(path):
elif len(splits) == 2:
parent, file = splits
else:
- raise ("Unknown filepath split for path {}".format(path))
+ raise (f"Unknown filepath split for path {path}")
filename, ext = os.path.splitext(file)
return parent, filename, ext
-def merge_annotateddatasets(cfg, trainingsetfolder_full):
+def parse_video_filenames(videos: list[str]) -> list[str]:
+ """Parses the names of all videos listed in a project's ``config.yaml`` file.
+
+ Goes through the paths all videos listed for a project, and removes entries with a
+ duplicate video name (e.g. if a video is listed twice, once with the path
+ ``/data/video-1.mov`` and once with the path ``/my-dlc-project/videos/video-1.mov``,
+ then ``video-1`` will only be returned once). The order of videos listed is
+ preserved.
+
+ This prevents the same labeled-data to be added multiple times when merging
+ annotated datasets.
+
+ Prints a warning for each filename with duplicate video paths.
+
+ Args:
+ videos: the videos listed in the project's config.yaml file
+
+ Returns:
+ the filenames of videos listed in the project's config.yaml file, with duplicate
+ entries removed
"""
- Merges all the h5 files for all labeled-datasets (from individual videos).
+ filenames = []
+ filename_to_videos = {}
+ for video in videos:
+ _, filename, _ = _robust_path_split(video)
+ videos_with_filename = filename_to_videos.get(filename, [])
+ if len(videos_with_filename) == 0:
+ filenames.append(filename)
+
+ videos_with_filename.append(video)
+ filename_to_videos[filename] = videos_with_filename
+
+ for filename, videos in filename_to_videos.items():
+ if len(videos) > 1:
+ video_str = "\n * " + "\n * ".join(videos)
+ logging.warning(
+ f"Found multiple videos with the same filename (``{filename}``). To "
+ f"avoid issues, please edit your project's `config.yaml` file to have "
+ f"each video added only once.\nDuplicate entries: {video_str}"
+ )
+
+ return filenames
+
+
+def drop_likelihood_columns(df: pd.DataFrame) -> pd.DataFrame:
+ """Drop any columns whose coord level is named 'likelihood'.
+
+ This sanitizes annotation DataFrames coming from h5/csv files before they are
+ used for training dataset generation.
+
+ # NOTE @C-Achard 2026-05-18: This is used in several places as a guard
+ Most call sites using this should instead go through a canonical, validated project loading function
+ AND THEN do any custom local processing they require. The current design is hard to maintain and error prone,
+ and lacks a clearly documented, centralized project I/O interface.
+ """
+ if not isinstance(df.columns, pd.MultiIndex):
+ return df
+
+ coord_level = "coords" if "coords" in df.columns.names else df.columns.names[-1]
+ coord_values = df.columns.get_level_values(coord_level)
+
+ likelihood_mask = coord_values == "likelihood"
+ if likelihood_mask.any():
+ logging.warning("Detected likelihood columns in annotation data; dropping them.", stacklevel=2)
+ df = df.drop(columns=df.columns[likelihood_mask])
+
+ return df
+
+
+def merge_annotateddatasets(cfg, trainingsetfolder_full):
+ """Merges all the h5 files for all labeled-datasets (from individual videos).
This is a bit of a mess because of cross platform compatibility.
- Within platform comp. is straightforward. But if someone labels on windows and wants to train on a unix cluster or colab...
+ Within platform comp. is straightforward.
+ But if someone labels on windows and wants to train on a unix cluster or colab...
"""
AnnotationData = []
data_path = Path(os.path.join(cfg["project_path"], "labeled-data"))
videos = cfg["video_sets"].keys()
- for video in videos:
- _, filename, _ = _robust_path_split(video)
- file_path = os.path.join(
- data_path / filename, f'CollectedData_{cfg["scorer"]}.h5'
- )
+ video_filenames = parse_video_filenames(videos)
+ for filename in video_filenames:
+ file_path = os.path.join(data_path / filename, f"CollectedData_{cfg['scorer']}.h5")
try:
data = pd.read_hdf(file_path)
conversioncode.guarantee_multiindex_rows(data)
if data.columns.levels[0][0] != cfg["scorer"]:
print(
- f"{file_path} labeled by a different scorer. This data will not be utilized in training dataset creation. If you need to merge datasets across scorers, see https://github.com/DeepLabCut/DeepLabCut/wiki/Using-labeled-data-in-DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)"
+ f"{file_path} labeled by a different scorer. "
+ "This data will not be utilized in training dataset creation."
+ "If you need to merge datasets across scorers, see "
+ "https://github.com/DeepLabCut/DeepLabCut/wiki/Using-labeled-data-in\
+ -DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)"
)
continue
AnnotationData.append(data)
@@ -494,7 +550,8 @@ def merge_annotateddatasets(cfg, trainingsetfolder_full):
if not len(AnnotationData):
print(
- "Annotation data was not found by splitting video paths (from config['video_sets']). An alternative route is taken..."
+ "Annotation data was not found by splitting video paths (from config['video_sets']). "
+ "An alternative route is taken..."
)
AnnotationData = conversioncode.merge_windowsannotationdataONlinuxsystem(cfg)
if not len(AnnotationData):
@@ -514,10 +571,18 @@ def merge_annotateddatasets(cfg, trainingsetfolder_full):
bodyparts = multianimalbodyparts + uniquebodyparts
else:
bodyparts = cfg["bodyparts"]
- AnnotationData = AnnotationData.reindex(
- bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts")
- )
- filename = os.path.join(trainingsetfolder_full, f'CollectedData_{cfg["scorer"]}')
+ AnnotationData = AnnotationData.reindex(bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts"))
+ # Filter out any stray likelihood columns that may have been concatenated in
+ # see napari-deeplabcut #204 and DeepLabCut #3319
+ AnnotationData = drop_likelihood_columns(AnnotationData)
+
+ if AnnotationData.empty:
+ logging.warning(
+ "The annotated dataframe is empty after reindexing using config. "
+ "Hint: are bodyparts correctly listed in the configuration?"
+ )
+
+ filename = os.path.join(trainingsetfolder_full, f"CollectedData_{cfg['scorer']}")
AnnotationData.to_hdf(filename + ".h5", key="df_with_missing", mode="w")
AnnotationData.to_csv(filename + ".csv") # human readable.
return AnnotationData
@@ -528,10 +593,12 @@ def SplitTrials(
trainFraction=0.8,
enforce_train_fraction=False,
):
- """Split a trial index into train and test sets. Also checks that the trainFraction is a two digit number between 0 an 1. The reason
- is that the folders contain the trainfraction as int(100*trainFraction).
- If enforce_train_fraction is True, train and test indices are padded with -1
- such that the ratio of their lengths is exactly the desired train fraction.
+ """Split a trial index into train and test sets.
+
+ Also checks that the trainFraction is a two digit number between 0 an 1. The reason
+ is that the folders contain the trainfraction as int(100*trainFraction). If
+ enforce_train_fraction is True, train and test indices are padded with -1 such that
+ the ratio of their lengths is exactly the desired train fraction.
"""
if trainFraction > 1 or trainFraction < 0:
print(
@@ -586,13 +653,14 @@ def pad_train_test_indices(train_inds, test_inds, train_fraction):
def mergeandsplit(config, trainindex=0, uniform=True):
- """
- This function allows additional control over "create_training_dataset".
+ """This function allows additional control over "create_training_dataset".
- Merge annotated data sets (from different folders) and split data in a specific way, returns the split variables (train/test indices).
+ Merge annotated data sets (from different folders) and split data in a specific way,
+ returns the split variables (train/test indices).
Importantly, this allows one to freeze a split.
- One can also either create a uniform split (uniform = True; thereby indexing TrainingFraction in config file) or leave-one-folder out split
+ One can also either create a uniform split (uniform = True; thereby indexing TrainingFraction in config file)
+ or leave-one-folder out split
by passing the index of the corresponding video from the config.yaml file as variable trainindex.
Parameter
@@ -601,8 +669,10 @@ def mergeandsplit(config, trainindex=0, uniform=True):
Full path of the config.yaml file as a string.
trainindex: int, optional
- Either (in case uniform = True) indexes which element of TrainingFraction in the config file should be used (note it is a list!).
- Alternatively (uniform = False) indexes which folder is dropped, i.e. the first if trainindex=0, the second if trainindex =1, etc.
+ Either (in case uniform = True) indexes which element of TrainingFraction
+ in the config file should be used (note it is a list!).
+ Alternatively (uniform = False) indexes which folder is dropped,
+ i.e. the first if trainindex=0, the second if trainindex =1, etc.
uniform: bool, optional
Perform uniform split (disregarding folder structure in labeled data), or (if False) leave one folder out.
@@ -611,49 +681,49 @@ def mergeandsplit(config, trainindex=0, uniform=True):
--------
To create a leave-one-folder-out model:
>>> trainIndices, testIndices=deeplabcut.mergeandsplit(config,trainindex=0,uniform=False)
- returns the indices for the first video folder (as defined in config file) as testIndices and all others as trainIndices.
+ returns the indices for the first video folder (as defined in config file)
+ as testIndices and all others as trainIndices.
You can then create the training set by calling (e.g. defining it as Shuffle 3):
>>> deeplabcut.create_training_dataset(config,Shuffles=[3],trainIndices=trainIndices,testIndices=testIndices)
To freeze a (uniform) split (i.e. iid sampled from all the data):
>>> trainIndices, testIndices=deeplabcut.mergeandsplit(config,trainindex=0,uniform=True)
- You can then create two model instances that have the identical trainingset. Thereby you can assess the role of various parameters on the performance of DLC.
- >>> deeplabcut.create_training_dataset(config,Shuffles=[0,1],trainIndices=[trainIndices, trainIndices],testIndices=[testIndices, testIndices])
+ You can then create two model instances that have the identical trainingset.
+ Thereby you can assess the role of various parameters on the performance of DLC.
+ >>> deeplabcut.create_training_dataset(
+ ... config,Shuffles=[0,1],trainIndices=[trainIndices, trainIndices],
+ ... testIndices=[testIndices, testIndices])
--------
-
"""
# Loading metadata from config file:
cfg = auxiliaryfunctions.read_config(config)
scorer = cfg["scorer"]
project_path = cfg["project_path"]
# Create path for training sets & store data there
- trainingsetfolder = auxiliaryfunctions.get_training_set_folder(
- cfg
- ) # Path concatenation OS platform independent
- auxiliaryfunctions.attempt_to_make_folder(
- Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True
- )
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg) # Path concatenation OS platform independent
+ auxiliaryfunctions.attempt_to_make_folder(Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True)
fn = os.path.join(project_path, trainingsetfolder, "CollectedData_" + cfg["scorer"])
try:
- Data = pd.read_hdf(fn + ".h5")
+ data = pd.read_hdf(fn + ".h5")
+ data = drop_likelihood_columns(data)
except FileNotFoundError:
- Data = merge_annotateddatasets(
+ data = merge_annotateddatasets(
cfg,
Path(os.path.join(project_path, trainingsetfolder)),
)
- if Data is None:
+ if data is None:
return [], []
- conversioncode.guarantee_multiindex_rows(Data)
- Data = Data[scorer] # extract labeled data
+ conversioncode.guarantee_multiindex_rows(data)
+ data = data[scorer] # extract labeled data
- if uniform == True:
+ if uniform:
TrainingFraction = cfg["TrainingFraction"]
trainFraction = TrainingFraction[trainindex]
trainIndices, testIndices = SplitTrials(
- range(len(Data.index)),
+ range(len(data.index)),
trainFraction,
True,
)
@@ -662,7 +732,7 @@ def mergeandsplit(config, trainindex=0, uniform=True):
test_video_name = [Path(i).stem for i in videos][trainindex]
print("Excluding the following folder (from training):", test_video_name)
trainIndices, testIndices = [], []
- for index, name in enumerate(Data.index):
+ for index, name in enumerate(data.index):
if test_video_name == name[1]: # this is the video name
# print(name,test_video_name)
testIndices.append(index)
@@ -672,7 +742,7 @@ def mergeandsplit(config, trainindex=0, uniform=True):
return trainIndices, testIndices
-@lru_cache(maxsize=None)
+@cache
def read_image_shape_fast(path):
# Blazing fast and does not load the image into memory
with Image.open(path) as img:
@@ -689,23 +759,49 @@ def to_matlab_cell(array):
outer[0, 0] = array.astype("int64")
return outer
+ # Again, remove likelihood if present
+ df = drop_likelihood_columns(df)
+
+ if isinstance(df.columns, pd.MultiIndex):
+ coord_level = "coords" if "coords" in df.columns.names else df.columns.names[-1]
+ coord_values = df.columns.get_level_values(coord_level)
+
+ has_x = "x" in coord_values
+ has_y = "y" in coord_values
+
+ if not (has_x and has_y):
+ raise ValueError(
+ f"Training data must contain x/y coordinates. Found coordinate labels: {list(pd.unique(coord_values))}"
+ )
+
for i in train_inds:
data = dict()
filename = df.index[i]
data["image"] = filename
img_shape = read_image_shape_fast(os.path.join(project_path, *filename))
data["size"] = img_shape
- temp = df.iloc[i].values.reshape(-1, 2)
+
+ row = df.iloc[i].values
+
+ if row.size % 2 != 0:
+ raise ValueError(
+ "Training data row does not contain an even number of coordinate values "
+ f"after dropping non-coordinate columns. Row size={row.size}, "
+ f"image={filename}"
+ )
+
+ temp = row.reshape(-1, 2)
joints = np.c_[range(nbodyparts), temp]
joints = joints[~np.isnan(joints).any(axis=1)].astype(int)
- # Check that points lie within the image
+
inside = np.logical_and(
np.logical_and(joints[:, 1] < img_shape[2], joints[:, 1] > 0),
np.logical_and(joints[:, 2] < img_shape[1], joints[:, 2] > 0),
)
if not all(inside):
joints = joints[inside]
- if joints.size: # Exclude images without labels
+
+ if joints.size:
data["joints"] = joints
train_data.append(data)
matlab_data.append(
@@ -715,8 +811,10 @@ def to_matlab_cell(array):
to_matlab_cell(data["joints"]),
)
)
+
matlab_data = np.asarray(
- matlab_data, dtype=[("image", "O"), ("size", "O"), ("joints", "O")]
+ matlab_data,
+ dtype=[("image", "O"), ("size", "O"), ("joints", "O")],
)
return train_data, matlab_data
@@ -726,13 +824,17 @@ def create_training_dataset(
num_shuffles=1,
Shuffles=None,
windows2linux=False,
- userfeedback=False,
+ userfeedback=True,
trainIndices=None,
testIndices=None,
net_type=None,
+ detector_type=None,
augmenter_type=None,
posecfg_template=None,
superanimal_name="",
+ weight_init: WeightInitialization | None = None,
+ engine: Engine | None = None,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
):
"""Creates a training dataset.
@@ -751,7 +853,7 @@ def create_training_dataset(
Shuffles: list[int], optional
Alternatively the user can also give a list of shuffles.
- userfeedback: bool, optional, default=False
+ userfeedback: bool, optional, default=True
If ``False``, all requested train/test splits are created (no matter if they
already exist). If you want to assure that previous splits etc. are not
overwritten, set this to ``True`` and you will be asked for each split.
@@ -764,41 +866,117 @@ def create_training_dataset(
List of one or multiple lists containing test indexes.
net_type: list, optional, default=None
- Type of networks. Currently supported options are
-
- * ``resnet_50``
- * ``resnet_101``
- * ``resnet_152``
- * ``mobilenet_v2_1.0``
- * ``mobilenet_v2_0.75``
- * ``mobilenet_v2_0.5``
- * ``mobilenet_v2_0.35``
- * ``efficientnet-b0``
- * ``efficientnet-b1``
- * ``efficientnet-b2``
- * ``efficientnet-b3``
- * ``efficientnet-b4``
- * ``efficientnet-b5``
- * ``efficientnet-b6``
+ Type of networks. The options available depend on which engine is used.
+ Currently supported options are:
+ TensorFlow
+ * ``resnet_50``
+ * ``resnet_101``
+ * ``resnet_152``
+ * ``mobilenet_v2_1.0``
+ * ``mobilenet_v2_0.75``
+ * ``mobilenet_v2_0.5``
+ * ``mobilenet_v2_0.35``
+ * ``efficientnet-b0``
+ * ``efficientnet-b1``
+ * ``efficientnet-b2``
+ * ``efficientnet-b3``
+ * ``efficientnet-b4``
+ * ``efficientnet-b5``
+ * ``efficientnet-b6``
+ PyTorch (call ``deeplabcut.pose_estimation_pytorch.available_models()`` for
+ a complete list)
+ * ``animaltokenpose_base``
+ * ``cspnext_m``
+ * ``cspnext_s``
+ * ``cspnext_x``
+ * ``ctd_coam_w32``
+ * ``ctd_coam_w48``
+ * ``ctd_prenet_cspnext_m``
+ * ``ctd_prenet_cspnext_x``
+ * ``ctd_prenet_rtmpose_x_human``
+ * ``ctd_prenet_hrnet_w32``
+ * ``ctd_prenet_hrnet_w48``
+ * ``ctd_prenet_rtmpose_m``
+ * ``ctd_prenet_rtmpose_x``
+ * ``ctd_prenet_rtmpose_x_human``
+ * ``dekr_w18``
+ * ``dekr_w32``
+ * ``dekr_w48``
+ * ``dlcrnet_stride16_ms5``
+ * ``dlcrnet_stride32_ms5``
+ * ``hrnet_w18``
+ * ``hrnet_w32``
+ * ``hrnet_w48``
+ * ``resnet_101``
+ * ``resnet_50``
+ * ``rtmpose_m``
+ * ``rtmpose_s``
+ * ``rtmpose_x``
+ * ``top_down_cspnext_m``
+ * ``top_down_cspnext_s``
+ * ``top_down_cspnext_x``
+ * ``top_down_hrnet_w18``
+ * ``top_down_hrnet_w32``
+ * ``top_down_hrnet_w48``
+ * ``top_down_resnet_101``
+ * ``top_down_resnet_50``
+
+ detector_type: string, optional, default=None
+ Only for the PyTorch engine.
+ When passing creating shuffles for top-down models, you can specify which
+ detector you want. If the detector_type is None, the ```ssdlite``` will be used.
+ The list of all available detectors can be obtained by calling
+ ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported options:
+ * ``ssdlite``
+ * ``fasterrcnn_mobilenet_v3_large_fpn``
+ * ``fasterrcnn_resnet50_fpn_v2``
augmenter_type: string, optional, default=None
- Type of augmenter. Currently supported augmenters are
-
- * ``default``
- * ``scalecrop``
- * ``imgaug``
- * ``tensorpack``
- * ``deterministic``
+ Type of augmenter. The options available depend on which engine is used.
+ Currently supported options are:
+ TensorFlow
+ * ``default``
+ * ``scalecrop``
+ * ``imgaug``
+ * ``tensorpack``
+ * ``deterministic``
+ PyTorch
+ * ``albumentations``
posecfg_template: string, optional, default=None
+ Only for the TensorFlow engine.
Path to a ``pose_cfg.yaml`` file to use as a template for generating the new
one for the current iteration. Useful if you would like to start with the same
parameters a previous training iteration. None uses the default
``pose_cfg.yaml``.
superanimal_name: string, optional, default=""
- Specify the superanimal name is transfer learning with superanimal is desired. This makes sure the pose config template uses superanimal configs as template
-
+ Only for the TensorFlow engine. For the PyTorch engine, use the ``weight_init``
+ parameter.
+ Specify the superanimal name is transfer learning with superanimal is desired.
+ This makes sure the pose config template uses superanimal configs as template.
+
+ weight_init: WeightInitialisation, optional, default=None
+ PyTorch engine only. Specify how model weights should be initialized. The
+ default mode uses transfer learning from ImageNet weights.
+
+ engine: Engine, optional
+ Whether to create a pose config for a Tensorflow or PyTorch model. Defaults to
+ the value specified in the project configuration file. If no engine is specified
+ for the project, defaults to ``deeplabcut.compat.DEFAULT_ENGINE``.
+
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None, default = None,
+ If using a conditional-top-down (CTD) net_type, this argument should be
+ specified. It defines the conditions that will be used with the CTD model.
+ It can be either:
+ * A shuffle number (ctd_conditions: int), which must correspond to a
+ bottom-up (BU) network type.
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file.
+ * A shuffle number and a particular snapshot
+ (ctd_conditions: tuple[int, str] | tuple[int, int]), which respectively
+ correspond to a bottom-up (BU) network type and a particular snapshot
+ name or index.
Returns
-------
@@ -818,14 +996,16 @@ def create_training_dataset(
Examples
--------
- Linux/MacOS
-
+ Linux/MacOS:
>>> deeplabcut.create_training_dataset(
'/analysis/project/reaching-task/config.yaml', num_shuffles=1,
)
- Windows
+ >>> deeplabcut.create_training_dataset(
+ '/analysis/project/reaching-task/config.yaml', Shuffles=[2], engine=deeplabcut.Engine.TF,
+ )
+ Windows:
>>> deeplabcut.create_training_dataset(
'C:\\Users\\Ulf\\looming-task\\config.yaml', Shuffles=[3,17,5],
)
@@ -837,19 +1017,17 @@ def create_training_dataset(
warnings.warn(
"`windows2linux` has no effect since 2.2.0.4 and will be removed in 2.2.1.",
FutureWarning,
+ stacklevel=2,
)
# Loading metadata from config file:
cfg = auxiliaryfunctions.read_config(config)
- dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
+ auxiliaryfunctions.get_deeplabcut_path()
if superanimal_name != "":
- supermodels = parse_available_supermodels()
- posecfg_template = os.path.join(
- dlc_root_path,
- "pose_estimation_tensorflow",
- "superanimal_configs",
- supermodels[superanimal_name],
+ raise ValueError(
+ "Invalid argument superanimal_name. This functionality has been "
+ "removed. Please use modelzoo.build_weight_init() instead."
)
if posecfg_template:
@@ -858,9 +1036,7 @@ def create_training_dataset(
and not posecfg_template.endswith("superquadruped.yaml")
and not posecfg_template.endswith("supertopview.yaml")
):
- raise ValueError(
- "posecfg_template argument must contain path to a pose_cfg.yaml file"
- )
+ raise ValueError("posecfg_template argument must contain path to a pose_cfg.yaml file")
else:
print("Reloading pose_cfg parameters from " + posecfg_template + "\n")
from deeplabcut.utils.auxiliaryfunctions import read_plainconfig
@@ -876,12 +1052,20 @@ def create_training_dataset(
num_shuffles,
Shuffles,
net_type=net_type,
+ detector_type=detector_type,
trainIndices=trainIndices,
testIndices=testIndices,
+ userfeedback=userfeedback,
+ engine=engine,
+ weight_init=weight_init,
+ ctd_conditions=ctd_conditions,
)
else:
scorer = cfg["scorer"]
project_path = cfg["project_path"]
+ if engine is None:
+ engine = compat.get_project_engine(cfg)
+
# Create path for training sets & store data there
trainingsetfolder = auxiliaryfunctions.get_training_set_folder(
cfg
@@ -890,6 +1074,11 @@ def create_training_dataset(
Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True
)
+ # Create the trainset metadata file, if it doesn't yet exist
+ if not metadata.TrainingDatasetMetadata.path(cfg).exists():
+ trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg)
+ trainset_metadata.save()
+
Data = merge_annotateddatasets(
cfg,
Path(os.path.join(project_path, trainingsetfolder)),
@@ -901,40 +1090,56 @@ def create_training_dataset(
# loading & linking pretrained models
if net_type is None: # loading & linking pretrained models
net_type = cfg.get("default_net_type", "resnet_50")
+ elif engine == Engine.PYTORCH:
+ pass
else:
- if (
- "resnet" in net_type
- or "mobilenet" in net_type
- or "efficientnet" in net_type
- or "dlcrnet" in net_type
- ):
+ if "resnet" in net_type or "mobilenet" in net_type or "efficientnet" in net_type or "dlcrnet" in net_type:
pass
else:
raise ValueError("Invalid network type:", net_type)
+ top_down = False
+ if engine == Engine.PYTORCH:
+ if net_type.startswith("top_down_"):
+ top_down = True
+ net_type = net_type[len("top_down_") :]
+
+ augmenters = compat.get_available_aug_methods(engine)
+ default_augmenter = augmenters[0]
if augmenter_type is None:
- augmenter_type = cfg.get("default_augmenter", "imgaug")
+ augmenter_type = cfg.get("default_augmenter", default_augmenter)
+
if augmenter_type is None: # this could be in config.yaml for old projects!
# updating variable if null/None! #backwardscompatability
- auxiliaryfunctions.edit_config(config, {"default_augmenter": "imgaug"})
- augmenter_type = "imgaug"
- elif augmenter_type not in [
- "default",
- "scalecrop",
- "imgaug",
- "tensorpack",
- "deterministic",
- ]:
- raise ValueError("Invalid augmenter type:", augmenter_type)
+ augmenter_type = default_augmenter
+ auxiliaryfunctions.edit_config(config, {"default_augmenter": augmenter_type})
+ elif augmenter_type not in augmenters:
+ # as the default augmenter might not be available for the given engine
+ augmenter_type = default_augmenter
+ logging.info(
+ f"Default augmenter {augmenter_type} not available for engine "
+ f"{engine}: using {default_augmenter} instead"
+ )
+
+ if augmenter_type not in augmenters:
+ if engine != Engine.PYTORCH:
+ raise ValueError(
+ f"Invalid augmenter type: {augmenter_type} (available: for engine={engine}: {augmenters})"
+ )
+
+ logging.info(f"Switching augmentation to {default_augmenter} for PyTorch")
+ augmenter_type = default_augmenter
if posecfg_template:
if net_type != prior_cfg["net_type"]:
print(
- "WARNING: Specified net_type does not match net_type from posecfg_template path entered. Proceed with caution."
+ "WARNING: Specified net_type does not match net_type from "
+ "posecfg_template path entered. Proceed with caution."
)
if augmenter_type != prior_cfg["dataset_type"]:
print(
- "WARNING: Specified augmenter_type does not match dataset_type from posecfg_template path entered. Proceed with caution."
+ "WARNING: Specified augmenter_type does not match dataset_type "
+ "from posecfg_template path entered. Proceed with caution."
)
# Loading the encoder (if necessary downloading from TF)
@@ -943,16 +1148,14 @@ def create_training_dataset(
defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml")
elif posecfg_template:
defaultconfigfile = posecfg_template
- model_path = auxfun_models.check_for_weights(
- net_type, Path(dlcparent_path)
- )
- if Shuffles is None:
- Shuffles = range(1, num_shuffles + 1)
+ if engine == Engine.PYTORCH:
+ model_path = dlcparent_path
else:
- Shuffles = [i for i in Shuffles if isinstance(i, int)]
+ model_path = auxfun_models.check_for_weights(net_type, Path(dlcparent_path))
+
+ Shuffles = validate_shuffles(cfg, Shuffles, num_shuffles, userfeedback)
- # print(trainIndices,testIndices, Shuffles, augmenter_type,net_type)
if trainIndices is None and testIndices is None:
splits = [
(
@@ -965,51 +1168,40 @@ def create_training_dataset(
]
else:
if len(trainIndices) != len(testIndices) != len(Shuffles):
- raise ValueError(
- "Number of Shuffles and train and test indexes should be equal."
- )
+ raise ValueError("Number of Shuffles and train and test indexes should be equal.")
splits = []
- for shuffle, (train_inds, test_inds) in enumerate(
- zip(trainIndices, testIndices)
- ):
- trainFraction = round(
- len(train_inds) * 1.0 / (len(train_inds) + len(test_inds)), 2
- )
- print(
- f"You passed a split with the following fraction: {int(100 * trainFraction)}%"
- )
+ for shuffle, (train_inds, test_inds) in enumerate(zip(trainIndices, testIndices, strict=False)):
+ trainFraction = round(len(train_inds) * 1.0 / (len(train_inds) + len(test_inds)), 2)
+ print(f"You passed a split with the following fraction: {int(100 * trainFraction)}%")
# Now that the training fraction is guaranteed to be correct,
# the values added to pad the indices are removed.
train_inds = np.asarray(train_inds)
train_inds = train_inds[train_inds != -1]
test_inds = np.asarray(test_inds)
test_inds = test_inds[test_inds != -1]
- splits.append(
- (trainFraction, Shuffles[shuffle], (train_inds, test_inds))
- )
+ splits.append((trainFraction, Shuffles[shuffle], (train_inds, test_inds)))
- bodyparts = cfg["bodyparts"]
+ bodyparts = auxiliaryfunctions.get_bodyparts(cfg)
nbodyparts = len(bodyparts)
for trainFraction, shuffle, (trainIndices, testIndices) in splits:
if len(trainIndices) > 0:
if userfeedback:
- trainposeconfigfile, _, _ = training.return_train_network_path(
+ trainposeconfigfile, _, _ = compat.return_train_network_path(
config,
shuffle=shuffle,
trainingsetindex=cfg["TrainingFraction"].index(trainFraction),
+ engine=engine,
)
if trainposeconfigfile.is_file():
askuser = input(
- "The model folder is already present. If you continue, it will overwrite the existing model (split). Do you want to continue?(yes/no): "
+ "The model folder is already present. "
+ "If you continue, it will overwrite the existing model (split). "
+ "Do you want to continue?(yes/no): "
)
- if (
- askuser == "no"
- or askuser == "No"
- or askuser == "N"
- or askuser == "No"
- ):
+ if askuser == "no" or askuser == "No" or askuser == "N" or askuser == "No":
raise Exception(
- "Use the Shuffles argument as a list to specify a different shuffle index. Check out the help for more details."
+ "Use the Shuffles argument as a list to specify a different shuffle index. "
+ "Check out the help for more details."
)
####################################################
@@ -1019,19 +1211,13 @@ def create_training_dataset(
(
datafilename,
metadatafilename,
- ) = auxiliaryfunctions.get_data_and_metadata_filenames(
- trainingsetfolder, trainFraction, shuffle, cfg
- )
+ ) = auxiliaryfunctions.get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, cfg)
################################################################################
# Saving data file (convert to training file for deeper cut (*.mat))
################################################################################
- data, MatlabData = format_training_data(
- Data, trainIndices, nbodyparts, project_path
- )
- sio.savemat(
- os.path.join(project_path, datafilename), {"dataset": MatlabData}
- )
+ data, MatlabData = format_training_data(Data, trainIndices, nbodyparts, project_path)
+ sio.savemat(os.path.join(project_path, datafilename), {"dataset": MatlabData})
################################################################################
# Saving metadata (Pickle file)
@@ -1043,30 +1229,36 @@ def create_training_dataset(
testIndices,
trainFraction,
)
+ metadata.update_metadata(
+ cfg=cfg,
+ train_fraction=trainFraction,
+ shuffle=shuffle,
+ engine=engine,
+ train_indices=trainIndices,
+ test_indices=testIndices,
+ overwrite=not userfeedback,
+ )
################################################################################
# Creating file structure for training &
# Test files as well as pose_yaml files (containing training and testing information)
#################################################################################
modelfoldername = auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg
- )
- auxiliaryfunctions.attempt_to_make_folder(
- Path(config).parents[0] / modelfoldername, recursive=True
- )
- auxiliaryfunctions.attempt_to_make_folder(
- str(Path(config).parents[0] / modelfoldername) + "/train"
- )
- auxiliaryfunctions.attempt_to_make_folder(
- str(Path(config).parents[0] / modelfoldername) + "/test"
+ trainFraction,
+ shuffle,
+ cfg,
+ engine=engine,
)
+ auxiliaryfunctions.attempt_to_make_folder(Path(config).parents[0] / modelfoldername, recursive=True)
+ auxiliaryfunctions.attempt_to_make_folder(str(Path(config).parents[0] / modelfoldername) + "/train")
+ auxiliaryfunctions.attempt_to_make_folder(str(Path(config).parents[0] / modelfoldername) + "/test")
path_train_config = str(
os.path.join(
cfg["project_path"],
Path(modelfoldername),
"train",
- "pose_cfg.yaml",
+ engine.pose_cfg_name,
)
)
path_test_config = str(
@@ -1077,76 +1269,212 @@ def create_training_dataset(
"pose_cfg.yaml",
)
)
- # str(cfg['proj_path']+'/'+Path(modelfoldername) / 'test' / 'pose_cfg.yaml')
- items2change = {
- "dataset": datafilename,
- "metadataset": metadatafilename,
- "num_joints": len(bodyparts),
- "all_joints": [[i] for i in range(len(bodyparts))],
- "all_joints_names": [str(bpt) for bpt in bodyparts],
- "init_weights": model_path,
- "project_path": str(cfg["project_path"]),
- "net_type": net_type,
- "dataset_type": augmenter_type,
- }
-
- items2drop = {}
- if augmenter_type == "scalecrop":
- # these values are dropped as scalecrop
- # doesn't have rotation implemented
- items2drop = {"rotation": 0, "rotratio": 0.0}
- # Also drop maDLC smart cropping augmentation parameters
- for key in ["pre_resize", "crop_size", "max_shift", "crop_sampling"]:
- items2drop[key] = None
-
- trainingdata = MakeTrain_pose_yaml(
- items2change, path_train_config, defaultconfigfile, items2drop
- )
+ if engine == Engine.TF:
+ if weight_init is not None:
+ raise ValueError(
+ "Weight initialization is not supported for TensorFlow engine. "
+ "Pretrained weights are automatically downloaded."
+ )
+ items2change = {
+ "dataset": datafilename,
+ "engine": engine.aliases[0],
+ "metadataset": metadatafilename,
+ "num_joints": len(bodyparts),
+ "all_joints": [[i] for i in range(len(bodyparts))],
+ "all_joints_names": [str(bpt) for bpt in bodyparts],
+ "init_weights": model_path,
+ "project_path": str(cfg["project_path"]),
+ "net_type": net_type,
+ "dataset_type": augmenter_type,
+ }
+
+ items2drop = {}
+ if augmenter_type == "scalecrop":
+ # these values are dropped as scalecrop
+ # doesn't have rotation implemented
+ items2drop = {"rotation": 0, "rotratio": 0.0}
+ # Also drop maDLC smart cropping augmentation parameters
+ for key in [
+ "pre_resize",
+ "crop_size",
+ "max_shift",
+ "crop_sampling",
+ ]:
+ items2drop[key] = None
+
+ trainingdata = MakeTrain_pose_yaml(
+ items2change,
+ path_train_config,
+ defaultconfigfile,
+ items2drop,
+ save=(engine == Engine.TF),
+ )
- keys2save = [
- "dataset",
- "num_joints",
- "all_joints",
- "all_joints_names",
- "net_type",
- "init_weights",
- "global_scale",
- "location_refinement",
- "locref_stdev",
- ]
- MakeTest_pose_yaml(trainingdata, keys2save, path_test_config)
- print(
- "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!"
- )
+ keys2save = [
+ "dataset",
+ "num_joints",
+ "all_joints",
+ "all_joints_names",
+ "net_type",
+ "init_weights",
+ "global_scale",
+ "location_refinement",
+ "locref_stdev",
+ ]
+ MakeTest_pose_yaml(trainingdata, keys2save, path_test_config)
+ print(
+ "The training dataset is successfully created. Use the function"
+ "'train_network' to start training. Happy training!"
+ )
+ elif engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ make_pytorch_pose_config,
+ make_pytorch_test_config,
+ )
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import (
+ make_super_animal_finetune_config,
+ )
+
+ if weight_init is not None and weight_init.with_decoder:
+ pytorch_cfg = make_super_animal_finetune_config(
+ project_config=cfg,
+ pose_config_path=path_train_config,
+ model_name=net_type,
+ detector_name=detector_type,
+ weight_init=weight_init,
+ save=True,
+ )
+ else:
+ pytorch_cfg = make_pytorch_pose_config(
+ project_config=cfg,
+ pose_config_path=path_train_config,
+ net_type=net_type,
+ top_down=top_down,
+ detector_type=detector_type,
+ weight_init=weight_init,
+ save=True,
+ ctd_conditions=ctd_conditions,
+ )
+
+ make_pytorch_test_config(pytorch_cfg, path_test_config, save=True)
return splits
def get_largestshuffle_index(config):
"""Returns the largest shuffle for all dlc-models in the current iteration."""
- cfg = auxiliaryfunctions.read_config(config)
- project_path = cfg["project_path"]
- iterate = "iteration-" + str(cfg["iteration"])
- dlc_model_path = os.path.join(project_path, "dlc-models", iterate)
- if os.path.isdir(dlc_model_path):
- models = os.listdir(dlc_model_path)
- # sort the model directories
- models.sort(key=lambda f: int("".join(filter(str.isdigit, f))))
-
- # get the shuffle index and offset by 1.
- max_shuffle_index = int(models[-1].split("shuffle")[-1]) + 1
+ shuffle_indices = get_existing_shuffle_indices(config)
+ if len(shuffle_indices) > 0:
+ return shuffle_indices[-1]
+
+ return None
+
+
+def get_existing_shuffle_indices(
+ cfg: dict | str | Path,
+ train_fraction: float | None = None,
+ engine: Engine | None = None,
+) -> list[int]:
+ """
+ Args:
+ cfg: The content of a project configuration file, or the path to the project
+ configuration file.
+ train_fraction: If defined, only get the indices of shuffles with this train
+ fraction.
+ engine: If specified, returns only the shuffle indices that were created with
+ the given engine. Can only be used when train_fraction is also defined.
+
+ Returns:
+ the indices of existing shuffles for this iteration of the project, sorted by
+ ascending index
+ """
+
+ def is_valid_data_stem(stem: str) -> bool:
+ if len(stem) == 0:
+ return False
+ suffix = stem.split("_")[-1]
+ if len(suffix) == 0:
+ return False
+ info = suffix.split("shuffle")
+ if len(info) != 2:
+ return False
+ train_frac, idx = info
+ return (
+ train_frac.isdigit()
+ and idx.isdigit()
+ and (train_fraction is None or int(train_frac) == int(100 * train_fraction))
+ )
+
+ if isinstance(cfg, (str, Path)):
+ cfg = auxiliaryfunctions.read_config(cfg)
+
+ project = Path(cfg["project_path"])
+ trainset_folder = project / auxiliaryfunctions.get_training_set_folder(cfg)
+ if not trainset_folder.exists():
+ return []
+
+ shuffle_indices = [
+ int(p.stem.split("shuffle")[-1])
+ for p in trainset_folder.iterdir()
+ if (p.stem.startswith("Documentation_data") and p.suffix == ".pickle" and is_valid_data_stem(p.stem))
+ ]
+ if engine is not None:
+ if train_fraction is None:
+ raise ValueError(f"Must select {train_fraction} to filter shuffles by engine")
+
+ shuffle_indices = [
+ idx
+ for idx in shuffle_indices
+ if (
+ project
+ / auxiliaryfunctions.get_model_folder(
+ trainFraction=train_fraction,
+ shuffle=idx,
+ cfg=cfg,
+ engine=engine,
+ )
+ ).exists()
+ ]
+
+ return sorted(shuffle_indices)
+
+
+def validate_shuffles(
+ cfg: dict,
+ shuffles: list[int] | None,
+ num_shuffles: int | None,
+ userfeedback: bool,
+) -> list[int]:
+ existing_shuffles = get_existing_shuffle_indices(cfg)
+ if shuffles is None:
+ first_index = 1
+ if len(existing_shuffles) > 0:
+ first_index = existing_shuffles[-1] + 1
+
+ shuffles = range(first_index, num_shuffles + first_index)
else:
- max_shuffle_index = 0
+ shuffles = [i for i in shuffles if isinstance(i, int)]
+ for shuffle_idx in shuffles:
+ if userfeedback and shuffle_idx in existing_shuffles:
+ raise ValueError(
+ f"Cannot create shuffle {shuffle_idx} as it already exists - "
+ f"you must either create the dataset with `userfeedback=False` "
+ f"or delete the shuffle with index {shuffle_idx} manually (in "
+ f"`dlc-models`/`dlc-models-pytorch` and in the "
+ f"`training-datasets` folder) if you want to create a new "
+ f"shuffle with that index. You can otherwise create a shuffle "
+ f"with a new index. Existing indices are {existing_shuffles}."
+ )
- return max_shuffle_index
+ return shuffles
def create_training_model_comparison(
config,
trainindex=0,
num_shuffles=1,
- net_types=["resnet_50"],
- augmenter_types=["imgaug"],
+ net_types=None,
+ augmenter_types=None,
userfeedback=False,
windows2linux=False,
):
@@ -1236,12 +1564,17 @@ def create_training_model_comparison(
of how to use ``shuffle_list``.
"""
# read cfg file
+ if augmenter_types is None:
+ augmenter_types = ["imgaug"]
+ if net_types is None:
+ net_types = ["resnet_50"]
cfg = auxiliaryfunctions.read_config(config)
if windows2linux:
warnings.warn(
"`windows2linux` has no effect since 2.2.0.4 and will be removed in 2.2.1.",
FutureWarning,
+ stacklevel=2,
)
# create log file
@@ -1257,13 +1590,15 @@ def create_training_model_comparison(
else:
pass
- largestshuffleindex = get_largestshuffle_index(config)
+ existing_shuffles = get_existing_shuffle_indices(cfg)
+ if len(existing_shuffles) == 0:
+ largestshuffleindex = 0
+ else:
+ largestshuffleindex = existing_shuffles[-1] + 1
shuffle_list = []
for shuffle in range(num_shuffles):
- trainIndices, testIndices = mergeandsplit(
- config, trainindex=trainindex, uniform=True
- )
+ trainIndices, testIndices = mergeandsplit(config, trainindex=trainindex, uniform=True)
for idx_net, net in enumerate(net_types):
for idx_aug, aug in enumerate(augmenter_types):
get_max_shuffle_idx = (
@@ -1298,3 +1633,193 @@ def create_training_model_comparison(
logger.info(log_info)
return shuffle_list
+
+
+def create_training_dataset_from_existing_split(
+ config: str,
+ from_shuffle: int,
+ from_trainsetindex: int = 0,
+ num_shuffles: int = 1,
+ shuffles: list[int] | None = None,
+ userfeedback: bool = True,
+ net_type: str | None = None,
+ detector_type: str | None = None,
+ augmenter_type: str | None = None,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
+ posecfg_template: dict | None = None,
+ superanimal_name: str = "",
+ weight_init: WeightInitialization | None = None,
+ engine: Engine | None = None,
+) -> None | list[int]:
+ """Labels from all the extracted frames are merged into a single .h5 file. Only the
+ videos included in the config file are used to create this dataset.
+
+ Args:
+ config: Full path of the ``config.yaml`` file as a string.
+
+ from_shuffle: The index of the shuffle from which to copy the train/test split.
+
+ from_trainsetindex: The trainset index of the shuffle from which to use the data
+ split. Default is 0.
+
+ num_shuffles: Number of shuffles of training dataset to create, used if
+ ``shuffles`` is None.
+
+ shuffles: If defined, ``num_shuffles`` is ignored and a shuffle is created for
+ each index given in the list.
+
+ userfeedback: If ``False``, all requested train/test splits are created (no
+ matter if they already exist). If you want to assure that previous splits
+ etc. are not overwritten, set this to ``True`` and you will be asked for
+ each existing split if you want to overwrite it.
+
+ net_type: The type of network to create the shuffle for. Currently supported
+ options for engine=Engine.TF are:
+ * ``resnet_50``
+ * ``resnet_101``
+ * ``resnet_152``
+ * ``mobilenet_v2_1.0``
+ * ``mobilenet_v2_0.75``
+ * ``mobilenet_v2_0.5``
+ * ``mobilenet_v2_0.35``
+ * ``efficientnet-b0``
+ * ``efficientnet-b1``
+ * ``efficientnet-b2``
+ * ``efficientnet-b3``
+ * ``efficientnet-b4``
+ * ``efficientnet-b5``
+ * ``efficientnet-b6``
+ Currently supported options for engine=Engine.TF can be obtained by calling
+ ``deeplabcut.pose_estimation_pytorch.available_models()``.
+
+ detector_type: string, optional, default=None
+ Only for the PyTorch engine.
+ When passing creating shuffles for top-down models, you can specify which
+ detector you want. If the detector_type is None, the ```ssdlite``` will be
+ used. The list of all available detectors can be obtained by calling
+ ``deeplabcut.pose_estimation_pytorch.available_detectors()``. Supported
+ options:
+ * ``ssdlite``
+ * ``fasterrcnn_mobilenet_v3_large_fpn``
+ * ``fasterrcnn_resnet50_fpn_v2``
+
+ augmenter_type: Type of augmenter. Currently supported augmenters for
+ engine=Engine.TF are
+ * ``default``
+ * ``scalecrop``
+ * ``imgaug``
+ * ``tensorpack``
+ * ``deterministic``
+ The only supported augmenter for Engine.PYTORCH is ``albumentations``.
+
+ posecfg_template: Only for Engine.TF. Path to a ``pose_cfg.yaml`` file to use as
+ a template for generating the new one for the current iteration. Useful if
+ you would like to start with the same parameters a previous training
+ iteration. None uses the default ``pose_cfg.yaml``.
+
+ superanimal_name: Specify the superanimal name is transfer learning with
+ superanimal is desired. This makes sure the pose config template uses
+ superanimal configs as template.
+
+ weight_init: Only for Engine.PYTORCH. Specify how model weights should be
+ initialized. The default mode uses transfer learning from ImageNet weights.
+
+ engine: Whether to create a pose config for a Tensorflow or PyTorch model.
+ Defaults to the value specified in the project configuration file. If no
+ engine is specified for the project, defaults to
+ ``deeplabcut.compat.DEFAULT_ENGINE``.
+
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None, default = None,
+ If using a conditional-top-down (CTD) net_type, this argument should be
+ specified. It defines the conditions that will be used with the CTD model.
+ It can be either:
+ * A shuffle number (ctd_conditions: int), which must correspond to a
+ bottom-up (BU) network type.
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file.
+ * A shuffle number and a particular snapshot
+ (ctd_conditions: tuple[int, str] | tuple[int, int]), which
+ respectively correspond to a bottom-up (BU) network type and a
+ particular snapshot name or index.
+
+ Returns:
+ If training dataset was successfully created, a list of tuples is returned.
+ The first two elements in each tuple represent the training fraction and the
+ shuffle value. The last two elements in each tuple are arrays of integers
+ representing the training and test indices.
+
+ Returns None if training dataset could not be created.
+
+ Raises:
+ ValueError: If the shuffle from which to copy the data split doesn't exist.
+ """
+ cfg = auxiliaryfunctions.read_config(config)
+ trainset_meta_path = metadata.TrainingDatasetMetadata.path(cfg)
+ if not trainset_meta_path.exists():
+ meta = metadata.TrainingDatasetMetadata.create(cfg)
+ meta.save()
+ else:
+ meta = metadata.TrainingDatasetMetadata.load(cfg, load_splits=False)
+
+ shuffle = meta.get(trainset_index=from_trainsetindex, index=from_shuffle)
+ shuffle = shuffle.load_split(cfg, trainset_path=trainset_meta_path.parent)
+
+ num_copies = num_shuffles
+ if shuffles is not None:
+ num_copies = len(shuffles)
+
+ # pad the train and test indices with -1s so the training fraction is exact
+ train_idx = list(shuffle.split.train_indices)
+ test_idx = list(shuffle.split.test_indices)
+ n_train, n_test = len(train_idx), len(test_idx)
+
+ train_fraction = round(cfg["TrainingFraction"][from_trainsetindex], 2)
+ if round(n_train / (n_train + n_test), 2) != train_fraction:
+ train_padding, test_padding = _compute_padding(train_fraction, n_train, n_test)
+ train_idx = train_idx + (train_padding * [-1])
+ test_idx = test_idx + (test_padding * [-1])
+
+ return create_training_dataset(
+ config=config,
+ num_shuffles=num_shuffles,
+ Shuffles=shuffles,
+ userfeedback=userfeedback,
+ trainIndices=[train_idx for _ in range(num_copies)],
+ testIndices=[test_idx for _ in range(num_copies)],
+ net_type=net_type,
+ detector_type=detector_type,
+ augmenter_type=augmenter_type,
+ posecfg_template=posecfg_template,
+ superanimal_name=superanimal_name,
+ weight_init=weight_init,
+ engine=engine,
+ ctd_conditions=ctd_conditions,
+ )
+
+
+def _compute_padding(
+ train_fraction: float,
+ num_train: int,
+ num_test: int,
+) -> tuple[int, int]:
+ """Computes the amount of padding to add to train/test indices such that
+ train_fraction = num_train / (num_train + num_test).
+
+ Returns:
+ the number of padding indices to add to the train indices
+ the number of padding indices to add to the test indices
+ """
+ if train_fraction <= 0 or train_fraction >= 1:
+ raise ValueError(f"The training fraction must satisfy 0 < TrainingFraction < 1, but {train_fraction} was found")
+
+ base_images = 100
+ train_step = int(round(round(train_fraction, 2) * base_images))
+ test_step = base_images - train_step
+
+ tgt_train = train_step
+ tgt_test = test_step
+ while tgt_train < num_train or tgt_test < num_test:
+ tgt_train += train_step
+ tgt_test += test_step
+
+ return (tgt_train - num_train), (tgt_test - num_test)
diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py
index f164804876..cb3a747524 100644
--- a/deeplabcut/gui/components.py
+++ b/deeplabcut/gui/components.py
@@ -8,10 +8,16 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+
import os
+from pathlib import Path
from PySide6 import QtWidgets
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, Slot
+from PySide6.QtGui import QIcon
+
+from deeplabcut.core.config import read_config_as_dict
from deeplabcut.gui.dlc_params import DLCParams
from deeplabcut.gui.widgets import ConfigEditor
@@ -54,7 +60,7 @@ def _create_grid_layout(
alignment=None,
spacing: int = 20,
margins: tuple = None,
-) -> QtWidgets.QGridLayout():
+) -> QtWidgets.QGridLayout:
layout = QtWidgets.QGridLayout()
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setSpacing(spacing)
@@ -64,6 +70,50 @@ def _create_grid_layout(
return layout
+def set_combo_items(combo_box: QtWidgets.QComboBox, items: list[str], index: int = 0):
+ """Safely replaces all items in a QComboBox and sets the current index, ensuring
+ that the `currentTextChanged` signal is emitted exactly once (and only if items are
+ present).
+
+ This method suppresses intermediate signal emissions that can be triggered
+ by `clear()` and `addItems()` — both of which may emit multiple signals
+ depending on the underlying Qt model and signal connections.
+
+ It also handles the edge case where the item at the target index is already
+ selected: by default, Qt will not emit a signal if the index doesn't change.
+ To ensure consistent behavior, this method temporarily sets the index to -1
+ (i.e., no selection), which is done with signals blocked, then restores the
+ intended index — causing the signal to emit once and only once.
+
+ Parameters:
+ combo_box (QComboBox): The combo box to update.
+ items (list of str): New items to populate the combo box.
+ index (int): The index to select after updating items. Defaults to 0.
+
+ Note:
+ - If the items list is empty, no item will be selected and no signal will be emitted.
+ - This method is designed to be safe for use with PySide, where signals
+ cannot be manually emitted, and future-proof if multiple slots are connected.
+ """
+ combo_box.blockSignals(True)
+ combo_box.clear()
+ combo_box.addItems(items)
+ combo_box.blockSignals(False)
+
+ if not items:
+ combo_box.setCurrentIndex(-1)
+ return
+
+ current = combo_box.currentIndex()
+ if current == index:
+ # Temporarily change index to suppress duplicate signal
+ combo_box.blockSignals(True)
+ combo_box.setCurrentIndex(-1)
+ combo_box.blockSignals(False)
+
+ combo_box.setCurrentIndex(index)
+
+
class BodypartListWidget(QtWidgets.QListWidget):
def __init__(
self,
@@ -73,7 +123,7 @@ def __init__(
# NOTE: Is there a case where a specific list should
# have bodyparts other than the root? I don't think so.
):
- super(BodypartListWidget, self).__init__()
+ super().__init__()
self.root = root
self.parent = parent
@@ -89,47 +139,63 @@ def __init__(
self.itemSelectionChanged.connect(self.update_selected_bodyparts)
+ def refresh(self):
+ self.clear()
+ self.addItems(self.root.all_bodyparts)
+ self.update_selected_bodyparts()
+
def update_selected_bodyparts(self):
self.selected_bodyparts = [item.text() for item in self.selectedItems()]
self.root.logger.info(f"Selected bodyparts:\n\t{self.selected_bodyparts}")
class VideoSelectionWidget(QtWidgets.QWidget):
- def __init__(self, root: QtWidgets.QMainWindow, parent: QtWidgets.QWidget):
- super(VideoSelectionWidget, self).__init__(parent)
+ def __init__(
+ self,
+ root: QtWidgets.QMainWindow,
+ parent: QtWidgets.QWidget,
+ *,
+ hide_videotype: bool = False,
+ sync_videotype_with_selection: bool = False,
+ strict_videotype_filter: bool = False,
+ ):
+ super().__init__(parent)
self.root = root
self.parent = parent
- self._init_layout()
+ # Optional safeties; defaults preserve current behavior
+ self.sync_videotype_with_selection = sync_videotype_with_selection
+ self.strict_videotype_filter = strict_videotype_filter
- def _init_layout(self):
+ self._init_layout(hide_videotype)
+
+ def _init_layout(self, hide_videotype: bool):
layout = _create_horizontal_layout()
# Videotype selection
self.videotype_widget = QtWidgets.QComboBox()
self.videotype_widget.setMinimumWidth(100)
self.videotype_widget.addItems(DLCParams.VIDEOTYPES)
- self.videotype_widget.setCurrentText(self.root.video_type)
+ self.videotype_widget.setCurrentText(self._normalize_videotype(self.root.video_type))
self.root.video_type_.connect(self.videotype_widget.setCurrentText)
self.videotype_widget.currentTextChanged.connect(self.update_videotype)
# Select videos
self.select_video_button = QtWidgets.QPushButton("Select videos")
self.select_video_button.setMaximumWidth(200)
- self.select_video_button.clicked.connect(self.select_videos)
+ self.select_video_button.clicked.connect(self.update_videos)
self.root.video_files_.connect(self._update_video_selection)
# Number of selected videos text
- self.selected_videos_text = QtWidgets.QLabel(
- ""
- ) # updated when videos are selected
+ self.selected_videos_text = QtWidgets.QLabel("")
# Clear video selection
self.clear_videos = QtWidgets.QPushButton("Clear selection")
self.clear_videos.clicked.connect(self.clear_selected_videos)
- layout.addWidget(self.videotype_widget)
+ if not hide_videotype:
+ layout.addWidget(self.videotype_widget)
layout.addWidget(self.select_video_button)
layout.addWidget(self.selected_videos_text)
layout.addWidget(self.clear_videos, alignment=Qt.AlignRight)
@@ -140,40 +206,333 @@ def _init_layout(self):
def files(self):
return self.root.video_files
- def update_videotype(self, vtype):
+ def _normalize_videotype(self, vtype: str) -> str:
+ return (vtype or "").lower().lstrip(".")
+
+ @property
+ def selected_suffixes(self) -> set[str]:
+ """Return normalized suffixes (without leading dot) of currently selected files."""
+ suffixes = set()
+ for f in self.files:
+ suffix = Path(f).suffix.lower().lstrip(".")
+ if suffix:
+ suffixes.add(suffix)
+ return suffixes
+
+ def get_effective_videotype(
+ self,
+ prefer_selected_files: bool = False,
+ with_dot: bool = True,
+ ) -> str:
+ """
+ Return the videotype to use.
+
+ By default, preserves current behavior and uses the dropdown.
+ If prefer_selected_files=True and the selected files all share one suffix,
+ that suffix is used instead.
+ """
+ videotype = self._normalize_videotype(self.videotype_widget.currentText())
+
+ if prefer_selected_files:
+ suffixes = self.selected_suffixes
+ if len(suffixes) == 1:
+ videotype = next(iter(suffixes))
+
+ if with_dot and videotype:
+ return f".{videotype}"
+ return videotype
+
+ def get_files_grouped_by_suffix(self, keep_dot: bool = False) -> dict[str, list[str]]:
+ """Return a dict grouping selected files by their suffixes."""
+ groups: dict[str, list[str]] = {}
+ for f in self.files:
+ suffix = Path(f).suffix.lower()
+ if not keep_dot:
+ suffix = suffix.lstrip(".")
+ groups.setdefault(suffix, []).append(f)
+ return groups
+
+ def _all_supported_video_patterns(self) -> list[str]:
+ """Return all supported video patterns in both lower and upper case."""
+ return [f"*.{ext.lower()}" for ext in DLCParams.VIDEOTYPES[1:]] + [
+ f"*.{ext.upper()}" for ext in DLCParams.VIDEOTYPES[1:]
+ ]
+
+ def _build_video_filter(self) -> str:
+ """
+ Build the file dialog filter.
+
+ By default, preserve current behavior: show all supported video types.
+ If strict_videotype_filter is enabled, restrict to the currently selected
+ videotype when it is non-empty. If the current dropdown value is empty
+ (the "all types" option), fall back to the full supported-extension filter.
+ """
+ all_video_types = self._all_supported_video_patterns()
+
+ if self.strict_videotype_filter:
+ current = self.get_effective_videotype(
+ prefer_selected_files=False,
+ with_dot=False,
+ )
+
+ if current:
+ video_types = [f"*.{current.lower()}", f"*.{current.upper()}"]
+ else:
+ # "All types" entry selected: keep the dialog usable
+ video_types = all_video_types
+ else:
+ video_types = all_video_types
+
+ return f"Videos ({' '.join(video_types)})"
+
+ def _set_videotype_silently(self, vtype: str):
+ """
+ Update the dropdown/root videotype without triggering update_videotype(),
+ because that method clears the current selection.
+
+ Only updates state if the videotype is supported by the combo box.
+ Otherwise, leaves the current state unchanged.
+ """
+ normalized = self._normalize_videotype(vtype)
+ current = self._normalize_videotype(self.videotype_widget.currentText())
+
+ if not normalized:
+ self.root.logger.warning("Attempted to set an empty videotype silently; keeping current selection.")
+ return
+
+ # Validate against actual combo-box items
+ if self.videotype_widget.findText(normalized) == -1:
+ self.root.logger.warning(
+ f"Attempted to set unsupported videotype '{normalized}' silently; "
+ f"keeping current videotype '{current}'."
+ )
+ return
+
+ if normalized != current:
+ self.videotype_widget.blockSignals(True)
+ self.videotype_widget.setCurrentText(normalized)
+ self.videotype_widget.blockSignals(False)
+
+ self.root.video_type = normalized
+
+ def update_videotype(self, vtype: str):
+ normalized = self._normalize_videotype(vtype)
self.clear_selected_videos()
- self.root.video_type = vtype
+ self.root.video_type = normalized
def _update_video_selection(self, videopaths):
n_videos = len(self.root.video_files)
if n_videos:
- self.selected_videos_text.setText(f"{n_videos} videos selected")
+ suffixes = self.selected_suffixes
+ if len(suffixes) == 1:
+ suffix = next(iter(suffixes))
+ self.selected_videos_text.setText(f"{n_videos} videos selected (.{suffix})")
+ elif len(suffixes) > 1:
+ counts = {
+ suffix: len(files) for suffix, files in self.get_files_grouped_by_suffix(keep_dot=False).items()
+ }
+ summary = ", ".join(f"{count} .{suffix}" for suffix, count in sorted(counts.items()))
+ self.selected_videos_text.setText(
+ f"{n_videos} videos selected ({summary}; will run in separate batches)"
+ )
+ else:
+ self.selected_videos_text.setText(f"{n_videos} videos selected")
+
self.select_video_button.setText("Add more videos")
else:
self.selected_videos_text.setText("")
self.select_video_button.setText("Select videos")
- def select_videos(self):
- cwd = self.root.project_folder
+ def update_videos(self):
+ directory_to_open = self.root.project_folder
+ video_filter = self._build_video_filter()
+
filenames = QtWidgets.QFileDialog.getOpenFileNames(
- self,
- "Select video(s) to analyze",
- cwd,
- f"Videos ({' *.'.join(DLCParams.VIDEOTYPES)[1:]})",
+ parent=self,
+ caption="Select video(s) to analyze",
+ dir=directory_to_open,
+ filter=video_filter,
)
if filenames[0]:
- # Qt returns a tuple (list of files, filetype)
- self.root.video_files = [os.path.abspath(vid) for vid in filenames[0]]
+ abs_files = [os.path.abspath(vid) for vid in filenames[0]]
+ self.root.add_video_files(abs_files)
+
+ # Optional safety: sync dropdown to selected file suffix
+ if self.sync_videotype_with_selection:
+ suffixes = {Path(v).suffix.lower().lstrip(".") for v in abs_files if Path(v).suffix}
+
+ if len(suffixes) == 1:
+ inferred = next(iter(suffixes))
+ self._set_videotype_silently(inferred)
+ self.root.logger.info(f"Inferred videotype '{inferred}' from selected file(s)")
+ elif len(suffixes) > 1:
+ self.root.logger.warning(
+ f"Selected videos have mixed suffixes {sorted(suffixes)}; "
+ "keeping current videotype dropdown unchanged."
+ )
def clear_selected_videos(self):
- self.root.video_files = set()
- self.root.logger.info(f"Cleared selected videos")
+ self.root.clear_video_files()
+ self.root.logger.info("Cleared selected videos")
+
+
+class SnapshotSelectionWidget(QtWidgets.QWidget):
+ def __init__(
+ self,
+ root: QtWidgets.QMainWindow,
+ parent: QtWidgets.QWidget,
+ margins: tuple,
+ select_button_text: str,
+ ):
+ super().__init__(parent)
+ self.root = root
+ self.parent = parent
+ self.selected_snapshot = None
+ self._init_layout(margins, select_button_text)
+
+ def _init_layout(self, margins, select_button_text):
+ layout = _create_horizontal_layout(margins=margins)
+
+ # Select snapshot
+ self.select_snapshot_button = QtWidgets.QPushButton(select_button_text)
+ self.select_snapshot_button.setMaximumWidth(200)
+ self.select_snapshot_button.clicked.connect(self.select_snapshot)
+
+ # Selected snapshot text
+ self.selected_snapshot_text = QtWidgets.QLabel("") # updated when snapshot is selected
+
+ # Clear snapshot selection
+ self.clear_snapshot_button = QtWidgets.QPushButton("Clear selection")
+ self.clear_snapshot_button.clicked.connect(self.clear_selected_snapshot)
+ self.clear_snapshot_button.hide()
+
+ layout.addWidget(self.select_snapshot_button)
+ layout.addWidget(self.selected_snapshot_text)
+ layout.addWidget(self.clear_snapshot_button, alignment=Qt.AlignRight)
+
+ self.setLayout(layout)
+
+ def _update_selected_snapshot_display(self):
+ if self.selected_snapshot is None:
+ self.selected_snapshot_text.setText("")
+ self.clear_snapshot_button.hide()
+ else:
+ self.selected_snapshot_text.setText(f"{os.path.basename(self.selected_snapshot)}")
+ self.clear_snapshot_button.show()
+
+ def select_snapshot(self):
+ # Create a filter string with both lowercase and uppercase extensions
+ snapshot_types = ["*.pt", "*.PT"]
+ snapshot_filter = f"Snapshots ({' '.join(snapshot_types)})"
+
+ directory_to_open = self.root.models_folder
+
+ selected_snapshot, _ = QtWidgets.QFileDialog.getOpenFileName(
+ parent=self,
+ caption="Select snapshot to start training from",
+ dir=directory_to_open,
+ filter=snapshot_filter,
+ )
+ # When Canceling a file selection, Qt returns an empty string as selected file
+ if selected_snapshot:
+ self.selected_snapshot = os.path.abspath(selected_snapshot)
+
+ self._update_selected_snapshot_display()
+
+ def clear_selected_snapshot(self):
+ self.selected_snapshot = None
+ self._update_selected_snapshot_display()
+
+
+class ConditionsSelectionWidget(QtWidgets.QWidget):
+ def __init__(
+ self,
+ root: QtWidgets.QMainWindow,
+ parent: QtWidgets.QWidget,
+ ):
+ super().__init__(parent=parent)
+ self.root = root
+ self.parent = parent
+ self.selected_conditions = None
+ self._init_layout()
+
+ def _init_layout(self):
+ layout = _create_horizontal_layout()
+
+ # Select conditions
+ self.select_conditions_button = QtWidgets.QPushButton("Select conditions")
+ self.select_conditions_button.setMaximumWidth(200)
+ self.select_conditions_button.clicked.connect(self.select_conditions)
+
+ # Selected conditions text
+ self.selected_conditions_text = QtWidgets.QLabel("") # updated when conditions are selected
+
+ layout.addWidget(self.select_conditions_button)
+ layout.addWidget(self.selected_conditions_text)
+
+ self.setLayout(layout)
+
+ def _update_selected_conditions_display(self):
+ def _shorten_path(path: str, max_length: int = 30) -> str:
+ if len(path) <= max_length:
+ return path
+ return "..." + path[-(max_length - 3) :]
+
+ self.selected_conditions_text.setText(
+ "" if self.selected_conditions is None else f"{_shorten_path(self.selected_conditions)}"
+ )
+
+ def select_conditions(self):
+ def _is_model_bu(selected_conditions) -> bool:
+ model_config_path = Path(selected_conditions).parent / "pytorch_config.yaml"
+ model_config = read_config_as_dict(model_config_path)
+ return model_config.get("method").lower() == "bu"
+
+ # Create a filter string with both lowercase and uppercase extensions
+ snapshots_label = "Snapshots"
+ h5_predictions_label = "H5 predictions"
+ json_prediction_label = "Json predictions"
+ snapshot_types = ["*.pt", "*.PT"]
+ h5_predictions_types = ["*.h5", "*.H5"]
+ json_prediction_types = ["*.json", "*.JSON"]
+ conditions_filter = ";;".join(
+ [
+ f"{snapshots_label} ({' '.join(snapshot_types)})",
+ f"{h5_predictions_label} ({' '.join(h5_predictions_types)})",
+ f"{json_prediction_label} ({' '.join(json_prediction_types)})",
+ ]
+ )
+
+ directory_to_open = self.root.project_folder
+
+ selected_conditions, selected_filter = QtWidgets.QFileDialog.getOpenFileName(
+ parent=self,
+ caption="Select conditions to use during inference (snapshot or predictions file)",
+ dir=directory_to_open,
+ filter=conditions_filter,
+ )
+ if selected_filter.startswith(snapshots_label) and selected_conditions:
+ if not _is_model_bu(selected_conditions):
+ msg = _create_message_box(
+ "Invalid conditions",
+ (
+ f"The selected snapshot ({selected_conditions}) cannot be "
+ "used as conditions because it is not a Bottom-Up model."
+ ),
+ )
+ msg.exec_()
+ selected_conditions = None
+
+ # When Canceling a file selection, Qt returns an empty string as selected file
+ self.selected_conditions = str(os.path.abspath(selected_conditions)) if selected_conditions else None
+
+ self._update_selected_conditions_display()
class TrainingSetSpinBox(QtWidgets.QSpinBox):
def __init__(self, root, parent):
- super(TrainingSetSpinBox, self).__init__(parent)
+ super().__init__(parent)
self.root = root
self.parent = parent
@@ -185,14 +544,20 @@ def __init__(self, root, parent):
class ShuffleSpinBox(QtWidgets.QSpinBox):
def __init__(self, root, parent):
- super(ShuffleSpinBox, self).__init__(parent)
+ super().__init__(parent)
self.root = root
self.parent = parent
- self.setMaximum(100)
+ self.setMaximum(10_000)
self.setValue(self.root.shuffle_value)
self.valueChanged.connect(self.root.update_shuffle)
+ self.root.shuffle_change.connect(self.update_shuffle)
+
+ @Slot(int)
+ def update_shuffle(self, new_shuffle: int):
+ if new_shuffle != self.value():
+ self.setValue(new_shuffle)
class DefaultTab(QtWidgets.QWidget):
@@ -202,7 +567,7 @@ def __init__(
parent: QtWidgets.QWidget = None,
h1_description: str = "",
):
- super(DefaultTab, self).__init__(parent)
+ super().__init__(parent)
self.parent = parent
self.root = root
@@ -217,9 +582,7 @@ def __init__(
def _init_default_layout(self):
# Add tab header
- self.main_layout.addWidget(
- _create_label_widget(self.h1_description, "font:bold;", (10, 10, 0, 10))
- )
+ self.main_layout.addWidget(_create_label_widget(self.h1_description, "font:bold;", (10, 10, 0, 10)))
# Add separating line
self.separator = QtWidgets.QFrame()
@@ -235,10 +598,8 @@ def _init_default_layout(self):
class EditYamlButton(QtWidgets.QPushButton):
- def __init__(
- self, button_label: str, filepath: str, parent: QtWidgets.QWidget = None
- ):
- super(EditYamlButton, self).__init__(button_label)
+ def __init__(self, button_label: str, filepath: str, parent: QtWidgets.QWidget = None):
+ super().__init__(parent)
self.filepath = filepath
self.parent = parent
@@ -260,7 +621,7 @@ def __init__(
file_text: str = None,
parent=None,
):
- super(BrowseFilesButton, self).__init__(button_label)
+ super().__init__(parent)
self.filetype = filetype
self.single_file_only = single_file
self.cwd = cwd
@@ -301,3 +662,48 @@ def browse_files(self):
if filepaths:
self.files.update(filepaths[0])
+
+
+def _create_message_box(text, info_text):
+ msg = QtWidgets.QMessageBox()
+ msg.setIcon(QtWidgets.QMessageBox.Information)
+ msg.setText(text)
+ msg.setInformativeText(info_text)
+
+ msg.setWindowTitle("Info")
+ msg.setMinimumWidth(900)
+ logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep
+ logo = logo_dir + "/assets/logo.png"
+ msg.setWindowIcon(QIcon(logo))
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ return msg
+
+
+def _create_confirmation_box(title, description):
+ msg = QtWidgets.QMessageBox()
+ msg.setIcon(QtWidgets.QMessageBox.Information)
+ msg.setText(title)
+ msg.setInformativeText(description)
+
+ msg.setWindowTitle("Confirmation")
+ msg.setMinimumWidth(900)
+ logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep
+ logo = logo_dir + "/assets/logo.png"
+ msg.setWindowIcon(QIcon(logo))
+ msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ return msg
+
+
+def set_layout_contents_visible(layout: QtWidgets.QLayout, visible: bool):
+ for i in range(layout.count()):
+ item = layout.itemAt(i)
+
+ # If it's a widget item
+ widget = item.widget()
+ if widget is not None:
+ widget.setVisible(visible)
+
+ # If it's a nested layout
+ child_layout = item.layout()
+ if child_layout is not None:
+ set_layout_contents_visible(child_layout, visible)
diff --git a/deeplabcut/gui/dialogs/__init__.py b/deeplabcut/gui/dialogs/__init__.py
new file mode 100644
index 0000000000..abf3848d70
--- /dev/null
+++ b/deeplabcut/gui/dialogs/__init__.py
@@ -0,0 +1,28 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from collections.abc import Sequence
+
+from .debug_dialog import (
+ DebugTextDialog,
+ create_generate_debug_log_action,
+ make_issue_report_provider,
+ make_log_text_provider,
+ show_debug_report_dialog,
+)
+
+__all__: Sequence[str] = (
+ "DebugTextDialog",
+ "create_generate_debug_log_action",
+ "make_issue_report_provider",
+ "make_log_text_provider",
+ "show_debug_report_dialog",
+)
diff --git a/deeplabcut/gui/dialogs/debug_dialog.py b/deeplabcut/gui/dialogs/debug_dialog.py
new file mode 100644
index 0000000000..788284d631
--- /dev/null
+++ b/deeplabcut/gui/dialogs/debug_dialog.py
@@ -0,0 +1,321 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterable
+
+from PySide6 import QtGui
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction, QFontDatabase, QKeySequence, QTextCursor
+from PySide6.QtWidgets import (
+ QApplication,
+ QDialog,
+ QHBoxLayout,
+ QLabel,
+ QPlainTextEdit,
+ QPushButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from deeplabcut.core.debug import (
+ ExecutableSpec,
+ InMemoryDebugRecorder,
+ LibrarySpec,
+ build_debug_report,
+ get_debug_recorder,
+ install_debug_recorder,
+)
+
+
+def make_log_text_provider(
+ *,
+ recorder: InMemoryDebugRecorder | None,
+ limit: int = 300,
+) -> Callable[[], str]:
+ """Return a callable that renders recent captured logs."""
+
+ def _provider() -> str:
+ if recorder is None:
+ return ""
+ return recorder.render_text(limit=limit)
+
+ return _provider
+
+
+def make_issue_report_provider(
+ *,
+ recorder: InMemoryDebugRecorder | None,
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_module_paths: bool = False,
+ include_executable_paths: bool = True,
+ log_limit: int = 300,
+) -> Callable[[], str]:
+ """Return a callable that builds a full DLC debug report.
+
+ ``libraries`` and ``executables`` are normalized to tuples so the returned
+ provider can be called repeatedly even if the caller passed a generator or
+ another one-shot iterable.
+ """
+ libraries_snapshot = None if libraries is None else tuple(libraries)
+ executables_snapshot = None if executables is None else tuple(executables)
+
+ def _provider() -> str:
+ return build_debug_report(
+ recorder=recorder,
+ libraries=libraries_snapshot,
+ executables=executables_snapshot,
+ include_module_paths=include_module_paths,
+ include_executable_paths=include_executable_paths,
+ log_limit=log_limit,
+ )
+
+ return _provider
+
+
+class DebugTextDialog(QDialog):
+ """
+ Minimal, application-agnostic debug text viewer.
+
+ This widget only knows how to:
+ - fetch text from a callable
+ - display it read-only
+ - copy it to clipboard
+ - refresh it on demand
+
+ It intentionally knows nothing about:
+ - recorder internals
+ - DLC main window internals
+ - environment/report formatting
+ """
+
+ def __init__(
+ self,
+ *,
+ title: str,
+ text_provider: Callable[[], str],
+ parent: QWidget | None = None,
+ initial_hint: str = "Read-only diagnostic output",
+ ) -> None:
+ super().__init__(parent=parent)
+ self.setWindowTitle(title)
+ self.setModal(False)
+ self.resize(950, 700)
+
+ self._text_provider = text_provider
+
+ self._build_ui(initial_hint=initial_hint)
+
+ def update_content(
+ self,
+ *,
+ title: str | None = None,
+ text_provider: Callable[[], str] | None = None,
+ hint: str | None = None,
+ ) -> None:
+ """Update dialog metadata when reusing an existing instance."""
+ if title is not None:
+ self.setWindowTitle(title)
+ if text_provider is not None:
+ self._text_provider = text_provider
+ if hint is not None:
+ self._hint_label.setText(hint)
+
+ def _build_ui(self, *, initial_hint: str) -> None:
+ layout = QVBoxLayout(self)
+
+ self._hint_label = QLabel(initial_hint, self)
+ self._hint_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ layout.addWidget(self._hint_label)
+
+ self._text_edit = QPlainTextEdit(self)
+ self._text_edit.setReadOnly(True)
+ self._text_edit.setLineWrapMode(QPlainTextEdit.NoWrap)
+
+ # Use a fixed-width system font for logs / reports
+ font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
+ self._text_edit.setFont(font)
+
+ layout.addWidget(self._text_edit, stretch=1)
+
+ button_row = QHBoxLayout()
+
+ self._status_label = QLabel("", self)
+ self._status_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ button_row.addWidget(self._status_label, stretch=1)
+
+ self._refresh_btn = QPushButton("Refresh", self)
+ self._refresh_btn.clicked.connect(self.refresh_text)
+ button_row.addWidget(self._refresh_btn)
+
+ self._copy_btn = QPushButton("Copy to clipboard", self)
+ self._copy_btn.clicked.connect(self.copy_to_clipboard)
+ button_row.addWidget(self._copy_btn)
+
+ self._close_btn = QPushButton("Close", self)
+ self._close_btn.clicked.connect(self.close)
+ button_row.addWidget(self._close_btn)
+
+ layout.addLayout(button_row)
+
+ # Optional keyboard shortcut
+ copy_action = QAction(self)
+ copy_action.setShortcut(QKeySequence.StandardKey.Copy)
+ copy_action.triggered.connect(self.copy_to_clipboard)
+ self.addAction(copy_action)
+
+ def refresh_text(self) -> None:
+ try:
+ QApplication.setOverrideCursor(Qt.WaitCursor)
+ text = self._text_provider()
+ except Exception as exc:
+ text = f"[debug-dialog] failed to build debug text\n\n{exc!r}"
+ finally:
+ QApplication.restoreOverrideCursor()
+
+ self._text_edit.setPlainText(text or "")
+ self._text_edit.moveCursor(QTextCursor.MoveOperation.Start)
+ self._status_label.setText("")
+
+ def copy_to_clipboard(self) -> None:
+ try:
+ text = self._text_edit.toPlainText()
+ QApplication.clipboard().setText(text)
+ self._status_label.setText("Copied to clipboard")
+ except Exception:
+ self._status_label.setText("Could not copy to clipboard")
+
+ def showEvent(self, event: QtGui.QShowEvent) -> None:
+ """Refresh each time the dialog becomes visible."""
+ super().showEvent(event)
+ self.refresh_text()
+
+
+def _get_or_create_debug_dialog(
+ *,
+ parent: QWidget,
+ title: str,
+ text_provider: Callable[[], str],
+ text_hint: str,
+ attr_name: str = "_dlc_debug_dialog",
+) -> DebugTextDialog:
+ """
+ Reuse a single dialog instance attached to ``parent``.
+
+ Storing the dialog on the main window avoids accidental garbage collection
+ and prevents opening a pile of duplicate windows.
+ """
+ dlg = getattr(parent, attr_name, None)
+ if isinstance(dlg, DebugTextDialog):
+ dlg.update_content(
+ title=title,
+ text_provider=text_provider,
+ hint=text_hint,
+ )
+ return dlg
+
+ dlg = DebugTextDialog(
+ title=title,
+ text_provider=text_provider,
+ parent=parent,
+ initial_hint=text_hint,
+ )
+ setattr(parent, attr_name, dlg)
+ return dlg
+
+
+def show_debug_report_dialog(
+ *,
+ parent: QWidget,
+ recorder: InMemoryDebugRecorder | None = None,
+ logger_name: str = "deeplabcut",
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_module_paths: bool = False,
+ include_executable_paths: bool = True,
+ log_limit: int = 300,
+ dialog_attr_name: str = "_dlc_debug_dialog",
+) -> DebugTextDialog:
+ """
+ Open (or reuse) the full diagnostic report dialog.
+
+ If ``recorder`` is not provided, this function tries to reuse an existing
+ recorder for the given logger namespace and installs one if missing.
+ """
+ if recorder is None:
+ recorder = get_debug_recorder(logger_name=logger_name)
+ if recorder is None:
+ recorder = install_debug_recorder(logger_name=logger_name)
+
+ provider = make_issue_report_provider(
+ recorder=recorder,
+ libraries=libraries,
+ executables=executables,
+ include_module_paths=include_module_paths,
+ include_executable_paths=include_executable_paths,
+ log_limit=log_limit,
+ )
+
+ dlg = _get_or_create_debug_dialog(
+ parent=parent,
+ title="DeepLabCut debug log",
+ text_provider=provider,
+ text_hint=("Diagnostic report for issue reporting. Use Refresh to update, then Copy to clipboard."),
+ attr_name=dialog_attr_name,
+ )
+ # dlg.refresh_text() # redundant
+ dlg.show()
+ dlg.raise_()
+ dlg.activateWindow()
+ return dlg
+
+
+def create_generate_debug_log_action(
+ *,
+ parent: QWidget,
+ recorder: InMemoryDebugRecorder | None = None,
+ logger_name: str = "deeplabcut",
+ libraries: Iterable[LibrarySpec | str] | None = None,
+ executables: Iterable[ExecutableSpec | str] | None = None,
+ include_module_paths: bool = False,
+ include_executable_paths: bool = True,
+ log_limit: int = 300,
+ text: str = "&Generate debug log...",
+ status_tip: str = "Generate a diagnostic report for troubleshooting",
+ dialog_attr_name: str = "_dlc_debug_dialog",
+) -> QAction:
+ """
+ Create a QAction that opens the DLC debug report dialog.
+
+ Typical usage in ``MainWindow.create_actions``::
+
+ self.generateDebugLogAction = create_generate_debug_log_action(parent=self)
+ """
+ action = QAction(text, parent)
+ action.setStatusTip(status_tip)
+
+ def _open_dialog() -> None:
+ show_debug_report_dialog(
+ parent=parent,
+ recorder=recorder,
+ logger_name=logger_name,
+ libraries=libraries,
+ executables=executables,
+ include_module_paths=include_module_paths,
+ include_executable_paths=include_executable_paths,
+ log_limit=log_limit,
+ dialog_attr_name=dialog_attr_name,
+ )
+
+ action.triggered.connect(_open_dialog)
+ return action
diff --git a/deeplabcut/gui/displays/__init__.py b/deeplabcut/gui/displays/__init__.py
new file mode 100644
index 0000000000..117d127147
--- /dev/null
+++ b/deeplabcut/gui/displays/__init__.py
@@ -0,0 +1,10 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/gui/displays/selected_shuffle_display.py b/deeplabcut/gui/displays/selected_shuffle_display.py
new file mode 100644
index 0000000000..d1ae3732b1
--- /dev/null
+++ b/deeplabcut/gui/displays/selected_shuffle_display.py
@@ -0,0 +1,125 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Module to display information about the selected shuffle in the GUI."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import PySide6.QtCore as QtCore
+from PySide6 import QtWidgets
+
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils import auxiliaryfunctions
+
+
+class SelectedShuffleDisplay(QtWidgets.QWidget):
+ """A widget displaying information about the selected shuffle."""
+
+ pose_cfg_signal = QtCore.Signal(dict)
+
+ def __init__(self, root, row_margin: int = 25):
+ super().__init__()
+ self.root = root
+
+ self._row_margin = row_margin
+
+ self._current_index: int | None = None
+ self._engine: Engine | None = None
+ self._is_top_down: bool = False
+ self._net_type: str | None = None
+ self._pose_cfg: dict | None = None
+
+ self._label = QtWidgets.QLabel("Shuffle info:")
+ self._label.setStyleSheet(f"margin: 0px 0px {self._row_margin}px 0px")
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(self._label)
+ self.setLayout(layout)
+
+ # initialize the display
+ self._update_display(self.root.shuffle_value)
+
+ # update the display when the shuffle or selected engine changes, or when a new
+ # shuffle has been created
+ self.root.shuffle_change.connect(self._update_display)
+ self.root.engine_change.connect(self._update_display)
+ self.root.shuffle_created.connect(self._update_display)
+
+ @property
+ def pose_cfg(self) -> dict | None:
+ return self._pose_cfg
+
+ @pose_cfg.setter
+ def pose_cfg(self, value: dict | None) -> None:
+ self._pose_cfg = value
+ self.pose_cfg_signal.emit(self._pose_cfg)
+
+ @QtCore.Slot(int)
+ def _update_display(self, new_index: int) -> None:
+ self._current_index = new_index
+
+ try:
+ pose_cfg_path = Path(self.root.pose_cfg_path)
+ except ValueError:
+ self._set_text_error(f"Failed to read shuffle {self._current_index} - check that it exists!")
+ return
+ except ModuleNotFoundError as err:
+ # Loading a TF shuffle but TF is not installed
+ self._set_text_error(
+ f"Failed to read shuffle {self._current_index} due to error `{err}`.\n"
+ "If the error is `ModuleNotFoundError: No module named 'tensorflow'`, "
+ f"this is because\nshuffle {self._current_index} uses the tensorflow "
+ " engine, but TensorFlow is not installed in your environment.\n"
+ "Ignore this error if you'll just train PyTorch models. To train "
+ "TensorFlow models, install it with \n"
+ " Windows/Linux: pip install 'deeplabcut[tf]'\n"
+ " Apple Silicon: pip install 'deeplabcut[apple_mchips]'"
+ )
+ return
+
+ if not pose_cfg_path.exists():
+ self._set_text_error(f"The model configuration file {pose_cfg_path} was not created")
+ return
+
+ self._read_pose_config(pose_cfg_path)
+ self._set_text()
+
+ def _set_text(self) -> None:
+ engine_str = "None"
+ if self._engine is not None:
+ engine_str = self._engine.aliases[0]
+
+ text = f"net type: {self._net_type} | engine: {engine_str}"
+ if self._engine == Engine.PYTORCH and self._is_top_down:
+ text += " | top-down"
+
+ style = f"margin: 0px 0px {self._row_margin}px 0px;"
+ if self._engine != self.root.engine:
+ warning = "Change the selected Engine in the top-right to use this shuffle!"
+ text = warning + " | " + text
+ style += " color: orange;"
+
+ self._label.setStyleSheet(style)
+ self._label.setText(text)
+
+ def _set_text_error(self, error: str) -> None:
+ self._label.setText(error)
+ style = f"margin: 0px 0px {self._row_margin}px 0px; color: orange;"
+ self._label.setStyleSheet(style)
+ self.pose_cfg = None
+
+ def _read_pose_config(self, pose_cfg_path: Path) -> None:
+ pose_cfg = auxiliaryfunctions.read_plainconfig(str(pose_cfg_path))
+
+ self._engine = Engine.PYTORCH if "pytorch" in pose_cfg_path.stem.lower() else Engine.TF
+ self._net_type = pose_cfg.get("net_type", "UNKNOWN")
+ self._is_top_down = self._engine == Engine.PYTORCH and pose_cfg.get("method").lower() == "td"
+ self.pose_cfg = pose_cfg
diff --git a/deeplabcut/gui/displays/shuffle_metadata_viewer.py b/deeplabcut/gui/displays/shuffle_metadata_viewer.py
new file mode 100644
index 0000000000..ec44d5a725
--- /dev/null
+++ b/deeplabcut/gui/displays/shuffle_metadata_viewer.py
@@ -0,0 +1,63 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Widget to display existing shuffles."""
+
+from __future__ import annotations
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+import deeplabcut.generate_training_dataset.metadata as metadata
+
+
+class ShuffleMetadataViewer(QtWidgets.QDialog):
+ """Viewer for shuffle metadata."""
+
+ def __init__(self, root: QtWidgets.QMainWindow, parent: QtWidgets.QWidget):
+ super().__init__(parent)
+ self.root = root
+ self.parent = parent
+ self.file_content = _load_metadata(self.root.cfg)
+
+ self.setWindowTitle("Existing Shuffles: Metadata")
+ self.setMinimumWidth(400)
+ self.setMinimumHeight(400)
+
+ scroll = QtWidgets.QScrollArea()
+ scroll.setWidgetResizable(True)
+
+ inner_layout = QtWidgets.QVBoxLayout()
+ inner_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+ inner_layout.setSpacing(0)
+ inner_layout.setContentsMargins(0, 0, 0, 0)
+
+ for line in self.file_content:
+ inner_layout.addWidget(QtWidgets.QLabel(line))
+
+ inner = QtWidgets.QFrame(scroll)
+ inner.setLayout(inner_layout)
+ scroll.setWidget(inner)
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addWidget(scroll)
+ self.setLayout(layout)
+
+
+def _load_metadata(cfg: dict) -> list[str]:
+ metadata_path = metadata.TrainingDatasetMetadata.path(cfg)
+ if not metadata_path.exists():
+ trainset_meta = metadata.TrainingDatasetMetadata.create(cfg)
+ trainset_meta.save()
+
+ with open(metadata_path) as file:
+ raw_metadata = file.read()
+
+ return raw_metadata.split("\n")
diff --git a/deeplabcut/gui/dlc_params.py b/deeplabcut/gui/dlc_params.py
index 2a267aac67..fce5d15a52 100644
--- a/deeplabcut/gui/dlc_params.py
+++ b/deeplabcut/gui/dlc_params.py
@@ -13,6 +13,7 @@ class DLCParams:
"",
"avi",
"mp4",
+ "mkv",
"mov",
]
@@ -30,8 +31,6 @@ class DLCParams:
"efficientnet-b6",
]
- IMAGE_AUGMENTERS = ["default", "tensorpack", "imgaug"]
-
FRAME_EXTRACTION_ALGORITHMS = ["kmeans", "uniform"]
OUTLIER_EXTRACTION_ALGORITHMS = ["jump", "fitting", "uncertain", "manual"]
diff --git a/deeplabcut/gui/launch_script.py b/deeplabcut/gui/launch_script.py
index 6ada864102..698070c0e0 100644
--- a/deeplabcut/gui/launch_script.py
+++ b/deeplabcut/gui/launch_script.py
@@ -18,16 +18,17 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import sys
+
import os
-import logging
+import sys
import PySide6.QtWidgets as QtWidgets
import qdarkstyle
-from deeplabcut.gui import BASE_DIR
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap
+from deeplabcut.gui import BASE_DIR
+
def launch_dlc():
app = QtWidgets.QApplication(sys.argv)
@@ -40,7 +41,7 @@ def launch_dlc():
splash.show()
stylefile = os.path.join(BASE_DIR, "style.qss")
- with open(stylefile, "r") as f:
+ with open(stylefile) as f:
app.setStyleSheet(f.read())
dark_stylesheet = qdarkstyle.load_stylesheet_pyside2()
diff --git a/deeplabcut/gui/media/dlc-pt.png b/deeplabcut/gui/media/dlc-pt.png
new file mode 100644
index 0000000000..d0ac99c187
Binary files /dev/null and b/deeplabcut/gui/media/dlc-pt.png differ
diff --git a/deeplabcut/gui/media/dlc-tf.png b/deeplabcut/gui/media/dlc-tf.png
new file mode 100644
index 0000000000..79d06f0528
Binary files /dev/null and b/deeplabcut/gui/media/dlc-tf.png differ
diff --git a/deeplabcut/gui/style.qss b/deeplabcut/gui/style.qss
index 19164d5d57..feaf66e3d4 100644
--- a/deeplabcut/gui/style.qss
+++ b/deeplabcut/gui/style.qss
@@ -1,8 +1,8 @@
- /*
+ /*
Variables used
--------------
- widgets height: 25px
+ widgets height: 25px
*/
@@ -28,4 +28,4 @@ QComboBox{
QLineEdit{
height: 25px;
-}
\ No newline at end of file
+}
diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py
index 40f651f044..1131ebfc3a 100644
--- a/deeplabcut/gui/tabs/analyze_videos.py
+++ b/deeplabcut/gui/tabs/analyze_videos.py
@@ -8,30 +8,51 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from dataclasses import dataclass
from functools import partial
+from pathlib import Path
+
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
-from deeplabcut.gui.utils import move_to_separate_thread
-from deeplabcut.gui.widgets import ConfigEditor
+import deeplabcut
from deeplabcut.gui.components import (
- DefaultTab,
BodypartListWidget,
+ DefaultTab,
ShuffleSpinBox,
VideoSelectionWidget,
_create_grid_layout,
- _create_label_widget,
_create_horizontal_layout,
+ _create_label_widget,
_create_vertical_layout,
)
-
-import deeplabcut
+from deeplabcut.gui.utils import move_to_separate_thread
+from deeplabcut.gui.widgets import ConfigEditor
from deeplabcut.utils.auxiliaryfunctions import edit_config
+@dataclass(frozen=True)
+class AnalyzeVideosOptions:
+ config: str
+ shuffle: int
+ save_as_csv: bool
+ filter_data: bool
+ plot_trajectories: bool
+ show_trajectory_plots: bool
+ displayed_bodyparts: tuple[str, ...]
+ create_video_all_detections: bool
+ auto_track: bool
+ calibrate_assembly: bool
+ assemble_with_ID_only: bool
+ num_animals_in_videos: int | None
+ cropping: tuple[int, int, int, int] | None
+ dynamic_cropping_params: tuple[bool, float, int]
+ track_method: str | None
+
+
class AnalyzeVideos(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(AnalyzeVideos, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self._set_page()
@@ -41,7 +62,9 @@ def files(self):
def _set_page(self):
self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
- self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.video_selection_widget = VideoSelectionWidget(
+ self.root, self, hide_videotype=True, sync_videotype_with_selection=True
+ )
self.main_layout.addWidget(self.video_selection_widget)
tmp_layout = _create_horizontal_layout()
@@ -114,12 +137,6 @@ def _generate_layout_other_options(self, layout):
tmp_layout.addWidget(self.save_as_csv)
- self.save_as_nwb = QtWidgets.QCheckBox("Save result(s) as nwb")
- self.save_as_nwb.setCheckState(Qt.Unchecked)
- self.save_as_nwb.stateChanged.connect(self.update_nwb_choice)
-
- tmp_layout.addWidget(self.save_as_csv)
-
# Filter predictions
self.filter_predictions = QtWidgets.QCheckBox("Filter predictions")
self.filter_predictions.setCheckState(Qt.Unchecked)
@@ -178,66 +195,52 @@ def _generate_layout_multianimal(self, layout):
self.calibrate_assembly_checkbox = QtWidgets.QCheckBox("Calibrate assembly")
self.calibrate_assembly_checkbox.setCheckState(Qt.Unchecked)
- self.calibrate_assembly_checkbox.stateChanged.connect(
- self.update_calibrate_assembly
- )
+ self.calibrate_assembly_checkbox.stateChanged.connect(self.update_calibrate_assembly)
tmp_layout.addWidget(self.calibrate_assembly_checkbox, 0, 2)
- self.assemble_with_ID_only_checkbox = QtWidgets.QCheckBox(
- "Assemble with ID only"
- )
+ self.assemble_with_ID_only_checkbox = QtWidgets.QCheckBox("Assemble with ID only")
self.assemble_with_ID_only_checkbox.setCheckState(Qt.Unchecked)
- self.assemble_with_ID_only_checkbox.stateChanged.connect(
- self.update_assemble_with_ID_only
- )
+ self.assemble_with_ID_only_checkbox.stateChanged.connect(self.update_assemble_with_ID_only)
tmp_layout.addWidget(self.assemble_with_ID_only_checkbox, 0, 3)
- self.create_detections_video_checkbox = QtWidgets.QCheckBox(
- "Create video with all detections"
- )
+ self.create_detections_video_checkbox = QtWidgets.QCheckBox("Create video with all detections")
self.create_detections_video_checkbox.setCheckState(Qt.Unchecked)
- self.create_detections_video_checkbox.stateChanged.connect(
- self.update_create_video_detections
- )
+ self.create_detections_video_checkbox.stateChanged.connect(self.update_create_video_detections)
tmp_layout.addWidget(self.create_detections_video_checkbox, 0, 4)
layout.addLayout(tmp_layout)
def update_create_video_detections(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Create video with all detections {s}")
def update_assemble_with_ID_only(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Assembly with ID only {s}")
def update_calibrate_assembly(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Assembly calibration {s}")
def update_tracker_type(self, method):
self.root.logger.info(f"Using {method.upper()} tracker")
def update_csv_choice(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Save results as CSV {s}")
- def update_nwb_choice(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
- self.root.logger.info(f"Save results as NWB {s}")
-
def update_filter_choice(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Filtering predictions {s}")
def update_showfigs_choice(self, state):
- if state == Qt.Checked:
+ if Qt.CheckState(state) == Qt.Checked:
self.root.logger.info("Plots will show as pop ups.")
else:
self.root.logger.info("Plots will not show up.")
def update_crop_choice(self, state):
- if state == Qt.Checked:
+ if Qt.CheckState(state) == Qt.Checked:
self.root.logger.info("Dynamic bodypart cropping ENABLED.")
self.dynamic_cropping = True
else:
@@ -245,7 +248,8 @@ def update_crop_choice(self, state):
self.dynamic_cropping = False
def update_plot_trajectory_choice(self, state):
- if state == Qt.Checked:
+ if Qt.CheckState(state) == Qt.Checked:
+ self.bodyparts_list_widget.refresh()
self.bodyparts_list_widget.show()
self.bodyparts_list_widget.setEnabled(True)
self.show_trajectory_plots.setEnabled(True)
@@ -264,32 +268,31 @@ def edit_config_file(self):
editor = ConfigEditor(self.root.config)
editor.show()
- def analyze_videos(self):
+ def _collect_options(self) -> AnalyzeVideosOptions:
config = self.root.config
shuffle = self.root.shuffle_value
-
- videos = list(self.files)
- save_as_csv = self.save_as_csv.checkState() == Qt.Checked
- videotype = self.video_selection_widget.videotype_widget.currentText()
+ save_as_csv = self.save_as_csv.isChecked()
+ filter_data = self.filter_predictions.isChecked()
+ plot_trajectories = self.plot_trajectories.isChecked()
+ show_trajectory_plots = self.show_trajectory_plots.isChecked()
+ displayed_bodyparts = tuple(self.bodyparts_list_widget.selected_bodyparts) if plot_trajectories else ()
if self.root.is_multianimal:
- calibrate_assembly = (
- self.calibrate_assembly_checkbox.checkState() == Qt.Checked
- )
- assemble_with_ID_only = (
- self.assemble_with_ID_only_checkbox.checkState() == Qt.Checked
- )
+ calibrate_assembly = self.calibrate_assembly_checkbox.isChecked()
+ assemble_with_ID_only = self.assemble_with_ID_only_checkbox.isChecked()
track_method = self.tracker_type_widget.currentText()
- edit_config(self.root.config, {"default_track_method": track_method})
num_animals_in_videos = self.num_animals_in_videos.value()
+ create_video_all_detections = self.create_detections_video_checkbox.isChecked()
else:
calibrate_assembly = False
- num_animals_in_videos = None
assemble_with_ID_only = False
+ track_method = None
+ num_animals_in_videos = None
+ create_video_all_detections = False
cropping = None
-
- if self.root.cfg["cropping"] == "True":
+ crop_flag = self.root.cfg.get("cropping", False)
+ if str(crop_flag).lower() == "true":
cropping = (
self.root.cfg["x1"],
self.root.cfg["x2"],
@@ -298,94 +301,143 @@ def analyze_videos(self):
)
dynamic_cropping_params = (False, 0.5, 10)
- try:
- if self.dynamic_cropping:
- dynamic_cropping_params = (True, 0.5, 10)
- except AttributeError:
- pass
-
- func = partial(
- deeplabcut.analyze_videos,
- config,
- videos=videos,
- videotype=videotype,
+ if getattr(self, "dynamic_cropping", False):
+ dynamic_cropping_params = (True, 0.5, 10)
+
+ return AnalyzeVideosOptions(
+ config=config,
shuffle=shuffle,
save_as_csv=save_as_csv,
- cropping=cropping,
- dynamic=dynamic_cropping_params,
+ filter_data=filter_data,
+ plot_trajectories=plot_trajectories,
+ show_trajectory_plots=show_trajectory_plots,
+ displayed_bodyparts=displayed_bodyparts,
+ create_video_all_detections=create_video_all_detections,
auto_track=self.root.is_multianimal,
- n_tracks=num_animals_in_videos,
- calibrate=calibrate_assembly,
- identity_only=assemble_with_ID_only,
+ calibrate_assembly=calibrate_assembly,
+ assemble_with_ID_only=assemble_with_ID_only,
+ num_animals_in_videos=num_animals_in_videos,
+ cropping=cropping,
+ dynamic_cropping_params=dynamic_cropping_params,
+ track_method=track_method,
)
- self.worker, self.thread = move_to_separate_thread(func)
- self.worker.finished.connect(lambda: self.analyze_videos_btn.setEnabled(True))
- self.worker.finished.connect(lambda: self.root._progress_bar.hide())
- self.worker.finished.connect(lambda: self.run_enabled())
- self.thread.start()
- self.analyze_videos_btn.setEnabled(False)
- self.root._progress_bar.show()
-
- def run_enabled(self):
- config = self.root.config
- shuffle = self.root.shuffle_value
-
- videos = list(self.files)
- save_as_csv = self.save_as_csv.checkState() == Qt.Checked
- save_as_nwb = self.save_as_nwb.checkState() == Qt.Checked
- filter_data = self.filter_predictions.checkState() == Qt.Checked
- videotype = self.video_selection_widget.videotype_widget.currentText()
- try:
- create_video_all_detections = (
- self.create_detections_video_checkbox.checkState() == Qt.Checked
- )
- except AttributeError:
- create_video_all_detections = False
- if create_video_all_detections:
+ def _get_video_batches(self):
+ """
+ Returns a list of (videotype, videos) pairs.
+ videotype should include the leading dot, e.g. '.avi'.
+ """
+ groups = self.video_selection_widget.get_files_grouped_by_suffix(keep_dot=True)
+ batches = [(suffix, videos) for suffix, videos in sorted(groups.items()) if suffix]
+ return batches
+
+ def _get_unique_video_parent_folders(self, batches: list[tuple[str, list[str]]]) -> list[str]:
+ folders = []
+ seen = set()
+
+ for _, videos in batches:
+ for video in videos:
+ parent = str(Path(video).parent.resolve())
+ if parent not in seen:
+ seen.add(parent)
+ folders.append(parent)
+
+ return folders
+
+ def _run_pipeline(self, options: AnalyzeVideosOptions, batches: list[tuple[str, list[str]]]):
+ for videotype, videos in batches:
+ try:
+ self.root.logger.info(f"Analyzing {len(videos)} video(s) with extension {videotype}")
+
+ deeplabcut.analyze_videos(
+ options.config,
+ videos=videos,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
+ save_as_csv=options.save_as_csv,
+ cropping=options.cropping,
+ dynamic=options.dynamic_cropping_params,
+ auto_track=options.auto_track,
+ n_tracks=options.num_animals_in_videos,
+ calibrate=options.calibrate_assembly,
+ identity_only=options.assemble_with_ID_only,
+ )
+
+ self._run_postprocessing_for_group(options, videotype, videos)
+ except Exception as e:
+ exc = f"Error analyzing videos {videos} with extension {videotype}: {e}"
+ self.root.logger.error(exc, exc_info=True)
+ raise RuntimeError(exc) from e
+
+ # Run CSV conversion once per unique folder, after all batches
+ if options.auto_track and options.save_as_csv:
+ self._convert_outputs_to_csv_once_per_folder(batches)
+
+ def _run_postprocessing_for_group(
+ self,
+ options: AnalyzeVideosOptions,
+ videotype: str,
+ videos: list[str],
+ ):
+ if options.create_video_all_detections:
deeplabcut.create_video_with_all_detections(
- config,
+ options.config,
videos=videos,
- videotype=videotype,
- shuffle=shuffle,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
)
- if filter_data:
+ if options.filter_data:
deeplabcut.filterpredictions(
- config,
+ options.config,
video=videos,
- videotype=videotype,
- shuffle=shuffle,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
filtertype="median",
windowlength=5,
- save_as_csv=save_as_csv,
+ save_as_csv=options.save_as_csv,
+ track_method=options.track_method,
)
- if self.plot_trajectories.checkState() == Qt.Checked:
- bdpts = self.bodyparts_list_widget.selected_bodyparts
- self.root.logger.debug(
- f"Selected body parts for plot_trajectories: {bdpts}"
- )
- showfig = self.show_trajectory_plots.checkState() == Qt.Checked
+ if options.plot_trajectories:
deeplabcut.plot_trajectories(
- config,
+ options.config,
videos=videos,
- displayedbodyparts=bdpts,
- videotype=videotype,
- shuffle=shuffle,
- filtered=filter_data,
- showfigures=showfig,
+ displayedbodyparts=options.displayed_bodyparts,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
+ filtered=options.filter_data,
+ showfigures=options.show_trajectory_plots,
+ track_method=options.track_method,
)
- if self.root.is_multianimal and save_as_csv:
+ def _convert_outputs_to_csv_once_per_folder(self, batches: list[tuple[str, list[str]]]):
+ folders = self._get_unique_video_parent_folders(batches)
+
+ for folder in folders:
+ self.root.logger.info(f"Converting H5 outputs to CSV in folder: {folder}")
deeplabcut.analyze_videos_converth5_to_csv(
- videos,
- listofvideos=True,
+ folder,
+ listofvideos=False,
)
- if save_as_nwb:
- deeplabcut.analyze_videos_converth5_to_nwb(
- config,
- videos,
- listofvideos=True,
- )
+ def analyze_videos(self):
+ options = self._collect_options()
+ batches = self._get_video_batches()
+
+ if not batches:
+ self.root.logger.warning("No videos selected.")
+ return
+
+ # Keep config in sync with GUI choice before launching worker
+ if self.root.is_multianimal and options.track_method is not None:
+ edit_config(self.root.config, {"default_track_method": options.track_method})
+
+ func = partial(self._run_pipeline, options, batches)
+
+ self.worker, self.thread = move_to_separate_thread(func)
+ self.worker.finished.connect(lambda: self.analyze_videos_btn.setEnabled(True))
+ self.worker.finished.connect(lambda: self.root._progress_bar.hide())
+ self.thread.start()
+ self.analyze_videos_btn.setEnabled(False)
+ self.root._progress_bar.show()
diff --git a/deeplabcut/gui/tabs/create_project.py b/deeplabcut/gui/tabs/create_project.py
index 6eaf66fa38..615806df27 100644
--- a/deeplabcut/gui/tabs/create_project.py
+++ b/deeplabcut/gui/tabs/create_project.py
@@ -4,26 +4,183 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
import os
from datetime import datetime
-import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions
+from PySide6 import QtCore, QtWidgets
+from PySide6.QtGui import QBrush, QColor, QDesktopServices, QIcon, QPainter, QPen
+
+from deeplabcut.create_project import create_new_project, create_new_project_3d
from deeplabcut.gui import BASE_DIR
from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.tabs.docs import (
+ URL_3D,
+ URL_MA_CONFIGURE,
+ URL_USE_GUIDE_SCENARIO,
+)
from deeplabcut.gui.widgets import ClickableLabel, ItemSelectionFrame
+from deeplabcut.utils import auxiliaryfunctions
-from PySide6 import QtCore, QtWidgets
-from PySide6.QtGui import QIcon
+
+class DynamicTextList(QtWidgets.QWidget):
+ """Dynamically add text entries."""
+
+ def __init__(self, label_text="bodyparts", parent=None):
+ super().__init__(parent)
+ self.label_text = label_text
+ self.layout = QtWidgets.QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+
+ # Set maximum width for the widget
+ self.setMaximumWidth(300)
+
+ # Add explanatory label
+ label = QtWidgets.QLabel(label_text)
+ self.layout.addWidget(label)
+
+ # Create scroll area and its widget
+ self.scroll = QtWidgets.QScrollArea()
+ self.scroll.setWidgetResizable(True)
+ self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
+ self.scroll.setFrameShape(QtWidgets.QFrame.NoFrame) # Remove frame border
+
+ # Create widget to hold the entries
+ self.entries_widget = QtWidgets.QWidget()
+ self.entries_layout = QtWidgets.QVBoxLayout(self.entries_widget)
+ self.entries_layout.setContentsMargins(0, 0, 0, 0)
+ self.entries_layout.setSpacing(5) # Consistent spacing between entries
+ self.entries_layout.setAlignment(QtCore.Qt.AlignTop) # Align entries to top
+
+ # Add stretch at the bottom to keep entries at top
+ self.entries_layout.addStretch()
+
+ self.scroll.setWidget(self.entries_widget)
+
+ # Set fixed height for 6 items
+ self.entry_height = 30 # Fixed height for each entry
+ self.padding = 10 # Extra padding
+ self.scroll.setFixedHeight(5 * self.entry_height + self.padding)
+
+ # Add scroll area to main layout
+ self.layout.addWidget(self.scroll)
+
+ self.entries = []
+ self.add_entry()
+
+ def add_entry(self):
+ # Create horizontal layout for index and entry
+ entry_layout = QtWidgets.QHBoxLayout()
+ entry_layout.setContentsMargins(0, 0, 10, 0)
+ entry_layout.setSpacing(5) # Consistent spacing between index and entry
+
+ # Create container widget for the entry row
+ entry_widget = QtWidgets.QWidget()
+ entry_widget.setFixedHeight(self.entry_height)
+ entry_widget.setLayout(entry_layout)
+
+ # Add index label
+ index_label = QtWidgets.QLabel(str(len(self.entries) + 1) + ".")
+ index_label.setFixedWidth(20) # Set fixed width for alignment
+ entry_layout.addWidget(index_label)
+
+ # Add text entry
+ entry = QtWidgets.QLineEdit()
+ entry.setFixedHeight(self.entry_height - 6) # Slightly smaller than container
+ entry.textChanged.connect(self._on_text_changed)
+ entry.textEdited.connect(lambda text: self._check_for_spaces(entry, text))
+ self.entries.append((entry, index_label)) # Store both widgets
+ entry_layout.addWidget(entry)
+
+ # Insert the new entry before the stretch
+ self.entries_layout.insertWidget(len(self.entries) - 1, entry_widget)
+
+ def _check_for_spaces(self, entry, text):
+ if " " in text:
+ msg = QtWidgets.QMessageBox()
+ msg.setIcon(QtWidgets.QMessageBox.Warning)
+ msg.setText(f"Spaces are not allowed in the {self.label_text} list. Use underscores instead.")
+ msg.setWindowTitle("Warning")
+ msg.exec_()
+ entry.setText(entry.text().replace(" ", "_"))
+
+ def _on_text_changed(self):
+ # If the last entry has text, add a new empty entry
+ if self.entries[-1][0].text():
+ self.add_entry()
+
+ # Remove any empty entries except the last one
+ entries_to_remove = []
+ for i, (entry, _) in enumerate(self.entries[:-1]):
+ if not entry.text():
+ entries_to_remove.append(i)
+
+ for i in reversed(entries_to_remove):
+ entry_widget = self.entries[i][0].parent()
+ self.entries_layout.removeWidget(entry_widget)
+ entry_widget.deleteLater()
+ self.entries.pop(i)
+
+ self._update_indices() # Update the indices after removal
+
+ def get_entries(self):
+ return [entry[0].text() for entry in self.entries if entry[0].text()]
+
+ def _update_indices(self):
+ for i, (_entry, index_label) in enumerate(self.entries):
+ index_label.setText(str(i + 1) + ".")
+
+
+class Switch(QtWidgets.QPushButton):
+ def __init__(self, on_text="Yes", off_text="No", width=80, parent=None):
+ super().__init__(parent)
+ self.on_text = on_text
+ self.off_text = off_text
+ self.setCheckable(True)
+ self.setFixedWidth(width)
+ self.setMinimumHeight(22)
+
+ def paintEvent(self, event):
+ # Colors: https://qdarkstylesheet.readthedocs.io/en/latest/color_reference.html
+ label = self.on_text if self.isChecked() else self.off_text
+ bg_color = "#00ff00" if self.isChecked() else "#9DA9B5"
+
+ radius = 10
+ width = 32
+ center = self.rect().center()
+
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.translate(center)
+ painter.setBrush(QColor(69, 83, 100)) # Lighter gray background
+
+ pen = QPen("#455364")
+ pen.setWidth(2)
+ painter.setPen(pen)
+
+ painter.drawRoundedRect(QtCore.QRect(-width, -radius, 2 * width, 2 * radius), radius, radius)
+ painter.setBrush(QBrush(bg_color))
+ sw_rect = QtCore.QRect(-radius, -radius, width + radius, 2 * radius)
+ if not self.isChecked():
+ sw_rect.moveLeft(-width)
+
+ painter.drawRoundedRect(sw_rect, radius, radius)
+
+ pen = QPen("#000000")
+ pen.setWidth(2)
+ painter.setPen(pen)
+ painter.drawText(sw_rect, QtCore.Qt.AlignCenter, label)
class ProjectCreator(QtWidgets.QDialog):
+ """Project creation dialog."""
+
def __init__(self, parent):
- super(ProjectCreator, self).__init__(parent)
+ super().__init__(parent)
self.parent = parent
self.setWindowTitle("New Project")
self.setModal(True)
@@ -34,6 +191,19 @@ def __init__(self, parent):
self.exp_default = ""
self.loc_default = parent.project_folder
+ self.bodypart_list = None
+ self.individuals_list = None
+ self.unique_bodyparts_list = None
+
+ self.toggle_3d = Switch()
+ self.toggle_3d.setChecked(False)
+ self.madlc_toggle = Switch()
+ self.madlc_toggle.setChecked(False)
+ self.unique_toggle = Switch()
+ self.unique_toggle.setChecked(False)
+ self.identity_toggle = Switch()
+ self.identity_toggle.setChecked(False)
+
main_layout = QtWidgets.QVBoxLayout(self)
self.user_frame = self.lay_out_user_frame()
self.video_frame = self.lay_out_video_frame()
@@ -80,56 +250,195 @@ def lay_out_user_frame(self):
grid.addWidget(self.loc_line, 2, 1)
vbox.addLayout(grid)
- self.madlc_box = QtWidgets.QCheckBox("Is it a multi-animal project?")
- self.madlc_box.setChecked(False)
- vbox.addWidget(self.madlc_box)
+ widget_3d = self.build_toggle_widget(
+ switch=self.toggle_3d,
+ question="Do you want to create a 3D pose estimation project?",
+ help_text="(What is needed for a 3D project?)",
+ docs_link=URL_3D,
+ )
+ madlc_widget = self.build_toggle_widget(
+ switch=self.madlc_toggle,
+ question="Are there multiple individuals in your videos?",
+ help_text="(Why does this matter?)",
+ docs_link=URL_USE_GUIDE_SCENARIO,
+ )
+ # Only visible when the maDLC widget is checked
+ unique_widget = self.build_toggle_widget(
+ switch=self.unique_toggle,
+ question="Do you have unique bodyparts in your video?",
+ help_text="(What are unique bodyparts?)",
+ docs_link=URL_MA_CONFIGURE,
+ )
+ unique_widget.setVisible(False)
+
+ # Labelling with identity
+ identity_widget = self.build_toggle_widget(
+ switch=self.identity_toggle,
+ question="Label with identity?",
+ help_text="(What is labeling with identity?)",
+ docs_link=URL_MA_CONFIGURE,
+ )
+ identity_widget.setVisible(False)
+
+ vbox.addWidget(widget_3d, alignment=QtCore.Qt.AlignTop)
+ vbox.addWidget(madlc_widget, alignment=QtCore.Qt.AlignTop)
+ vbox.addWidget(unique_widget, alignment=QtCore.Qt.AlignTop)
+ vbox.addWidget(identity_widget, alignment=QtCore.Qt.AlignTop)
+
+ # Create horizontal layout for the two lists
+ lists_layout = QtWidgets.QHBoxLayout()
+ lists_layout.setAlignment(QtCore.Qt.AlignTop)
+
+ # Create both DynamicTextList widgets as class attributes
+ self.bodypart_list = DynamicTextList(
+ label_text="Bodyparts to track",
+ parent=self,
+ )
+
+ self.individuals_list = DynamicTextList(
+ label_text="Individual names",
+ parent=self,
+ )
+ self.individuals_list.setVisible(False)
+
+ self.unique_bodyparts_list = DynamicTextList(
+ label_text="Unique bodyparts to track",
+ parent=self,
+ )
+ self.unique_bodyparts_list.setVisible(False)
+
+ # Connect toggle state to individuals list visibility, unique, identity
+ self.madlc_toggle.toggled.connect(self.individuals_list.setVisible)
+ self.madlc_toggle.toggled.connect(unique_widget.setVisible)
+ self.madlc_toggle.toggled.connect(identity_widget.setVisible)
+
+ # Connect the unique_toggle to the unique_bodyparts_list
+ self.unique_toggle.toggled.connect(
+ lambda yes: self.unique_bodyparts_list.setVisible(yes and self.madlc_toggle.isChecked())
+ )
+
+ # Connect 3d toggle to all other option visibility
+ self.toggle_3d.toggled.connect(lambda yes: madlc_widget.setVisible(not yes))
+ self.toggle_3d.toggled.connect(
+ lambda checked_3d: unique_widget.setVisible(not checked_3d and self.madlc_toggle.isChecked())
+ )
+ self.toggle_3d.toggled.connect(
+ lambda checked_3d: identity_widget.setVisible(not checked_3d and self.madlc_toggle.isChecked())
+ )
+ self.toggle_3d.toggled.connect(lambda checked_3d: self.bodypart_list.setVisible(not checked_3d))
+ self.toggle_3d.toggled.connect(
+ lambda checked_3d: self.individuals_list.setVisible(not checked_3d and self.madlc_toggle.isChecked())
+ )
+ self.toggle_3d.toggled.connect(
+ lambda checked_3d: self.unique_bodyparts_list.setVisible(
+ not checked_3d and self.madlc_toggle.isChecked() and self.unique_toggle.isChecked()
+ )
+ )
+
+ # Add both lists to the horizontal layout with top alignment
+ lists_layout.addWidget(self.bodypart_list, alignment=QtCore.Qt.AlignTop)
+ lists_layout.addWidget(self.individuals_list, alignment=QtCore.Qt.AlignTop)
+ lists_layout.addWidget(self.unique_bodyparts_list, alignment=QtCore.Qt.AlignTop)
+
+ # Add the horizontal layout to the main vertical layout
+ vbox.addLayout(lists_layout)
return user_frame
+ def build_toggle_widget(
+ self,
+ switch: Switch,
+ question: str,
+ help_text: str,
+ docs_link: str,
+ ) -> QtWidgets.QWidget:
+ toggle_layout = QtWidgets.QHBoxLayout()
+ toggle_layout.setContentsMargins(0, 0, 0, 0)
+ toggle_layout.setSpacing(10)
+
+ toggle_label = QtWidgets.QLabel(question)
+ toggle_label.setAlignment(QtCore.Qt.AlignLeft)
+ help_label = ClickableLabel(help_text, parent=self)
+ help_label.setStyleSheet("text-decoration: underline; font-weight: bold;")
+ help_label.setCursor(QtCore.Qt.PointingHandCursor)
+ help_label.signal.connect(lambda: QDesktopServices.openUrl(QtCore.QUrl(docs_link)))
+
+ toggle_layout.addWidget(switch, alignment=QtCore.Qt.AlignLeft)
+ toggle_layout.addWidget(toggle_label, alignment=QtCore.Qt.AlignLeft)
+ toggle_layout.addStretch()
+ toggle_layout.addWidget(help_label, alignment=QtCore.Qt.AlignRight)
+ toggle_widget = QtWidgets.QWidget()
+ toggle_widget.setLayout(toggle_layout)
+ return toggle_widget
+
def lay_out_video_frame(self):
video_frame = ItemSelectionFrame([], self)
- self.cam_combo = QtWidgets.QComboBox(video_frame)
- self.cam_combo.addItems(map(str, (1, 2)))
- self.cam_combo.currentTextChanged.connect(self.check_num_cameras)
- ncam_label = QtWidgets.QLabel("Number of cameras:")
- ncam_label.setBuddy(self.cam_combo)
-
self.copy_box = QtWidgets.QCheckBox("Copy videos to project folder")
self.copy_box.setChecked(False)
- browse_button = QtWidgets.QPushButton("Browse videos")
+ # Add checkbox for selecting individual files
+ self.select_files_box = QtWidgets.QCheckBox("Select individual files")
+ self.select_files_box.setChecked(False)
+
+ browse_button = QtWidgets.QPushButton("Browse for videos")
browse_button.clicked.connect(self.browse_videos)
clear_button = QtWidgets.QPushButton("Clear")
clear_button.clicked.connect(video_frame.fancy_list.clear)
- layout1 = QtWidgets.QHBoxLayout()
- layout1.addWidget(ncam_label)
- layout1.addWidget(self.cam_combo)
- layout2 = QtWidgets.QHBoxLayout()
- layout2.addWidget(browse_button)
- layout2.addWidget(clear_button)
- video_frame.layout.insertLayout(0, layout1)
- video_frame.layout.addLayout(layout2)
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(browse_button)
+ layout.addWidget(clear_button)
+ video_frame.layout.addLayout(layout)
video_frame.layout.addWidget(self.copy_box)
+ video_frame.layout.addWidget(self.select_files_box)
+ self.toggle_3d.toggled.connect(lambda yes: self.copy_box.setVisible(not yes))
+ self.toggle_3d.toggled.connect(lambda yes: browse_button.setVisible(not yes))
+ self.toggle_3d.toggled.connect(lambda yes: clear_button.setVisible(not yes))
+ self.toggle_3d.toggled.connect(lambda yes: video_frame.setVisible(not yes))
+ self.toggle_3d.toggled.connect(lambda yes: self.select_files_box.setVisible(not yes))
return video_frame
def browse_videos(self):
- folder = QtWidgets.QFileDialog.getExistingDirectory(
- self,
- "Please select a folder",
- self.loc_default,
- )
- if not folder:
- return
+ options = QtWidgets.QFileDialog.Options()
+ options |= QtWidgets.QFileDialog.DontUseNativeDialog
+
+ if self.select_files_box.isChecked():
+ # Select individual video files
+ video_types = [f"*.{ext.lower()}" for ext in DLCParams.VIDEOTYPES[1:]] + [
+ f"*.{ext.upper()}" for ext in DLCParams.VIDEOTYPES[1:]
+ ]
+ video_filter = f"Videos ({' '.join(video_types)})"
+
+ files, _ = QtWidgets.QFileDialog.getOpenFileNames(
+ self,
+ "Select video files",
+ self.loc_default,
+ video_filter,
+ options=options,
+ )
+
+ if files:
+ for video in files:
+ self.video_frame.fancy_list.add_item(video)
+ else:
+ # Browse folders for videos
+ folder = QtWidgets.QFileDialog.getExistingDirectory(
+ self,
+ "Please select a folder",
+ self.loc_default,
+ options,
+ )
+ if not folder:
+ return
- for video in auxiliaryfunctions.grab_files_in_folder(
- folder,
- relative=False,
- ):
- if os.path.splitext(video)[1][1:] in DLCParams.VIDEOTYPES[1:]:
- self.video_frame.fancy_list.add_item(video)
+ for video in auxiliaryfunctions.grab_files_in_folder(
+ folder,
+ relative=False,
+ ):
+ if os.path.splitext(video)[1][1:].lower() in DLCParams.VIDEOTYPES[1:]:
+ self.video_frame.fancy_list.add_item(video)
def finalize_project(self):
fields = [self.proj_line, self.exp_line]
@@ -142,13 +451,13 @@ def finalize_project(self):
if empty:
return
- n_cameras = int(self.cam_combo.currentText())
+ create_3d = self.toggle_3d.isChecked()
try:
- if n_cameras > 1:
- _ = deeplabcut.create_new_project_3d(
+ if create_3d:
+ _ = create_new_project_3d(
self.proj_default,
self.exp_default,
- n_cameras,
+ 2,
self.loc_default,
)
else:
@@ -158,12 +467,10 @@ def finalize_project(self):
self.video_frame.fancy_list.setStyleSheet("border: 1px solid red")
return
else:
- self.video_frame.fancy_list.setStyleSheet(
- self.video_frame.fancy_list._default_style
- )
+ self.video_frame.fancy_list.setStyleSheet(self.video_frame.fancy_list._default_style)
to_copy = self.copy_box.isChecked()
- is_madlc = self.madlc_box.isChecked()
- config = deeplabcut.create_new_project(
+ is_madlc = self.madlc_toggle.isChecked()
+ config = create_new_project(
self.proj_default,
self.exp_default,
videos,
@@ -171,39 +478,53 @@ def finalize_project(self):
to_copy,
multianimal=is_madlc,
)
+
+ if self.bodypart_list is not None:
+ bodypart_key = "bodyparts"
+ updates = {}
+ if is_madlc:
+ bodypart_key = "multianimalbodyparts"
+ if self.individuals_list is not None:
+ individuals = self.individuals_list.get_entries()
+ if len(individuals) > 0:
+ updates["individuals"] = individuals
+
+ if self.unique_toggle.isChecked() and self.unique_bodyparts_list is not None:
+ unique_bodyparts = self.unique_bodyparts_list.get_entries()
+ if len(unique_bodyparts) > 0:
+ updates["uniquebodyparts"] = unique_bodyparts
+
+ if self.identity_toggle.isChecked():
+ updates["identity"] = True
+
+ bodyparts = self.bodypart_list.get_entries()
+ if len(bodyparts) > 0:
+ updates[bodypart_key] = bodyparts
+
+ if len(updates) > 0:
+ cfg: dict = auxiliaryfunctions.read_config(config)
+ cfg.update(**updates)
+ auxiliaryfunctions.write_config(config, cfg)
+
self.parent.load_config(config)
- self.parent._update_project_state(
- config=config,
- loaded=True,
- )
+ self.parent._update_project_state(config=config, loaded=True)
except FileExistsError:
- print('Project "{}" already exists!'.format(self.proj_default))
+ print(f'Project "{self.proj_default}" already exists!')
return
- msg = QtWidgets.QMessageBox(text=f"New project created")
+ msg = QtWidgets.QMessageBox(text="New project created")
msg.setIcon(QtWidgets.QMessageBox.Information)
msg.exec_()
self.close()
def on_click(self):
- dirname = QtWidgets.QFileDialog.getExistingDirectory(
- self, "Please select a folder", self.loc_default
- )
+ dirname = QtWidgets.QFileDialog.getExistingDirectory(self, "Please select a folder", self.loc_default)
if not dirname:
return
self.loc_default = dirname
self.update_project_location()
- def check_num_cameras(self, value):
- val = int(value)
- for child in self.video_frame.children():
- if child.isWidgetType() and not isinstance(child, QtWidgets.QComboBox):
- if val > 1:
- child.setDisabled(True)
- else:
- child.setDisabled(False)
-
def update_project_name(self, text):
self.proj_default = text
self.update_project_location()
diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py
index 7101b0e5b3..ff3a35e45c 100644
--- a/deeplabcut/gui/tabs/create_training_dataset.py
+++ b/deeplabcut/gui/tabs/create_training_dataset.py
@@ -8,21 +8,42 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+
import os
+import re
+from importlib import import_module
+from pathlib import Path
+import dlclibrary
from PySide6 import QtWidgets
-from PySide6.QtCore import Qt
-from PySide6.QtGui import QIcon
+from PySide6.QtCore import Qt, Slot
-from deeplabcut.gui.dlc_params import DLCParams
+import deeplabcut
+import deeplabcut.compat as compat
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.generate_training_dataset import get_existing_shuffle_indices
+from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
from deeplabcut.gui.components import (
+ ConditionsSelectionWidget,
DefaultTab,
ShuffleSpinBox,
+ _create_confirmation_box,
_create_grid_layout,
_create_label_widget,
+ _create_message_box,
+ set_combo_items,
+)
+from deeplabcut.gui.displays.shuffle_metadata_viewer import ShuffleMetadataViewer
+from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.widgets import launch_napari
+from deeplabcut.modelzoo import build_weight_init
+from deeplabcut.pose_estimation_pytorch import (
+ available_models,
+ is_model_cond_top_down,
+ is_model_top_down,
)
-
-import deeplabcut
from deeplabcut.utils.auxiliaryfunctions import (
get_data_and_metadata_filenames,
get_training_set_folder,
@@ -31,7 +52,7 @@
class CreateTrainingDataset(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(CreateTrainingDataset, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self.model_comparison = False
@@ -40,16 +61,32 @@ def __init__(self, root, parent, h1_description):
self._generate_layout_attributes(self.layout_attributes)
self.main_layout.addLayout(self.layout_attributes)
+ self.mapping_button = QtWidgets.QPushButton("Edit Conversion Table")
+ self.mapping_button.clicked.connect(self.edit_conversion_table)
+ self.mapping_button.setVisible(False)
+ self.root.engine_change.connect(self.set_edit_table_visibility)
+
self.ok_button = QtWidgets.QPushButton("Create Training Dataset")
self.ok_button.setMinimumWidth(150)
self.ok_button.clicked.connect(self.create_training_dataset)
+ self.main_layout.addWidget(self.mapping_button, alignment=Qt.AlignRight)
self.main_layout.addWidget(self.ok_button, alignment=Qt.AlignRight)
+ self.view_shuffles_button = QtWidgets.QPushButton("View Existing Shuffles")
+ self.view_shuffles_button.clicked.connect(self.view_shuffles)
+ self.main_layout.addWidget(self.view_shuffles_button, alignment=Qt.AlignLeft)
+
self.help_button = QtWidgets.QPushButton("Help")
self.help_button.clicked.connect(self.show_help_dialog)
self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft)
+ def set_edit_table_visibility(self) -> None:
+ has_conversion_tables = bool(self.root.cfg.get("SuperAnimalConversionTables", {}))
+ is_pytorch_engine = self.root.engine == Engine.PYTORCH
+ is_finetuning = self.weight_init_selector.with_decoder
+ self.mapping_button.setVisible(has_conversion_tables & is_pytorch_engine & is_finetuning)
+
def show_help_dialog(self):
dialog = QtWidgets.QDialog(self)
layout = QtWidgets.QVBoxLayout()
@@ -74,29 +111,83 @@ def _generate_layout_attributes(self, layout):
shuffle_label = QtWidgets.QLabel("Shuffle")
self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+ # Dataset choices
+ self.weight_init_label = QtWidgets.QLabel("Weight Initialization")
+ self.weight_init_selector = WeightInitializationSelector(self.root)
+ self.update_weight_init_methods(self.root.engine)
+ self.root.engine_change.connect(self.update_weight_init_methods)
+
# Augmentation method
augmentation_label = QtWidgets.QLabel("Augmentation method")
self.aug_choice = QtWidgets.QComboBox()
- self.aug_choice.addItems(DLCParams.IMAGE_AUGMENTERS)
- self.aug_choice.setCurrentText("imgaug")
+ self.update_aug_methods(self.root.engine)
+ self.root.engine_change.connect(self.update_aug_methods)
self.aug_choice.currentTextChanged.connect(self.log_augmentation_choice)
# Neural Network
nnet_label = QtWidgets.QLabel("Network architecture")
self.net_choice = QtWidgets.QComboBox()
- nets = DLCParams.NNETS.copy()
- if not self.root.is_multianimal:
- nets.remove("dlcrnet_ms5")
- self.net_choice.addItems(nets)
- self.net_choice.setCurrentText("resnet_50")
+ self.net_choice.setMinimumWidth(200)
+ self.update_nets(self.root.engine)
+ self.root.engine_change.connect(self.update_nets)
self.net_choice.currentTextChanged.connect(self.log_net_choice)
+ # Update Net types when selected weight init changes
+ self.weight_init_selector.weight_init_choice.currentTextChanged.connect(lambda _: self.update_nets(None))
+ self.weight_init_selector.weight_init_choice.currentTextChanged.connect(
+ lambda _: self.set_edit_table_visibility()
+ )
+
+ # Detector selection for top-down models
+ self.detector_label = QtWidgets.QLabel("Detector architecture")
+ self.detector_choice = QtWidgets.QComboBox()
+ self.detector_choice.setMinimumWidth(200)
+ self.update_detectors(engine=self.root.engine)
+ self.root.engine_change.connect(lambda engine: self.update_detectors(engine=engine))
+ self.net_choice.currentTextChanged.connect(
+ lambda new_net_choice: self.update_detectors(net_choice=new_net_choice)
+ )
+
+ # Conditions selection for CTD models
+ self.conditions_label = QtWidgets.QLabel("Conditions")
+ self.conditions_selection_widget = ConditionsSelectionWidget(root=self.root, parent=self)
+ self.update_conditions(engine=self.root.engine)
+ self.root.engine_change.connect(lambda engine: self.update_conditions(engine=engine))
+ self.net_choice.currentTextChanged.connect(
+ lambda new_net_choice: self.update_conditions(engine=self.root.engine, net_choice=new_net_choice)
+ )
+
+ # Overwrite selection
+ self.overwrite = QtWidgets.QCheckBox("Overwrite if exists")
+ self.overwrite.setChecked(False)
+ self.overwrite.setToolTip(
+ "When checked, creating a new shuffle with an index that already exists "
+ "will overwrite the existing index. Be careful with this option as you "
+ "might lose data."
+ )
+ self.overwrite.stateChanged.connect(lambda s: self.root.logger.info(f"Overwrite: {s}"))
+
+ # Use same data split as another shuffle
+ self.data_split_selection = DataSplitSelector(self.root, self)
+
layout.addWidget(shuffle_label, 0, 0)
layout.addWidget(self.shuffle, 0, 1)
- layout.addWidget(nnet_label, 0, 2)
- layout.addWidget(self.net_choice, 0, 3)
- layout.addWidget(augmentation_label, 0, 4)
- layout.addWidget(self.aug_choice, 0, 5)
+ layout.addWidget(self.weight_init_label, 0, 2)
+ layout.addWidget(self.weight_init_selector, 0, 3)
+
+ layout.addWidget(nnet_label, 1, 0)
+ layout.addWidget(self.net_choice, 1, 1)
+ layout.addWidget(augmentation_label, 1, 2)
+ layout.addWidget(self.aug_choice, 1, 3)
+
+ layout.addWidget(self.detector_label, 2, 0)
+ layout.addWidget(self.detector_choice, 2, 1)
+
+ layout.addWidget(self.conditions_label, 3, 0)
+ layout.addWidget(self.conditions_selection_widget, 3, 1)
+
+ layout.addWidget(self.overwrite, 4, 0)
+ layout.addWidget(self.data_split_selection, 5, 0)
def log_net_choice(self, net):
self.root.logger.info(f"Network architecture set to {net.upper()}")
@@ -104,34 +195,139 @@ def log_net_choice(self, net):
def log_augmentation_choice(self, augmentation):
self.root.logger.info(f"Image augmentation set to {augmentation.upper()}")
+ def edit_conversion_table(self):
+ # Test beforehand whether a conversion table exists
+ memory_replay_folder = Path(self.root.project_folder) / "memory_replay"
+ conversion_matrix_out_path = str(memory_replay_folder / "confusion_matrix.png")
+ files = [self.root.config]
+ if os.path.exists(conversion_matrix_out_path):
+ files.append(conversion_matrix_out_path)
+ _ = launch_napari(files)
+
def create_training_dataset(self):
shuffle = self.shuffle.value()
+ cfg = self.root.cfg
+ existing_indices = get_existing_shuffle_indices(
+ cfg=cfg, train_fraction=cfg["TrainingFraction"][self.root.trainingset_index]
+ )
+
+ overwrite = self.overwrite.isChecked()
+ if shuffle in existing_indices:
+ if overwrite:
+ if not self._confirm_overwrite(shuffle, existing_indices):
+ return
+ else:
+ msg = _create_message_box(
+ "The training dataset could not be created.",
+ (
+ f"Shuffle {shuffle} already exists - you can create a new "
+ "training dataset with an unused shuffle index (existing "
+ f"shuffles are {existing_indices}) or you can overwrite the "
+ f"shuffle by ticking the 'Overwrite' checkbox"
+ ),
+ )
+ msg.exec_()
+ self.root.writer.write("Training dataset creation failed.")
+ return
if self.model_comparison:
raise NotImplementedError
# TODO: finish model_comparison
- deeplabcut.create_training_model_comparison(
- config_file,
- num_shuffles=shuffle,
- net_types=self.net_type,
- augmenter_types=self.aug_type,
- )
+ # deeplabcut.create_training_model_comparison(
+ # config_file,
+ # num_shuffles=shuffle,
+ # net_types=self.net_type,
+ # augmenter_types=self.aug_type,
+ # )
else:
- if self.root.is_multianimal:
- deeplabcut.create_multianimaltraining_dataset(
- self.root.config,
- shuffle,
- Shuffles=[self.shuffle.value()],
- net_type=self.net_choice.currentText(),
+ try:
+ engine = self.root.engine
+ net_type = self.net_choice.currentText()
+ detector_type = None
+ ctd_conditions = None
+ if engine == Engine.TF:
+ import_module("tensorflow")
+
+ # try importing TF so they can't create shuffles for it if they
+ # don't have it installed
+ elif engine == Engine.PYTORCH:
+ if is_model_top_down(net_type):
+ detector_type = self.detector_choice.currentText()
+ elif is_model_cond_top_down(net_type):
+ ctd_conditions = self._build_ctd_conditions(
+ self.conditions_selection_widget.selected_conditions
+ )
+
+ try:
+ weight_init = self.weight_init_selector.get_super_animal_weight_init(
+ net_type,
+ detector_type,
+ )
+ except ValueError as err:
+ print(f"The training dataset could not be created: {err}.")
+ return
+
+ if self.data_split_selection.selected:
+ deeplabcut.create_training_dataset_from_existing_split(
+ self.root.config,
+ from_shuffle=self.data_split_selection.from_shuffle,
+ shuffles=[self.shuffle.value()],
+ net_type=net_type,
+ detector_type=detector_type,
+ userfeedback=not overwrite,
+ weight_init=weight_init,
+ engine=engine,
+ ctd_conditions=ctd_conditions,
+ )
+
+ elif self.root.is_multianimal:
+ deeplabcut.create_multianimaltraining_dataset(
+ self.root.config,
+ shuffle,
+ Shuffles=[self.shuffle.value()],
+ net_type=net_type,
+ detector_type=detector_type,
+ userfeedback=not overwrite,
+ weight_init=weight_init,
+ engine=engine,
+ ctd_conditions=ctd_conditions,
+ )
+ else:
+ deeplabcut.create_training_dataset(
+ self.root.config,
+ shuffle,
+ Shuffles=[self.shuffle.value()],
+ net_type=net_type,
+ detector_type=detector_type,
+ augmenter_type=self.aug_choice.currentText(),
+ userfeedback=not overwrite,
+ weight_init=weight_init,
+ engine=engine,
+ ctd_conditions=ctd_conditions,
+ )
+ except ValueError as err:
+ msg = _create_message_box(
+ "The training dataset could not be created.",
+ str(err),
)
- else:
- deeplabcut.create_training_dataset(
- self.root.config,
- shuffle,
- Shuffles=[self.shuffle.value()],
- net_type=self.net_choice.currentText(),
- augmenter_type=self.aug_choice.currentText(),
+ msg.exec_()
+ return
+ except ModuleNotFoundError as err:
+ info_text = (
+ f"Error `{err}`. If the error is `ModuleNotFoundError: No module "
+ "named 'tensorflow'`, this is because you tried creating a "
+ "TensorFlow shuffle, but TensorFlow is not installed in your "
+ "environment. To create TensorFlow shuffles (and use TensorFlow "
+ "models), install it with\n"
+ " Windows/Linux:\n"
+ " pip install 'deeplabcut[tf]'\n"
+ " Apple Silicon:\n"
+ " pip install 'deeplabcut[apple_mchips]'"
)
+ msg = _create_message_box("The training dataset could not be created.", info_text)
+ msg.exec_()
+ return
+
# Check that training data files were indeed created.
trainingsetfolder = get_training_set_folder(self.root.cfg)
filenames = list(
@@ -144,10 +340,8 @@ def create_training_dataset(self):
)
if self.root.is_multianimal:
filenames[0] = filenames[0].replace("mat", "pickle")
- if all(
- os.path.exists(os.path.join(self.root.project_folder, file))
- for file in filenames
- ):
+ if all(os.path.exists(os.path.join(self.root.project_folder, file)) for file in filenames):
+ self.root.shuffle_created.emit(self.shuffle.value())
msg = _create_message_box(
"The training dataset is successfully created.",
"Use the function 'train_network' to start training. Happy training!",
@@ -162,17 +356,449 @@ def create_training_dataset(self):
msg.exec_()
self.root.writer.write("Training dataset creation failed.")
+ def _confirm_overwrite(self, shuffle: int, existing_indices: list[int]) -> bool:
+ """Asks the user to confirm that they want to overwrite a shuffle.
+
+ Args:
+ shuffle: the shuffle the user wants to overwrite
+ existing_indices: the indices of existing shuffles
+
+ Returns:
+ whether the user confirmed overwriting the shuffle
+ """
+ try:
+ engine = get_shuffle_engine(self.root.cfg, self.root.trainingset_index, shuffle)
+ engine_str = f" (with engine '{engine.aliases[0]}')"
+ except ValueError:
+ engine_str = ""
+
+ conf = _create_confirmation_box(
+ title=f"Are you sure you want to overwrite shuffle {shuffle}?",
+ description=(
+ f"As shuffle {shuffle} already exists{engine_str}, the training-dataset files would be overwritten."
+ ),
+ )
+ result = conf.exec()
+ if result != QtWidgets.QMessageBox.Yes:
+ msg = _create_message_box(
+ text="The training dataset was not be created.",
+ info_text=(f"You can create a shuffle with another index. Existing indices are {existing_indices}"),
+ )
+ msg.exec_()
+ self.root.writer.write("Training dataset creation interrupted.")
+ return False
+
+ return True
+
+ def _build_ctd_conditions(self, conditions_path: str | Path) -> Path | tuple[int, str]:
+ """
+ Builds CTD conditions in appropriate format from path to conditions.
+ Args:
+ conditions_path: str | Path:
+ path to conditions (path to snapshot or to predictions)
+
+ Returns:
+ ctd_conditions: Path | tuple[int, str]
+ ctd conditions in the right format for deeplabcut.create_training_dataset() API method.
+
+ Raises:
+ Value error if conditions are missing or invalid.
+ """
+ if conditions_path is None:
+ raise ValueError("No conditions were selected for CTD model.")
+ else:
+ conditions_path = Path(conditions_path)
+ if conditions_path.suffix.lower() in [".h5", ".json"]:
+ return conditions_path
+ elif conditions_path.suffix.lower() == ".pt":
+ match = re.search(r"shuffle(\d+)", str(conditions_path))
+ if match:
+ shuffle_number = int(match.group(1))
+ else:
+ raise ValueError("Shuffle number could not be extracted from path.")
+ snapshot_filename = conditions_path.name
+ return shuffle_number, snapshot_filename
+ else:
+ raise ValueError("Unsupported conditions file type")
+
+ @Slot(Engine)
+ def update_nets(self, engine: Engine | None) -> None:
+ if engine is None:
+ engine = self.root.engine
+
+ default_net = None
+ if engine == Engine.TF:
+ nets = DLCParams.NNETS.copy()
+ if not self.root.is_multianimal:
+ nets.remove("dlcrnet_ms5")
+ else:
+ nets = available_models()
+ net_filter = self.get_net_filter()
+ default_net = self.get_default_net()
+ td_prefix = "top_down_"
+ if net_filter is not None:
+ nets = [
+ n
+ for n in nets
+ if (n in net_filter or (n.startswith(td_prefix) and n[len(td_prefix) :] in net_filter))
+ ]
+
+ if default_net is None:
+ default_net = self.root.cfg.get("default_net_type", "resnet_50")
+ if (
+ engine == Engine.TF
+ and default_net not in DLCParams.NNETS
+ or engine == Engine.PYTORCH
+ and default_net not in available_models()
+ ):
+ default_net = "resnet_50"
+
+ set_combo_items(
+ combo_box=self.net_choice,
+ items=nets,
+ index=nets.index(default_net) if default_net in nets else 0,
+ )
+
+ @Slot(Engine)
+ def update_detectors(
+ self,
+ engine: Engine | None = None,
+ net_choice: str | None = None,
+ ) -> None:
+ if engine is None:
+ engine = self.root.engine
+
+ if engine == Engine.TF:
+ detectors = []
+ else:
+ # FIXME: Circular imports make it impossible to import this at the top
+ from deeplabcut.pose_estimation_pytorch import available_detectors
+
+ detectors = available_detectors()
+ det_filter = self.get_detector_filter()
+ if det_filter is not None:
+ detectors = [d for d in detectors if d in det_filter]
+
+ default_detector = self.get_default_detector()
+ try:
+ index = detectors.index(default_detector)
+ except ValueError:
+ try:
+ index = detectors.index("ssdlite")
+ except ValueError:
+ index = -1
+ set_combo_items(
+ combo_box=self.detector_choice,
+ items=detectors,
+ index=index,
+ )
+
+ if net_choice is None:
+ net_choice = self.net_choice.currentText()
+
+ if engine == Engine.PYTORCH and is_model_top_down(net_choice):
+ self.detector_label.show()
+ self.detector_choice.show()
+ else:
+ self.detector_label.hide()
+ self.detector_choice.hide()
+
+ @Slot(Engine)
+ def update_conditions(
+ self,
+ engine: Engine | None = None,
+ net_choice: str | None = None,
+ ) -> None:
+ if engine is None:
+ engine = self.root.engine
+
+ if net_choice is None:
+ net_choice = self.net_choice.currentText()
+
+ if engine == Engine.PYTORCH and is_model_cond_top_down(net_choice):
+ self.conditions_label.show()
+ self.conditions_selection_widget.show()
+ else:
+ self.conditions_label.hide()
+ self.conditions_selection_widget.hide()
+
+ @Slot(Engine)
+ def update_aug_methods(self, engine: Engine) -> None:
+ methods = compat.get_available_aug_methods(engine)
+ set_combo_items(
+ combo_box=self.aug_choice,
+ items=methods,
+ index=0,
+ )
+
+ @Slot(Engine)
+ def update_weight_init_methods(self, engine: Engine) -> None:
+ if engine != Engine.PYTORCH:
+ self.weight_init_label.hide()
+ self.weight_init_selector.hide()
+ return
+
+ self.weight_init_label.show()
+ self.weight_init_selector.update_choices(list(_WEIGHT_INIT_OPTIONS.keys()))
+ self.weight_init_selector.show()
+
+ def get_net_filter(self) -> list[str] | None:
+ """Returns: the net type that can be used based on weight initialization"""
+ if self.root.engine != Engine.PYTORCH:
+ return None
+
+ if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS:
+ return None
+
+ weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]
+ if "super_animal" in weight_init_cfg:
+ return dlclibrary.get_available_models(weight_init_cfg["super_animal"])
+
+ return None
+
+ def get_detector_filter(self) -> list[str] | None:
+ """Returns: the detectors that can be used based on weight initialization"""
+ if self.root.engine != Engine.PYTORCH:
+ return None
+
+ if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS:
+ return None
+
+ weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]
+ if "super_animal" in weight_init_cfg:
+ return dlclibrary.get_available_detectors(weight_init_cfg["super_animal"])
+
+ return None
+
+ def get_default_net(self) -> str | None:
+ """Returns: the net type that can be used based on weight initialization"""
+ if self.root.engine != Engine.PYTORCH:
+ return None
+
+ if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS:
+ return None
+
+ weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]
+ return weight_init_cfg.get("default_net")
+
+ def get_default_detector(self) -> str | None:
+ """Returns: the detector type that can be used based on weight initialization"""
+ if self.root.engine != Engine.PYTORCH:
+ return None
+
+ if self.weight_init_selector.weight_init not in _WEIGHT_INIT_OPTIONS:
+ return None
+
+ weight_init_cfg = _WEIGHT_INIT_OPTIONS[self.weight_init_selector.weight_init]
+ return weight_init_cfg.get("default_detector")
+
+ def view_shuffles(self) -> None:
+ viewer = ShuffleMetadataViewer(root=self.root, parent=self)
+ viewer.show()
+
+
+class WeightInitializationSelector(QtWidgets.QWidget):
+ """Widget to select weight initialization."""
+
+ def __init__(self, root):
+ super().__init__()
+ self.root = root
+
+ self.weight_init_choice = QtWidgets.QComboBox()
+
+ self.memory_replay_label = QtWidgets.QLabel("With memory replay")
+ self.memory_replay_box = QtWidgets.QCheckBox()
+ self.memory_replay_label.hide()
+ self.memory_replay_box.hide()
+
+ memory_replay_layout = QtWidgets.QHBoxLayout()
+ memory_replay_layout.addWidget(self.memory_replay_label)
+ memory_replay_layout.addWidget(self.memory_replay_box)
+
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(self.weight_init_choice)
+ layout.addLayout(memory_replay_layout)
+ self.setLayout(layout)
+
+ self.weight_init_choice.currentTextChanged.connect(self._choice_changed)
+
+ @property
+ def weight_init(self) -> str:
+ return self.weight_init_choice.currentText()
+
+ @property
+ def with_decoder(self) -> bool:
+ weight_init_choice = self.weight_init_choice.currentText()
+ return "fine-tuning" in weight_init_choice.lower()
+
+ @property
+ def memory_replay(self) -> bool:
+ return self.memory_replay_box.isChecked()
+
+ def update_choices(self, choices: list[str]) -> None:
+ """Updates the WeightInitialization methods that can be selected."""
+ set_combo_items(
+ combo_box=self.weight_init_choice,
+ items=choices,
+ )
+
+ def get_super_animal_weight_init(
+ self,
+ net_type: str,
+ detector_type: str,
+ ) -> WeightInitialization | None:
+ """
+ Args:
+ net_type: The architecture of the pose model from which to fine-tune a
+ SuperAnimal model.
+ detector_type: The architecture of the detector from which to fine-tune a
+ SuperAnimal model.
+
+ Raises:
+ ValueError if WeightInitialization should be defined but could not be
+ created (e.g. if there's no conversion table).
+ """
+ if self.root.engine != Engine.PYTORCH:
+ return None
+
+ weight_init_choice = self.weight_init_choice.currentText()
+ if "imagenet" in weight_init_choice.lower():
+ return
+
+ weight_init_data = _WEIGHT_INIT_OPTIONS[weight_init_choice]
+ super_animal = weight_init_data["super_animal"]
+ if net_type.startswith("top_down_"):
+ net_type = net_type[len("top_down_") :]
+ try:
+ weight_init = build_weight_init(
+ self.root.cfg,
+ super_animal=super_animal,
+ model_name=net_type,
+ detector_name=detector_type,
+ with_decoder=self.with_decoder,
+ memory_replay=self.memory_replay,
+ )
+ except ValueError as err:
+ QtWidgets.QMessageBox.critical(
+ self,
+ "Error",
+ (
+ f"No Conversion table specified for {super_animal} in the project "
+ "configuration file. Please create a conversion table using the GUI"
+ ", with ``deeplabcut.modelzoo.utils.create_conversion_table``, or "
+ "by adding it to your project's configuration file manually."
+ ),
+ )
+ raise err
+
+ return weight_init
+
+ def _choice_changed(self, state: str) -> None:
+ if "fine-tuning" in str(state).lower():
+ self.memory_replay_label.show()
+ self.memory_replay_box.show()
+ else:
+ self.memory_replay_label.hide()
+ self.memory_replay_box.hide()
+
+
+class DataSplitSelector(QtWidgets.QWidget):
+ """Allows users to create training sets with the same train/test split as
+ another."""
+
+ def __init__(self, root: QtWidgets.QMainWindow, parent: QtWidgets.QWidget):
+ super().__init__()
+ self.root = root
+ self.parent = parent
+
+ self.setToolTip(
+ "This allows you to create a shuffle where the data split is the same as "
+ "one of your existing shuffles (the images on which the model is "
+ "trained/tested are the same)."
+ )
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ box_layout = QtWidgets.QHBoxLayout()
+ box_layout.setSpacing(0)
+ box_layout.setContentsMargins(0, 0, 0, 0)
+
+ selector_layout = QtWidgets.QHBoxLayout()
+ selector_layout.setSpacing(0)
+ selector_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.shuffle_label = QtWidgets.QLabel("From shuffle:")
+ self.shuffle_label.hide()
+ self.shuffle_selector = QtWidgets.QSpinBox()
+ self.shuffle_selector.setMaximum(10_000)
+ self.shuffle_selector.setValue(0)
+ self.shuffle_selector.hide()
+
+ self.box = QtWidgets.QCheckBox(parent=self)
+ self.box.stateChanged.connect(self._checkbox_status_changed)
+ self.box_label = QtWidgets.QLabel("Use an existing data split")
+
+ box_layout.addWidget(self.box)
+ box_layout.addWidget(self.box_label)
+ selector_layout.addWidget(self.shuffle_label)
+ selector_layout.addWidget(self.shuffle_selector)
+ layout.addLayout(box_layout)
+ layout.addLayout(selector_layout)
+ self.setLayout(layout)
+
+ @property
+ def selected(self) -> bool:
+ return self.box.isChecked()
+
+ @property
+ def from_shuffle(self) -> int:
+ """The shuffle from which to copy the data split."""
+ return self.shuffle_selector.value()
+
+ def _checkbox_status_changed(self, state: int) -> None:
+ if Qt.CheckState(state) == Qt.Checked:
+ self.shuffle_selector.show()
+ self.shuffle_label.show()
+ else:
+ self.shuffle_selector.hide()
+ self.shuffle_label.hide()
-def _create_message_box(text, info_text):
- msg = QtWidgets.QMessageBox()
- msg.setIcon(QtWidgets.QMessageBox.Information)
- msg.setText(text)
- msg.setInformativeText(info_text)
- msg.setWindowTitle("Info")
- msg.setMinimumWidth(900)
- logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep
- logo = logo_dir + "/assets/logo.png"
- msg.setWindowIcon(QIcon(logo))
- msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
- return msg
+_WEIGHT_INIT_OPTIONS = { # FIXME - Generate dynamically
+ "Transfer Learning - ImageNet": {
+ "model_filter": None,
+ "detector_filter": None,
+ },
+ "Transfer Learning - SuperAnimal Bird": {
+ "default_net": "top_down_resnet_50",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_bird",
+ },
+ "Transfer Learning - SuperAnimal Quadruped": {
+ "default_net": "top_down_hrnet_w32",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_quadruped",
+ },
+ "Transfer Learning - SuperAnimal TopViewMouse": {
+ "default_net": "top_down_hrnet_w32",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_topviewmouse",
+ },
+ "Fine-tuning - SuperAnimal Bird": {
+ "default_net": "top_down_resnet_50",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_bird",
+ },
+ "Fine-tuning - SuperAnimal Quadruped": {
+ "default_net": "top_down_hrnet_w32",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_quadruped",
+ },
+ "Fine-tuning - SuperAnimal TopViewMouse": {
+ "default_net": "top_down_hrnet_w32",
+ "default_detector": "fasterrcnn_mobilenet_v3_large_fpn",
+ "super_animal": "superanimal_topviewmouse",
+ },
+}
diff --git a/deeplabcut/gui/tabs/create_videos.py b/deeplabcut/gui/tabs/create_videos.py
index e1f140762c..dcbec6a232 100644
--- a/deeplabcut/gui/tabs/create_videos.py
+++ b/deeplabcut/gui/tabs/create_videos.py
@@ -11,6 +11,7 @@
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
+import deeplabcut
from deeplabcut.gui.components import (
BodypartListWidget,
DefaultTab,
@@ -21,12 +22,10 @@
_create_vertical_layout,
)
-import deeplabcut
-
class CreateVideos(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(CreateVideos, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self.bodyparts_to_use = self.root.all_bodyparts
self._set_page()
@@ -55,9 +54,7 @@ def _set_page(self):
self.main_layout.addLayout(tmp_layout)
- self.main_layout.addWidget(
- _create_label_widget("Video Parameters", "font:bold")
- )
+ self.main_layout.addWidget(_create_label_widget("Video Parameters", "font:bold"))
self.layout_video_parameters = _create_vertical_layout()
self._generate_layout_video_parameters(self.layout_video_parameters)
self.main_layout.addLayout(self.layout_video_parameters)
@@ -134,18 +131,33 @@ def _generate_layout_video_parameters(self, layout):
# Skeleton
self.draw_skeleton_checkbox = QtWidgets.QCheckBox("Draw skeleton")
- self.draw_skeleton_checkbox.setCheckState(Qt.Checked)
+ self.draw_skeleton_checkbox.setCheckState(Qt.Unchecked)
self.draw_skeleton_checkbox.stateChanged.connect(self.update_draw_skeleton)
tmp_layout.addWidget(self.draw_skeleton_checkbox)
# Filtered data
self.use_filtered_data_checkbox = QtWidgets.QCheckBox("Use filtered data")
self.use_filtered_data_checkbox.setCheckState(Qt.Unchecked)
- self.use_filtered_data_checkbox.stateChanged.connect(
- self.update_use_filtered_data
- )
+ self.use_filtered_data_checkbox.stateChanged.connect(self.update_use_filtered_data)
tmp_layout.addWidget(self.use_filtered_data_checkbox)
+ # Selector for p-cutoff
+ pcutoff_widget = QtWidgets.QWidget()
+ pcutoff_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
+ pcutoff_label = QtWidgets.QLabel("Plotting confidence cutoff (pcutoff)")
+ self.pcutoff_selector = QtWidgets.QDoubleSpinBox()
+ self.pcutoff_selector.setMinimum(0.0)
+ self.pcutoff_selector.setMaximum(1.0)
+ self.pcutoff_selector.setValue(0.6)
+ self.pcutoff_selector.setSingleStep(0.05)
+ pcutoff_layout.addWidget(pcutoff_label)
+ pcutoff_layout.addWidget(self.pcutoff_selector)
+ pcutoff_widget.setLayout(pcutoff_layout)
+ pcutoff_widget.setToolTip(
+ "This value sets the confidence threshold, above which predictions are shown in the labeled videos."
+ )
+ tmp_layout.addWidget(pcutoff_widget)
+
# Plot trajectories
self.plot_trajectories = QtWidgets.QCheckBox("Plot trajectories")
self.plot_trajectories.setCheckState(Qt.Unchecked)
@@ -153,13 +165,9 @@ def _generate_layout_video_parameters(self, layout):
tmp_layout.addWidget(self.plot_trajectories)
# High quality video
- self.create_high_quality_video = QtWidgets.QCheckBox(
- "High quality video (slow)"
- )
+ self.create_high_quality_video = QtWidgets.QCheckBox("High quality video (slow)")
self.create_high_quality_video.setCheckState(Qt.Unchecked)
- self.create_high_quality_video.stateChanged.connect(
- self.update_high_quality_video
- )
+ self.create_high_quality_video.stateChanged.connect(self.update_high_quality_video)
tmp_layout.addWidget(self.create_high_quality_video)
nested_tmp_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
@@ -179,24 +187,20 @@ def _generate_layout_video_parameters(self, layout):
layout.addLayout(tmp_layout, Qt.AlignLeft)
def update_high_quality_video(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"High quality {s}.")
def update_plot_trajectory_choice(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Plot trajectories {s}.")
def update_selected_bodyparts(self):
- selected_bodyparts = [
- item.text() for item in self.bodyparts_list_widget.selectedItems()
- ]
- self.root.logger.info(
- f"Selected bodyparts for plotting:\n\t{selected_bodyparts}"
- )
+ selected_bodyparts = [item.text() for item in self.bodyparts_list_widget.selectedItems()]
+ self.root.logger.info(f"Selected bodyparts for plotting:\n\t{selected_bodyparts}")
self.bodyparts_to_use = selected_bodyparts
def update_use_all_bodyparts(self, s):
- if s == Qt.Checked:
+ if Qt.CheckState(s) == Qt.Checked:
self.bodyparts_list_widget.setEnabled(False)
self.bodyparts_list_widget.hide()
self.root.logger.info("Plot all bodyparts ENABLED.")
@@ -207,15 +211,15 @@ def update_use_all_bodyparts(self, s):
self.root.logger.info("Plot all bodyparts DISABLED.")
def update_use_filtered_data(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Use filtered data {s}")
def update_draw_skeleton(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Draw skeleton {s}")
def update_overwrite_videos(self, state):
- s = "ENABLED" if state == Qt.Checked else "DISABLED"
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
self.root.logger.info(f"Overwrite videos {s}")
def update_color_by(self, text):
@@ -243,13 +247,10 @@ def create_videos(self):
# Single animal scenario.
# Color is based on bodypart.
color_by = "bodypart"
- filtered = bool(self.use_filtered_data_checkbox.checkState())
+ filtered = self.use_filtered_data_checkbox.isChecked()
bodyparts = "all"
- if (
- len(self.bodyparts_to_use) != 0
- and self.plot_all_bodyparts.checkState() != Qt.Checked
- ):
+ if len(self.bodyparts_to_use) != 0 and not self.plot_all_bodyparts.isChecked():
self.update_selected_bodyparts()
bodyparts = self.bodyparts_to_use
@@ -258,28 +259,29 @@ def create_videos(self):
videos=videos,
shuffle=shuffle,
filtered=filtered,
- save_frames=bool(self.create_high_quality_video.checkState()),
+ save_frames=self.create_high_quality_video.isChecked(),
+ pcutoff=self.pcutoff_selector.value(),
displayedbodyparts=bodyparts,
- draw_skeleton=bool(self.draw_skeleton_checkbox.checkState()),
+ draw_skeleton=self.draw_skeleton_checkbox.isChecked(),
trailpoints=trailpoints,
color_by=color_by,
+ overwrite=self.overwrite_videos.isChecked(),
)
if all(videos_created):
self.root.writer.write("Labeled videos created.")
else:
- failed_videos = [
- video for success, video in zip(videos_created, videos) if not success
- ]
+ failed_videos = [video for success, video in zip(videos_created, videos, strict=False) if not success]
failed_videos_str = ", ".join(failed_videos)
self.root.writer.write(f"Failed to create videos from {failed_videos_str}.")
- if self.plot_trajectories.checkState():
+ if self.plot_trajectories.isChecked():
deeplabcut.plot_trajectories(
config=config,
videos=videos,
shuffle=shuffle,
filtered=filtered,
displayedbodyparts=bodyparts,
+ pcutoff=self.pcutoff_selector.value(),
)
def build_skeleton(self, *args):
diff --git a/deeplabcut/gui/tabs/docs.py b/deeplabcut/gui/tabs/docs.py
new file mode 100644
index 0000000000..1f52118e19
--- /dev/null
+++ b/deeplabcut/gui/tabs/docs.py
@@ -0,0 +1,15 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+BASE_URL = "https://deeplabcut.github.io/DeepLabCut/docs/"
+README = "https://deeplabcut.github.io/DeepLabCut/README.html"
+URL_3D = BASE_URL + "Overviewof3D.html"
+URL_MA_CONFIGURE = BASE_URL + "maDLC_UserGuide.html#configure-the-project"
+URL_USE_GUIDE_SCENARIO = BASE_URL + "UseOverviewGuide.html#what-scenario-do-you-have"
diff --git a/deeplabcut/gui/tabs/evaluate_network.py b/deeplabcut/gui/tabs/evaluate_network.py
index b94590a33e..4eb31b63eb 100644
--- a/deeplabcut/gui/tabs/evaluate_network.py
+++ b/deeplabcut/gui/tabs/evaluate_network.py
@@ -8,17 +8,21 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+
import os
+from pathlib import Path
+
import matplotlib.image as mpimg
from matplotlib.backends.backend_qt5agg import (
FigureCanvasQTAgg as FigureCanvas,
)
from matplotlib.figure import Figure
from PySide6 import QtWidgets
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, Slot
import deeplabcut
-from deeplabcut.utils.auxiliaryfunctions import get_evaluation_folder
+from deeplabcut.core.engine import Engine
from deeplabcut.gui.components import (
BodypartListWidget,
DefaultTab,
@@ -27,7 +31,9 @@
_create_label_widget,
_create_vertical_layout,
)
-from deeplabcut.gui.widgets import ConfigEditor
+from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay
+from deeplabcut.gui.widgets import ConfigEditor, launch_napari
+from deeplabcut.utils import auxiliaryfunctions
class GridCanvas(QtWidgets.QDialog):
@@ -41,7 +47,7 @@ def __init__(self, image_paths, parent=None):
self.canvas = FigureCanvas(self.figure)
layout.addWidget(self.canvas)
- for image_path, gridspec in zip(image_paths[:9], self.grid):
+ for image_path, gridspec in zip(image_paths[:9], self.grid, strict=False):
ax = self.figure.add_subplot(gridspec)
ax.set_axis_off()
img = mpimg.imread(image_path)
@@ -50,7 +56,7 @@ def __init__(self, image_paths, parent=None):
class EvaluateNetwork(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(EvaluateNetwork, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self.bodyparts_to_use = self.root.all_bodyparts
@@ -80,9 +86,7 @@ def _set_page(self):
self.edit_inferencecfg_btn.clicked.connect(self.open_inferencecfg_editor)
if self.root.is_multianimal:
- self.main_layout.addWidget(
- self.edit_inferencecfg_btn, alignment=Qt.AlignRight
- )
+ self.main_layout.addWidget(self.edit_inferencecfg_btn, alignment=Qt.AlignRight)
self.main_layout.addWidget(self.ev_nw_button, alignment=Qt.AlignRight)
self.main_layout.addWidget(self.opt_button, alignment=Qt.AlignRight)
@@ -91,6 +95,9 @@ def _set_page(self):
self.help_button.clicked.connect(self.show_help_dialog)
self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft)
+ self.root.engine_change.connect(self._on_engine_change)
+ self._on_engine_change(self.root.engine)
+
def show_help_dialog(self):
dialog = QtWidgets.QDialog(self)
layout = QtWidgets.QVBoxLayout()
@@ -107,9 +114,11 @@ def show_help_dialog(self):
def _generate_layout_attributes(self, layout):
opt_text = QtWidgets.QLabel("Shuffle")
self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+ self.shuffle_display = SelectedShuffleDisplay(self.root, row_margin=0)
layout.addWidget(opt_text)
layout.addWidget(self.shuffle)
+ layout.addWidget(self.shuffle_display)
def open_inferencecfg_editor(self):
editor = ConfigEditor(self.root.inference_cfg_path)
@@ -123,27 +132,17 @@ def plot_maps(self):
# Display all images
dest_folder = os.path.join(
self.root.project_folder,
- str(
- get_evaluation_folder(
- self.root.cfg["TrainingFraction"][0], shuffle, self.root.cfg
- )
- ),
+ str(auxiliaryfunctions.get_evaluation_folder(self.root.cfg["TrainingFraction"][0], shuffle, self.root.cfg)),
"maps",
)
- image_paths = [
- os.path.join(dest_folder, file)
- for file in os.listdir(dest_folder)
- if file.endswith(".png")
- ]
+ image_paths = [os.path.join(dest_folder, file) for file in os.listdir(dest_folder) if file.endswith(".png")]
canvas = GridCanvas(image_paths, parent=self)
canvas.show()
def _generate_additional_attributes(self, layout):
tmp_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
- self.plot_predictions = QtWidgets.QCheckBox(
- "Plot predictions (as in standard DLC projects)"
- )
+ self.plot_predictions = QtWidgets.QCheckBox("Plot predictions (as in standard DLC projects)")
self.plot_predictions.stateChanged.connect(self.update_plot_predictions)
tmp_layout.addWidget(self.plot_predictions)
@@ -159,46 +158,68 @@ def _generate_additional_attributes(self, layout):
layout.addWidget(self.bodyparts_list_widget, alignment=Qt.AlignLeft)
def update_map_choice(self, state):
- if state == Qt.Checked:
+ if Qt.CheckState(state) == Qt.Checked:
self.root.logger.info("Plot scoremaps ENABLED")
else:
self.root.logger.info("Plot predictions DISABLED")
def update_plot_predictions(self, s):
- if s == Qt.Checked:
+ if Qt.CheckState(s) == Qt.Checked:
self.root.logger.info("Plot predictions ENABLED")
else:
self.root.logger.info("Plot predictions DISABLED")
def update_bodypart_choice(self, s):
- if s == Qt.Checked:
+ if Qt.CheckState(s) == Qt.Checked:
self.bodyparts_list_widget.setEnabled(False)
self.bodyparts_list_widget.hide()
self.root.logger.info("Use all bodyparts")
else:
self.bodyparts_list_widget.setEnabled(True)
self.bodyparts_list_widget.show()
- self.root.logger.info(
- f"Use selected bodyparts only: {self.bodyparts_list_widget.selected_bodyparts}"
- )
+ self.root.logger.info(f"Use selected bodyparts only: {self.bodyparts_list_widget.selected_bodyparts}")
def evaluate_network(self):
config = self.root.config
-
- Shuffles = [self.root.shuffle_value]
- plotting = self.plot_predictions.checkState() == Qt.Checked
+ shuffle = self.root.shuffle_value
+ plotting = self.plot_predictions.isChecked()
bodyparts_to_use = "all"
if (
- len(self.root.all_bodyparts)
- != len(self.bodyparts_list_widget.selected_bodyparts)
- ) and self.use_all_bodyparts.checkState() == False:
+ len(self.root.all_bodyparts) != len(self.bodyparts_list_widget.selected_bodyparts)
+ ) and not self.use_all_bodyparts.isChecked():
bodyparts_to_use = self.bodyparts_list_widget.selected_bodyparts
deeplabcut.evaluate_network(
config,
- Shuffles=Shuffles,
+ Shuffles=[shuffle],
plotting=plotting,
show_errors=True,
comparisonbodyparts=bodyparts_to_use,
)
+
+ if plotting:
+ project_cfg = self.root.cfg
+ eval_folder = auxiliaryfunctions.get_evaluation_folder(
+ trainFraction=project_cfg["TrainingFraction"][0],
+ shuffle=shuffle,
+ cfg=project_cfg,
+ )
+ scorer, _ = auxiliaryfunctions.get_scorer_name(
+ cfg=project_cfg,
+ shuffle=shuffle,
+ trainFraction=project_cfg["TrainingFraction"][0],
+ )
+
+ image_dir = Path(self.root.project_folder) / eval_folder / f"LabeledImages_{scorer}"
+ labeled_images = [str(p) for p in image_dir.rglob("*.png")]
+ if len(labeled_images) > 0:
+ _ = launch_napari(labeled_images)
+
+ @Slot(Engine)
+ def _on_engine_change(self, engine: Engine) -> None:
+ if engine == Engine.PYTORCH:
+ self.opt_button.hide()
+ return
+
+ self.opt_button.show()
diff --git a/deeplabcut/gui/tabs/extract_frames.py b/deeplabcut/gui/tabs/extract_frames.py
index 27c18c3083..9e8b92ba8f 100644
--- a/deeplabcut/gui/tabs/extract_frames.py
+++ b/deeplabcut/gui/tabs/extract_frames.py
@@ -9,28 +9,28 @@
# Licensed under GNU Lesser General Public License v3.0
#
from functools import partial
+from pathlib import Path
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
-from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.generate_training_dataset import extract_frames
from deeplabcut.gui.components import (
DefaultTab,
VideoSelectionWidget,
_create_grid_layout,
_create_label_widget,
)
+from deeplabcut.gui.dlc_params import DLCParams
from deeplabcut.gui.utils import move_to_separate_thread
from deeplabcut.gui.widgets import launch_napari
-from deeplabcut.generate_training_dataset import extract_frames
def select_cropping_area(config, videos=None):
- """
- Interactively select the cropping area of all videos in the config.
- A user interface pops up with a frame to select the cropping parameters.
- Use the left click to draw a box and hit the button 'set cropping parameters'
- to store the cropping parameters for a video in the config.yaml file.
+ """Interactively select the cropping area of all videos in the config. A user
+ interface pops up with a frame to select the cropping parameters. Use the left click
+ to draw a box and hit the button 'set cropping parameters' to store the cropping
+ parameters for a video in the config.yaml file.
Parameters
----------
@@ -46,8 +46,8 @@ def select_cropping_area(config, videos=None):
cfg : dict
Updated project configuration
"""
- from deeplabcut.utils import auxiliaryfunctions
from deeplabcut.gui.widgets import FrameCropper
+ from deeplabcut.utils import auxiliaryfunctions
cfg = auxiliaryfunctions.read_config(config)
if videos is None:
@@ -81,8 +81,9 @@ def select_cropping_area(config, videos=None):
class ExtractFrames(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(ExtractFrames, self).__init__(root, parent, h1_description)
-
+ super().__init__(root, parent, h1_description)
+ self.worker = None
+ self.thread = None
self._set_page()
def _set_page(self):
@@ -93,7 +94,8 @@ def _set_page(self):
self.main_layout.addWidget(
_create_label_widget(
- "Optional: frame extraction from a video subset", "font:bold"
+ "Frame extraction from a video subset (optional for automatic extraction)",
+ "font:bold",
)
)
self.video_selection_widget = VideoSelectionWidget(self.root, self)
@@ -127,25 +129,19 @@ def _generate_layout_attributes(self, layout):
self.extraction_method_widget = QtWidgets.QComboBox()
options = ["automatic", "manual"]
self.extraction_method_widget.addItems(options)
- self.extraction_method_widget.currentTextChanged.connect(
- self.log_extraction_method
- )
+ self.extraction_method_widget.currentTextChanged.connect(self.log_extraction_method)
# Frame extraction algorithm
ext_algo_label = QtWidgets.QLabel("Extraction algorithm")
self.extraction_algorithm_widget = QtWidgets.QComboBox()
self.extraction_algorithm_widget.addItems(DLCParams.FRAME_EXTRACTION_ALGORITHMS)
- self.extraction_algorithm_widget.currentTextChanged.connect(
- self.log_extraction_algorithm
- )
+ self.extraction_algorithm_widget.currentTextChanged.connect(self.log_extraction_algorithm)
# Frame cropping
frame_crop_label = QtWidgets.QLabel("Frame cropping")
self.frame_cropping_widget = QtWidgets.QComboBox()
self.frame_cropping_widget.addItems(["disabled", "read from config", "GUI"])
- self.frame_cropping_widget.currentTextChanged.connect(
- self.log_frame_cropping_choice
- )
+ self.frame_cropping_widget.currentTextChanged.connect(self.log_frame_cropping_choice)
# Cluster step
cluster_step_label = QtWidgets.QLabel("Cluster step")
@@ -194,7 +190,19 @@ def extract_frames(self):
config = self.root.config
mode = self.extraction_method_widget.currentText()
if mode == "manual":
- _ = launch_napari(list(self.video_selection_widget.files)[0])
+ videos = list(self.video_selection_widget.files)
+ if not videos:
+ QtWidgets.QMessageBox.critical(
+ self,
+ "Error",
+ "Please select exactly one video to extract frames from.",
+ )
+ return
+ first_video = videos[0]
+ if len(videos) > 1:
+ self.root.writer.write(f"Only the first video ({first_video}) will be opened.")
+ video_path_in_folder = self._check_symlink(first_video)
+ _ = launch_napari(str(video_path_in_folder))
return
algo = self.extraction_algorithm_widget.currentText()
@@ -221,7 +229,8 @@ def extract_frames(self):
userfeedback=False,
videos_list=self.video_selection_widget.files or None,
)
- self.worker, self.thread = move_to_separate_thread(func)
+
+ self.worker, self.thread = move_to_separate_thread(func, capture_outputs=True)
self.worker.finished.connect(lambda: self.ok_button.setEnabled(True))
self.worker.finished.connect(lambda: self.root._progress_bar.hide())
self.thread.finished.connect(self._show_success_message)
@@ -230,10 +239,63 @@ def extract_frames(self):
self.root._progress_bar.show()
def _show_success_message(self):
+ message = "Failed to create worker: it is None"
+ root_message = "failed to extract frames: worker is None"
+ if self.worker is not None:
+ failed = self.worker.outputs
+ if failed is None:
+ # outputs are None during manual frame extraction
+ return
+
+ if len(failed) == 0:
+ message = "Frame extraction failed. Please check your terminal output for more information."
+ elif all(failed):
+ message = "Frame extraction failed. Video files must be corrupted."
+ elif any(failed):
+ message = "Although most frames were extracted, some were invalid."
+ root_message = "failed to extract (some) frames"
+ else:
+ message = "Frames were successfully extracted, for the videos of interest."
+ root_message = "successfully extracted frames"
+
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Information)
- msg.setText("Frames were successfully extracted, for the videos of interest.")
+ msg.setText(message)
msg.setWindowTitle("Info")
msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
msg.exec_()
- self.root.writer.write("Frames successfully extracted.")
+ self.root.writer.write(root_message)
+
+ def _check_symlink(self, video_path: str | Path) -> Path:
+ """Checks that a video is in the DeepLabCut 'videos' folder.
+
+ This is required before launching manual frame extraction. When users select
+ a symlink of a video using the VideoSelectionWidget, the path is resolved to the
+ true path of the video (which leads napari-deeplabcut to save the frames in the
+ incorrect folder).
+
+ Args:
+ video_path: the path to a video in a DeepLabCut project or a video that was
+ added to the project
+
+ Returns:
+ the path to the video (or symlink) in the project's 'videos' folder
+
+ Raises:
+ FileNotFoundError if there is no symlink or video in the 'videos' folder for
+ the given video
+ """
+ video_path = Path(video_path).resolve()
+ project_videos = (Path(self.root.config).parent / "videos").resolve()
+ if video_path.parent == project_videos:
+ return video_path
+
+ symlink_path = project_videos / video_path.name
+ if not symlink_path.exists():
+ raise FileNotFoundError(
+ f"Could not find the video {video_path.name} in your project videos. "
+ f"Did you add the video (you can do so in the 'Manage Project' tab)? "
+ f"There should be a file in {symlink_path}."
+ )
+
+ return symlink_path
diff --git a/deeplabcut/gui/tabs/extract_outlier_frames.py b/deeplabcut/gui/tabs/extract_outlier_frames.py
index ac62911ae5..cdb7ec3830 100644
--- a/deeplabcut/gui/tabs/extract_outlier_frames.py
+++ b/deeplabcut/gui/tabs/extract_outlier_frames.py
@@ -11,7 +11,7 @@
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
-from deeplabcut.gui.dlc_params import DLCParams
+import deeplabcut
from deeplabcut.gui.components import (
DefaultTab,
ShuffleSpinBox,
@@ -19,14 +19,13 @@
_create_horizontal_layout,
_create_label_widget,
)
+from deeplabcut.gui.dlc_params import DLCParams
from deeplabcut.gui.widgets import launch_napari
-import deeplabcut
-
class ExtractOutlierFrames(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(ExtractOutlierFrames, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self.filelist = []
self._set_page()
@@ -47,9 +46,7 @@ def _set_page(self):
self._generate_multianimal_options(self.layout_attributes)
self.main_layout.addLayout(self.layout_attributes)
- self.main_layout.addWidget(
- _create_label_widget("Frame extraction options", "font:bold")
- )
+ self.main_layout.addWidget(_create_label_widget("Frame extraction options", "font:bold"))
self.layout_extraction_options = _create_horizontal_layout()
self._generate_layout_extraction_options(self.layout_extraction_options)
self.main_layout.addLayout(self.layout_extraction_options)
@@ -67,9 +64,7 @@ def _set_page(self):
self.merge_data_button.clicked.connect(self.merge_dataset)
self.merge_data_button.setMinimumWidth(150)
- self.main_layout.addWidget(
- self.extract_outlierframes_button, alignment=Qt.AlignRight
- )
+ self.main_layout.addWidget(self.extract_outlierframes_button, alignment=Qt.AlignRight)
self.main_layout.addWidget(self.label_outliers_button, alignment=Qt.AlignRight)
self.main_layout.addWidget(self.merge_data_button, alignment=Qt.AlignRight)
@@ -115,9 +110,7 @@ def _generate_layout_extraction_options(self, layout):
self.outlier_algorithm_widget = QtWidgets.QComboBox()
self.outlier_algorithm_widget.addItems(DLCParams.OUTLIER_EXTRACTION_ALGORITHMS)
self.outlier_algorithm_widget.setMinimumWidth(200)
- self.outlier_algorithm_widget.currentTextChanged.connect(
- self.update_outlier_algorithm
- )
+ self.outlier_algorithm_widget.currentTextChanged.connect(self.update_outlier_algorithm)
layout.addWidget(opt_text)
layout.addWidget(self.outlier_algorithm_widget)
@@ -126,9 +119,7 @@ def update_tracker_type(self, method):
self.root.logger.info(f"Using {method.upper()} tracker")
def update_outlier_algorithm(self, algorithm):
- self.root.logger.info(
- f"Using {algorithm.upper()} algorithm for frame extraction"
- )
+ self.root.logger.info(f"Using {algorithm.upper()} algorithm for frame extraction")
def extract_outlier_frames(self):
config = self.root.config
@@ -145,7 +136,7 @@ def extract_outlier_frames(self):
config: {config},
shuffle: {shuffle},
videos: {videos},
- videotype: {videotype},
+ video_extensions: {videotype},
outlier algorithm: {outlieralgorithm},
track method: {track_method}
"""
@@ -153,7 +144,7 @@ def extract_outlier_frames(self):
deeplabcut.extract_outlier_frames(
config=config,
videos=videos,
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
outlieralgorithm=outlieralgorithm,
track_method=track_method,
@@ -168,7 +159,8 @@ def merge_dataset(self):
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.setText(
- "Make sure that you have refined all the labels before merging the dataset.If you merge the dataset, you need to re-create the training dataset before you start the training. Are you ready to merge the dataset?"
+ "Make sure that you have refined all the labels before merging the dataset.If you merge the dataset, you"
+ "need to re-create the training dataset before you start the training. Are you ready to merge the dataset?"
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py
index 507f61a00b..509b04fcd3 100644
--- a/deeplabcut/gui/tabs/label_frames.py
+++ b/deeplabcut/gui/tabs/label_frames.py
@@ -8,16 +8,91 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+
import os
+from pathlib import Path
+
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
+
from deeplabcut.generate_training_dataset import check_labels
from deeplabcut.gui.components import DefaultTab
from deeplabcut.gui.widgets import launch_napari
+from deeplabcut.utils.skeleton import SkeletonBuilder
+
+
+def label_frames(config_path: str | Path | None = None, image_folder: str | None = None):
+ """Launches the napari-deeplabcut labelling GUI.
+
+ For more information on labelling data with napari-deeplabcut, see our docs:
+ https://github.com/DeepLabCut/napari-deeplabcut?tab=readme-ov-file#usage
+
+ If no parameters are given, the napari-deeplabcut labelling GUI is simply open,
+ and the folder containing the images to label can be dropped into the GUI.
+ If the `config_path` and the `image_folder` are given as arguments, the given
+ `image_folder` for the project is opened in the napari-deeplabcut GUI to be labeled.
+ If only the `config_path` is given, the first image folder is opened.
-def label_frames(config_path):
- _ = launch_napari(config_path)
+ Parameters
+ ----------
+ config_path: str, Path, None
+ Full path of the project config.yaml file.
+
+ image_folder: str, None
+ Name of the image folder to open for labelling.
+
+ Examples
+ --------
+ Opening the napari-deeplabcut annotation GUI without opening a specific folder of
+ images to label. You then need to drag-and-drop your image folder into the GUI.
+ See the napari-deeplabcut docs linked above for more information about labelling in
+ napari-deeplabcut.
+ >>> import deeplabcut
+ >>> deeplabcut.label_frames()
+
+ Opening the images extracted from the "2025-01-01-experiment7" video in
+ napari-deeplabcut on Windows. The project's folder structure should look as follows:
+ reaching-task/ # project root directory
+ ├── config.yaml # project configuration file
+ └── labeled-data/ # folder containing all extracted image folders
+ ├── ...
+ ├── 2025-01-01-experiment7 # folder containing the images to label
+ └── ...
+
+ >>> deeplabcut.label_frames(
+ >>> "C:\\myproject\\reaching-task\\config.yaml",
+ >>> "2025-01-01-experiment7",
+ >>> )
+
+ Opening the images extracted from the first video listed in the project
+ configuration in napari-deeplabcut on a Unix system.
+ >>> deeplabcut.label_frames("/users/john/project/config.yaml")
+ """
+ files = None
+ if config_path is None:
+ if image_folder is not None:
+ raise ValueError(
+ f"If the ``config_path`` is None, the ``image_folder`` must be None "
+ f"too. Found {image_folder}. To label the images in {image_folder}, "
+ f"give the project configuration file as `config_path`."
+ )
+ else:
+ data_dir = Path(config_path).parent / "labeled-data"
+ if image_folder is None:
+ image_dirs = [path for path in data_dir.iterdir() if path.is_dir()]
+ if len(image_dirs) == 0:
+ raise ValueError(
+ f"Could not find any image folders in {data_dir}. Please check "
+ f"the config path given to `deeplabcut.label_frames(...)`"
+ )
+ image_dir = list(sorted(image_dirs))[0]
+ else:
+ image_dir = data_dir / image_folder
+
+ files = [str(image_dir), str(config_path)]
+ _ = launch_napari(files=files)
refine_labels = label_frames
@@ -25,7 +100,7 @@ def label_frames(config_path):
class LabelFrames(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(LabelFrames, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self._set_page()
@@ -34,8 +109,11 @@ def _set_page(self):
self.label_frames_btn.clicked.connect(self.label_frames)
self.check_labels_btn = QtWidgets.QPushButton("Check Labels")
self.check_labels_btn.clicked.connect(self.check_labels)
+ self.build_skeleton_btn = QtWidgets.QPushButton("Build skeleton")
+ self.build_skeleton_btn.clicked.connect(self.build_skeleton)
self.main_layout.addWidget(self.label_frames_btn, alignment=Qt.AlignLeft)
self.main_layout.addWidget(self.check_labels_btn, alignment=Qt.AlignLeft)
+ self.main_layout.addWidget(self.build_skeleton_btn, alignment=Qt.AlignLeft)
def log_color_by_option(self, choice):
self.root.logger.info(f"Labeled images will by colored by {choice.upper()}")
@@ -44,9 +122,7 @@ def label_frames(self):
dialog = QtWidgets.QFileDialog(self)
dialog.setFileMode(QtWidgets.QFileDialog.Directory)
dialog.setViewMode(QtWidgets.QFileDialog.Detail)
- dialog.setDirectory(
- os.path.join(os.path.dirname(self.root.config), "labeled-data")
- )
+ dialog.setDirectory(os.path.join(os.path.dirname(self.root.config), "labeled-data"))
if dialog.exec_():
folder = dialog.selectedFiles()[0]
has_h5 = False
@@ -60,3 +136,8 @@ def label_frames(self):
def check_labels(self):
check_labels(self.root.config, visualizeindividuals=self.root.is_multianimal)
+ labeled_images = (Path(self.root.config).parent / "labeled-data").rglob("*_labeled/*.png")
+ _ = launch_napari(labeled_images, plugin="napari", stack=True)
+
+ def build_skeleton(self, *args):
+ SkeletonBuilder(self.root.config)
diff --git a/deeplabcut/gui/tabs/manage_project.py b/deeplabcut/gui/tabs/manage_project.py
index 932adcf827..b4d05e7d2a 100644
--- a/deeplabcut/gui/tabs/manage_project.py
+++ b/deeplabcut/gui/tabs/manage_project.py
@@ -9,16 +9,18 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
+
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
- QPushButton,
QFileDialog,
QLabel,
QLineEdit,
+ QPushButton,
)
+
from deeplabcut.create_project import add_new_videos
-from deeplabcut.gui.dlc_params import DLCParams
from deeplabcut.gui.components import DefaultTab, _create_horizontal_layout
+from deeplabcut.gui.dlc_params import DLCParams
from deeplabcut.gui.widgets import ConfigEditor
diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py
index 2607fab362..b40252354d 100644
--- a/deeplabcut/gui/tabs/modelzoo.py
+++ b/deeplabcut/gui/tabs/modelzoo.py
@@ -9,21 +9,29 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
+import webbrowser
from functools import partial
+from pathlib import Path
-import deeplabcut
+import dlclibrary
from PySide6 import QtWidgets
-from PySide6.QtCore import Qt, Signal, QTimer, QRegularExpression
-from PySide6.QtGui import QPixmap, QRegularExpressionValidator
+from PySide6.QtCore import QRegularExpression, QSize, Qt, QTimer, Signal, Slot
+from PySide6.QtGui import QIcon, QPixmap, QRegularExpressionValidator
+
+import deeplabcut
+from deeplabcut.core.engine import Engine
+from deeplabcut.gui import BASE_DIR
from deeplabcut.gui.components import (
DefaultTab,
VideoSelectionWidget,
- _create_label_widget,
_create_grid_layout,
+ _create_label_widget,
+ set_combo_items,
+ set_layout_contents_visible,
)
-from deeplabcut.gui import BASE_DIR
from deeplabcut.gui.utils import move_to_separate_thread
-from deeplabcut.modelzoo.utils import parse_available_supermodels
+from deeplabcut.gui.widgets import ClickableLabel
+from deeplabcut.pose_estimation_pytorch.apis.utils import TORCHVISION_DETECTORS
class RegExpValidator(QRegularExpressionValidator):
@@ -40,47 +48,246 @@ def __init__(self, root, parent, h1_description):
super().__init__(root, parent, h1_description)
self._val_pattern = QRegularExpression(r"(\d{3,5},\s*)+\d{3,5}")
self._set_page()
+ self.root.engine_change.connect(self._on_engine_change)
+ self.root.engine_change.connect(self._update_available_models)
+ self._update_pose_models(self.model_combo.currentText())
+ self._update_detectors(self.model_combo.currentText())
+ self._destfolder = None
+ self.worker = None
+ self.thread = None
@property
def files(self):
return self.video_selection_widget.files
def _set_page(self):
+ # Create Run button first so it exists for any method that references it
+ self.run_button = QtWidgets.QPushButton("Run")
+ self.run_button.setStyleSheet(
+ """
+ QPushButton {
+ background-color: #4CAF50;
+ color: white;
+ font-weight: bold;
+ }
+ QPushButton:disabled {
+ background-color: #9E9E9E;
+ color: white;
+ font-weight: bold;
+ }
+ """
+ )
+ self.run_button.setFixedWidth(120)
+ self.run_button.clicked.connect(self.run_video_inference_superanimal)
+ button_layout = QtWidgets.QHBoxLayout()
+ button_layout.addStretch()
+ button_layout.addWidget(self.run_button)
+ button_layout.addStretch()
+
self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
- self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.video_selection_widget = VideoSelectionWidget(self.root, self, hide_videotype=True)
self.main_layout.addWidget(self.video_selection_widget)
- model_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+ self._build_common_attributes()
+ self._build_tf_attributes()
+ self._build_torch_attributes()
+
+ self.home_button = QtWidgets.QPushButton("Return to Welcome page")
+ self.home_button.clicked.connect(self.root._generate_welcome_page)
+ self.main_layout.addWidget(self.home_button, alignment=Qt.AlignLeft)
+ self.help_button = QtWidgets.QPushButton("Help")
+ self.help_button.clicked.connect(self.show_help_dialog)
+ self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft)
- section_title = _create_label_widget(
- "Supermodel Settings", "font:bold", (0, 50, 0, 0)
+ self.go_to_button = QtWidgets.QPushButton("Read Documentation")
+ # go to url
+ # https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models
+ # when button is clicked
+ self.go_to_button.clicked.connect(
+ lambda: webbrowser.open(
+ "https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html#about-the-superanimal-models"
+ )
)
+ self.main_layout.addWidget(self.go_to_button, alignment=Qt.AlignLeft)
+
+ # Add the Run button layout
+ self.main_layout.addLayout(button_layout)
- model_combo_text = QtWidgets.QLabel("Supermodel name")
+ self._on_engine_change(self.root.engine)
+
+ def _add_supermodel_section(self, layout: QtWidgets.QGridLayout) -> None:
+ # --- Supermodel selection ---
+ section_title = QtWidgets.QLabel("Supermodel settings")
+ section_title.setStyleSheet("font-weight: bold; font-size: 16px;")
+ model_combo_text = QtWidgets.QLabel("Supermodel")
+ model_combo_text.setMinimumWidth(150)
self.model_combo = QtWidgets.QComboBox()
- supermodels = parse_available_supermodels()
- self.model_combo.addItems(supermodels.keys())
+ self.model_combo.setMinimumWidth(250)
+ layout.addWidget(section_title, 0, 0, 1, 6)
+ layout.addWidget(model_combo_text, 1, 0)
+ layout.addWidget(self.model_combo, 1, 1)
+
+ def _add_pose_model_settings_row(self, layout: QtWidgets.QGridLayout):
+ # --- Pose Model Type and Pose Confidence Threshold on the same line (now row 2) ---
+ pose_model_row = QtWidgets.QHBoxLayout()
+ pose_model_label = QtWidgets.QLabel("Pose Model Type")
+ pose_model_label.setMinimumWidth(150)
+ self.net_type_selector = QtWidgets.QComboBox()
+ self.net_type_selector.setMinimumWidth(180)
+ pose_conf_label = QtWidgets.QLabel("Pose confidence threshold")
+ pose_conf_label.setMinimumWidth(170)
+ self.pose_threshold_spinbox = QtWidgets.QDoubleSpinBox(
+ decimals=2,
+ minimum=0.0,
+ maximum=1.0,
+ singleStep=0.01,
+ value=0.4,
+ wrapping=True,
+ )
+ self.pose_threshold_spinbox.setMaximumWidth(100)
+ batch_size_combo_label = QtWidgets.QLabel("Pose model batch size")
+ self.batch_size_combo = QtWidgets.QComboBox()
+ self.batch_size_combo.setMinimumWidth(100)
+ self.batch_size_combo.addItems([str(2**i) for i in range(6)])
+ self.batch_size_combo.setCurrentIndex(0)
+ pose_model_row.addWidget(pose_model_label)
+ pose_model_row.addWidget(self.net_type_selector)
+ pose_model_row.addSpacing(20)
+ pose_model_row.addWidget(pose_conf_label)
+ pose_model_row.addWidget(self.pose_threshold_spinbox)
+ pose_model_row.addSpacing(20)
+ pose_model_row.addWidget(batch_size_combo_label)
+ pose_model_row.addWidget(self.batch_size_combo)
+ pose_model_row.addStretch()
+ layout.addLayout(pose_model_row, 2, 0, 1, 6)
+ def _add_detector_settings_row(self, layout: QtWidgets.QGridLayout):
+ # --- Detector Type and Detector Confidence Threshold on the same line (now row 3) ---
+ detector_label = QtWidgets.QLabel("Detector Type")
+ detector_label.setMinimumWidth(150)
+ self.detector_type_selector = QtWidgets.QComboBox()
+ self.detector_type_selector.setMinimumWidth(180)
+ detector_conf_label = QtWidgets.QLabel("Detector confidence threshold")
+ detector_conf_label.setMinimumWidth(170)
+ self.detector_threshold_spinbox = QtWidgets.QDoubleSpinBox(
+ decimals=2,
+ minimum=0.0,
+ maximum=1.0,
+ singleStep=0.01,
+ value=0.1,
+ wrapping=True,
+ )
+ self.detector_threshold_spinbox.setMaximumWidth(100)
+ max_individuals_label = QtWidgets.QLabel("Maximum number of individuals")
+ max_individuals_label.setMinimumWidth(180)
+ self.max_individuals_spinbox = QtWidgets.QSpinBox()
+ self.max_individuals_spinbox.setRange(1, 100)
+ self.max_individuals_spinbox.setValue(1)
+ self.max_individuals_spinbox.setMaximumWidth(100)
+ detector_batch_size_combo_label = QtWidgets.QLabel("Detector batch size")
+ self.detector_batch_size_combo = QtWidgets.QComboBox()
+ self.detector_batch_size_combo.setMinimumWidth(100)
+ self.detector_batch_size_combo.addItems([str(2**i) for i in range(6)])
+ self.detector_batch_size_combo.setCurrentIndex(0)
+ self.detector_row = QtWidgets.QHBoxLayout()
+ self.detector_row.addWidget(detector_label)
+ self.detector_row.addWidget(self.detector_type_selector)
+ self.detector_row.addSpacing(20)
+ self.detector_row.addWidget(detector_conf_label)
+ self.detector_row.addWidget(self.detector_threshold_spinbox)
+ self.detector_row.addSpacing(20)
+ self.detector_row.addWidget(max_individuals_label)
+ self.detector_row.addWidget(self.max_individuals_spinbox)
+ self.detector_row.addSpacing(20)
+ self.detector_row.addWidget(detector_batch_size_combo_label)
+ self.detector_row.addWidget(self.detector_batch_size_combo)
+ self.detector_row.addStretch()
+ layout.addLayout(self.detector_row, 3, 0, 1, 6)
+
+ def _add_output_settings_section(self, layout: QtWidgets.QGridLayout):
+ loc_label = ClickableLabel("Folder to store results:", parent=self)
+ loc_label.signal.connect(self.select_folder)
+ self.loc_line = QtWidgets.QLineEdit(
+ "",
+ self,
+ )
+ self.loc_line.setReadOnly(True)
+ action = self.loc_line.addAction(
+ QIcon(os.path.join(BASE_DIR, "assets", "icons", "open2.png")),
+ QtWidgets.QLineEdit.TrailingPosition,
+ )
+ action.triggered.connect(self.select_folder)
+
+ self.create_labeled_video_checkbox = QtWidgets.QCheckBox("Create labeled video")
+ self.create_labeled_video_checkbox.setChecked(True)
+
+ layout.addWidget(loc_label, 4, 0)
+ layout.addWidget(self.loc_line, 4, 1)
+ layout.addWidget(self.create_labeled_video_checkbox, 5, 0)
+
+ def _build_common_attributes(self) -> None:
+ settings_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+
+ self._add_supermodel_section(settings_layout)
+ self._add_pose_model_settings_row(settings_layout)
+ self._add_detector_settings_row(settings_layout)
+ self._add_output_settings_section(settings_layout)
+
+ self.settings_widget = QtWidgets.QWidget()
+ self.settings_widget.setLayout(settings_layout)
+ self.main_layout.addWidget(self.settings_widget)
+
+ self.model_combo.currentTextChanged.connect(self._update_pose_models)
+ self.model_combo.currentTextChanged.connect(self._update_detectors)
+ self.model_combo.currentTextChanged.connect(self._update_adaptation_detector_visibility)
+
+ def _add_tf_scales_row(self, layout: QtWidgets.QGridLayout):
scales_label = QtWidgets.QLabel("Scale list")
+ scales_label.setMinimumWidth(300)
self.scales_line = QtWidgets.QLineEdit("", parent=self)
- self.scales_line.setPlaceholderText(
- "Optionally input a list of integer sizes separated by commas..."
- )
+ self.scales_line.setMinimumWidth(500)
+ self.scales_line.setPlaceholderText("Optionally input a list of integer sizes separated by commas...")
validator = RegExpValidator(self._val_pattern, self)
validator.validationChanged.connect(self._handle_validation_change)
self.scales_line.setValidator(validator)
-
tooltip_label = QtWidgets.QLabel()
- tooltip_label.setPixmap(
- QPixmap(os.path.join(BASE_DIR, "assets", "icons", "help2.png")).scaledToWidth(30)
- )
+ tooltip_label.setPixmap(QPixmap(os.path.join(BASE_DIR, "assets", "icons", "help2.png")).scaledToWidth(30))
tooltip_label.setToolTip(
- "Approximate animal sizes in pixels, for spatial pyramid search. If left blank, defaults to video height +/- 50 pixels",
+ "Approximate animal sizes in pixels, for spatial pyramid search. If left "
+ "blank, defaults to video height +/- 50 pixels"
)
+ scales_row = QtWidgets.QHBoxLayout()
+ scales_row.addWidget(scales_label)
+ scales_row.addWidget(self.scales_line)
+ scales_row.addWidget(tooltip_label)
+ layout.addLayout(scales_row, 1, 0, 1, 2)
+ def _add_use_adaptation_row(self, layout: QtWidgets.QGridLayout, layout_row: int):
+ # --- Adaptation Checkbox with Help Button (TF section) ---
self.adapt_checkbox = QtWidgets.QCheckBox("Use video adaptation")
self.adapt_checkbox.setChecked(True)
+ self.adapt_checkbox.setStyleSheet("font-weight: bold; font-size: 16px; padding: 6px 12px;")
+ # Add help button
+ adapt_help_btn = QtWidgets.QToolButton()
+ adapt_help_btn.setIcon(QIcon(os.path.join(BASE_DIR, "assets", "icons", "help2.png")))
+ adapt_help_btn.setIconSize(QSize(24, 24))
+ adapt_help_btn.setToolTip("What is video adaptation?")
+ def show_adapt_help():
+ QtWidgets.QMessageBox.information(
+ self,
+ "Video Adaptation",
+ "This will adapt the model on the fly to your video data in a self-supervised way.",
+ )
+
+ adapt_help_btn.clicked.connect(show_adapt_help)
+ use_adaptation_row = QtWidgets.QHBoxLayout()
+ use_adaptation_row.addWidget(self.adapt_checkbox)
+ use_adaptation_row.addWidget(adapt_help_btn)
+ use_adaptation_row.addStretch()
+ layout.addLayout(use_adaptation_row, layout_row, 0, 1, 2)
+
+ def _add_tf_adaptation_settings_row(self, layout: QtWidgets.QGridLayout):
pseudo_threshold_label = QtWidgets.QLabel("Pseudo-label confidence threshold")
self.pseudo_threshold_spinbox = QtWidgets.QDoubleSpinBox(
decimals=2,
@@ -90,41 +297,109 @@ def _set_page(self):
value=0.1,
wrapping=True,
)
- self.pseudo_threshold_spinbox.setMaximumWidth(300)
-
+ self.pseudo_threshold_spinbox.setMaximumWidth(100)
adapt_iter_label = QtWidgets.QLabel("Number of adaptation iterations")
+ adapt_iter_label.setMinimumWidth(300)
self.adapt_iter_spinbox = QtWidgets.QSpinBox()
self.adapt_iter_spinbox.setRange(100, 10000)
self.adapt_iter_spinbox.setValue(1000)
self.adapt_iter_spinbox.setSingleStep(100)
self.adapt_iter_spinbox.setGroupSeparatorShown(True)
self.adapt_iter_spinbox.setMaximumWidth(300)
+ self.tf_adaptation_settings_row = QtWidgets.QHBoxLayout()
+ self.tf_adaptation_settings_row.addWidget(pseudo_threshold_label)
+ self.tf_adaptation_settings_row.addWidget(self.pseudo_threshold_spinbox)
+ self.tf_adaptation_settings_row.addSpacing(20)
+ self.tf_adaptation_settings_row.addWidget(adapt_iter_label)
+ self.tf_adaptation_settings_row.addWidget(self.adapt_iter_spinbox)
+ layout.addLayout(self.tf_adaptation_settings_row, 3, 0, 1, 6)
- model_settings_layout.addWidget(section_title, 0, 0)
- model_settings_layout.addWidget(model_combo_text, 1, 0)
- model_settings_layout.addWidget(self.model_combo, 1, 1)
- model_settings_layout.addWidget(scales_label, 2, 0)
- model_settings_layout.addWidget(self.scales_line, 2, 1)
- model_settings_layout.addWidget(tooltip_label, 2, 2)
- model_settings_layout.addWidget(self.adapt_checkbox, 3, 0)
- model_settings_layout.addWidget(pseudo_threshold_label, 4, 0)
- model_settings_layout.addWidget(self.pseudo_threshold_spinbox, 4, 1)
- model_settings_layout.addWidget(adapt_iter_label, 5, 0)
- model_settings_layout.addWidget(self.adapt_iter_spinbox, 5, 1)
- self.main_layout.addLayout(model_settings_layout)
+ def _build_tf_attributes(self) -> None:
+ tf_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0))
- self.run_button = QtWidgets.QPushButton("Run")
- self.run_button.clicked.connect(self.run_video_adaptation)
- self.main_layout.addWidget(self.run_button, alignment=Qt.AlignRight)
+ self._add_tf_scales_row(tf_settings_layout)
+ self._add_use_adaptation_row(tf_settings_layout, 2)
+ self._add_tf_adaptation_settings_row(tf_settings_layout)
- self.help_button = QtWidgets.QPushButton("Help")
- self.help_button.clicked.connect(self.show_help_dialog)
- self.main_layout.addWidget(self.help_button, alignment=Qt.AlignLeft)
+ self.adapt_checkbox.stateChanged.connect(self._adapt_checkbox_status_changed)
+
+ self.tf_widget = QtWidgets.QWidget()
+ self.tf_widget.setLayout(tf_settings_layout)
+ self.tf_widget.hide()
+ self.main_layout.addWidget(self.tf_widget)
+
+ def _add_torch_adaptation_settings_row(self, layout: QtWidgets.QGridLayout):
+ # Compact adaptation settings row
+ pseudo_threshold_label = QtWidgets.QLabel("Pseudo-label confidence threshold")
+ pseudo_threshold_label.setMinimumWidth(200)
+ self.torch_pseudo_threshold_spinbox = QtWidgets.QDoubleSpinBox(
+ decimals=2,
+ minimum=0.01,
+ maximum=1.0,
+ singleStep=0.05,
+ value=0.1,
+ wrapping=True,
+ )
+ self.torch_pseudo_threshold_spinbox.setMaximumWidth(100)
+ adapt_epoch_label = QtWidgets.QLabel("Number of adaptation epochs")
+ adapt_epoch_label.setMinimumWidth(180)
+ self.torch_adapt_epoch_spinbox = QtWidgets.QSpinBox()
+ self.torch_adapt_epoch_spinbox.setRange(1, 50)
+ self.torch_adapt_epoch_spinbox.setValue(4)
+ self.torch_adapt_epoch_spinbox.setMaximumWidth(100)
+ self.adapt_det_epoch_label = QtWidgets.QLabel("Number of detector adaptation epochs")
+ self.adapt_det_epoch_label.setMinimumWidth(200)
+ self.torch_adapt_det_epoch_spinbox = QtWidgets.QSpinBox()
+ self.torch_adapt_det_epoch_spinbox.setRange(1, 50)
+ self.torch_adapt_det_epoch_spinbox.setValue(4)
+ self.torch_adapt_det_epoch_spinbox.setMaximumWidth(100)
+ self.torch_adaptation_settings_row = QtWidgets.QHBoxLayout()
+ self.torch_adaptation_settings_row.addWidget(pseudo_threshold_label)
+ self.torch_adaptation_settings_row.addWidget(self.torch_pseudo_threshold_spinbox)
+ self.torch_adaptation_settings_row.addSpacing(20)
+ self.torch_adaptation_settings_row.addWidget(adapt_epoch_label)
+ self.torch_adaptation_settings_row.addWidget(self.torch_adapt_epoch_spinbox)
+ self.torch_adaptation_settings_row.addSpacing(20)
+ self.torch_adaptation_settings_row.addWidget(self.adapt_det_epoch_label)
+ self.torch_adaptation_settings_row.addWidget(self.torch_adapt_det_epoch_spinbox)
+ self.torch_adaptation_settings_row.addStretch()
+ layout.addLayout(self.torch_adaptation_settings_row, 2, 0, 1, 6)
+
+ def _build_torch_attributes(self) -> None:
+ torch_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+
+ self._add_use_adaptation_row(torch_settings_layout, 1)
+ self._add_torch_adaptation_settings_row(torch_settings_layout)
+
+ self.adapt_checkbox.stateChanged.connect(self._adapt_checkbox_status_changed)
+
+ self.torch_widget = QtWidgets.QWidget()
+ self.torch_widget.setLayout(torch_settings_layout)
+ self.torch_widget.hide()
+ self.main_layout.addWidget(self.torch_widget)
+
+ def _adapt_checkbox_status_changed(self, state: int) -> None:
+ if self.root.engine == Engine.TF:
+ set_layout_contents_visible(self.tf_adaptation_settings_row, Qt.CheckState(state) == Qt.Checked)
+ elif self.root.engine == Engine.PYTORCH:
+ set_layout_contents_visible(self.torch_adaptation_settings_row, Qt.CheckState(state) == Qt.Checked)
+ if Qt.CheckState(state) == Qt.Checked:
+ self._update_adaptation_detector_visibility(self.model_combo.currentText())
+
+ def select_folder(self):
+ dirname = QtWidgets.QFileDialog.getExistingDirectory(self, "Please select a folder", self.root.project_folder)
+ if not dirname:
+ return
+
+ self._destfolder = dirname
+ self.loc_line.setText(dirname)
def show_help_dialog(self):
dialog = QtWidgets.QDialog(self)
layout = QtWidgets.QVBoxLayout()
- label = QtWidgets.QLabel(deeplabcut.video_inference_superanimal.__doc__, self)
+ help_text = deeplabcut.video_inference_superanimal.__doc__
+
+ label = QtWidgets.QLabel(help_text, self)
scroll = QtWidgets.QScrollArea()
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
@@ -144,43 +419,186 @@ def _handle_validation_change(self, state):
self.scales_line.setStyleSheet(f"border: 1px solid {color}")
QTimer.singleShot(500, lambda: self.scales_line.setStyleSheet(""))
- def run_video_adaptation(self):
- videos = list(self.files)
- if not videos:
+ def run_video_inference_superanimal(self):
+ files = list(self.files)
+ if not files:
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Critical)
- msg.setText("You must select a video file")
+ msg.setText("You must select video files")
msg.setWindowTitle("Error")
msg.setMinimumWidth(400)
msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
msg.exec_()
return
- scales = []
- scales_ = self.scales_line.text()
- if scales_:
- if (
- self.scales_line.validator().validate(scales_, 0)[0]
- == RegExpValidator.Acceptable
- ):
- scales = list(map(int, scales_.split(",")))
supermodel_name = self.model_combo.currentText()
- videotype = self.video_selection_widget.videotype_widget.currentText()
-
- func = partial(
- deeplabcut.video_inference_superanimal,
- videos,
- supermodel_name,
- videotype=videotype,
- video_adapt=self.adapt_checkbox.isChecked(),
- scale_list=scales,
- pseudo_threshold=self.pseudo_threshold_spinbox.value(),
- adapt_iterations=self.adapt_iter_spinbox.value(),
- )
+ create_labeled_video = self.create_labeled_video_checkbox.isChecked()
+ batch_size = int(self.batch_size_combo.currentText())
+ detector_batch_size = int(self.detector_batch_size_combo.currentText())
+ kwargs = self._gather_kwargs()
- self.worker, self.thread = move_to_separate_thread(func)
- self.worker.finished.connect(lambda: self.run_button.setEnabled(True))
- self.worker.finished.connect(lambda: self.root._progress_bar.hide())
- self.thread.start()
+ can_run_in_background = True
self.run_button.setEnabled(False)
self.root._progress_bar.show()
+ try:
+ # Use standard function for other models
+ if can_run_in_background:
+ func = partial(
+ deeplabcut.video_inference_superanimal,
+ files,
+ supermodel_name,
+ dest_folder=self._destfolder,
+ create_labeled_video=create_labeled_video,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ **kwargs,
+ )
+ self.worker, self.thread = move_to_separate_thread(func)
+ self.worker.finished.connect(self.signal_analysis_complete)
+ self.thread.start()
+ else:
+ print(f"Calling video_inference_superanimal with kwargs={kwargs}")
+ deeplabcut.video_inference_superanimal(
+ files,
+ supermodel_name,
+ dest_folder=self._destfolder,
+ create_labeled_video=create_labeled_video,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ **kwargs,
+ )
+ self.signal_analysis_complete()
+
+ except Exception as e:
+ print(f"[Error] {e}")
+ self.run_button.setEnabled(True)
+ self.root._progress_bar.hide()
+
+ def signal_analysis_complete(self):
+ self.run_button.setEnabled(True)
+ self.root._progress_bar.hide()
+
+ # Check if labeled videos were actually created
+ files = list(self.files)
+ videos_created = []
+
+ # Determine the output folder
+ output_folder = self._destfolder if self._destfolder else Path(files[0]).parent
+
+ for video_path in files:
+ video_name = Path(video_path).stem
+ labeled_videos = list(Path(output_folder).glob(f"{video_name}_*_labeled*.mp4"))
+ if labeled_videos:
+ videos_created.extend([str(v) for v in labeled_videos])
+
+ # Show appropriate message
+ if videos_created:
+ msg = QtWidgets.QMessageBox(
+ text="SuperAnimal video inference complete!\n\nCreated labeled videos:\n" + "\n".join(videos_created)
+ )
+ msg.setIcon(QtWidgets.QMessageBox.Information)
+ msg.exec_()
+ else:
+ msg = QtWidgets.QMessageBox(
+ text="SuperAnimal video inference complete, but no labeled videos were created."
+ )
+ msg.setIcon(QtWidgets.QMessageBox.Warning)
+ msg.exec_()
+
+ def stop_processes(self):
+ """Stop any running processes."""
+ if self.thread and self.thread.isRunning():
+ print("Stopping running processes...")
+ self.thread.quit()
+ self.thread.wait(5000) # Wait up to 5 seconds
+ if self.thread.isRunning():
+ self.thread.terminate()
+ self.thread.wait(2000)
+ self.worker = None
+ self.thread = None
+ self.run_button.setEnabled(True)
+ self.root._progress_bar.hide()
+
+ def closeEvent(self, event):
+ """Override closeEvent to stop processes when tab is closed."""
+ self.stop_processes()
+ super().closeEvent(event)
+
+ def _gather_kwargs(self) -> dict:
+ kwargs = dict(model_name=self.net_type_selector.currentText())
+
+ if self.root.engine == Engine.TF:
+ scales = []
+ scales_ = self.scales_line.text()
+ if scales_:
+ if self.scales_line.validator().validate(scales_, 0)[0] == RegExpValidator.Acceptable:
+ scales = list(map(int, scales_.split(",")))
+ kwargs["scale_list"] = scales
+ kwargs["video_adapt"] = self.adapt_checkbox.isChecked()
+ kwargs["pseudo_threshold"] = self.pseudo_threshold_spinbox.value()
+ kwargs["adapt_iterations"] = self.adapt_iter_spinbox.value()
+ else:
+ kwargs["detector_name"] = self.detector_type_selector.currentText()
+ kwargs["video_adapt"] = self.adapt_checkbox.isChecked()
+ kwargs["pseudo_threshold"] = self.pose_threshold_spinbox.value()
+ kwargs["bbox_threshold"] = self.detector_threshold_spinbox.value()
+ kwargs["detector_epochs"] = self.torch_adapt_det_epoch_spinbox.value()
+ kwargs["pose_epochs"] = self.torch_adapt_epoch_spinbox.value()
+ kwargs["max_individuals"] = self.max_individuals_spinbox.value()
+
+ return kwargs
+
+ def _update_available_models(self, engine: Engine) -> None:
+ current_dataset = self.model_combo.currentText()
+
+ if engine == Engine.TF:
+ supermodels = ["superanimal_topviewmouse", "superanimal_quadruped"]
+ else:
+ supermodels = dlclibrary.get_available_datasets()
+
+ set_combo_items(
+ combo_box=self.model_combo,
+ items=supermodels,
+ index=(supermodels.index(current_dataset) if current_dataset in supermodels else 0),
+ )
+
+ def _update_pose_models(self, super_animal: str) -> None:
+ if len(super_animal) == 0:
+ set_combo_items(combo_box=self.net_type_selector, items=[])
+ return
+
+ set_combo_items(
+ combo_box=self.net_type_selector,
+ items=(["dlcrnet"] if self.root.engine == Engine.TF else dlclibrary.get_available_models(super_animal)),
+ )
+
+ def _update_detectors(self, super_animal: str) -> None:
+ if len(super_animal) == 0:
+ set_combo_items(combo_box=self.detector_type_selector, items=[])
+ return
+
+ items = []
+ if self.root.engine == Engine.PYTORCH:
+ if super_animal == "superanimal_humanbody":
+ items = list(TORCHVISION_DETECTORS.keys())
+ else:
+ items = dlclibrary.get_available_detectors(super_animal)
+ set_combo_items(combo_box=self.detector_type_selector, items=items)
+ set_layout_contents_visible(self.detector_row, self.root.engine == Engine.PYTORCH)
+
+ def _update_adaptation_detector_visibility(self, superanimal: str):
+ self.adapt_det_epoch_label.setVisible(superanimal != "superanimal_humanbody")
+ self.torch_adapt_det_epoch_spinbox.setVisible(superanimal != "superanimal_humanbody")
+
+ @Slot(Engine)
+ def _on_engine_change(self, engine: Engine) -> None:
+ self._update_available_models(engine)
+ if engine == Engine.PYTORCH:
+ self.tf_widget.hide()
+ self.torch_widget.show()
+ else:
+ self.torch_widget.hide()
+ self.tf_widget.show()
+
+ # Hide widgets in detector row
+ set_layout_contents_visible(self.detector_row, engine == Engine.PYTORCH)
diff --git a/deeplabcut/gui/tabs/open_project.py b/deeplabcut/gui/tabs/open_project.py
index 42985be676..c21cfc78cc 100644
--- a/deeplabcut/gui/tabs/open_project.py
+++ b/deeplabcut/gui/tabs/open_project.py
@@ -10,14 +10,13 @@
#
import os
-from PySide6 import QtWidgets, QtCore
+from PySide6 import QtCore, QtWidgets
from PySide6.QtGui import QIcon
-from PySide6.QtWidgets import QCheckBox
class OpenProject(QtWidgets.QDialog):
def __init__(self, parent):
- super(OpenProject, self).__init__(parent)
+ super().__init__(parent)
self.setWindowTitle("Load Existing Project")
diff --git a/deeplabcut/gui/tabs/refine_tracklets.py b/deeplabcut/gui/tabs/refine_tracklets.py
index e32d0bf906..1e841f8b44 100644
--- a/deeplabcut/gui/tabs/refine_tracklets.py
+++ b/deeplabcut/gui/tabs/refine_tracklets.py
@@ -10,10 +10,12 @@
#
import os
from pathlib import Path
+
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
-from deeplabcut.gui.widgets import ConfigEditor
+import deeplabcut
+from deeplabcut.core import trackingutils
from deeplabcut.gui.components import (
DefaultTab,
ShuffleSpinBox,
@@ -22,15 +24,13 @@
_create_horizontal_layout,
_create_label_widget,
)
-
-import deeplabcut
-from deeplabcut.pose_estimation_tensorflow.lib import trackingutils
+from deeplabcut.gui.widgets import ConfigEditor
from deeplabcut.utils.auxiliaryfunctions import GetScorerName
class RefineTracklets(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(RefineTracklets, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self._set_page()
@property
@@ -122,9 +122,7 @@ def _generate_layout_attributes(self, layout):
layout.addWidget(self.num_animals_in_videos)
def _generate_layout_refinement(self, layout):
- section_title = _create_label_widget(
- "Refinement Settings", "font:bold", (0, 50, 0, 0)
- )
+ section_title = _create_label_widget("Refinement Settings", "font:bold", (0, 50, 0, 0))
# Min swap length
swap_length_label = QtWidgets.QLabel("Min swap length to highlight")
@@ -205,7 +203,7 @@ def create_tracks(self):
deeplabcut.stitch_tracklets(
self.root.config,
self.files,
- videotype=self.video_selection_widget.videotype_widget.currentText(),
+ video_extensions=self.video_selection_widget.videotype_widget.currentText(),
shuffle=self.shuffle.value(),
n_tracks=self.num_animals_in_videos.value(),
)
@@ -219,7 +217,7 @@ def filter_tracks(self):
deeplabcut.filterpredictions(
self.root.config,
self.files,
- videotype=videotype,
+ video_extensions=videotype,
shuffle=self.shuffle.value(),
filtertype=self.filter_type_widget.currentText(),
windowlength=self.window_length_widget.value(),
@@ -230,7 +228,8 @@ def merge_dataset(self):
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.setText(
- "Make sure that you have refined all the labels before merging the dataset.If you merge the dataset, you need to re-create the training dataset before you start the training. Are you ready to merge the dataset?"
+ "Make sure that you have refined all the labels before merging the dataset.If you merge the dataset, you"
+ "need to re-create the training dataset before you start the training. Are you ready to merge the dataset?"
)
msg.setWindowTitle("Warning")
msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py
index a4ffbcef52..c7b30595a1 100644
--- a/deeplabcut/gui/tabs/train_network.py
+++ b/deeplabcut/gui/tabs/train_network.py
@@ -8,47 +8,114 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from __future__ import annotations
+
import os
-from pathlib import Path
+from dataclasses import dataclass
from PySide6 import QtWidgets
-from PySide6.QtCore import Qt
+from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QIcon
+import deeplabcut.compat as compat
+from deeplabcut.core.engine import Engine
from deeplabcut.gui.components import (
DefaultTab,
ShuffleSpinBox,
+ SnapshotSelectionWidget,
_create_grid_layout,
_create_label_widget,
)
+from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay
from deeplabcut.gui.widgets import ConfigEditor
-import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions
+
+@dataclass
+class IntTrainAttribute:
+ label: str
+ fn_key: str
+ default: int
+ min: int
+ max: int
+ tooltip: str | None = None
+
+
+@dataclass
+class TrainAttributeRow:
+ attributes: list[IntTrainAttribute]
+ description: str | None = None
+ show_when_cfg: tuple[str, str] | None = None
class TrainNetwork(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(TrainNetwork, self).__init__(root, parent, h1_description)
-
- # use the default pose_cfg file for default values
- default_pose_cfg_path = os.path.join(
- Path(deeplabcut.__file__).parent, "pose_cfg.yaml"
- )
- pose_cfg = auxiliaryfunctions.read_plainconfig(default_pose_cfg_path)
- self.display_iters = str(pose_cfg["display_iters"])
- self.save_iters = str(pose_cfg["save_iters"])
- self.max_iters = str(pose_cfg["multi_step"][-1][-1])
+ super().__init__(root, parent, h1_description)
+ self._shuffle: ShuffleSpinBox = ShuffleSpinBox(root=self.root, parent=self)
+ self._shuffle_display = SelectedShuffleDisplay(self.root)
+ self._attribute_layouts: dict[Engine, QtWidgets.QWidget] = {}
+ self._attribute_kwargs: dict[Engine, dict] = {}
+ self._rows_with_requirements: list = []
self._set_page()
+ self.root.engine_change.connect(self._on_engine_change)
+ self._shuffle_display.pose_cfg_signal.connect(self._pose_cfg_change)
+
+ @Slot(Engine)
+ def _on_engine_change(self, engine: Engine) -> None:
+ for e, layout in self._attribute_layouts.items():
+ if e == engine:
+ layout.show()
+ else:
+ layout.hide()
+ self._update_snapshot_selection_widgets_visibility()
+
+ def _update_snapshot_selection_widgets_visibility(self):
+ if self.root.engine == Engine.PYTORCH:
+ self.resume_from_snapshot_label.show()
+ self.snapshot_selection_widget.show()
+ # Display detector snapshot selection widget only if in Top-Down mode
+ if self._shuffle_display.pose_cfg.get("method", "").lower() == "td":
+ self.detector_snapshot_selection_widget.show()
+ else:
+ self.detector_snapshot_selection_widget.hide()
+ else:
+ self.resume_from_snapshot_label.hide()
+ self.snapshot_selection_widget.hide()
+ self.detector_snapshot_selection_widget.hide()
+
def _set_page(self):
self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
- self.layout_attributes = _create_grid_layout(margins=(20, 0, 0, 0))
- self._generate_layout_attributes(self.layout_attributes)
- self.main_layout.addLayout(self.layout_attributes)
+ self._generate_layout_attributes()
+
+ self.resume_from_snapshot_label = _create_label_widget(
+ "[Optional]: Select a snapshot to resume training from", "font:bold"
+ )
+ self.resume_from_snapshot_label.setToolTip(
+ ""
+ "If you've already trained a model on this shuffle, you can continue training it instead of starting "
+ "from scratch again. When using top-down models, you can also choose a detector to resume training"
+ "from."
+ " "
+ )
+ self.main_layout.addWidget(self.resume_from_snapshot_label)
+
+ self.snapshot_selection_widget = SnapshotSelectionWidget(
+ self.root, self, margins=(30, 0, 0, 0), select_button_text="Select snapshot"
+ )
+ self.main_layout.addWidget(self.snapshot_selection_widget)
+
+ self.detector_snapshot_selection_widget = SnapshotSelectionWidget(
+ self.root,
+ self,
+ margins=(30, 0, 0, 0),
+ select_button_text="Select detector snapshot",
+ )
+ self.main_layout.addWidget(self.detector_snapshot_selection_widget)
- self.main_layout.addWidget(_create_label_widget("")) # dummy label
+ self._pose_cfg_change(
+ self._shuffle_display.pose_cfg
+ ) # also calls _update_snapshot_selection_widgets_visibility
self.edit_posecfg_btn = QtWidgets.QPushButton("Edit pose_cfg.yaml")
self.edit_posecfg_btn.setMinimumWidth(150)
@@ -68,7 +135,7 @@ def _set_page(self):
def show_help_dialog(self):
dialog = QtWidgets.QDialog(self)
layout = QtWidgets.QVBoxLayout()
- label = QtWidgets.QLabel(deeplabcut.train_network.__doc__, self)
+ label = QtWidgets.QLabel(compat.train_network.__doc__, self)
scroll = QtWidgets.QScrollArea()
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
@@ -78,66 +145,77 @@ def show_help_dialog(self):
dialog.setLayout(layout)
dialog.exec_()
- def _generate_layout_attributes(self, layout):
- # Shuffle
+ def _generate_layout_attributes(self) -> None:
+ row_margin = 25
+
+ # top layout
shuffle_label = QtWidgets.QLabel("Shuffle")
- self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
-
- # Display iterations
- dispiters_label = QtWidgets.QLabel("Display iterations")
- self.display_iters_spin = QtWidgets.QSpinBox()
- self.display_iters_spin.setMinimum(1)
- self.display_iters_spin.setMaximum(int(self.max_iters))
- self.display_iters_spin.setValue(1000)
- self.display_iters_spin.valueChanged.connect(self.log_display_iters)
-
- # Save iterations
- saveiters_label = QtWidgets.QLabel("Save iterations")
- self.save_iters_spin = QtWidgets.QSpinBox()
- self.save_iters_spin.setMinimum(1)
- self.save_iters_spin.setMaximum(int(self.max_iters))
- self.save_iters_spin.setValue(50000)
- self.save_iters_spin.valueChanged.connect(self.log_save_iters)
-
- # Max iterations
- maxiters_label = QtWidgets.QLabel("Maximum iterations")
- self.max_iters_spin = QtWidgets.QSpinBox()
- self.max_iters_spin.setMinimum(1)
- self.max_iters_spin.setMaximum(int(self.max_iters))
- self.max_iters_spin.setValue(100000)
- self.max_iters_spin.valueChanged.connect(self.log_max_iters)
-
- # Max number snapshots to keep
- snapkeep_label = QtWidgets.QLabel("Number of snapshots to keep")
- self.snapshots = QtWidgets.QSpinBox()
- self.snapshots.setMinimum(1)
- self.snapshots.setMaximum(100)
- self.snapshots.setValue(5)
- self.snapshots.valueChanged.connect(self.log_snapshots)
-
- layout.addWidget(shuffle_label, 0, 0)
- layout.addWidget(self.shuffle, 0, 1)
- layout.addWidget(dispiters_label, 0, 2)
- layout.addWidget(self.display_iters_spin, 0, 3)
- layout.addWidget(saveiters_label, 0, 4)
- layout.addWidget(self.save_iters_spin, 0, 5)
- layout.addWidget(maxiters_label, 0, 6)
- layout.addWidget(self.max_iters_spin, 0, 7)
- layout.addWidget(snapkeep_label, 0, 8)
- layout.addWidget(self.snapshots, 0, 9)
- # layout.addWidget()
-
- def log_display_iters(self, value):
- self.root.logger.info(f"Display iters set to {value}")
-
- def log_save_iters(self, value):
- self.root.logger.info(f"Save iters set to {value}")
-
- def log_max_iters(self, value):
- self.root.logger.info(f"Max iters set to {value}")
-
- def log_snapshots(self, value):
- self.root.logger.info(f"Max snapshots to keep set to {value}")
+ shuffle_label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px")
+ self._shuffle.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px")
+ self._shuffle_display.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px")
+
+ base_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+ base_layout.addWidget(shuffle_label, 0, 0)
+ base_layout.addWidget(self._shuffle, 0, 1)
+ base_layout.addWidget(self._shuffle_display, 0, 2)
+ base_layout_widget = QtWidgets.QWidget()
+ base_layout_widget.setLayout(base_layout)
+ self.main_layout.addWidget(base_layout_widget)
+
+ for engine in Engine:
+ train_attributes = get_train_attributes(engine)
+
+ # Other parameters
+ param_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+ param_layout.setVerticalSpacing(0)
+
+ self._attribute_kwargs[engine] = {}
+ row_index = 1
+ for row in train_attributes:
+ row_elements = []
+ if row.description is not None:
+ row_label = QtWidgets.QLabel(row.description)
+ row_label.setStyleSheet("font-weight: bold")
+ row_elements.append(row_label)
+ param_layout.addWidget(row_label, row_index, 0)
+ row_index += 1
+
+ for j, attribute in enumerate(row.attributes):
+ label = QtWidgets.QLabel(attribute.label)
+ spin_box = QtWidgets.QSpinBox()
+ spin_box.setMinimum(attribute.min)
+ spin_box.setMaximum(attribute.max)
+ spin_box.setValue(attribute.default)
+ spin_box.valueChanged.connect(
+ lambda new_val, attr=attribute: self.log_attribute_change(attr, new_val)
+ )
+ self._attribute_kwargs[engine][attribute.fn_key] = spin_box
+
+ # Pad below to create spacing with other rows
+ label.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px")
+ spin_box.setStyleSheet(f"margin: 0px 0px {row_margin}px 0px")
+
+ row_elements.append(label)
+ row_elements.append(spin_box)
+
+ param_layout.addWidget(label, row_index, 2 * j)
+ param_layout.addWidget(spin_box, row_index, 2 * j + 1)
+
+ if row.show_when_cfg is not None:
+ self._rows_with_requirements.append((row.show_when_cfg, row_elements))
+
+ row_index += 1
+
+ layout_widget = QtWidgets.QWidget()
+ layout_widget.setLayout(param_layout)
+ self._attribute_layouts[engine] = layout_widget
+ if engine != self.root.engine:
+ layout_widget.hide()
+
+ self.main_layout.addWidget(layout_widget)
+
+ def log_attribute_change(self, attribute: IntTrainAttribute, value: int) -> None:
+ self.root.logger.info(f"{attribute.label} set to {value}")
def open_posecfg_editor(self):
editor = ConfigEditor(self.root.pose_cfg_path)
@@ -145,28 +223,24 @@ def open_posecfg_editor(self):
def train_network(self):
config = self.root.config
- shuffle = int(self.shuffle.value())
- max_snapshots_to_keep = int(self.snapshots.value())
- displayiters = int(self.display_iters_spin.value())
- saveiters = int(self.save_iters_spin.value())
- maxiters = int(self.max_iters_spin.value())
-
- deeplabcut.train_network(
- config,
- shuffle,
- gputouse=None,
- max_snapshots_to_keep=max_snapshots_to_keep,
- autotune=None,
- displayiters=displayiters,
- saveiters=saveiters,
- maxiters=maxiters,
- )
+ shuffle = int(self._shuffle.value())
+
+ kwargs = dict(gputouse=None, autotune=False)
+ for k, spin_box in self._attribute_kwargs[self.root.engine].items():
+ kwargs[k] = int(spin_box.value())
+ if self.root.engine == Engine.PYTORCH:
+ snapshot_to_start_training_from = self.snapshot_selection_widget.selected_snapshot
+ if snapshot_to_start_training_from is not None:
+ kwargs["snapshot_path"] = snapshot_to_start_training_from
+ detector_to_start_training_from = self.detector_snapshot_selection_widget.selected_snapshot
+ if detector_to_start_training_from is not None:
+ kwargs["detector_path"] = detector_to_start_training_from
+
+ compat.train_network(config, shuffle, **kwargs)
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Information)
msg.setText("The network is now trained and ready to evaluate.")
- msg.setInformativeText(
- "Use the function 'evaluate_network' to evaluate the network."
- )
+ msg.setInformativeText("Use the function 'evaluate_network' to evaluate the network.")
msg.setWindowTitle("Info")
msg.setMinimumWidth(900)
@@ -175,3 +249,124 @@ def train_network(self):
msg.setWindowIcon(QIcon(self.logo))
msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
msg.exec_()
+
+ @Slot(dict)
+ def _pose_cfg_change(self, pose_cfg: dict | None) -> None:
+ if pose_cfg is None:
+ return
+
+ for requirement, widgets in self._rows_with_requirements:
+ key, value = requirement
+ show = pose_cfg.get(key) == value
+ for w in widgets:
+ if show:
+ w.show()
+ else:
+ w.hide()
+
+ self._update_snapshot_selection_widgets_visibility()
+
+
+def get_train_attributes(engine: Engine) -> list[TrainAttributeRow]:
+ if engine == Engine.TF:
+ return [
+ TrainAttributeRow(
+ attributes=[
+ IntTrainAttribute(
+ label="Display iterations",
+ fn_key="displayiters",
+ default=1000,
+ min=1,
+ max=1000,
+ ),
+ IntTrainAttribute(
+ label="Number of snapshots to keep",
+ fn_key="max_snapshots_to_keep",
+ default=5,
+ min=1,
+ max=100,
+ ),
+ ],
+ ),
+ TrainAttributeRow(
+ attributes=[
+ IntTrainAttribute(
+ label="Maximum iterations",
+ fn_key="maxiters",
+ default=100_000,
+ min=1,
+ max=1_030_000,
+ ),
+ IntTrainAttribute(
+ label="Save iterations",
+ fn_key="saveiters",
+ default=50_000,
+ min=1,
+ max=50_000,
+ ),
+ ],
+ ),
+ ]
+ elif engine == Engine.PYTORCH:
+ return [
+ TrainAttributeRow(
+ attributes=[
+ IntTrainAttribute(
+ label="Display iterations",
+ fn_key="displayiters",
+ default=1_000,
+ min=1,
+ max=100_000,
+ ),
+ IntTrainAttribute(
+ label="Number of snapshots to keep",
+ fn_key="max_snapshots_to_keep",
+ default=5,
+ min=1,
+ max=100,
+ ),
+ ],
+ ),
+ TrainAttributeRow(
+ attributes=[
+ IntTrainAttribute(
+ label="Maximum epochs",
+ fn_key="epochs",
+ default=200,
+ min=1,
+ max=1000,
+ ),
+ IntTrainAttribute(
+ label="Save epochs",
+ fn_key="save_epochs",
+ default=50,
+ min=1,
+ max=250,
+ ),
+ ],
+ ),
+ TrainAttributeRow(
+ description="Detector parameters",
+ show_when_cfg=("method", "td"),
+ attributes=[
+ IntTrainAttribute(
+ label="Detector max epochs",
+ fn_key="detector_epochs",
+ default=200,
+ min=0,
+ max=1000,
+ tooltip="",
+ ),
+ IntTrainAttribute(
+ label="Detector save epochs",
+ fn_key="detector_save_epochs",
+ default=50,
+ min=1,
+ max=250,
+ tooltip="",
+ ),
+ ],
+ ),
+ ]
+
+ raise NotImplementedError(f"Unknown engine: {engine}")
diff --git a/deeplabcut/gui/tabs/unsupervised_id_tracking.py b/deeplabcut/gui/tabs/unsupervised_id_tracking.py
index ccea5ce392..9233fd45e9 100644
--- a/deeplabcut/gui/tabs/unsupervised_id_tracking.py
+++ b/deeplabcut/gui/tabs/unsupervised_id_tracking.py
@@ -9,9 +9,11 @@
# Licensed under GNU Lesser General Public License v3.0
#
from functools import partial
+
from PySide6 import QtWidgets
from PySide6.QtCore import Qt
+import deeplabcut
from deeplabcut.gui.components import (
DefaultTab,
ShuffleSpinBox,
@@ -21,12 +23,10 @@
)
from deeplabcut.gui.utils import move_to_separate_thread
-import deeplabcut
-
class UnsupervizedIdTracking(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(UnsupervizedIdTracking, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self._set_page()
@@ -112,7 +112,7 @@ def log_num_triplets(self, value):
def run_transformer(self):
config = self.root.config
- videos = self.files
+ videos = [v for v in self.files]
videotype = self.video_selection_widget.videotype_widget.currentText()
n_tracks = self.num_animals_in_videos.value()
shuffle = self.shuffle.value()
@@ -122,15 +122,13 @@ def run_transformer(self):
deeplabcut.transformer_reID,
config=config,
videos=videos,
- videotype=videotype,
+ video_extensions=videotype,
n_tracks=n_tracks,
shuffle=shuffle,
track_method=track_method,
)
self.worker, self.thread = move_to_separate_thread(func)
- self.worker.finished.connect(
- lambda: self.run_transformer_button.setEnabled(True)
- )
+ self.worker.finished.connect(lambda: self.run_transformer_button.setEnabled(True))
self.worker.finished.connect(lambda: self.root._progress_bar.hide())
self.thread.start()
self.run_transformer_button.setEnabled(False)
diff --git a/deeplabcut/gui/tabs/video_editor.py b/deeplabcut/gui/tabs/video_editor.py
index 38be396f05..dc7e8472e2 100644
--- a/deeplabcut/gui/tabs/video_editor.py
+++ b/deeplabcut/gui/tabs/video_editor.py
@@ -25,7 +25,7 @@
class VideoEditor(DefaultTab):
def __init__(self, root, parent, h1_description):
- super(VideoEditor, self).__init__(root, parent, h1_description)
+ super().__init__(root, parent, h1_description)
self._set_page()
@@ -48,6 +48,11 @@ def _set_page(self):
self.down_button.clicked.connect(self.downsample_videos)
self.main_layout.addWidget(self.down_button, alignment=Qt.AlignRight)
+ self.rotate_button = QtWidgets.QPushButton("Rotate")
+ self.rotate_button.setMinimumWidth(150)
+ self.rotate_button.clicked.connect(self.rotate_videos)
+ self.main_layout.addWidget(self.rotate_button, alignment=Qt.AlignRight)
+
self.trim_button = QtWidgets.QPushButton("Trim")
self.trim_button.setMinimumWidth(150)
self.trim_button.clicked.connect(self.trim_videos)
@@ -130,6 +135,16 @@ def log_video_stop(self, value):
def log_rotation_angle(self, value):
self.root.logger.info(f"Rotation angle set to {value}")
+ def rotate_videos(self):
+ if self.files:
+ for video in self.files:
+ if self.video_rotation.currentText() == "specific angle":
+ auxfun_videos.rotate_video(video, self.rotation_angle.value(), "Arbitrary")
+ elif self.video_rotation.currentText() == "clockwise":
+ auxfun_videos.rotate_video(video, 0, "Yes")
+ else:
+ self.root.logger.error("No videos selected...")
+
def trim_videos(self):
start = time.strftime("%H:%M:%S", time.gmtime(self.video_start.value()))
stop = time.strftime("%H:%M:%S", time.gmtime(self.video_stop.value()))
diff --git a/deeplabcut/gui/tracklet_toolbox.py b/deeplabcut/gui/tracklet_toolbox.py
index 388ffbeb42..295b853dd2 100644
--- a/deeplabcut/gui/tracklet_toolbox.py
+++ b/deeplabcut/gui/tracklet_toolbox.py
@@ -8,20 +8,22 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from threading import Event
+
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import matplotlib.transforms as mtransforms
import numpy as np
import pandas as pd
-from threading import Event
+from matplotlib.path import Path
+from matplotlib.widgets import Button, CheckButtons, LassoSelector, Slider, TextBox
+from PySide6.QtCore import QMutex
+from PySide6.QtWidgets import QMessageBox
+
from deeplabcut.gui.utils import move_to_separate_thread
from deeplabcut.refine_training_dataset.tracklets import TrackletManager
from deeplabcut.utils.auxfun_videos import VideoReader
from deeplabcut.utils.auxiliaryfunctions import attempt_to_make_folder
-from matplotlib.path import Path
-from matplotlib.widgets import Slider, LassoSelector, Button, CheckButtons
-from PySide6.QtWidgets import QMessageBox
-from PySide6.QtCore import QMutex
class DraggablePoint:
@@ -49,23 +51,13 @@ def __init__(self, point, bodyParts, individual_names=None, likelihood=None):
def connect(self):
"connect to all the events we need"
- self.cidpress = self.point.figure.canvas.mpl_connect(
- "button_press_event", self.on_press
- )
- self.cidrelease = self.point.figure.canvas.mpl_connect(
- "button_release_event", self.on_release
- )
- self.cidmotion = self.point.figure.canvas.mpl_connect(
- "motion_notify_event", self.on_motion
- )
- self.cidhover = self.point.figure.canvas.mpl_connect(
- "motion_notify_event", self.on_hover
- )
+ self.cidpress = self.point.figure.canvas.mpl_connect("button_press_event", self.on_press)
+ self.cidrelease = self.point.figure.canvas.mpl_connect("button_release_event", self.on_release)
+ self.cidmotion = self.point.figure.canvas.mpl_connect("motion_notify_event", self.on_motion)
+ self.cidhover = self.point.figure.canvas.mpl_connect("motion_notify_event", self.on_hover)
def on_press(self, event):
- """
- Define the event for the button press!
- """
+ """Define the event for the button press!"""
if event.inaxes != self.point.axes:
return
if DraggablePoint.lock is not None:
@@ -74,9 +66,7 @@ def on_press(self, event):
if not contains:
return
if event.button == 1:
- """
- This button press corresponds to the left click
- """
+ """This button press corresponds to the left click."""
self.press = (self.point.center), event.xdata, event.ydata
DraggablePoint.lock = self
canvas = self.point.figure.canvas
@@ -87,9 +77,11 @@ def on_press(self, event):
axes.draw_artist(self.point)
canvas.blit(axes.bbox)
elif event.button == 2:
- """
- To remove a predicted label. Internally, the coordinates of the selected predicted label is replaced with nan. The user needs to middle click for the event. After right
- click the data point is removed from the plot.
+ """To remove a predicted label.
+
+ Internally, the coordinates of the selected predicted label is replaced with
+ nan. The user needs to middle click for the event. After right click the
+ data point is removed from the plot.
"""
message = f"Do you want to remove the label {self.bodyParts}?"
if self.likelihood is not None:
@@ -112,9 +104,7 @@ def delete_data(self):
self.point.figure.canvas.draw()
def on_motion(self, event):
- """
- During the drag!
- """
+ """During the drag!"""
if DraggablePoint.lock is not self:
return
if event.inaxes != self.point.axes:
@@ -151,9 +141,8 @@ def on_release(self, event):
self.coords.append(self.final_point)
def on_hover(self, event):
- """
- Annotate the labels and likelihood when the user hovers over the data points.
- """
+ """Annotate the labels and likelihood when the user hovers over the data
+ points."""
vis = self.annot.get_visible()
if event.inaxes == self.point.axes:
@@ -299,17 +288,13 @@ def reconnect(self):
class TrackletVisualizer:
def __init__(self, manager, videoname, trail_len=50):
self.manager = manager
- self.cmap = plt.cm.get_cmap(
- manager.cfg["colormap"], len(set(manager.tracklet2id))
- )
+ self.cmap = plt.cm.get_cmap(manager.cfg["colormap"], len(set(manager.tracklet2id)))
self.videoname = videoname
self.video = VideoReader(videoname)
self.nframes = len(self.video)
# Take into consideration imprecise OpenCV estimation of total number of frames
if abs(self.nframes - manager.nframes) >= 0.05 * manager.nframes:
- print(
- "Video duration and data length do not match. Continuing nonetheless..."
- )
+ print("Video duration and data length do not match. Continuing nonetheless...")
self.trail_len = trail_len
self.help_text = ""
self.draggable = False
@@ -327,6 +312,9 @@ def __init__(self, manager, videoname, trail_len=50):
self.dps = []
+ self.swap_id1 = None
+ self.swap_id2 = None
+
def _prepare_canvas(self, manager, fig):
params = {
"keymap.save": "s",
@@ -358,12 +346,10 @@ def _prepare_canvas(self, manager, fig):
img = self.video.read_frame()
self.im = self.ax1.imshow(img)
- self.scat = self.ax1.scatter([], [], s=self.dotsize ** 2, picker=True)
+ self.scat = self.ax1.scatter([], [], s=self.dotsize**2, picker=True)
self.scat.set_offsets(manager.xy[:, 0])
self.scat.set_color(self.colors)
- self.trails = sum(
- [self.ax1.plot([], [], "-", lw=2, c=c) for c in self.colors], []
- )
+ self.trails = sum([self.ax1.plot([], [], "-", lw=2, c=c) for c in self.colors], [])
self.lines_x = sum(
[self.ax2.plot([], [], "-", lw=1, c=c, pickradius=5) for c in self.colors],
[],
@@ -374,10 +360,8 @@ def _prepare_canvas(self, manager, fig):
)
self.vline_x = self.ax2.axvline(0, 0, 1, c="k", ls=":")
self.vline_y = self.ax3.axvline(0, 0, 1, c="k", ls=":")
- custom_lines = [
- plt.Line2D([0], [0], color=self.cmap(i), lw=4)
- for i in range(len(manager.individuals))
- ]
+
+ custom_lines = [plt.Line2D([0], [0], color=self.cmap(i), lw=4) for i in range(len(manager.individuals))]
self.leg = self.fig.legend(
custom_lines,
manager.individuals,
@@ -392,9 +376,7 @@ def _prepare_canvas(self, manager, fig):
line.set_picker(5)
self.ax_slider = self.fig.add_axes([0.1, 0.1, 0.5, 0.03], facecolor="lightgray")
- self.ax_slider2 = self.fig.add_axes(
- [0.1, 0.05, 0.3, 0.03], facecolor="darkorange"
- )
+ self.ax_slider2 = self.fig.add_axes([0.1, 0.05, 0.3, 0.03], facecolor="darkorange")
self.slider = Slider(
self.ax_slider,
"# Frame",
@@ -420,10 +402,15 @@ def _prepare_canvas(self, manager, fig):
self.ax_flag = self.fig.add_axes([0.75, 0.1, 0.05, 0.03])
self.ax_save = self.fig.add_axes([0.80, 0.1, 0.05, 0.03])
self.ax_help = self.fig.add_axes([0.85, 0.1, 0.05, 0.03])
+ self.ax_swap = self.fig.add_axes([0.90, 0.1, 0.05, 0.03]) # New button
+
self.save_button = Button(self.ax_save, "Save", color="darkorange")
self.save_button.on_clicked(self.save)
self.help_button = Button(self.ax_help, "Help")
self.help_button.on_clicked(self.display_help)
+ self.swap_button = Button(self.ax_swap, "Swap") # New button
+ self.swap_button.on_clicked(self.swap_tracklets) # Placeholder action
+
self.drag_toggle = CheckButtons(self.ax_drag, ["Drag"])
self.drag_toggle.on_clicked(self.toggle_draggable_points)
self.flag_button = Button(self.ax_flag, "Flag")
@@ -441,9 +428,62 @@ def _prepare_canvas(self, manager, fig):
self.ax1_background = self.fig.canvas.copy_from_bbox(self.ax1.bbox)
self.fig.show()
+ # Create dropdowns for selecting tracklets to swap, placing them near the swap button
+ self.ax_dropdown1 = self.fig.add_axes([0.9, 0.15, 0.05, 0.03])
+ self.ax_dropdown2 = self.fig.add_axes([0.9, 0.20, 0.05, 0.03])
+ self.textbox1 = TextBox(self.ax_dropdown1, "ID 1")
+ self.textbox2 = TextBox(self.ax_dropdown2, "ID 2")
+ self.textbox1.on_submit(self.set_swap_id1)
+ self.textbox2.on_submit(self.set_swap_id2)
+
def show(self, fig=None):
self._prepare_canvas(self.manager, fig)
+ def swap_tracklets(self, event):
+ if self.swap_id1 is not None and self.swap_id2 is not None:
+ # Get tracklet indices for each individual
+ inds1 = [k for k in range(len(self.manager.tracklet2id)) if self.manager.tracklet2id[k] == self.swap_id1]
+ inds2 = [k for k in range(len(self.manager.tracklet2id)) if self.manager.tracklet2id[k] == self.swap_id2]
+
+ print(f"Swapping tracklets {self.swap_id1} and {self.swap_id2}")
+
+ # Frames to swap
+ frames = []
+ if len(self.cuts) == 2:
+ frames = list(range(min(self.cuts), max(self.cuts) + 1))
+ elif len(self.cuts) == 1:
+ frames = [self.cuts[0]]
+ else:
+ frames = list(range(self.curr_frame, self.manager.nframes))
+
+ # Swap the tracklets
+ for i in range(min(len(inds1), len(inds2))):
+ self.manager.swap_tracklets(inds1[i], inds2[i], frames)
+ self.display_traces()
+ self.slider.set_val(self.curr_frame)
+
+ def set_swap_id1(self, val):
+ # check that the input is a valid from the list of individuals
+ if int(val) in self.manager.tracklet2id:
+ self.swap_id1 = int(val)
+ print("ID 1 set.")
+ else:
+ print(f"Invalid ID. Please select a valid ID from the list of individuals: {set(self.manager.tracklet2id)}")
+ self.swap_id1 = None
+
+ def set_swap_id2(self, val):
+ # check that the input is a valid from the list of individuals
+ if int(val) in self.manager.tracklet2id:
+ self.swap_id2 = int(val)
+ print("ID 2 set.")
+ else:
+ print(f"Invalid ID. Please select a valid ID from the list of individuals: {set(self.manager.tracklet2id)}")
+ self.swap_id2 = None
+
+ def terminate(self, event):
+ plt.close(self.fig)
+ self.player.terminate()
+
def fill_shaded_areas(self):
self.clean_collections()
if self.picked_pair:
@@ -456,12 +496,8 @@ def fill_shaded_areas(self):
facecolor="darkgray",
alpha=0.2,
)
- trans = mtransforms.blended_transform_factory(
- self.ax_slider.transData, self.ax_slider.transAxes
- )
- self.ax_slider.vlines(
- np.flatnonzero(mask), 0, 0.5, color="darkorange", transform=trans
- )
+ trans = mtransforms.blended_transform_factory(self.ax_slider.transData, self.ax_slider.transAxes)
+ self.ax_slider.vlines(np.flatnonzero(mask), 0, 0.5, color="darkorange", transform=trans)
def toggle_draggable_points(self, *args):
self.draggable = not self.draggable
@@ -517,9 +553,7 @@ def save_coords(self):
if not nrow.size:
return
nrow = nrow[0]
- if not np.array_equal(
- coords[nrow], dp.point.center
- ): # Keypoint has been displaced
+ if not np.array_equal(coords[nrow], dp.point.center): # Keypoint has been displaced
coords[nrow] = dp.point.center
prob[ind] = 1
self.manager.xy[nonempty, self._curr_frame] = coords
@@ -539,12 +573,8 @@ def flag_frame(self, *args):
facecolor="darkgray",
alpha=0.2,
)
- trans = mtransforms.blended_transform_factory(
- self.ax_slider.transData, self.ax_slider.transAxes
- )
- self.ax_slider.vlines(
- np.flatnonzero(mask), 0, 0.5, color="darkorange", transform=trans
- )
+ trans = mtransforms.blended_transform_factory(self.ax_slider.transData, self.ax_slider.transAxes)
+ self.ax_slider.vlines(np.flatnonzero(mask), 0, 0.5, color="darkorange", transform=trans)
self.fig.canvas.draw_idle()
def on_scroll(self, event):
@@ -587,9 +617,9 @@ def on_press(self, event):
if len(self.cuts) > 1:
self.cuts.sort()
if self.picked_pair:
- self.manager.tracklet_swaps[self.picked_pair][
- self.cuts
- ] = ~self.manager.tracklet_swaps[self.picked_pair][self.cuts]
+ self.manager.tracklet_swaps[self.picked_pair][self.cuts] = ~self.manager.tracklet_swaps[
+ self.picked_pair
+ ][self.cuts]
self.fill_shaded_areas()
self.cuts = []
for line in self.ax_slider.lines:
@@ -604,12 +634,7 @@ def on_press(self, event):
except IndexError:
pass
else: # Smart point removal
- i = np.nanargmin(
- [
- self.calc_distance(*dp.point.center, event.xdata, event.ydata)
- for dp in self.dps
- ]
- )
+ i = np.nanargmin([self.calc_distance(*dp.point.center, event.xdata, event.ydata) for dp in self.dps])
closest_dp = self.dps[i]
label = closest_dp.individual_names, closest_dp.bodyParts
closest_dp.disconnect()
@@ -643,14 +668,10 @@ def move_backward(self):
def swap(self):
if self.picked_pair:
swap_inds = self.manager.get_swap_indices(*self.picked_pair)
- inds = np.insert(
- swap_inds, [0, len(swap_inds)], [0, self.manager.nframes - 1]
- )
+ inds = np.insert(swap_inds, [0, len(swap_inds)], [0, self.manager.nframes - 1])
if len(inds):
ind = np.argmax(inds > self.curr_frame)
- self.manager.swap_tracklets(
- *self.picked_pair, range(inds[ind - 1], inds[ind] + 1)
- )
+ self.manager.swap_tracklets(*self.picked_pair, range(inds[ind - 1], inds[ind] + 1))
self.display_traces()
self.slider.set_val(self.curr_frame)
@@ -676,9 +697,7 @@ def on_pick(self, event):
if self.picked:
num_individual = self.leg.get_lines().index(artist)
nrow = self.manager.tracklet2id.index(num_individual)
- inds = [
- nrow + self.manager.to_num_bodypart(pick) for pick in self.picked
- ]
+ inds = [nrow + self.manager.to_num_bodypart(pick) for pick in self.picked]
xy = self.manager.xy[self.picked]
p = self.manager.prob[self.picked]
mask = np.zeros(xy.shape[1], dtype=bool)
@@ -724,9 +743,7 @@ def on_click(self, event):
self.clean_collections()
def clean_collections(self):
- for coll in (
- self.ax2.collections + self.ax3.collections + self.ax_slider.collections
- ):
+ for coll in self.ax2.collections + self.ax3.collections + self.ax_slider.collections:
coll.remove()
def display_points(self, val):
@@ -747,7 +764,7 @@ def display_traces(self, only_picked=True):
inds = self.picked + list(self.picked_pair)
else:
inds = self.manager.swapping_bodyparts
- for n, (line_x, line_y) in enumerate(zip(self.lines_x, self.lines_y)):
+ for n, (line_x, line_y) in enumerate(zip(self.lines_x, self.lines_y, strict=False)):
if n in inds:
line_x.set_data(self.manager.times, self.manager.xy[n, :, 0])
line_y.set_data(self.manager.times, self.manager.xy[n, :, 1])
@@ -807,7 +824,7 @@ def on_change(self, val):
def update_dotsize(self, val):
self.dotsize = val
- self.scat.set_sizes([self.dotsize ** 2])
+ self.scat.set_sizes([self.dotsize**2])
@staticmethod
def calc_distance(x1, y1, x2, y2):
@@ -819,6 +836,7 @@ def save(self, *args):
def export_to_training_data(self, pcutoff=0.1):
import os
+
from skimage import io
inds = self.manager.find_edited_frames()
@@ -828,9 +846,7 @@ def export_to_training_data(self, pcutoff=0.1):
# Save additional frames to the labeled-data directory
strwidth = int(np.ceil(np.log10(self.nframes)))
- tmpfolder = os.path.join(
- self.manager.cfg["project_path"], "labeled-data", self.video.name
- )
+ tmpfolder = os.path.join(self.manager.cfg["project_path"], "labeled-data", self.video.name)
if os.path.isdir(tmpfolder):
print(
"Frames from video",
@@ -841,14 +857,8 @@ def export_to_training_data(self, pcutoff=0.1):
attempt_to_make_folder(tmpfolder)
index = []
for ind in inds:
- imagename = os.path.join(
- tmpfolder, "img" + str(ind).zfill(strwidth) + ".png"
- )
- index.append(
- tuple(
- (os.path.join(*imagename.rsplit(os.path.sep, 3)[-3:])).split("\\")
- )
- )
+ imagename = os.path.join(tmpfolder, "img" + str(ind).zfill(strwidth) + ".png")
+ index.append(tuple((os.path.join(*imagename.rsplit(os.path.sep, 3)[-3:])).split("\\")))
if not os.path.isfile(imagename):
self.video.set_to_frame(ind)
frame = self.video.read_frame()
@@ -857,9 +867,7 @@ def export_to_training_data(self, pcutoff=0.1):
continue
frame = frame.astype(np.ubyte)
if self.manager.cfg["cropping"]:
- x1, x2, y1, y2 = [
- int(self.manager.cfg[key]) for key in ("x1", "x2", "y1", "y2")
- ]
+ x1, x2, y1, y2 = [int(self.manager.cfg[key]) for key in ("x1", "x2", "y1", "y2")]
frame = frame[y1:y2, x1:x2]
io.imsave(imagename, frame)
@@ -873,14 +881,10 @@ def filter_low_prob(cols, prob):
cols.loc[mask] = np.nan
return cols
- df = df.groupby(level="bodyparts", axis=1, group_keys=False).apply(
- filter_low_prob, prob=pcutoff
- )
+ df = df.groupby(level="bodyparts", axis=1, group_keys=False).apply(filter_low_prob, prob=pcutoff)
df.index = pd.MultiIndex.from_tuples(index)
- machinefile = os.path.join(
- tmpfolder, "machinelabels-iter" + str(self.manager.cfg["iteration"]) + ".h5"
- )
+ machinefile = os.path.join(tmpfolder, "machinelabels-iter" + str(self.manager.cfg["iteration"]) + ".h5")
if os.path.isfile(machinefile):
df_old = pd.read_hdf(machinefile)
df_joint = pd.concat([df_old, df])
@@ -894,16 +898,16 @@ def filter_low_prob(cols, prob):
# Merge with the already existing annotated data
df.columns = df.columns.set_levels([self.manager.cfg["scorer"]], level="scorer")
df.drop("likelihood", level="coords", axis=1, inplace=True)
- output_path = os.path.join(
- tmpfolder, f'CollectedData_{self.manager.cfg["scorer"]}.h5'
- )
+ output_path = os.path.join(tmpfolder, f"CollectedData_{self.manager.cfg['scorer']}.h5")
if os.path.isfile(output_path):
print(
- "A training dataset file is already found for this video. The refined machine labels are merged to this data!"
+ "A training dataset file is already found for this video. The refined machine labels are merged to this"
+ "data!"
)
df_orig = pd.read_hdf(output_path)
df_joint = pd.concat([df, df_orig])
- # Now drop redundant ones keeping the first one [this will make sure that the refined machine file gets preference]
+ # Now drop redundant ones keeping the first one [this will make sure that
+ # the refined machine file gets preference]
df_joint = df_joint[~df_joint.index.duplicated(keep="first")]
df_joint.sort_index(inplace=True)
df_joint.to_hdf(output_path, key="df_with_missing", mode="w")
diff --git a/deeplabcut/gui/utils.py b/deeplabcut/gui/utils.py
index cd5369fb1a..1fae770fe9 100644
--- a/deeplabcut/gui/utils.py
+++ b/deeplabcut/gui/utils.py
@@ -8,7 +8,18 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-from PySide6 import QtCore
+import json
+import re
+import urllib.request
+from collections.abc import Callable
+
+from PySide6 import QtCore, QtNetwork
+
+try:
+ from packaging.version import InvalidVersion, Version
+except Exception: # packaging should usually be available, but keep fallback safe
+ Version = None
+ InvalidVersion = Exception
class Worker(QtCore.QObject):
@@ -23,9 +34,25 @@ def run(self):
self.finished.emit()
-def move_to_separate_thread(func):
+class CaptureWorker(Worker):
+ """A worker that captures outputs from methods that are run."""
+
+ def __init__(self, func: Callable):
+ super().__init__(func)
+ self.outputs = None
+
+ def run(self):
+ self.outputs = self.func()
+ self.finished.emit()
+
+
+def move_to_separate_thread(func: Callable, capture_outputs: bool = False):
thread = QtCore.QThread()
- worker = Worker(func)
+ if capture_outputs:
+ worker = CaptureWorker(func)
+ else:
+ worker = Worker(func)
+
worker.finished.connect(worker.deleteLater)
worker.moveToThread(thread)
thread.started.connect(worker.run)
@@ -38,12 +65,172 @@ def stop_thread():
return worker, thread
-def is_latest_deeplabcut_version():
- import json
- import urllib.request
- from deeplabcut import VERSION
+def parse_version(version: str) -> tuple[int, int, int]:
+ """Parses a version string into a tuple of (major, minor, patch)."""
+ match = re.search(r"(\d+)\.(\d+)\.(\d+)", version)
+ if match:
+ return tuple(int(part) for part in match.groups())
+ else:
+ raise ValueError(f"Invalid version format: {version}")
+
+
+def check_pypi_version(package_name: str, installed_version: str, timeout: float = 5.0):
+ """
+ Return (is_latest, latest_version) for a package on PyPI.
+
+ - Uses a real network timeout via urllib.
+ - Treats locally newer/dev versions as up-to-date when packaging is available.
+ """
+ url = f"https://pypi.org/pypi/{package_name}/json"
+
+ with urllib.request.urlopen(url, timeout=timeout) as response:
+ contents = response.read()
- url = "https://pypi.org/pypi/deeplabcut/json"
- contents = urllib.request.urlopen(url).read()
latest_version = json.loads(contents)["info"]["version"]
- return VERSION == latest_version, latest_version
+
+ if Version is not None:
+ try:
+ is_latest = Version(installed_version) >= Version(latest_version)
+ except InvalidVersion:
+ is_latest = installed_version == latest_version
+ else:
+ is_latest = installed_version == latest_version
+
+ return is_latest, latest_version
+
+
+def is_latest_deeplabcut_version(timeout: float = 5.0):
+ from deeplabcut import __version__
+
+ return check_pypi_version("deeplabcut", __version__, timeout=timeout)
+
+
+def is_latest_plugin_version(timeout: float = 5.0):
+ from napari_deeplabcut import __version__
+
+ return check_pypi_version("napari-deeplabcut", __version__, timeout=timeout)
+
+
+class UpdateChecker(QtCore.QObject):
+ finished = QtCore.Signal(object) # emits result dict
+
+ DLC_URL = "https://pypi.org/pypi/deeplabcut/json"
+ NAPARI_DLC_URL = "https://pypi.org/pypi/napari-deeplabcut/json"
+
+ def __init__(self, dlc_version: str, plugin_version: str, timeout_ms: int = 5000, parent=None):
+ super().__init__(parent)
+ self._dlc_version = dlc_version
+ self._plugin_version = plugin_version
+ self._timeout_ms = timeout_ms
+
+ self._manager = QtNetwork.QNetworkAccessManager(self)
+ self._timer = QtCore.QTimer(self)
+ self._timer.setSingleShot(True)
+ self._timer.timeout.connect(self._on_timeout)
+
+ self._running = False
+ self._silent = True
+ self._replies = {}
+ self._result = {}
+
+ def is_running(self) -> bool:
+ return self._running
+
+ def check(self, silent: bool = True):
+ if self._running:
+ # if a manual check happens while a silent one is running,
+ # keep the in-flight request but upgrade the result visibility
+ self._silent = self._silent and silent
+ return
+
+ self._running = True
+ self._silent = silent
+ self._result = {
+ "silent": silent,
+ "is_latest": True,
+ "latest_version": None,
+ "is_latest_plugin": True,
+ "latest_plugin_version": None,
+ "error": None,
+ }
+
+ self._start_request("dlc", self.DLC_URL)
+ self._start_request("napari-dlc", self.NAPARI_DLC_URL)
+ self._timer.start(self._timeout_ms)
+
+ def cancel(self):
+ if not self._running:
+ return
+ self._silent = True
+ self._abort_all()
+ self._finish()
+
+ def _start_request(self, key: str, url: str):
+ req = QtNetwork.QNetworkRequest(QtCore.QUrl(url))
+ req.setHeader(
+ QtNetwork.QNetworkRequest.KnownHeaders.UserAgentHeader,
+ "DeepLabCut GUI UpdateChecker",
+ )
+
+ reply = self._manager.get(req)
+ self._replies[key] = reply
+ reply.finished.connect(lambda key=key, reply=reply: self._on_reply_finished(key, reply))
+
+ def _on_reply_finished(self, key: str, reply: QtNetwork.QNetworkReply):
+ try:
+ if reply.error() != QtNetwork.QNetworkReply.NetworkError.NoError:
+ # keep the first network-ish error but remain non-fatal overall
+ if self._result["error"] is None:
+ self._result["error"] = reply.errorString()
+ return
+
+ payload = bytes(reply.readAll())
+ latest_version = json.loads(payload.decode("utf-8"))["info"]["version"]
+
+ if key == "dlc":
+ self._result["latest_version"] = latest_version
+ self._result["is_latest"] = self._is_up_to_date(self._dlc_version, latest_version)
+ else:
+ self._result["latest_plugin_version"] = latest_version
+ self._result["is_latest_plugin"] = self._is_up_to_date(self._plugin_version, latest_version)
+ except Exception as e:
+ if self._result["error"] is None:
+ self._result["error"] = str(e)
+ finally:
+ reply.deleteLater()
+ self._replies.pop(key, None)
+
+ if self._running and not self._replies:
+ self._finish()
+
+ def _on_timeout(self):
+ if not self._running:
+ return
+ if self._result["error"] is None:
+ self._result["error"] = "Update check timed out."
+ self._abort_all()
+ self._finish()
+
+ def _abort_all(self):
+ for reply in list(self._replies.values()):
+ if reply is not None and reply.isRunning():
+ reply.abort()
+ reply.deleteLater()
+ self._replies.clear()
+
+ def _finish(self):
+ if not self._running:
+ return
+ self._timer.stop()
+ self._running = False
+ self._result["silent"] = self._silent
+ self.finished.emit(self._result)
+
+ @staticmethod
+ def _is_up_to_date(installed: str, latest: str) -> bool:
+ if Version is not None:
+ try:
+ return Version(installed) >= Version(latest)
+ except InvalidVersion:
+ return installed == latest
+ return installed == latest
diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py
index 4e16ab83f7..f8a1e4c932 100644
--- a/deeplabcut/gui/widgets.py
+++ b/deeplabcut/gui/widgets.py
@@ -10,39 +10,32 @@
#
import ast
import os
-import warnings
+from queue import Queue
-import matplotlib.colors as mcolors
import napari
import numpy as np
-import pandas as pd
-from matplotlib.collections import LineCollection
-from matplotlib.path import Path
-from matplotlib.backends.backend_qt5agg import (
- NavigationToolbar2QT,
- FigureCanvasQTAgg as FigureCanvas,
-)
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT
from matplotlib.figure import Figure
-from matplotlib.widgets import RectangleSelector, Button, LassoSelector
-from queue import Queue
+from matplotlib.widgets import Button, LassoSelector, RectangleSelector
from PySide6 import QtCore, QtWidgets
-from PySide6.QtGui import QStandardItemModel, QStandardItem, QCursor, QAction
-from scipy.spatial import cKDTree as KDTree
-from skimage import io
+from PySide6.QtGui import QAction, QCursor, QStandardItem, QStandardItemModel
from deeplabcut.utils import auxiliaryfunctions
from deeplabcut.utils.auxfun_videos import VideoWriter
+from deeplabcut.utils.skeleton import SkeletonBuilder as BaseSkeletonBuilder
-def launch_napari(files=None):
+def launch_napari(files=None, plugin="napari-deeplabcut", stack=False):
viewer = napari.Viewer()
- # Automatically activate the napari-deeplabcut plugin
- for action in viewer.window.plugins_menu.actions():
- if "deeplabcut" in action.text():
- action.trigger()
- break
+ if plugin == "napari-deeplabcut":
+ # Automatically activate the napari-deeplabcut plugin
+ _, _ = viewer.window.add_plugin_dock_widget(
+ "napari-deeplabcut",
+ "Keypoint controls",
+ )
if files is not None:
- viewer.open(files, plugin="napari-deeplabcut")
+ viewer.open(files, plugin=plugin, stack=stack)
return viewer
@@ -60,9 +53,7 @@ def __init__(self, parent, **kwargs):
layout.addWidget(self.canvas)
def getfigure(self):
- """
- Returns the figure, axes and canvas
- """
+ """Returns the figure, axes and canvas."""
return self.figure, self.axes, self.canvas
def resetView(self):
@@ -72,7 +63,7 @@ def resetView(self):
class DragDropListView(QtWidgets.QListView):
def __init__(self, parent=None):
- super(DragDropListView, self).__init__(parent)
+ super().__init__(parent)
self.parent = parent
self.setAcceptDrops(True)
self.setDropIndicatorShown(True)
@@ -127,7 +118,7 @@ def dropEvent(self, event):
class ItemSelectionFrame(QtWidgets.QFrame):
def __init__(self, items, parent=None):
- super(ItemSelectionFrame, self).__init__(parent)
+ super().__init__(parent)
self.setFrameShape(self.Shape.StyledPanel)
self.setLineWidth(0)
@@ -174,15 +165,13 @@ def toggle_select(self, state):
class NavigationToolbar(NavigationToolbar2QT):
- toolitems = [
- t for t in NavigationToolbar2QT.toolitems if t[0] in ("Home", "Pan", "Zoom")
- ]
+ toolitems = [t for t in NavigationToolbar2QT.toolitems if t[0] in ("Home", "Pan", "Zoom")]
def set_message(self, msg):
pass
def release_zoom(self, event):
- super(NavigationToolbar, self).release_zoom(event)
+ super().release_zoom(event)
self.zoom()
@@ -197,12 +186,15 @@ def write(self, text):
def flush(self):
pass
+ def isatty(self):
+ return False
+
class StreamReceiver(QtCore.QThread):
new_text = QtCore.Signal(str)
def __init__(self, queue):
- super(StreamReceiver, self).__init__()
+ super().__init__()
self.queue = queue
def run(self):
@@ -215,7 +207,7 @@ class ClickableLabel(QtWidgets.QLabel):
signal = QtCore.Signal()
def __init__(self, text="", color="turquoise", parent=None):
- super(ClickableLabel, self).__init__(text, parent)
+ super().__init__(text, parent)
self._default_style = self.styleSheet()
self.color = color
self.setStyleSheet(f"color: {self.color}")
@@ -236,7 +228,7 @@ class ItemCreator(QtWidgets.QDialog):
created = QtCore.Signal(QtWidgets.QTreeWidgetItem)
def __init__(self, parent=None):
- super(ItemCreator, self).__init__(parent)
+ super().__init__(parent)
self.parent = parent
vbox = QtWidgets.QVBoxLayout(self)
self.field1 = QtWidgets.QLineEdit(self)
@@ -264,7 +256,7 @@ def form_item(self):
# TODO Insert skeleton link
class ContextMenu(QtWidgets.QMenu):
def __init__(self, parent):
- super(ContextMenu, self).__init__(parent)
+ super().__init__(parent)
self.parent = parent
self.current_item = parent.tree.currentItem()
insert = QAction("Insert", self)
@@ -286,24 +278,13 @@ def fix_path(self):
self.current_item.setText(1, os.path.split(self.parent.filename)[0])
-class CustomDelegate(QtWidgets.QItemDelegate):
- # Hack to make the first column read-only, as we do not want users to touch it.
- # The cleaner solution would be to use a QTreeView and QAbstractItemModel,
- # but that is a lot of rework for little benefits.
- def createEditor(self, parent, option, index):
- if index.column() != 0:
- return super(CustomDelegate, self).createEditor(parent, option, index)
- return None
-
-
class DictViewer(QtWidgets.QWidget):
def __init__(self, cfg, filename="", parent=None):
- super(DictViewer, self).__init__(parent)
+ super().__init__(parent)
self.cfg = cfg
self.filename = filename
self.parent = parent
self.tree = QtWidgets.QTreeWidget()
- self.tree.setItemDelegate(CustomDelegate())
self.tree.setHeaderLabels(["Parameter", "Value"])
self.tree.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
self.tree.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
@@ -399,9 +380,7 @@ def get_nested_key(cfg, keys):
def edit_value(self, item):
keys, value = self.walk_recursively_to_root(item)
- if (
- "crop" not in keys
- ): # 'crop' should not be cast, otherwise it is understood as a list
+ if "crop" not in keys: # 'crop' should not be cast, otherwise it is understood as a list
value = self.cast_to_right_type(value)
self.set_value(self.cfg, keys, value)
@@ -449,9 +428,9 @@ def add_row(self, key, val, tree_widget):
class ConfigEditor(QtWidgets.QDialog):
def __init__(self, config, parent=None):
- super(ConfigEditor, self).__init__(parent)
+ super().__init__(parent)
self.config = config
- if config.endswith("config.yaml"):
+ if config.endswith("config.yaml") and not config.endswith("pytorch_config.yaml"):
self.read_func = auxiliaryfunctions.read_config
self.write_func = auxiliaryfunctions.write_config
else:
@@ -484,12 +463,12 @@ def keyPressEvent(self, e):
def accept(self):
self.write_func(self.config, self.cfg)
- super(ConfigEditor, self).accept()
+ super().accept()
class FrameCropper(QtWidgets.QDialog):
def __init__(self, video, parent=None):
- super(FrameCropper, self).__init__(parent)
+ super().__init__(parent)
self.clip = VideoWriter(video)
self.fig = Figure()
@@ -538,100 +517,39 @@ def validate_crop(self, *args):
def display_help(self, *args):
print(
- "1. Use left click to select the region of interest. A red box will be drawn around the selected region. \n\n2. Use the corner points to expand the box and center to move the box around the image. \n\n3. Click "
+ "1. Use left click to select the region of interest. A red box will be drawn around the selected region."
+ "\n\n2. Use the corner points to expand the box and center to move the box around the image. \n\n3. Click"
)
-class SkeletonBuilder(QtWidgets.QDialog):
+class SkeletonBuilder(QtWidgets.QDialog, BaseSkeletonBuilder):
def __init__(self, config_path, parent=None):
- super(SkeletonBuilder, self).__init__(parent)
- self.config_path = config_path
- self.cfg = auxiliaryfunctions.read_config(config_path)
- # Find uncropped labeled data
- self.df = None
- found = False
- root = os.path.join(self.cfg["project_path"], "labeled-data")
- for dir_ in os.listdir(root):
- folder = os.path.join(root, dir_)
- if os.path.isdir(folder) and not any(
- folder.endswith(s) for s in ("cropped", "labeled")
- ):
- self.df = pd.read_hdf(
- os.path.join(folder, f'CollectedData_{self.cfg["scorer"]}.h5')
- )
- row, col = self.pick_labeled_frame()
- if "individuals" in self.df.columns.names:
- self.df = self.df.xs(col, axis=1, level="individuals")
- self.xy = self.df.loc[row].values.reshape((-1, 2))
- missing = np.flatnonzero(np.isnan(self.xy).all(axis=1))
- if not missing.size:
- found = True
- break
- if self.df is None:
- raise IOError("No labeled data were found.")
-
- self.bpts = self.df.columns.get_level_values("bodyparts").unique()
- if not found:
- warnings.warn(
- f"A fully labeled animal could not be found. "
- f"{', '.join(self.bpts[missing])} will need to be manually connected in the config.yaml."
- )
- self.tree = KDTree(self.xy)
- # Handle image previously annotated on a different platform
- if isinstance(row, str):
- sep = "/" if "/" in row else "\\"
- row = row.split(sep)
- self.image = io.imread(os.path.join(self.cfg["project_path"], *row))
- self.inds = set()
- self.segs = set()
- # Draw the skeleton if already existent
- if self.cfg["skeleton"]:
- for bone in self.cfg["skeleton"]:
- pair = np.flatnonzero(self.bpts.isin(bone))
- if len(pair) != 2:
- continue
- pair_sorted = tuple(sorted(pair))
- self.inds.add(pair_sorted)
- self.segs.add(tuple(map(tuple, self.xy[pair_sorted, :])))
+ QtWidgets.QDialog.__init__(self, parent)
+ self._parent = parent
+ self.setWindowTitle("Skeleton Builder")
+ BaseSkeletonBuilder.__init__(self, config_path)
+ def build_ui(self):
self.fig = Figure()
self.ax = self.fig.add_subplot(111)
self.ax.axis("off")
+
ax_clear = self.fig.add_axes([0.85, 0.55, 0.1, 0.1])
ax_export = self.fig.add_axes([0.85, 0.45, 0.1, 0.1])
+
self.clear_button = Button(ax_clear, "Clear")
self.clear_button.on_clicked(self.clear)
+
self.export_button = Button(ax_export, "Export")
self.export_button.on_clicked(self.export)
+
self.fig.canvas.mpl_connect("pick_event", self.on_pick)
+
self.canvas = FigureCanvas(self.fig)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.canvas)
self.setLayout(layout)
- self.lines = LineCollection(
- self.segs, colors=mcolors.to_rgba(self.cfg["skeleton_color"])
- )
- self.lines.set_picker(True)
- self._show()
-
- def pick_labeled_frame(self):
- # Find the most 'complete' animal
- try:
- count = self.df.groupby(level="individuals", axis=1).count()
- if "single" in count:
- count.drop("single", axis=1, inplace=True)
- except KeyError:
- count = self.df.count(axis=1).to_frame()
- mask = count.where(count == count.values.max())
- kept = mask.stack().index.to_list()
- np.random.shuffle(kept)
- picked = kept.pop()
- row = picked[:-1]
- col = picked[-1]
- return row, col
-
- def _show(self):
lo = np.nanmin(self.xy, axis=0)
hi = np.nanmax(self.xy, axis=0)
center = (hi + lo) / 2
@@ -639,6 +557,7 @@ def _show(self):
ampl = 1.3
w *= ampl
h *= ampl
+
self.ax.set_xlim(center[0] - w / 2, center[0] + w / 2)
self.ax.set_ylim(center[1] - h / 2, center[1] + h / 2)
self.ax.imshow(self.image)
@@ -647,40 +566,18 @@ def _show(self):
self.ax.invert_yaxis()
self.lasso = LassoSelector(self.ax, onselect=self.on_select)
- self.show()
+ self.canvas.draw_idle()
- def clear(self, *args):
- self.inds.clear()
- self.segs.clear()
- self.lines.set_segments(self.segs)
-
- def export(self, *args):
- inds_flat = set(ind for pair in self.inds for ind in pair)
- unconnected = [i for i in range(len(self.xy)) if i not in inds_flat]
- if len(unconnected):
- warnings.warn(
- f"You didn't connect all the bodyparts (which is fine!). This is just a note to let you know."
- )
- self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in self.inds]
- auxiliaryfunctions.write_config(self.config_path, self.cfg)
-
- def on_pick(self, event):
- if event.mouseevent.button == 3:
- removed = event.artist.get_segments().pop(event.ind[0])
- self.segs.remove(tuple(map(tuple, removed)))
- self.inds.remove(tuple(self.tree.query(removed)[1]))
-
- def on_select(self, verts):
- self.path = Path(verts)
- self.verts = verts
- inds = self.tree.query_ball_point(verts, 5)
- inds_unique = []
- for lst in inds:
- if len(lst) and lst[0] not in inds_unique:
- inds_unique.append(lst[0])
- for pair in zip(inds_unique, inds_unique[1:]):
- pair_sorted = tuple(sorted(pair))
- self.inds.add(pair_sorted)
- self.segs.add(tuple(map(tuple, self.xy[pair_sorted, :])))
- self.lines.set_segments(self.segs)
- self.fig.canvas.draw_idle()
+ def read_config(self, config_path):
+ return auxiliaryfunctions.read_config(config_path)
+
+ def write_config(self, config_path, cfg):
+ # Normalize to plain lists before writing config.yaml
+ cfg = dict(cfg)
+ if "skeleton" in cfg:
+ cfg["skeleton"] = [list(pair) for pair in cfg["skeleton"]]
+ auxiliaryfunctions.write_config(config_path, cfg)
+
+ def display(self):
+ # No-op, the dialog is shown/exec'd by the caller
+ pass
diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py
index fa458d79d4..e5b8fb466a 100644
--- a/deeplabcut/gui/window.py
+++ b/deeplabcut/gui/window.py
@@ -8,74 +8,82 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import os
import logging
-import subprocess
+import os
import sys
+import warnings
from functools import cached_property
+from importlib.resources import files
+from importlib.util import find_spec
from pathlib import Path
-from typing import List
+
import qdarkstyle
+from napari_deeplabcut import __version__ as NAPARI_DLC_VERSION
+from PySide6 import QtCore, QtGui, QtWidgets
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction, QIcon, QPixmap
+from PySide6.QtWidgets import (
+ QComboBox,
+ QLabel,
+ QMainWindow,
+ QMenu,
+ QMessageBox,
+ QSizePolicy,
+ QWidget,
+)
import deeplabcut
-from deeplabcut import auxiliaryfunctions, VERSION
-from deeplabcut.gui import BASE_DIR, components, utils
-from deeplabcut.gui.tabs import *
+from deeplabcut import __version__ as DLC_VERSION
+from deeplabcut import auxiliaryfunctions, compat
+from deeplabcut.core.debug import install_debug_recorder
+from deeplabcut.core.engine import Engine
+from deeplabcut.gui import BASE_DIR, components
+from deeplabcut.gui.dialogs import create_generate_debug_log_action
+from deeplabcut.gui.tabs import (
+ AnalyzeVideos,
+ CreateTrainingDataset,
+ CreateVideos,
+ EvaluateNetwork,
+ ExtractFrames,
+ ExtractOutlierFrames,
+ LabelFrames,
+ ManageProject,
+ ModelZoo,
+ OpenProject,
+ ProjectCreator,
+ RefineTracklets,
+ TrainNetwork,
+ UnsupervizedIdTracking,
+ VideoEditor,
+)
+from deeplabcut.gui.utils import UpdateChecker
from deeplabcut.gui.widgets import StreamReceiver, StreamWriter
-from napari_deeplabcut import misc
-from PySide6.QtWidgets import QMessageBox, QMenu, QWidget, QMainWindow
-from PySide6 import QtCore
-from PySide6.QtGui import QIcon, QAction
-from PySide6 import QtWidgets, QtGui
-from PySide6.QtCore import Qt
-
-
-def _check_for_updates():
- is_latest, latest_version = utils.is_latest_deeplabcut_version()
- is_latest_plugin, latest_plugin_version = misc.is_latest_version()
- if is_latest and is_latest_plugin:
- msg = QtWidgets.QMessageBox(
- text=f"DeepLabCut is up-to-date",
- )
- msg.exec_()
- else:
- if not is_latest and is_latest_plugin:
- text = f"DeepLabCut {latest_version} available"
- command = "pip", "install", "-U", "deeplabcut"
- elif not is_latest_plugin and is_latest:
- text = f"DeepLabCut labeling plugin {latest_plugin_version} available"
- command = "pip", "install", "-U", "napari-deeplabcut"
- else:
- text = f"DeepLabCut {latest_version}\nand labeling plugin {latest_plugin_version} available"
- command = "pip", "install", "-U", "deeplabcut", "napari-deeplabcut"
- msg = QtWidgets.QMessageBox(
- text=text,
- )
- msg.setIcon(QtWidgets.QMessageBox.Information)
- update_btn = msg.addButton("Update", msg.AcceptRole)
- msg.setDefaultButton(update_btn)
- _ = msg.addButton("Skip", msg.RejectRole)
- msg.exec_()
- if msg.clickedButton() is update_btn:
- subprocess.check_call(
- [sys.executable, "-m", *command]
- )
+warnings.filterwarnings(
+ "ignore",
+ message=r".*shibokensupport/signature/parser.py:269: RuntimeWarning: pyside_type_init:_resolve_value.*",
+ category=RuntimeWarning,
+)
class MainWindow(QMainWindow):
config_loaded = QtCore.Signal()
video_type_ = QtCore.Signal(str)
video_files_ = QtCore.Signal(set)
+ engine_change = QtCore.Signal(Engine)
+ shuffle_change = QtCore.Signal(int)
+ shuffle_created = QtCore.Signal(int)
def __init__(self, app):
- super(MainWindow, self).__init__()
+ super().__init__()
self.app = app
screen_size = app.screens()[0].size()
self.screen_width = screen_size.width()
self.screen_height = screen_size.height()
+ self._closing = False
- self.logger = logging.getLogger("GUI")
+ self.logger = logging.getLogger("deeplabcut.gui")
+ self.console_logger = logging.getLogger("deeplabcut.gui.console")
self.config = None
self.loaded = False
@@ -85,6 +93,29 @@ def __init__(self, app):
self.videotype = "mp4"
self.files = set()
+ self._engine = Engine.PYTORCH
+
+ # Update checks
+ self._update_process = None
+ self._update_process_output = []
+
+ self._scheduled_update_check_silent = True
+ self._update_check_timer = QtCore.QTimer(self)
+ self._update_check_timer.setSingleShot(True)
+ self._update_check_timer.timeout.connect(self._trigger_scheduled_update_check)
+ self._updater = UpdateChecker(
+ dlc_version=DLC_VERSION,
+ plugin_version=NAPARI_DLC_VERSION,
+ timeout_ms=5000,
+ parent=self,
+ )
+ self._updater.finished.connect(self._on_update_check_finished)
+
+ # Debug recorder
+ self._debug_recorder = install_debug_recorder(
+ logger_name="deeplabcut", handler_level=logging.INFO, ensure_logger_level=logging.INFO
+ )
+
self.default_set()
self._generate_welcome_page()
@@ -104,6 +135,10 @@ def __init__(self, app):
self.receiver = StreamReceiver(self.writer.queue)
self.receiver.new_text.connect(self.print_to_status_bar)
+ # create logger to also log to the console
+ logging.basicConfig()
+ self.console_logger.setLevel(logging.INFO)
+
self._progress_bar = QtWidgets.QProgressBar()
self._progress_bar.setMaximum(0)
self._progress_bar.hide()
@@ -112,6 +147,7 @@ def __init__(self, app):
def print_to_status_bar(self, text):
self.status_bar.showMessage(text)
self.status_bar.repaint()
+ self.console_logger.info(text)
@property
def toolbar(self):
@@ -151,6 +187,42 @@ def cfg(self):
cfg = {}
return cfg
+ @property
+ def engine(self) -> Engine:
+ return self._engine
+
+ @engine.setter
+ def engine(self, e: Engine) -> None:
+ if self._engine == e:
+ return
+
+ if e == e.TF:
+ if find_spec("tensorflow") is None:
+ err = ModuleNotFoundError("No module named 'tensorflow'")
+ msg = QtWidgets.QMessageBox()
+ msg.setIcon(QtWidgets.QMessageBox.Warning)
+ msg.setText("Cannot use the TensorFlow engine.")
+ msg.setInformativeText(
+ f"Error `{err}`\nCannot use the TensorFlow engine as TensorFlow "
+ "is not installed. To use it, install TensorFlow with\n"
+ " Windows/Linux:\n"
+ " pip install 'deeplabcut[tf]'\n"
+ " Apple Silicon:\n"
+ " pip install 'deeplabcut[apple_mchips]'\n\n"
+ "Please switch back to the PyTorch engine to use DeepLabCut, or install TensorFlow."
+ )
+
+ msg.setWindowTitle("Info")
+ msg.setMinimumWidth(900)
+ logo_dir = os.path.dirname(os.path.realpath("logo.png")) + os.path.sep
+ logo = logo_dir + "/assets/logo.png"
+ msg.setWindowIcon(QIcon(logo))
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+
+ self._engine = e
+ self.engine_change.emit(e)
+
@property
def project_folder(self) -> str:
return self.cfg.get("project_path", os.path.expanduser("~/Desktop"))
@@ -160,14 +232,14 @@ def is_multianimal(self) -> bool:
return bool(self.cfg.get("multianimalproject"))
@property
- def all_bodyparts(self) -> List:
+ def all_bodyparts(self) -> list:
if self.is_multianimal:
return self.cfg.get("multianimalbodyparts")
else:
return self.cfg["bodyparts"]
@property
- def all_individuals(self) -> List:
+ def all_individuals(self) -> list:
if self.is_multianimal:
return self.cfg.get("individuals")
else:
@@ -176,19 +248,31 @@ def all_individuals(self) -> List:
@property
def pose_cfg_path(self) -> str:
try:
- return os.path.join(
- self.cfg["project_path"],
- auxiliaryfunctions.get_model_folder(
- self.cfg["TrainingFraction"][int(self.trainingset_index)],
- int(self.shuffle_value),
- self.cfg,
- ),
- "train",
- "pose_cfg.yaml",
+ return str(
+ compat.return_train_network_path(
+ self.config,
+ shuffle=int(self.shuffle_value),
+ trainingsetindex=int(self.trainingset_index),
+ modelprefix="",
+ )[0]
)
except FileNotFoundError:
return str(Path(deeplabcut.__file__).parent / "pose_cfg.yaml")
+ @property
+ def models_folder(self) -> str:
+ try:
+ return str(
+ compat.return_train_network_path(
+ self.config,
+ shuffle=int(self.shuffle_value),
+ trainingsetindex=int(self.trainingset_index),
+ modelprefix="",
+ )[2]
+ )
+ except FileNotFoundError:
+ return self.project_folder()
+
@property
def inference_cfg_path(self) -> str:
return os.path.join(
@@ -208,6 +292,7 @@ def update_cfg(self, text):
def update_shuffle(self, value):
self.shuffle_value = value
+ self.shuffle_change.emit(value)
self.logger.info(f"Shuffle set to {self.shuffle_value}")
@property
@@ -224,13 +309,203 @@ def video_type(self, ext):
def video_files(self):
return self.files
- @video_files.setter
- def video_files(self, video_files):
- self.files = set(video_files)
- self.video_files_.emit(self.files)
- self.logger.info(f"Videos selected to analyze:\n{self.files}")
+ def check_for_updates(self, *, silent=True, delay_ms=0):
+ """Start an update check immediately or schedule it after a delay."""
+ if self._closing:
+ return
+
+ # supersede old requests
+ if self._update_check_timer.isActive():
+ self._update_check_timer.stop()
+
+ if delay_ms > 0:
+ self._scheduled_update_check_silent = silent
+ self._update_check_timer.start(delay_ms)
+ return
+
+ self._updater.check(silent=silent)
+
+ def _trigger_scheduled_update_check(self):
+ if self._closing:
+ return
+ self._updater.check(silent=self._scheduled_update_check_silent)
+
+ def _run_update_command(self, packages):
+ if not packages:
+ return
+
+ if self._update_process is not None and self._update_process.state() != QtCore.QProcess.NotRunning:
+ return
+
+ self._progress_bar.show()
+ self.status_bar.showMessage("Installing updates...")
+
+ self._update_process_output = []
+ self._update_process = QtCore.QProcess(self)
+ self._update_process.setProgram(sys.executable)
+ self._update_process.setArguments(["-m", "pip", "install", "-U", *packages])
+
+ self._update_process.finished.connect(self._on_update_process_finished)
+ self._update_process.errorOccurred.connect(self._on_update_process_error)
+ self._update_process.readyRead.connect(self._drain_update_process_output)
+ self._update_process.setProcessChannelMode(QtCore.QProcess.MergedChannels)
+ self._update_process.start()
+
+ def _drain_update_process_output(self):
+ if self._update_process is None:
+ return
+
+ data = bytes(self._update_process.readAll())
+ if not data:
+ return
+
+ text = data.decode(errors="replace")
+ self._update_process_output.append(text)
+
+ # Optional: surface some live feedback
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
+ if lines:
+ latest_line = lines[-1]
+ self.status_bar.showMessage(latest_line)
+ self.logger.info(latest_line)
+
+ def _cleanup_update_process(self):
+ if self._update_process is not None:
+ self._update_process.deleteLater()
+ self._update_process = None
+ self._update_process_output = []
+ self._progress_bar.hide()
+ self.status_bar.showMessage("www.deeplabcut.org")
+
+ def _on_update_process_error(self, error):
+ if self._closing:
+ self._cleanup_update_process()
+ return
+
+ error_strings = {
+ QtCore.QProcess.FailedToStart: (
+ "Process failed to start. Check that pip is available and you have sufficient permissions."
+ ),
+ QtCore.QProcess.Crashed: "Update process crashed unexpectedly.",
+ QtCore.QProcess.Timedout: "Update process timed out.",
+ QtCore.QProcess.WriteError: "Could not write to update process.",
+ QtCore.QProcess.ReadError: "Could not read from update process.",
+ QtCore.QProcess.UnknownError: "An unknown error occurred.",
+ }
+ message = error_strings.get(error, "An unknown error occurred.")
+ QtWidgets.QMessageBox.warning(self, "Update failed", message)
+
+ self._cleanup_update_process()
+
+ def _on_update_process_finished(self, exit_code, exit_status):
+ if self._closing:
+ self._cleanup_update_process()
+ return
+
+ if self._update_process is None:
+ return
+
+ self._progress_bar.hide()
+ self._drain_update_process_output()
+
+ output = "".join(self._update_process_output).strip()
+
+ if exit_status == QtCore.QProcess.NormalExit and exit_code == 0:
+ QtWidgets.QMessageBox.information(
+ self,
+ "Update complete",
+ "The update completed successfully.\n\nPlease restart DeepLabCut to use the updated packages.",
+ )
+ if output:
+ self.logger.info(output)
+ else:
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Update failed",
+ "The update command did not complete successfully.\n\n"
+ f"{output or 'No additional output was captured.'}",
+ )
+ if output:
+ self.logger.error(output)
+
+ self._cleanup_update_process()
+
+ def _on_update_check_finished(self, result):
+ if self._closing:
+ return
+
+ silent = result.get("silent", True)
+ error = result.get("error")
+
+ if error is not None:
+ self.logger.debug(f"Update check failed with error: {error!r}")
+ if not silent:
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Update check failed",
+ f"Could not check for updates.\n\n{error}",
+ )
+ return
+
+ is_latest = result["is_latest"]
+ latest_version = result["latest_version"]
+ is_latest_plugin = result["is_latest_plugin"]
+ latest_plugin_version = result["latest_plugin_version"]
+
+ if is_latest and is_latest_plugin:
+ if not silent:
+ QtWidgets.QMessageBox.information(
+ self,
+ "Up to date",
+ "You are using the latest version of DeepLabCut and the labeling plugin.",
+ )
+ return
+
+ parts = []
+ packages = []
+
+ if not is_latest:
+ parts.append(f"DeepLabCut {latest_version} available")
+ packages.append("deeplabcut")
+
+ if not is_latest_plugin:
+ parts.append(f"DeepLabCut labeling plugin {latest_plugin_version} available")
+ packages.append("napari-deeplabcut")
+
+ msg = QtWidgets.QMessageBox(self)
+ msg.setIcon(QtWidgets.QMessageBox.Information)
+ msg.setText("\n".join(parts))
+
+ update_btn = msg.addButton("Update", QtWidgets.QMessageBox.AcceptRole)
+ msg.addButton("Skip", QtWidgets.QMessageBox.RejectRole)
+ msg.setDefaultButton(update_btn)
+ msg.exec()
+
+ if msg.clickedButton() is update_btn:
+ self._run_update_command(packages)
+
+ def add_video_files(self, new_video_files):
+ """
+ Add new video files to the existing set of files. This method ensures no duplicates are added.
+ Emits a signal to notify about the updated set of files.
+ """
+ new_video_files = set(new_video_files)
+ self.files.update(new_video_files) # Add new items to the existing set
+ self.video_files_.emit(self.files) # Emit the updated set of files
+ self.logger.info(f"Videos added to analyze:\n{new_video_files}\nCurrent video files:\n{self.files}")
+
+ def clear_video_files(self):
+ """
+ Clear all video files from the existing set. Emits a signal to notify the change.
+ """
+ self.files.clear() # Reset the set to be empty
+ self.video_files_.emit(self.files) # Emit the empty set
+ self.logger.info("All video files have been cleared.")
def window_set(self):
+ WINDOW_RESIZE_FACTOR = 0.8
+ DEFAULT_MINIMUM_WIDTH, DEFAULT_MINIMUM_HEIGHT = 800, 600
+
self.setWindowTitle("DeepLabCut")
palette = QtGui.QPalette()
@@ -240,6 +515,17 @@ def window_set(self):
icon = os.path.join(BASE_DIR, "assets", "logo.png")
self.setWindowIcon(QIcon(icon))
+ # Set default window size and allow resizing
+ self.resize(
+ int(self.screen_width * WINDOW_RESIZE_FACTOR),
+ int(self.screen_height * WINDOW_RESIZE_FACTOR),
+ )
+ self.setMinimumSize(DEFAULT_MINIMUM_WIDTH, DEFAULT_MINIMUM_HEIGHT)
+ self.setMaximumSize(self.screen_width, self.screen_height)
+ self.setWindowFlag(Qt.WindowMaximizeButtonHint, True)
+ self.setWindowFlag(Qt.WindowMinimizeButtonHint, True)
+ self.setWindowFlag(Qt.WindowCloseButtonHint, True)
+
self.status_bar = self.statusBar()
self.status_bar.setObjectName("Status Bar")
self.status_bar.showMessage("www.deeplabcut.org")
@@ -250,7 +536,7 @@ def _generate_welcome_page(self):
self.layout.setSpacing(30)
title = components._create_label_widget(
- f"Welcome to the DeepLabCut Project Manager GUI {VERSION}!",
+ f"Welcome to the DeepLabCut Project Manager GUI {DLC_VERSION}!",
"font:bold; font-size:18px;",
margins=(0, 30, 0, 0),
)
@@ -262,12 +548,15 @@ def _generate_welcome_page(self):
image_widget.setContentsMargins(0, 0, 0, 0)
logo = os.path.join(BASE_DIR, "assets", "logo_transparent.png")
pixmap = QtGui.QPixmap(logo)
- image_widget.setPixmap(
- pixmap.scaledToHeight(400, QtCore.Qt.SmoothTransformation)
- )
+ image_widget.setPixmap(pixmap.scaledToHeight(400, QtCore.Qt.SmoothTransformation))
self.layout.addWidget(image_widget)
-
- description = "DeepLabCut™ is an open source tool for markerless pose estimation of user-defined body parts with deep learning.\nA. and M.W. Mathis Labs | http://www.deeplabcut.org\n\n To get started, create a new project, load an existing one, or try one of our pretrained models from the Model Zoo."
+ description = (
+ "DeepLabCut™ is an open source tool for markerless pose estimation of "
+ "user-defined body parts with deep learning.\n"
+ "A. and M.W. Mathis Labs | http://www.deeplabcut.org\n\n"
+ "To get started, create a new project, load an existing one, or try one "
+ "of our pretrained models from the Model Zoo."
+ )
label = components._create_label_widget(
description,
"font-size:12px; text-align: center;",
@@ -301,6 +590,7 @@ def _generate_welcome_page(self):
widget = QWidget()
widget.setLayout(self.layout)
self.setCentralWidget(widget)
+ self.check_for_updates(silent=True, delay_ms=2000)
def default_set(self):
self.name_default = ""
@@ -313,9 +603,7 @@ def create_actions(self, names):
self.newAction = QAction(self)
self.newAction.setText("&New Project...")
- self.newAction.setIcon(
- QIcon(os.path.join(BASE_DIR, "assets", "icons", names[0]))
- )
+ self.newAction.setIcon(QIcon(os.path.join(BASE_DIR, "assets", "icons", names[0])))
self.newAction.setShortcut("Ctrl+N")
self.newAction.setStatusTip("Create a new project...")
@@ -323,9 +611,7 @@ def create_actions(self, names):
# Creating actions using the second constructor
self.openAction = QAction("&Open...", self)
- self.openAction.setIcon(
- QIcon(os.path.join(BASE_DIR, "assets", "icons", names[1]))
- )
+ self.openAction.setIcon(QIcon(os.path.join(BASE_DIR, "assets", "icons", names[1])))
self.openAction.setShortcut("Ctrl+O")
self.openAction.setStatusTip("Open a project...")
self.openAction.triggered.connect(self._open_project)
@@ -339,9 +625,7 @@ def create_actions(self, names):
self.darkmodeAction.triggered.connect(self.darkmode)
self.helpAction = QAction("&Help", self)
- self.helpAction.setIcon(
- QIcon(os.path.join(BASE_DIR, "assets", "icons", names[2]))
- )
+ self.helpAction.setIcon(QIcon(os.path.join(BASE_DIR, "assets", "icons", names[2])))
self.helpAction.setStatusTip("Ask for help...")
self.helpAction.triggered.connect(self._ask_for_help)
@@ -349,7 +633,16 @@ def create_actions(self, names):
self.aboutAction.triggered.connect(self._learn_dlc)
self.check_updates = QAction("&Check for Updates...", self)
- self.check_updates.triggered.connect(_check_for_updates)
+ self.check_updates.triggered.connect(lambda: self.check_for_updates(silent=False))
+
+ self.buildDebugLogAction = create_generate_debug_log_action(
+ parent=self,
+ recorder=self._debug_recorder,
+ logger_name="deeplabcut",
+ include_module_paths=False,
+ include_executable_paths=True,
+ log_limit=1000,
+ )
def create_menu_bar(self):
menu_bar = self.menuBar()
@@ -362,9 +655,7 @@ def create_menu_bar(self):
self.file_menu.addAction(self.openAction)
self.recentfiles_menu = self.file_menu.addMenu("Open Recent")
- self.recentfiles_menu.triggered.connect(
- lambda a: self._update_project_state(a.text(), True)
- )
+ self.recentfiles_menu.triggered.connect(lambda a: self._update_project_state(a.text(), True))
self.file_menu.addAction(self.saveAction)
self.file_menu.addAction(self.exitAction)
@@ -379,19 +670,62 @@ def create_menu_bar(self):
help_menu = QMenu("&Help", self)
menu_bar.addMenu(help_menu)
help_menu.addAction(self.helpAction)
- help_menu.adjustSize()
+ help_menu.addAction(self.buildDebugLogAction)
+ help_menu.addSeparator()
help_menu.addAction(self.check_updates)
help_menu.addAction(self.aboutAction)
+ help_menu.adjustSize()
def update_menu_bar(self):
self.file_menu.removeAction(self.newAction)
self.file_menu.removeAction(self.openAction)
def create_toolbar(self):
+ self.toolbar.clear()
self.toolbar.addAction(self.newAction)
self.toolbar.addAction(self.openAction)
self.toolbar.addAction(self.helpAction)
+ size_policy = QSizePolicy() # QtWidgets.QSizePolicy.Policy.Expanding
+ size_policy.setHorizontalPolicy(QSizePolicy.Policy.Expanding)
+ spacer = QLabel()
+ spacer.setSizePolicy(size_policy)
+ spacer.setStyleSheet("background: transparent;")
+
+ engine_label = QLabel()
+ engine_label.autoFillBackground()
+ engine_label.setText("Engine")
+ engine_label.setStyleSheet("background: transparent;")
+
+ engine_icon = QLabel()
+ engine_icon.setStyleSheet("background: transparent;")
+
+ def _update_icon(engine: str):
+ file = files("deeplabcut.gui.media") / f"dlc-{engine}.png"
+ pixmap = QPixmap(str(file))
+ if not pixmap.isNull():
+ engine_icon.setPixmap(pixmap.scaled(56, 56, Qt.AspectRatioMode.KeepAspectRatio))
+
+ _update_icon("pt" if self.engine == Engine.PYTORCH else "tf")
+
+ engines = [engine for engine in Engine]
+
+ def _update_engine(index: int) -> None:
+ self.logger.info(f"Changed engine to {engines[index]}")
+ self.engine = engines[index]
+ _update_icon("pt" if self.engine == Engine.PYTORCH else "tf")
+
+ change_engine_widget = QComboBox()
+ change_engine_widget.addItems([e.aliases[0] for e in engines])
+ change_engine_widget.setFixedWidth(180)
+ change_engine_widget.currentIndexChanged.connect(_update_engine)
+ change_engine_widget.setCurrentIndex(engines.index(self.engine))
+
+ self.toolbar.addWidget(spacer)
+ self.toolbar.addWidget(engine_icon)
+ self.toolbar.addWidget(engine_label)
+ self.toolbar.addWidget(change_engine_widget)
+
def remove_action(self):
self.toolbar.removeAction(self.newAction)
self.toolbar.removeAction(self.openAction)
@@ -407,16 +741,16 @@ def _update_project_state(self, config, loaded):
def _ask_for_help(self):
dlg = QMessageBox(self)
dlg.setWindowTitle("Ask for help")
- dlg.setText(
- """Ask our community for help on the forum !"""
- )
+ dlg.setText("""Ask our community for help on the forum !""")
_ = dlg.exec()
def _learn_dlc(self):
dlg = QMessageBox(self)
dlg.setWindowTitle("Learn DLC")
dlg.setText(
- """Learn DLC with our docs and how-to guides !"""
+ "Learn DLC with "
+ ""
+ "our docs and how-to guides !"
)
_ = dlg.exec()
@@ -439,9 +773,7 @@ def _open_project(self):
def _goto_superanimal(self):
self.tab_widget = QtWidgets.QTabWidget()
self.tab_widget.setContentsMargins(0, 20, 0, 0)
- self.modelzoo = ModelZoo(
- root=self, parent=None, h1_description="DeepLabCut - Model Zoo"
- )
+ self.modelzoo = ModelZoo(root=self, parent=None, h1_description="DeepLabCut - Model Zoo")
self.tab_widget.addTab(self.modelzoo, "Model Zoo")
self.setCentralWidget(self.tab_widget)
@@ -475,31 +807,25 @@ def lightmode(self):
def add_tabs(self):
self.tab_widget = QtWidgets.QTabWidget()
self.tab_widget.setContentsMargins(0, 20, 0, 0)
- self.manage_project = ManageProject(
- root=self, parent=None, h1_description="DeepLabCut - Manage Project"
- )
- self.extract_frames = ExtractFrames(
- root=self, parent=None, h1_description="DeepLabCut - Extract Frames"
- )
- self.label_frames = LabelFrames(
- root=self, parent=None, h1_description="DeepLabCut - Label Frames"
- )
+ self.manage_project = ManageProject(root=self, parent=None, h1_description="DeepLabCut - Manage Project")
+ self.extract_frames = ExtractFrames(root=self, parent=None, h1_description="DeepLabCut - Extract Frames")
+ self.label_frames = LabelFrames(root=self, parent=None, h1_description="DeepLabCut - Label Frames")
self.create_training_dataset = CreateTrainingDataset(
root=self,
parent=None,
h1_description="DeepLabCut - Step 4. Create training dataset",
)
self.train_network = TrainNetwork(
- root=self, parent=None, h1_description="DeepLabCut - Train network"
+ root=self,
+ parent=None,
+ h1_description="DeepLabCut - Train network",
)
self.evaluate_network = EvaluateNetwork(
root=self,
parent=None,
h1_description="DeepLabCut - Evaluate Network",
)
- self.analyze_videos = AnalyzeVideos(
- root=self, parent=None, h1_description="DeepLabCut - Analyze Videos"
- )
+ self.analyze_videos = AnalyzeVideos(root=self, parent=None, h1_description="DeepLabCut - Analyze Videos")
self.unsupervised_id_tracking = UnsupervizedIdTracking(
root=self,
parent=None,
@@ -515,15 +841,9 @@ def add_tabs(self):
parent=None,
h1_description="DeepLabCut - Step 8. Extract outlier frames",
)
- self.refine_tracklets = RefineTracklets(
- root=self, parent=None, h1_description="DeepLabCut - Refine labels"
- )
- self.modelzoo = ModelZoo(
- root=self, parent=None, h1_description="DeepLabCut - Model Zoo"
- )
- self.video_editor = VideoEditor(
- root=self, parent=None, h1_description="DeepLabCut - Optional Video Editor"
- )
+ self.refine_tracklets = RefineTracklets(root=self, parent=None, h1_description="DeepLabCut - Refine labels")
+ self.modelzoo = ModelZoo(root=self, parent=None, h1_description="DeepLabCut - Model Zoo")
+ self.video_editor = VideoEditor(root=self, parent=None, h1_description="DeepLabCut - Optional Video Editor")
self.tab_widget.addTab(self.manage_project, "Manage project")
self.tab_widget.addTab(self.extract_frames, "Extract frames")
@@ -532,20 +852,16 @@ def add_tabs(self):
self.tab_widget.addTab(self.train_network, "Train network")
self.tab_widget.addTab(self.evaluate_network, "Evaluate network")
self.tab_widget.addTab(self.analyze_videos, "Analyze videos")
- self.tab_widget.addTab(
- self.unsupervised_id_tracking, "Unsupervised ID Tracking (*)"
- )
+ self.tab_widget.addTab(self.unsupervised_id_tracking, "Unsupervised ID Tracking (*)")
self.tab_widget.addTab(self.create_videos, "Create videos")
- self.tab_widget.addTab(
- self.extract_outlier_frames, "Extract outlier frames (*)"
- )
+ self.tab_widget.addTab(self.extract_outlier_frames, "Extract outlier frames (*)")
self.tab_widget.addTab(self.refine_tracklets, "Refine tracklets (*)")
self.tab_widget.addTab(self.modelzoo, "Model Zoo")
self.tab_widget.addTab(self.video_editor, "Video editor (*)")
if not self.is_multianimal:
- self.refine_tracklets.setEnabled(False)
- self.unsupervised_id_tracking.setEnabled(self.is_transreid_available())
+ self.tab_widget.removeTab(self.tab_widget.indexOf(self.unsupervised_id_tracking))
+ self.tab_widget.removeTab(self.tab_widget.indexOf(self.refine_tracklets))
self.setCentralWidget(self.tab_widget)
self.tab_widget.currentChanged.connect(self.refresh_active_tab)
@@ -565,9 +881,7 @@ def _attempt_attribute_update(widget_name, updated_value):
try:
widget = getattr(active_tab, widget_name)
method = getattr(widget, widget_to_attribute_map[type(widget)])
- self.logger.debug(
- f"Setting {widget_name}={updated_value} in tab '{tab_label}'"
- )
+ self.logger.debug(f"Setting {widget_name}={updated_value} in tab '{tab_label}'")
method(updated_value)
except AttributeError:
pass
@@ -576,15 +890,9 @@ def _attempt_attribute_update(widget_name, updated_value):
_attempt_attribute_update("cfg_line", self.config)
def is_transreid_available(self):
- if self.is_multianimal:
- try:
- from deeplabcut.pose_tracking_pytorch import transformer_reID
-
- return True
- except ModuleNotFoundError:
- return False
- else:
+ if not self.is_multianimal:
return False
+ return find_spec("deeplabcut.pose_tracking_pytorch.transformer_reID") is not None
def closeEvent(self, event):
print("Exiting...")
@@ -596,9 +904,21 @@ def closeEvent(self, event):
QtWidgets.QMessageBox.Cancel,
)
if answer == QtWidgets.QMessageBox.Yes:
+ self._closing = True
self.receiver.terminate()
- event.accept()
+
+ if self._update_check_timer.isActive():
+ self._update_check_timer.stop()
+
+ if self._updater is not None:
+ self._updater.cancel()
+
+ if self._update_process is not None and self._update_process.state() != QtCore.QProcess.NotRunning:
+ self._update_process.kill()
+ self._update_process.waitForFinished(1000)
+
self.save_settings()
+ event.accept()
else:
event.ignore()
print("")
diff --git a/deeplabcut/modelzoo/__init__.py b/deeplabcut/modelzoo/__init__.py
index 2dd1b06028..96c227b4b4 100644
--- a/deeplabcut/modelzoo/__init__.py
+++ b/deeplabcut/modelzoo/__init__.py
@@ -4,7 +4,8 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
+from deeplabcut.modelzoo.weight_initialization import build_weight_init
diff --git a/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv b/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv
new file mode 100644
index 0000000000..e23d1e280f
--- /dev/null
+++ b/deeplabcut/modelzoo/conversion_tables/conversion_table_quadruped.csv
@@ -0,0 +1,40 @@
+ap10k,animalpose,stanforddogs,cheetah,horse,webapp,MasterName
+nose,nose,Nose,nose,Nose,nose,nose
+,,,,,,upper_jaw
+,,,,,,lower_jaw
+,,,,,,mouth_end_right
+,,,,,,mouth_end_left
+right_eye,right_eye,R_Eye,r_eye,Eye,right_eye,right_eye
+,right_ear,R_EarBase,,,right_ear,right_earbase
+,,R_EarTip,,,,right_earend
+,,,,,,right_antler_base
+,,,,,,right_antler_end
+left_eye,left_eye,L_Eye,l_eye,,left_eye,left_eye
+,left_ear,L_EarBase,,,left_ear,left_earbase
+,,L_EarTip,,,,left_earend
+,,,,,,left_antler_base
+,,,,,,left_antler_end
+neck,,,neck_base,,,neck_base
+,,,,,,neck_end
+,throat,Throat,,,throat,throat_base
+,,,,,,throat_end
+,withers,Withers,,Wither,withers,back_base
+,,,,,,back_end
+,,,spine,,,back_middle
+root_of_tail,tailbase,TailBase,tail_base,,tailset,tail_base
+,,TailEnd,tail_tip,,,tail_end
+left_shoulder,left_front_elbow,L_F_Elbow,l_shoulder,,left_front_elbow,front_left_thai
+,left_front_knee,L_F_Knee,l_front_knee,,,front_left_knee
+left_front_paw,left_front_paw,L_F_Paw,l_front_paw,Nearfrontfoot,left_front_paw,front_left_paw
+right_shoulder,right_front_elbow,R_F_Elbow,r_shoulder,Elbow,right_front_elbow,front_right_thai
+,right_front_knee,R_F_Knee,r_front_knee,,,front_right_knee
+right_front_paw,right_front_paw,R_F_Paw,r_front_paw,Offfrontfoot,right_front_paw,front_right_paw
+left_back_paw,left_back_paw,L_B_Paw,l_back_paw,Nearhindfoot,left_back_paw,back_left_paw
+left_hip,left_back_elbow,L_B_Elbow,l_hip,,left_back_stifle,back_left_thai
+right_hip,right_back_elbow,R_B_Elbow,r_hip,Stifle,right_back_stifle,back_right_thai
+left_knee,left_back_knee,L_B_Knee,l_back_knee,,,back_left_knee
+right_knee,right_back_knee,R_B_Knee,r_back_knee,,,back_right_knee
+right_back_paw,right_back_paw,R_B_Paw,r_back_paw,Offhindfoot,right_back_paw,back_right_paw
+,,,,,,belly_bottom
+,,,,,,body_middle_right
+,,,,,,body_middle_left
diff --git a/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv b/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv
new file mode 100644
index 0000000000..b02098abee
--- /dev/null
+++ b/deeplabcut/modelzoo/conversion_tables/conversion_table_topview.csv
@@ -0,0 +1,28 @@
+treadmill_ole,swimming_ole,openfield_ole,MackenzieMausHaus, ChanLab,Daniel3Mouse,dlc-openfield,EPM ,FST,LBD,OFT,Mostafizur,3CSI,BM,TwoWhiteMice,MasterName
+head,head,head,nose,Nose,snout,snout,nose,nose,nose,nose,snout,nose,nose,Nose,nose
+,,,leftearbase,Ear_left,leftear,leftear,earl,earl,earl,earl,leftear,earl,earl,Left_ear,left_ear
+,,,rightearbase,Ear_right,rightear,rightear,earr,earr,earr,earr,rightear,earr,earr,Right_ear,right_ear
+,,,lefteartip,,,,,,,,,,,,left_ear_tip
+,,,righteartip,,,,,,,,,,,,right_ear_tip
+,,,lefteye,,,,,,,,,,,,left_eye
+,,,righteye,,,,,,,,,,,,right_eye
+spine 1,spine 1,,spine1,,shoulder,,neck,neck,neck,neck,shoulder,neck,neck,,neck
+,,,spine2,,spine1,,,,,,spine1,,,,mid_back
+spine 2,spine 2,middle,spine3,Center,spine2,,bodycentre,bodycentre,bodycentre,bodycentre,spine2,bodycenter,bodycenter,Centroid,mouse_center
+,,,spine4,,spine3,,,,,,spine3,,,,mid_backend
+spine 3,spine 3,,spine5,,spine4,,,,,,spine4,,,,mid_backend2
+spine 4,spine 4,,spine6,,,,,,,,,,,,mid_backend3
+base ,base ,tailbase,tailbase,Tail_base,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,tailbase,Tail_base,tail_base
+,,,tail1,,tail1,,,,,,tail1,,,,tail1
+tail 25,tail 25,,tail2,,tail2,,,,,,tail2,,,,tail2
+,,,tail3,,,,tailcentre,tailcentre,tailcentre,tailcentre,,tailcenter,tailcenter,,tail3
+tail 50 ,tail 50,,tail4,, ,,,,,,,,,,tail4
+tail 75,tail 75,,tail5,, ,,,,,,,,,,tail5
+,,,leftshoulder,, ,,,,,,,,,,left_shoulder
+,,,leftside,, ,,bcl,bcl,bcl,bcl,,bcl,bcl,Left_lateral,left_midside
+,,,lefthip,Lateral_left,,,hipl,hipl,hipl,hipl,,hipl,hipl,,left_hip
+,,,rightshoulder,,,,,,,,,,,,right_shoulder
+,,,rightside,,,,bcr,bcr,bcr,bcr,,bcr,bcr,Right_lateral,right_midside
+,,,righthip,Lateral_right,,,hipr,hipr,hipr,hipr,,hipr,hipr,,right_hip
+tail 100,tail 100,tailtip,,Tail_end,tailend,,tailtip,tailtip,tailtip,tailtip,tailend,tailtip,tailtip,Tail_end,tail_end
+,,,,,,,headcentre,headcentre,headcentre,headcentre,,headcenter,headcenter,,head_midpoint
diff --git a/deeplabcut/modelzoo/generalized_data_converter/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/__init__.py
new file mode 100644
index 0000000000..1c56abbdbf
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/__init__.py
@@ -0,0 +1,11 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from .utils import add_skeleton, create_modelprefix, customized_colormap
diff --git a/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py
new file mode 100644
index 0000000000..7a3cd50142
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/__init__.py
@@ -0,0 +1,11 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from .conversion_table import get_conversion_table
diff --git a/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py
new file mode 100644
index 0000000000..4519a66b5b
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/conversion_table/conversion_table.py
@@ -0,0 +1,138 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import warnings
+
+import numpy as np
+import pandas as pd
+
+
+class ConversionTableFromDict:
+ def __init__(self, raw_table_dict):
+ self.table_dict = raw_table_dict["conversion_table"]
+ self.master_keypoints = raw_table_dict["master_keypoints"]
+
+ def convert(self, kpt):
+ if kpt not in self.table_dict:
+ warnings.warn(f"{kpt} is defined in src space but not appeared in the conversion table", stacklevel=2)
+ return None
+ else:
+ return self.table_dict[kpt]
+
+
+class ConversionTableFromCSV:
+ """Base class only reads the table."""
+
+ def __init__(self, src_keypoints, table_path):
+ self.table_path = table_path
+
+ # sep removes leading and tailing white space
+ df = pd.read_csv(table_path, sep=r"\s*,\s*")
+
+ df.dropna(inplace=True, how="all")
+ # drop the row is MasterName has nan in the row
+ df = df.dropna(subset=["MasterName"])
+
+ self.df = df
+
+ self.src_keypoints = src_keypoints
+
+ kpt_list = df.to_numpy()
+
+ self.lookup_set = []
+
+ for i in range(len(kpt_list)):
+ kpts = np.array(kpt_list[i])
+ # remove nan
+
+ kpt_alias = set(kpts)
+
+ for k in list(kpt_alias):
+ if not isinstance(k, str):
+ kpt_alias.remove(k)
+
+ self.lookup_set.append(kpt_alias)
+
+ target_keypoints = df["MasterName"].values
+
+ # target_keypoints = target_keypoints[~np.isnan(target_keypoints.values)]
+
+ self.master_keypoints = target_keypoints
+
+ # paired when they both exist
+
+ # following assumes that either it's 1vs.1 from src to target
+ # or 1 vs. 0
+ # it could be 1 vs. 2 in horse data
+ self.table = {}
+ for src_kpt in src_keypoints:
+ for target_kpt in target_keypoints:
+ src_kpt_id = self._search(src_kpt)
+ target_kpt_id = self._search(target_kpt)
+
+ if src_kpt_id == -1 or target_kpt_id == -1:
+ # if any one of them not exist in the set
+ # skip
+ continue
+ if src_kpt_id == target_kpt_id:
+ self.table[src_kpt] = target_kpt
+
+ self.check_inclusion()
+
+ def _search(self, key):
+ """Return -1 if not found return kpt id if found."""
+ # [TODO] if it can be mapped to two, I can randomly return one
+ for kpt_id in range(len(self.lookup_set)):
+ if key in self.lookup_set[kpt_id]:
+ return kpt_id
+ return -1
+
+ def check_inclusion(self):
+ """Check if conversion table covers every keypoint contained in src proj."""
+ count = 0
+ print("src keypoints")
+ print(self.src_keypoints)
+ for kpt in self.src_keypoints:
+ index = self._search(kpt)
+ if index == -1:
+ pass
+ else:
+ count += 1
+ print(f"{count}/{len(self.src_keypoints)} keypoints will be converted")
+
+ def convert(self, kpt):
+ if kpt not in self.table:
+ warnings.warn(f"{kpt} is defined in src space but not appeared in the conversion table", stacklevel=2)
+ return None
+ else:
+ return self.table[kpt]
+
+ def get_subset(self, labname=""):
+
+ bodyparts = self.df[labname]
+
+ self.df["MasterName"]
+
+ ret = []
+
+ for bodypart in bodyparts:
+ if bodypart in self.table:
+ ret.append(self.table[bodypart])
+
+ return ret
+
+
+def get_conversion_table(keypoints=None, table_path="", table_dict=None):
+ if table_path is not None and keypoints is not None:
+ return ConversionTableFromCSV(keypoints, table_path)
+ elif table_dict:
+ return ConversionTableFromDict(table_dict)
+ else:
+ raise NotImplementedError("not supported")
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py
new file mode 100644
index 0000000000..0032f808f3
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/__init__.py
@@ -0,0 +1,17 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from .coco import COCOPoseDataset
+from .ma_dlc import MaDLCPoseDataset
+from .ma_dlc_dataframe import MaDLCDataFrame
+from .materialize import mat_func_factory
+from .multi import MultiSourceDataset
+from .single_dlc import SingleDLCPoseDataset
+from .single_dlc_dataframe import SingleDLCDataFrame
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py
new file mode 100644
index 0000000000..cde1d07167
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py
@@ -0,0 +1,307 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import copy
+import os
+import warnings
+
+import numpy as np
+
+from deeplabcut.modelzoo.generalized_data_converter.conversion_table import (
+ get_conversion_table,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import (
+ mat_func_factory,
+)
+
+
+def raw_2_imagename_with_id(image):
+ """Raw image data has filename and id.
+
+ we modify the imagename such that itis composed of both original imagename and image
+ id
+ """
+
+ file_name = image["file_name"]
+ image_name = file_name.split(os.sep)[-1]
+ pre, suffix = image_name.split(".")
+ image_id = image["id"]
+ return f"{pre}_{image_id}.{suffix}"
+
+
+def raw_2_imagename(image):
+ """Only getting the imagename part from the image object."""
+
+ file_name = image["file_name"]
+ image_name = file_name.split(os.sep)[-1]
+ return image_name
+
+
+class BasePoseDataset:
+ """Dual representation of generic and raw data.
+
+ For classes that inherits this class, the raw data is kept but generic data is
+ populated so you have dual representation.
+ """
+
+ def __init__(self):
+ # generic data is what all the manipulation is based on
+ self.generic_train_images = []
+ self.generic_test_images = []
+ self.generic_train_annotations = []
+ self.generic_test_annotations = []
+ # These maps are very important for later analysis, including max_individuals
+ # and trace back the original dataset etc.
+ self.imageid2anno = {}
+ self.dataset2images = {}
+ self.imageid2filename = {}
+ self.imageid2datasetname = {}
+ self.datasetname2imageids = {}
+ # meta keeps information for later analysis
+ self.meta = {}
+ # if conversion_table is None, dataset is not yet converted to super keypoints
+ self.conversion_table = None
+
+ def _build_maps(self):
+ self.datasetname2imageids[self.meta["dataset_name"]] = set()
+
+ total_annotations = self.generic_train_annotations + self.generic_test_annotations
+ for anno in total_annotations:
+ image_id = anno["image_id"]
+ if image_id not in self.imageid2anno:
+ self.imageid2anno[image_id] = []
+ self.imageid2anno[image_id].append(anno)
+
+ total_images = self.generic_train_images + self.generic_test_images
+ for image in total_images:
+ image_id = image["id"]
+ self.imageid2datasetname[image_id] = self.meta["dataset_name"]
+ file_name = image["file_name"]
+ self.imageid2filename[image_id] = file_name
+ self.datasetname2imageids[self.meta["dataset_name"]].add(image_id)
+
+ # in DLC, even if you have more than one annotations in one image, it does not
+ # mean it's a multi animal project
+ max_num = 0
+ for k in self.imageid2anno:
+ max_num = max(len(self.imageid2anno[k]), max_num)
+
+ self.meta["max_individuals"] = max_num
+ self.meta["imageid2filename"] = self.imageid2filename
+
+ def filter_by_pattern(self, pattern):
+
+ keep_ids = []
+ keep_train_images = []
+ keep_test_images = []
+ for img in self.generic_train_images + self.generic_test_images:
+ print(img["file_name"])
+ if pattern in img["file_name"]:
+ image_id = img["id"]
+ keep_ids.append(image_id)
+
+ for image in self.generic_train_images:
+ if image["id"] in keep_ids:
+ keep_train_images.append(image["id"])
+
+ self.generic_train_images = keep_train_images
+
+ for image in self.generic_test_images:
+ if image["id"] in keep_ids:
+ keep_test_images.append(image["id"])
+
+ self.generic_test_images = keep_test_images
+
+ keep_train_annotations = []
+ keep_test_annotations = []
+
+ for anno in self.generic_train_annotations:
+ if anno["image_id"] in keep_ids:
+ keep_train_annotations.append(anno)
+
+ self.generic_train_annotations = keep_train_annotations
+
+ for anno in self.generic_test_annotations:
+ if anno["image_id"] in keep_ids:
+ keep_test_annotations.append(anno)
+
+ self.generic_test_annotations = keep_test_annotations
+
+ def summary(self):
+ print(f"Summary of dataset {self.meta['dataset_name']}")
+ print("-------------")
+ print(f"max num individuals is {self.meta['max_individuals']}")
+ print(f"total keypoints : {len(self.meta['categories']['keypoints'])}")
+ print(f"total train images : {len(self.generic_train_images)}")
+ print(f"total test images : {len(self.generic_test_images)}")
+ print(f"total train annotations : {len(self.generic_train_annotations)}")
+ print(f"total test annotations : {len(self.generic_test_annotations)}")
+ print("-------------")
+
+ def populate_generic(self):
+ raise NotImplementedError("Must implement this function")
+
+ def materialize(
+ self,
+ proj_root,
+ framework="coco",
+ deepcopy=False,
+ append_image_id=True,
+ no_image_copy=False,
+ ):
+ mat_func = mat_func_factory(framework)
+ self.meta["mat_datasets"] = {self.meta["dataset_name"]: self}
+ self.meta["imageid2datasetname"] = self.imageid2datasetname
+ kwargs = dict(deepcopy=deepcopy, append_image_id=append_image_id)
+ if framework == "coco":
+ kwargs["no_image_copy"] = no_image_copy
+
+ mat_func(
+ proj_root,
+ self.generic_train_images,
+ self.generic_test_images,
+ self.generic_train_annotations,
+ self.generic_test_annotations,
+ self.meta,
+ **kwargs,
+ )
+
+ def whether_anno_image_match(self, images, annotations):
+ """Every image id should be annotated at least once There should not be any
+ image that is not being annotated There should not be any annotation for beyond
+ the set of given images."""
+
+ image_ids = set([image["id"] for image in images])
+
+ annotation_image_ids = set([anno["image_id"] for anno in annotations])
+
+ if image_ids != annotation_image_ids:
+ print("images-annotations", image_ids - annotation_image_ids)
+ print("len(images-annotatinos)", len(image_ids - annotation_image_ids))
+ print("annotations-images", annotation_image_ids - image_ids)
+ print("len(annotations-images)", len(annotation_image_ids - image_ids))
+ warnings.warn("annotation and image ids do not match", stacklevel=2)
+
+ def get_keypoints(self):
+ # TODO make sure it's always one element in a list
+ return self.meta["categories"]["keypoints"]
+
+ def _proj(self, annotations, conversion_table):
+
+ keypoints = self.get_keypoints()
+
+ kpt2index = {kpt: kpt_id for kpt_id, kpt in enumerate(keypoints)}
+
+ ret = []
+
+ master2src = {}
+ for kpt in keypoints:
+ conv_kpt = conversion_table.convert(kpt)
+ # sometimes a keypoint might not find its corresponding one from mastername
+ if conv_kpt is not None:
+ master2src[conv_kpt] = kpt
+
+ master_keypoints = conversion_table.master_keypoints
+
+ # need to change this in meta
+
+ for anno in annotations:
+ try:
+ kpts = anno["keypoints"]
+ except Exception:
+ print(anno)
+
+ new_kpts = np.zeros(len(master_keypoints) * 3)
+ new_num_kpts = len(master_keypoints)
+
+ for master_kpt_id, master_kpt_name in enumerate(master_keypoints):
+ # check whether the dataset has the corresponding keypoint
+ if master_kpt_name not in master2src:
+ new_kpts[master_kpt_id * 3 : master_kpt_id * 3 + 3] = -1
+ continue
+
+ src_kpt_name = master2src[master_kpt_name]
+ src_kpt_id = kpt2index[src_kpt_name]
+ new_kpts[master_kpt_id * 3 : master_kpt_id * 3 + 3] = kpts[src_kpt_id * 3 : src_kpt_id * 3 + 3]
+
+ # skipping empty frames after conversion
+ new_anno = copy.deepcopy(anno)
+ new_anno["keypoints"] = new_kpts
+ new_anno["num_keypoints"] = new_num_kpts
+ ret.append(new_anno)
+
+ return ret
+
+ def adjust_bbox_and_area(self):
+ """Called during conversion.
+
+ This is to remove the impact of keypoints that are potentially environmental
+ keypoints to the bbox and area calculation.
+ """
+ from .utils import calc_bboxes_from_keypoints
+
+ for annotation in self.generic_train_annotations + self.generic_test_annotations:
+ keypoints = annotation["keypoints"]
+ bbox_margin = 20
+
+ num_kpts = annotation["num_keypoints"]
+
+ keypoints = np.array(keypoints).reshape((num_kpts, 3))
+
+ mask = keypoints[:, 0] > 0
+ keypoints = keypoints[mask]
+
+ if keypoints.shape[0] == 0:
+ continue
+
+ xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints(
+ [keypoints],
+ slack=bbox_margin,
+ clip=True,
+ )[0][:4]
+
+ w = xmax - xmin
+ h = ymax - ymin
+ area = w * h
+ bbox = np.nan_to_num([xmin, ymin, w, h])
+
+ if "bbox" not in annotation:
+ annotation["bbox"] = bbox
+ if "area" not in annotation:
+ annotation["area"] = area
+
+ def project_with_conversion_table(self, table_path="", table_dict=None):
+ """Replace the generic annotations with those that are in superset keypoint
+ space."""
+ print(f"Converting {self.meta['dataset_name']}")
+
+ keypoints = self.get_keypoints()
+
+ self.conversion_table = get_conversion_table(keypoints=keypoints, table_path=table_path, table_dict=table_dict)
+
+ self.generic_train_annotations = self._proj(self.generic_train_annotations, self.conversion_table)
+
+ self.generic_test_annotations = self._proj(self.generic_test_annotations, self.conversion_table)
+
+ # all category id fixed to 1. So that it does not conflict with the background
+ # category id
+ for anno in self.generic_train_annotations + self.generic_test_annotations:
+ anno["category_id"] = 1
+
+ for img in self.generic_train_images + self.generic_test_images:
+ img["source_dataset"] = self.meta["dataset_name"]
+
+ self.adjust_bbox_and_area()
+ self.meta["categories"]["keypoints"] = self.conversion_table.master_keypoints
+ self.meta["categories"]["supercategory"] = "animal"
+ self.meta["categories"]["name"] = "superanimal"
+
+ # category id fixed to be 1, to avoid to conflict with background category id
+ self.meta["categories"]["id"] = 1
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py
new file mode 100644
index 0000000000..bdae876717
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py
@@ -0,0 +1,110 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+import pickle
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset
+from deeplabcut.utils import auxiliaryfunctions
+
+
+class BaseDLCPoseDataset(BasePoseDataset):
+ def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""):
+ super().__init__()
+
+ assert proj_root is not None and dataset_name is not None
+
+ self.meta["dataset_name"] = dataset_name
+ self.meta["proj_root"] = proj_root
+ self.meta["shuffle"] = shuffle
+ self.meta["modelprefix"] = modelprefix
+
+ self.proj_root = proj_root
+
+ if modelprefix:
+ config_file = os.path.join(self.proj_root, modelprefix + "_config.yaml")
+ else:
+ config_file = os.path.join(self.proj_root, "config.yaml")
+
+ cfg = auxiliaryfunctions.read_config(config_file)
+
+ task = cfg["Task"]
+
+ scorer = cfg["scorer"]
+
+ datasets_folder = os.path.join(
+ self.proj_root,
+ auxiliaryfunctions.GetTrainingSetFolder(cfg),
+ )
+
+ self.datasets_folder = datasets_folder
+
+ trainingFraction = int(cfg["TrainingFraction"][0] * 100)
+
+ path_dlc_collected = os.path.join(datasets_folder, f"CollectedData_{scorer}.h5")
+
+ path_dlc_document = os.path.join(
+ datasets_folder,
+ f"Documentation_data-{task}_{trainingFraction}shuffle{shuffle}.pickle",
+ )
+
+ df = pd.read_hdf(path_dlc_collected)
+
+ self.dlc_df = df
+
+ with open(path_dlc_document, "rb") as f:
+ document_data = pickle.load(f)
+
+ train_indices = document_data[1]
+ # index 2 is test indices
+ test_indices = document_data[2]
+
+ train_images = df.index[train_indices]
+ test_images = df.index[test_indices]
+
+ self.dlc_images = np.hstack([train_images, test_images])
+
+ df_train = df.loc[train_images]
+
+ df_test = df.loc[test_images]
+
+ self.coco_train = self._df2generic(df_train)
+
+ offset = len(self.coco_train["images"])
+
+ self.coco_test = self._df2generic(df_test, image_id_offset=offset)
+
+ self.populate_generic()
+
+ def _df2generic(self, df, image_id_offset=0):
+ raise NotImplementedError()
+
+ def populate_generic(self):
+
+ self.generic_train_images = self.coco_train["images"]
+ self.generic_test_images = self.coco_test["images"]
+ self.generic_train_annotations = self.coco_train["annotations"]
+ self.generic_test_annotations = self.coco_test["annotations"]
+
+ self.meta["categories"] = self.coco_test["categories"][0]
+
+ # to build maps for later analysis
+ self._build_maps()
+
+ print(f"Before checking trainset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_train_images, self.generic_train_annotations)
+
+ print(f"Before checking testset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_test_images, self.generic_test_annotations)
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py
new file mode 100644
index 0000000000..2c6200cc93
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/coco.py
@@ -0,0 +1,82 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import copy
+import json
+import os
+
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset
+
+
+class COCOPoseDataset(BasePoseDataset):
+ def __init__(
+ self,
+ proj_root,
+ dataset_name,
+ train_filename="train.json",
+ shuffle=None,
+ ):
+
+ super().__init__()
+
+ self.meta["dataset_name"] = dataset_name
+ self.meta["proj_root"] = proj_root
+
+ self.proj_root = proj_root
+ self.annotations_by_category = {}
+
+ self.train_json_obj = (
+ self._load_json(train_filename)
+ if shuffle is None
+ else self._load_json(train_filename.replace(".json", f"_shuffle{shuffle}.json"))
+ )
+ self.test_json_obj = (
+ self._load_json("test.json") if shuffle is None else self._load_json(f"test_shuffle{shuffle}.json")
+ )
+
+ self.populate_generic()
+
+ def _load_json(self, json_fn):
+ path = os.path.join(self.proj_root, "annotations", json_fn)
+ with open(path) as f:
+ json_obj = json.load(f)
+ return json_obj
+
+ def populate_generic(self):
+
+ temp_train_images = copy.deepcopy(self.train_json_obj["images"])
+ temp_test_images = copy.deepcopy(self.test_json_obj["images"])
+
+ for image in temp_train_images + temp_test_images:
+ image_path = image["file_name"]
+ # if os.sep not in image_path:
+ # assuming the file_name is mmpose style, i.e. only the image name is stored
+ # so we need to add back absolute path
+
+ image["file_name"] = os.path.join(self.proj_root, "images", image_path)
+
+ self.generic_train_images = temp_train_images
+ self.generic_test_images = temp_test_images
+
+ self.generic_train_annotations = self.train_json_obj["annotations"]
+
+ self.generic_test_annotations = self.test_json_obj["annotations"]
+
+ self.meta["categories"] = self.test_json_obj["categories"][0]
+
+ self._build_maps()
+
+ print(f"Before checking trainset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_train_images, self.generic_train_annotations)
+
+ print(f"Before checking testset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_test_images, self.generic_test_annotations)
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py
new file mode 100644
index 0000000000..e5f57b0a44
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc.py
@@ -0,0 +1,146 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base_dlc import (
+ BaseDLCPoseDataset,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import (
+ calc_bboxes_from_keypoints,
+ read_image_shape_fast,
+)
+
+
+class MaDLCPoseDataset(BaseDLCPoseDataset):
+ def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""):
+ super().__init__(proj_root, dataset_name, shuffle=shuffle, modelprefix=modelprefix)
+
+ def _df2generic(self, df, image_id_offset=0):
+ df = drop_likelihood_columns(df)
+ individuals = df.columns.get_level_values("individuals").unique().tolist()
+
+ unique_bpts = []
+
+ if "single" in individuals:
+ unique_bpts.extend(
+ df.xs("single", level="individuals", axis=1).columns.get_level_values("bodyparts").unique()
+ )
+ multi_bpts = (
+ df.xs(individuals[0], level="individuals", axis=1).columns.get_level_values("bodyparts").unique().tolist()
+ )
+
+ coco_categories = []
+
+ # assuming all individuals have the same name and same category id
+
+ individual = individuals[0]
+
+ category = {
+ "name": individual,
+ "id": 0,
+ "supercategory": "animal",
+ }
+
+ if individual == "single":
+ category["keypoints"] = unique_bpts
+ else:
+ category["keypoints"] = multi_bpts
+
+ coco_categories.append(category)
+
+ coco_images = []
+ coco_annotations = []
+
+ annotation_id = 0
+ image_id = -1
+ for _, file_name in enumerate(df.index):
+ data = df.loc[file_name]
+
+ # skipping all nan
+ if np.isnan(data.to_numpy()).all():
+ continue
+
+ image_id += 1
+
+ for _individual_id, individual in enumerate(individuals):
+ category_id = 0
+ try:
+ kpts = data.xs(individual, level="individuals").to_numpy().reshape((-1, 2))
+ except Exception:
+ # somehow there are duplicates. So only use the first occurrence
+ data = data.iloc[0]
+ kpts = data.xs(individual, level="individuals").to_numpy().reshape((-1, 2))
+
+ keypoints = np.zeros((len(kpts), 3))
+
+ keypoints[:, :2] = kpts
+
+ is_visible = ~pd.isnull(kpts).all(axis=1)
+
+ keypoints[:, 2] = np.where(is_visible, 2, 0)
+
+ num_keypoints = is_visible.sum()
+
+ bbox_margin = 20
+
+ xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints(
+ [keypoints],
+ slack=bbox_margin,
+ clip=True,
+ )[0][:4]
+
+ w = xmax - xmin
+ h = ymax - ymin
+ area = w * h
+ bbox = np.nan_to_num([xmin, ymin, w, h])
+ keypoints = np.nan_to_num(keypoints.flatten())
+
+ annotation_id += 1
+ annotation = {
+ "image_id": image_id + image_id_offset,
+ "num_keypoints": num_keypoints,
+ "keypoints": keypoints,
+ "id": annotation_id,
+ "category_id": category_id,
+ "area": area,
+ "bbox": bbox,
+ "iscrowd": 0,
+ }
+ if np.sum(keypoints) != 0:
+ coco_annotations.append(annotation)
+
+ # I think width and height are important
+
+ if isinstance(file_name, tuple):
+ image_path = os.path.join(self.proj_root, *list(file_name))
+ else:
+ image_path = os.path.join(self.proj_root, file_name)
+
+ _, height, width = read_image_shape_fast(image_path)
+
+ image = {
+ "file_name": image_path,
+ "width": width,
+ "height": height,
+ "id": image_id + image_id_offset,
+ }
+ coco_images.append(image)
+
+ ret_obj = {
+ "images": coco_images,
+ "annotations": coco_annotations,
+ "categories": coco_categories,
+ }
+ return ret_obj
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py
new file mode 100644
index 0000000000..b73036918c
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.py
@@ -0,0 +1,265 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ drop_likelihood_columns,
+ parse_video_filenames,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset
+from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import (
+ calc_bboxes_from_keypoints,
+ read_image_shape_fast,
+)
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, conversioncode
+
+
+def merge_annotateddatasets(cfg):
+ """Merges all the h5 files for all labeled-datasets (from individual videos).
+
+ This is a bit of a mess because of cross platform compatibility.
+
+ Within platform comp. is straightforward.
+ But if someone labels on windows and wants to train on a unix cluster or colab...
+ """
+ AnnotationData = []
+ data_path = Path(os.path.join(cfg["project_path"], "labeled-data"))
+ videos = cfg["video_sets"].keys()
+ video_filenames = parse_video_filenames(videos)
+ for filename in video_filenames:
+ file_path = os.path.join(data_path / filename, f"CollectedData_{cfg['scorer']}.h5")
+ try:
+ data = pd.read_hdf(file_path)
+ conversioncode.guarantee_multiindex_rows(data)
+ if data.columns.levels[0][0] != cfg["scorer"]:
+ print(
+ f"{file_path} labeled by a different scorer. "
+ "This data will not be utilized in training dataset creation. "
+ "If you need to merge datasets across scorers, see "
+ "https://github.com/DeepLabCut/DeepLabCut/wiki/"
+ "Using-labeled-data-in-DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)"
+ )
+ continue
+ AnnotationData.append(data)
+ except FileNotFoundError:
+ print(file_path, " not found (perhaps not annotated).")
+
+ if not len(AnnotationData):
+ print(
+ "Annotation data was not found by splitting video paths (from config['video_sets'])."
+ " An alternative route is taken..."
+ )
+ AnnotationData = conversioncode.merge_windowsannotationdataONlinuxsystem(cfg)
+ if not len(AnnotationData):
+ print("No data was found!")
+ return
+
+ AnnotationData = pd.concat(AnnotationData).sort_index()
+ # When concatenating DataFrames with misaligned column labels,
+ # all sorts of reordering may happen (mainly depending on 'sort' and 'join')
+ # Ensure the 'bodyparts' level agrees with the order in the config file.
+ if cfg.get("multianimalproject", False):
+ (
+ _,
+ uniquebodyparts,
+ multianimalbodyparts,
+ ) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
+ bodyparts = multianimalbodyparts + uniquebodyparts
+ else:
+ bodyparts = cfg["bodyparts"]
+ AnnotationData = AnnotationData.reindex(bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts"))
+ AnnotationData = drop_likelihood_columns(AnnotationData)
+
+ return AnnotationData
+
+
+class MaDLCDataFrame(BasePoseDataset):
+ def __init__(self, proj_root, dataset_name):
+ super().__init__()
+ assert proj_root is not None and dataset_name is not None
+ self.proj_root = proj_root
+ self.dataset_name = dataset_name
+ self.meta["dataset_name"] = dataset_name
+ self.meta["proj_root"] = proj_root
+ config_path = Path(proj_root) / "config.yaml"
+ # read config
+ cfg = auxiliaryfunctions.read_config(config_path)
+ # get the train folder
+
+ Data = merge_annotateddatasets(
+ cfg,
+ )
+
+ # now with this data, we construct necessary generic data
+
+ self.dlc_df = Data
+
+ images = self.dlc_df.index
+
+ ratio = 0.9
+
+ df_train = self.dlc_df.iloc[: int(len(images) * ratio)]
+ df_test = self.dlc_df.iloc[int(len(images) * ratio) :]
+
+ self.coco_train = self._df2generic(df_train)
+
+ offset = len(self.coco_train["images"])
+
+ self.coco_test = self._df2generic(df_test, image_id_offset=offset)
+
+ self.populate_generic()
+
+ def populate_generic(self):
+
+ self.generic_train_images = self.coco_train["images"]
+ self.generic_test_images = self.coco_test["images"]
+ self.generic_train_annotations = self.coco_train["annotations"]
+ self.generic_test_annotations = self.coco_test["annotations"]
+
+ self.meta["categories"] = self.coco_test["categories"][0]
+
+ # to build maps for later analysis
+ self._build_maps()
+
+ print(f"Before checking trainset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_train_images, self.generic_train_annotations)
+
+ print(f"Before checking testset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_test_images, self.generic_test_annotations)
+
+ def _df2generic(self, df, image_id_offset=0):
+ df = drop_likelihood_columns(df)
+ individuals = df.columns.get_level_values("individuals").unique().tolist()
+
+ unique_bpts = []
+
+ if "single" in individuals:
+ unique_bpts.extend(
+ df.xs("single", level="individuals", axis=1).columns.get_level_values("bodyparts").unique()
+ )
+ multi_bpts = (
+ df.xs(individuals[0], level="individuals", axis=1).columns.get_level_values("bodyparts").unique().tolist()
+ )
+
+ coco_categories = []
+
+ # assuming all individuals have the same name and same category id
+
+ individual = individuals[0]
+
+ category = {
+ "name": individual,
+ "id": 0,
+ "supercategory": "animal",
+ }
+
+ if individual == "single":
+ category["keypoints"] = unique_bpts
+ else:
+ category["keypoints"] = multi_bpts
+
+ coco_categories.append(category)
+
+ coco_images = []
+ coco_annotations = []
+
+ annotation_id = 0
+ image_id = -1
+ for _, file_name in enumerate(df.index):
+ data = df.loc[file_name]
+
+ # skipping all nan
+ if np.isnan(data.to_numpy()).all():
+ continue
+
+ image_id += 1
+
+ for _individual_id, individual in enumerate(individuals):
+ category_id = 0
+ try:
+ kpts = data.xs(individual, level="individuals").to_numpy().reshape((-1, 2))
+ except Exception:
+ # somehow there are duplicates. So only use the first occurrence
+ data = data.iloc[0]
+ kpts = data.xs(individual, level="individuals").to_numpy().reshape((-1, 2))
+
+ keypoints = np.zeros((len(kpts), 3))
+
+ keypoints[:, :2] = kpts
+
+ is_visible = ~pd.isnull(kpts).all(axis=1)
+
+ keypoints[:, 2] = np.where(is_visible, 2, 0)
+
+ num_keypoints = is_visible.sum()
+
+ bbox_margin = 20
+
+ xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints(
+ [keypoints],
+ slack=bbox_margin,
+ clip=True,
+ )[0][:4]
+
+ w = xmax - xmin
+ h = ymax - ymin
+ area = w * h
+ bbox = np.nan_to_num([xmin, ymin, w, h])
+ keypoints = np.nan_to_num(keypoints.flatten())
+
+ annotation_id += 1
+ annotation = {
+ "image_id": image_id + image_id_offset,
+ "num_keypoints": num_keypoints,
+ "keypoints": keypoints,
+ "id": annotation_id,
+ "category_id": category_id,
+ "area": area,
+ "bbox": bbox,
+ "iscrowd": 0,
+ }
+ if np.sum(keypoints) != 0:
+ coco_annotations.append(annotation)
+
+ # I think width and height are important
+
+ if isinstance(file_name, tuple):
+ image_path = os.path.join(self.proj_root, *list(file_name))
+ else:
+ image_path = os.path.join(self.proj_root, file_name)
+
+ _, height, width = read_image_shape_fast(image_path)
+
+ image = {
+ "file_name": image_path,
+ "width": width,
+ "height": height,
+ "id": image_id + image_id_offset,
+ }
+ coco_images.append(image)
+
+ ret_obj = {
+ "images": coco_images,
+ "annotations": coco_annotations,
+ "categories": coco_categories,
+ }
+ return ret_obj
+
+
+if __name__ == "__main__":
+ dataset = MaDLCDataFrame("/mnt/md0/shaokai/daniel3mouse", "3mouse")
+ dataset.summary()
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py
new file mode 100644
index 0000000000..1098ce8afb
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py
@@ -0,0 +1,659 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import json
+import os
+import pickle
+import shutil
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import scipy.io as sio
+import yaml
+
+import deeplabcut.compat as compat
+from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import (
+ create_multianimaltraining_dataset,
+ format_multianimal_training_data,
+)
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ create_training_dataset,
+)
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ format_training_data as format_single_training_data,
+)
+from deeplabcut.utils import auxiliaryfunctions
+
+
+def get_filename(filename):
+ if isinstance(filename, tuple):
+ filename = os.path.join(*filename)
+ return filename
+
+
+def modify_train_test_cfg(config_path, shuffle=1, modelprefix=""):
+ # get train_cfg from main cfg
+ # use dlcr net
+ # use gradient masking
+ # set batch size as 8
+ trainposeconfigfile, testposeconfigfile, snapshotfolder = compat.return_train_network_path(
+ config_path, shuffle=shuffle, modelprefix=modelprefix, trainingsetindex=0
+ )
+
+ train_cfg = auxiliaryfunctions.read_plainconfig(trainposeconfigfile)
+ train_cfg["multi_stage"] = True
+ train_cfg["batch_size"] = 8
+ train_cfg["gradient_masking"] = True
+
+ auxiliaryfunctions.write_plainconfig(trainposeconfigfile, train_cfg)
+
+ test_cfg = auxiliaryfunctions.read_plainconfig(testposeconfigfile)
+ test_cfg["multi_stage"] = True
+ test_cfg["batch_size"] = 8
+ test_cfg["gradient_masking"] = True
+
+ auxiliaryfunctions.write_plainconfig(testposeconfigfile, test_cfg)
+
+
+class NpEncoder(json.JSONEncoder):
+ def default(self, obj):
+ if isinstance(obj, np.integer):
+ return int(obj)
+ elif isinstance(obj, np.floating):
+ return float(obj)
+ elif isinstance(obj, np.ndarray):
+ return obj.tolist()
+ else:
+ return super().default(obj)
+
+
+class SingleDLC_config:
+ def __init__(self):
+ self.cfg = {k: v for k, v in vars().items() if "__" not in k and "self" not in k}
+
+ def create_cfg(self, proj_root, kwargs):
+ self.cfg.update(kwargs)
+ with open(os.path.join(proj_root, "config.yaml"), "w") as f:
+ yaml.dump(self.cfg, f)
+
+
+class MaDLC_config:
+ def __init__(self):
+ """Plain text only for generating templates Some variables can be configured by
+ the user later."""
+
+ self.cfg = {k: v for k, v in vars().items() if "__" not in k and "self" not in k}
+
+ def create_cfg(self, proj_root, kwargs):
+ self.cfg.update(kwargs)
+ with open(os.path.join(proj_root, "config.yaml"), "w") as f:
+ yaml.dump(self.cfg, f)
+
+
+def _generic2madlc(
+ proj_root,
+ train_images,
+ test_images,
+ train_annotations,
+ test_annotations,
+ meta,
+ deepcopy=False,
+ full_image_path=True,
+ append_image_id=True,
+):
+ """Within DeepLabCut, if we don't explicitly call deeplabcut.create_traindataset(),
+ the train and test split might just be arbitrarily messed up. So here we need to
+ calculate train and test indices to.
+
+ Args:
+ proj_root where to materialize the data
+ """
+
+ assert full_image_path, "DLC wants full image path"
+
+ os.makedirs(os.path.join(proj_root, "labeled-data"), exist_ok=True)
+
+ cfg_template = MaDLC_config()
+
+ individuals = [f"individual{i}" for i in range(meta["max_individuals"])]
+
+ bodyparts = meta["categories"]["keypoints"]
+
+ scorer = "maDLC_scorer"
+ # this line is taken from dlc's multi animal dataset creation function
+ train_fraction = round(len(train_images) * 1.0 / (len(train_images) + len(test_images)), 2)
+
+ # need to fake a video path
+ # let's use individual dataset names as fake video name
+ # merged_dataset_name = '_'.join(meta['mat_datasets'])
+ video_sets = {f"{dataset_name}.mp4": {"crop": "0, 400, 0, 400"} for dataset_name in meta["mat_datasets"]}
+
+ modify_dict = dict(
+ Task=meta["dataset_name"],
+ project_path=proj_root,
+ individuals=individuals,
+ scorer=scorer,
+ date="March30",
+ video_sets=video_sets,
+ bodyparts="MULTI!",
+ TrainingFraction=[train_fraction],
+ multianimalbodyparts=list(bodyparts),
+ )
+
+ cfg_template.create_cfg(proj_root, modify_dict)
+ # what's special in dlc or madlc creation is that we will need to
+ # use dlc's code for creating the project structure
+ # because you don't want to write your own. It's a lot of lines of code
+ # But at least we can focus on labeled-data
+
+ imageid2datasetname = meta["imageid2datasetname"]
+
+ for dataset_name in meta["mat_datasets"]:
+ os.makedirs(os.path.join(proj_root, "labeled-data", dataset_name), exist_ok=True)
+
+ # also, to make sure the split is right, we will have to pass the right indices
+
+ columnindex = pd.MultiIndex.from_product(
+ [[scorer], individuals, bodyparts, ["x", "y"]],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+
+ # it's important to put train first so the train_fraction parameter can work correctly
+ total_images = train_images + test_images
+ train_annotations + test_annotations
+
+ # DLC uses relative dest as index into dataframe
+ imageid2relativedest = {}
+ for image in total_images:
+ image_id = image["id"]
+ file_name = image["file_name"]
+ image_name = file_name.split(os.sep)[-1]
+ pre, suffix = image_name.split(".")
+ if append_image_id:
+ dest_image_name = f"{pre}_{image_id}.{suffix}"
+ else:
+ dest_image_name = image_name
+ # the generic data has original pointers to images in the original folders
+ # Here, we have to change the image name and location of these to fit corresponding framework's convention
+
+ dataset_name = imageid2datasetname[image_id]
+
+ dest = os.path.join(proj_root, "labeled-data", dataset_name, dest_image_name)
+ if deepcopy:
+ shutil.copy(file_name, dest)
+ else:
+ try:
+ os.symlink(file_name, dest)
+ except Exception:
+ pass
+
+ relative_dest = os.path.join("labeled-data", dataset_name, dest_image_name)
+
+ imageid2relativedest[image_id] = relative_dest
+
+ for dataset_name, dataset in meta["mat_datasets"].items():
+ dataset_total_images = dataset.generic_train_images + dataset.generic_test_images
+ dataset_total_annotations = dataset.generic_train_annotations + dataset.generic_test_annotations
+
+ dataset_index = []
+
+ for image in dataset_total_images:
+ image_id = image["id"]
+ relative_dest = imageid2relativedest[image_id]
+ dataset_index.append(relative_dest)
+
+ raw_data = np.zeros((len(dataset_total_images), len(columnindex))) * np.nan
+ df = pd.DataFrame(raw_data, columns=columnindex, index=dataset_index)
+ # so we know where to put the next annotation if there are multiple individuals in that image
+ imageid2filledindividualcount = {}
+
+ image_ids = []
+ for anno in dataset_total_annotations:
+ keypoints = anno["keypoints"]
+ image_id = anno["image_id"]
+ image_ids.append(image_id)
+ if image_id not in imageid2filledindividualcount:
+ imageid2filledindividualcount[image_id] = 0
+ else:
+ imageid2filledindividualcount[image_id] += 1
+ individual_id = imageid2filledindividualcount[image_id]
+
+ file_name = imageid2relativedest[image_id]
+ for kpt_id, kpt_name in enumerate(meta["categories"]["keypoints"]):
+ coord = keypoints[3 * kpt_id : 3 * kpt_id + 3]
+ # note dlc does not yet have visibility flag
+ # need to be careful here to assign right keypoints to right people
+ if coord[0] > 0 and coord[1] > 0:
+ # leave them to NaN if values are 0
+ df.loc[file_name][scorer, f"individual{individual_id}", kpt_name, "x"] = coord[0]
+ df.loc[file_name][scorer, f"individual{individual_id}", kpt_name, "y"] = coord[1]
+ elif coord[2] == -1:
+ df.loc[file_name][scorer, f"individual{individual_id}", kpt_name, "x"] = -1
+ df.loc[file_name][scorer, f"individual{individual_id}", kpt_name, "y"] = -1
+ df.to_hdf(
+ os.path.join(proj_root, "labeled-data", dataset_name, f"CollectedData_{scorer}.h5"),
+ key="df_with_missing",
+ mode="w",
+ )
+ # paf_graph default as None. But I am not sure how to do better
+ create_multianimaltraining_dataset(os.path.join(proj_root, "config.yaml"), paf_graph=None)
+
+ # dlc's merge_annotation messes up my indices, so I will need to overwrite the documentation file
+ # I could have done it in a more elegant way if I could modify part of DLC
+ # source code, but for backward compatibility reasons, overriding
+ # documentation is smarter
+
+ config_path = os.path.join(proj_root, "config.yaml")
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ train_folder = os.path.join(proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg))
+
+ datafilename, metafilename = auxiliaryfunctions.GetDataandMetaDataFilenames(train_folder, train_fraction, 1, cfg)
+
+ modify_train_test_cfg(config_path)
+
+ dlc_df = pd.read_hdf(os.path.join(train_folder, f"CollectedData_{scorer}.h5"))
+
+ # I strip off video info from the naming. For horse10, I need to get it back
+ parent_trace = {}
+
+ def _filter(image):
+ file_name = image["file_name"]
+
+ image_name = file_name.split(os.sep)[-1]
+ video_folder = file_name.split(os.sep)[-2]
+ pre, suffix = image_name.split(".")
+ image_id = image["id"]
+ if append_image_id:
+ ret = f"{pre}_{image_id}.{suffix}"
+ else:
+ ret = image_name
+ parent_trace[ret] = video_folder
+ return ret
+
+ _filter_train_images = list(map(_filter, train_images))
+ _filter_test_images = list(map(_filter, test_images))
+
+ with open(os.path.join(train_folder, "parent_trace.pickle"), "wb") as f:
+ pickle.dump(parent_trace, f)
+
+ trainIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if get_filename(image).split(os.sep)[-1] in _filter_train_images
+ ]
+ testIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if get_filename(image).split(os.sep)[-1] in _filter_test_images
+ ]
+
+ with open(metafilename, "rb") as f:
+ metafile = pickle.load(f)
+
+ metafile[1] = trainIndices
+ metafile[2] = testIndices
+
+ with open(metafilename, "wb") as f:
+ pickle.dump(metafile, f)
+
+ # need to overwrite the data pickle file too
+
+ len(bodyparts)
+
+ if "individuals" not in dlc_df.columns.names:
+ old_idx = dlc_df.columns.to_frame()
+ old_idx.insert(0, "individuals", "")
+ dlc_df.columns = pd.MultiIndex.from_frame(old_idx)
+
+ data = format_multianimal_training_data(dlc_df, trainIndices, cfg["project_path"])
+
+ datafilename = datafilename.split(".mat")[0] + ".pickle"
+
+ print(f"overwriting data file {datafilename}")
+
+ with open(os.path.join(proj_root, datafilename), "wb") as f:
+ pickle.dump(data, f, pickle.HIGHEST_PROTOCOL)
+
+
+def _generic2sdlc(
+ proj_root,
+ train_images,
+ test_images,
+ train_annotations,
+ test_annotations,
+ meta,
+ deepcopy=False,
+ full_image_path=True,
+ append_image_id=True,
+):
+
+ assert full_image_path, "DLC wants full image path"
+
+ os.makedirs(os.path.join(proj_root, "labeled-data"), exist_ok=True)
+
+ cfg_template = SingleDLC_config()
+
+ bodyparts = meta["categories"]["keypoints"]
+ scorer = "singleDLC_scorer"
+
+ train_fraction = round(len(train_images) * 1.0 / (len(train_images) + len(test_images)), 2)
+
+ # need to fake a video path
+ # let's use individual dataset names as fake video name
+
+ video_sets = {f"{dataset_name}.mp4": {"crop": "0, 400, 0, 400"} for dataset_name in meta["mat_datasets"].keys()}
+
+ modify_dict = dict(
+ Task=meta["dataset_name"],
+ project_path=proj_root,
+ scorer=scorer,
+ date="March30",
+ bodyparts=list(bodyparts),
+ video_sets=video_sets,
+ TrainingFraction=[train_fraction],
+ )
+
+ cfg_template.create_cfg(proj_root, modify_dict)
+
+ imageid2datasetname = meta["imageid2datasetname"]
+
+ for dataset_name in meta["mat_datasets"]:
+ os.makedirs(os.path.join(proj_root, "labeled-data", dataset_name), exist_ok=True)
+
+ columnindex = pd.MultiIndex.from_product([[scorer], bodyparts, ["x", "y"]], names=["scorer", "bodyparts", "coords"])
+
+ total_images = train_images + test_images
+ train_annotations + test_annotations
+
+ # DLC uses relative dest as index
+ imageid2relativedest = {}
+
+ for image in total_images:
+ imageid = image["id"]
+ image["file_name"]
+ imageid2datasetname[imageid]
+ count = 0
+ for image in total_images:
+ image_id = image["id"]
+ file_name = image["file_name"]
+
+ image_name = file_name.split(os.sep)[-1]
+ pre, suffix = image_name.split(".")
+
+ if append_image_id:
+ dest_image_name = f"{pre}_{image_id}.{suffix}"
+ else:
+ dest_image_name = image_name
+ # the generic data has original pointers to images in the original folders
+ # Here, we have to change the image name and location of these to fit corresponding framework's convention
+
+ dataset_name = imageid2datasetname[image_id]
+
+ dest = os.path.join(proj_root, "labeled-data", dataset_name, dest_image_name)
+ if deepcopy:
+ shutil.copy(file_name, dest)
+ else:
+ try:
+ os.symlink(file_name, dest)
+ except Exception:
+ pass
+
+ if dataset_name == "AwA-Pose":
+ count += 1
+
+ relative_dest = os.path.join("labeled-data", dataset_name, dest_image_name)
+ imageid2relativedest[image_id] = relative_dest
+
+ # so we know where to put the next annotation if there are multiple individuals in that image
+
+ for dataset_name, dataset in meta["mat_datasets"].items():
+ dataset_total_images = dataset.generic_train_images + dataset.generic_test_images
+ dataset_total_annotations = dataset.generic_train_annotations + dataset.generic_test_annotations
+
+ dataset_index = []
+ for image in dataset_total_images:
+ image["file_name"]
+
+ image_id = image["id"]
+ relative_dest = imageid2relativedest[image_id]
+
+ dataset_index.append(relative_dest)
+
+ raw_data = np.zeros((len(dataset_total_images), len(columnindex))) * np.nan
+
+ dataset_index = dataset_index
+
+ df = pd.DataFrame(raw_data, columns=columnindex, index=dataset_index)
+
+ for _idx, anno in enumerate(dataset_total_annotations):
+ keypoints = np.array(anno["keypoints"])
+ image_id = anno["image_id"]
+
+ file_name = imageid2relativedest[image_id]
+
+ for kpt_id, kpt_name in enumerate(meta["categories"]["keypoints"]):
+ coord = keypoints[3 * kpt_id : 3 * kpt_id + 3]
+ # note dlc does not yet have visibility flag
+ # need to be careful here to assign right keypoints to right people
+
+ if coord[0] > 0 and coord[1] > 0:
+ df.loc[file_name][scorer, kpt_name, "x"] = coord[0]
+ df.loc[file_name][scorer, kpt_name, "y"] = coord[1]
+ elif coord[2] == -1:
+ # if -1, this visibility flag means a given keypoint was not annotated in the original dataset
+ df.loc[file_name][scorer, kpt_name, "x"] = -1
+ df.loc[file_name][scorer, kpt_name, "y"] = -1
+
+ df = df.dropna(how="all")
+ df.to_hdf(
+ os.path.join(proj_root, "labeled-data", dataset_name, f"CollectedData_{scorer}.h5"),
+ key="df_with_missing",
+ mode="w",
+ )
+
+ create_training_dataset(os.path.join(proj_root, "config.yaml"))
+
+ # dlc's merge_annotation messes up my indices, so I will need to overwrite the documentation file
+ # I could have done it in a more elegant way if I could modify part of DLC
+ # source code, but for backward compatibility reasons, overriding
+ # documentation is smarter
+
+ config_path = os.path.join(proj_root, "config.yaml")
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ train_folder = os.path.join(proj_root, auxiliaryfunctions.GetTrainingSetFolder(cfg))
+
+ datafilename, metafilename = auxiliaryfunctions.GetDataandMetaDataFilenames(train_folder, train_fraction, 1, cfg)
+
+ modify_train_test_cfg(config_path)
+
+ dlc_df = pd.read_hdf(os.path.join(train_folder, f"CollectedData_{scorer}.h5"))
+
+ parent_trace = {}
+
+ def _filter(image):
+ file_name = image["file_name"]
+ image_name = file_name.split(os.sep)[-1]
+ video_folder = file_name.split(os.sep)[-2]
+ pre, suffix = image_name.split(".")
+ image_id = image["id"]
+ if append_image_id:
+ ret = f"{pre}_{image_id}.{suffix}"
+ else:
+ ret = image_name
+
+ parent_trace[ret] = video_folder
+
+ return ret
+
+ _filter_train_images = list(map(_filter, train_images))
+ _filter_test_images = list(map(_filter, test_images))
+
+ with open(os.path.join(train_folder, "parent_trace.pickle"), "wb") as f:
+ pickle.dump(parent_trace, f)
+
+ trainIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if get_filename(image).split(os.sep)[-1] in _filter_train_images
+ ]
+ testIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if get_filename(image).split(os.sep)[-1] in _filter_test_images
+ ]
+
+ with open(metafilename, "rb") as f:
+ metafile = pickle.load(f)
+
+ metafile[1] = trainIndices
+ metafile[2] = testIndices
+
+ with open(metafilename, "wb") as f:
+ pickle.dump(metafile, f)
+
+ # need to overwrite the true data file too
+ nbodyparts = len(bodyparts)
+
+ data, MatlabData = format_single_training_data(dlc_df, trainIndices, nbodyparts, cfg["project_path"])
+
+ print(f"overwriting data file {datafilename}")
+
+ sio.savemat(os.path.join(datafilename), {"dataset": MatlabData})
+
+
+def _generic2coco(
+ proj_root,
+ train_images,
+ test_images,
+ train_annotations,
+ test_annotations,
+ meta,
+ deepcopy: bool = False,
+ full_image_path: bool = True,
+ append_image_id: bool = True,
+ no_image_copy: bool = False,
+):
+ """
+ Take generic data and create coco structure
+ My generic definition of coco structure:
+ images
+ ...
+ annotations
+ - train.json
+ - test.json
+
+ Args:
+ deepcopy: Only when no_image_copy=False. If False, images are not copied from
+ their original location and symlinks are created instead.
+ full_image_path: Only when no_image_copy=False. If True, the ``file_name`` for
+ the images in the annotation files contain the resolved path to the images.
+ Otherwise, a relative path is used.
+ append_image_id: Only when no_image_copy=False. Appends the image IDs in the
+ dataset to the image names.
+ no_image_copy: Instead of copying images to the COCO dataset, the full paths to
+ the images in the original dataset are used in the annotations.
+ """
+
+ os.makedirs(os.path.join(proj_root, "images"), exist_ok=True)
+ os.makedirs(os.path.join(proj_root, "annotations"), exist_ok=True)
+
+ # from new path to old_path
+ lookuptable = {}
+
+ for annotation in train_annotations + test_annotations:
+ if "iscrowd" not in annotation:
+ annotation["iscrowd"] = 0
+
+ keypoints = annotation["keypoints"]
+ for kpt_id, _kpt_name in enumerate(meta["categories"]["keypoints"]):
+ coord = keypoints[3 * kpt_id : 3 * kpt_id + 3]
+ if coord[0] < 0 or coord[1] < 0:
+ coord[2] = -1
+
+ broken_links = []
+ # copying images via symbolic link
+ for image in train_images + test_images:
+ # important to resolve the filepath! Otherwise, errors can occur when running
+ # this code from Jupyter Notebooks
+ src = Path(image["file_name"]).resolve()
+ image_id = image["id"]
+
+ if not src.exists():
+ print("problem comes from", image["source_dataset"])
+ print(src)
+ broken_links.append(image_id)
+ continue
+
+ file_name = str(src)
+ dest = src
+ if not no_image_copy:
+ # in dlc, some images have same name but under different folder
+ # we used to use a parent folder to distinguish them, but it's only
+ # applicable to DLC so here it's easier to append an id into the filename
+
+ # not to repeatedly add image id in memory replay training
+ dest_image_name = src.name
+ if append_image_id:
+ dest_image_name = f"{src.stem}_{image_id}{src.suffix}"
+
+ dest = Path(proj_root) / "images" / dest_image_name
+ dest = dest.resolve()
+
+ file_name = str(Path(*dest.parts[-2:]))
+ if full_image_path:
+ file_name = str(dest)
+
+ if deepcopy:
+ shutil.copy(src, dest)
+ else:
+ try:
+ os.symlink(src, dest)
+ except Exception as err:
+ print(f"Could not create a symlink from {src} to {dest}: {err}")
+ pass
+
+ image["file_name"] = file_name
+ lookuptable[dest] = src
+
+ train_annotations = [train_anno for train_anno in train_annotations if train_anno["image_id"] not in broken_links]
+ test_annotations = [test_anno for test_anno in test_annotations if test_anno["image_id"] not in broken_links]
+
+ with open(os.path.join(proj_root, "annotations", "train.json"), "w") as f:
+ train_json_obj = dict(
+ images=train_images,
+ annotations=train_annotations,
+ categories=[meta["categories"]],
+ )
+
+ json.dump(train_json_obj, f, indent=4, cls=NpEncoder)
+
+ with open(os.path.join(proj_root, "annotations", "test.json"), "w") as f:
+ test_json_obj = dict(
+ images=test_images,
+ annotations=test_annotations,
+ categories=[meta["categories"]],
+ )
+
+ json.dump(test_json_obj, f, indent=4, cls=NpEncoder)
+
+ return lookuptable
+
+
+def mat_func_factory(framework):
+ assert framework in [
+ "coco",
+ "sdlc",
+ "madlc",
+ ], f"Does not support framework {framework}"
+ if framework == "madlc":
+ mat_func = _generic2madlc
+ elif framework == "coco":
+ mat_func = _generic2coco
+ elif framework == "sdlc":
+ mat_func = _generic2sdlc
+
+ return mat_func
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py
new file mode 100644
index 0000000000..b3e2b4db7a
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py
@@ -0,0 +1,268 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import warnings
+
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base import (
+ BasePoseDataset,
+ raw_2_imagename,
+ raw_2_imagename_with_id,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import (
+ mat_func_factory,
+)
+
+
+class MultiSourceDataset:
+ """
+ Parameters:
+ iid_ood_split: {'iid' : ['dataset1', 'dataset2'],
+ 'ood' : ['dataset3', 'dataset4'] }
+
+
+
+ """
+
+ def __init__(self, dataset_name, datasets, table_path):
+ self.datasets = datasets
+ #
+ self.name2genericdataset = {}
+
+ # useful maps for analysis
+ self.imageid2filename = {}
+ self.imageid2datasetname = {}
+ self.datasetname2imageids = {}
+ #
+ self.dataset_name = dataset_name
+
+ names = []
+ for dataset in datasets:
+ # Must project datasets to same keypoint space before merging
+ if table_path is not None:
+ dataset.project_with_conversion_table(table_path)
+ name = dataset.meta["dataset_name"]
+ names.append(name)
+ self.name2genericdataset[name] = dataset
+
+ self.meta = {}
+ self.meta["dataset_name"] = dataset_name
+ # after conversion, all datasets have same categories
+ self.meta["categories"] = dataset.meta["categories"]
+
+ # map id from local scope to global
+ self._update_imgids()
+
+ (
+ self.train_images,
+ self.test_images,
+ self.train_annotations,
+ self.test_annotations,
+ ) = self._merge_datasets(self.name2genericdataset)
+ self.meta["name2genericdataset"] = self.name2genericdataset
+
+ # only build maps after images are merged and ids are in global scope
+ self._build_maps()
+
+ def summary(self):
+ print(f"Summary of dataset {self.dataset_name}")
+ print("Decomposition of multi source datasets:")
+ for dataset_name, dataset in self.name2genericdataset.items():
+ n_images = len(dataset.generic_train_images) + len(dataset.generic_test_images)
+ n_annotations = len(dataset.generic_train_annotations) + len(dataset.generic_test_annotations)
+ print(f"{dataset_name} has {n_images} images, {n_annotations} annotations")
+
+ print(f"total train images : {len(self.train_images)}")
+ print(f"total test images : {len(self.test_images)}")
+
+ def _build_maps(self):
+
+ # shared by both scenarios
+
+ species_set = set()
+ for dataset_name, dataset in self.name2genericdataset.items():
+ # I could of course do this during merge to save compute, but doing it
+ # here makes the logic cleaner to understand
+ total_images = dataset.generic_train_images + dataset.generic_test_images
+
+ for image in total_images:
+ image_id = image["id"]
+ image_name = image["file_name"]
+ self.imageid2filename[image_id] = image_name
+
+ self.imageid2datasetname[image_id] = dataset_name
+
+ if dataset_name == "AwA-Pose":
+ species_set.add(image_name.split("/")[-1].split("_")[0])
+ self.meta["imageid2datasetname"] = self.imageid2datasetname
+
+ max_num = 0
+ for _dataset_name, dataset in self.name2genericdataset.items():
+ max_num = max(max_num, dataset.meta["max_individuals"])
+ self.meta["max_individuals"] = max_num
+ dataset_name = self.meta["dataset_name"]
+ print(f"Max individual in {dataset_name} is {max_num}")
+
+ def whether_anno_image_match(self, images, annotations):
+ """Every image id should be annotated at least once There should not be any
+ image that is not being annotated There should not be any annotation for beyond
+ the set of given images."""
+
+ image_ids = set([image["id"] for image in images])
+
+ annotation_image_ids = set([anno["image_id"] for anno in annotations])
+
+ if image_ids != annotation_image_ids:
+ print("images-annotations", image_ids - annotation_image_ids)
+ print("annotations-images", annotation_image_ids - image_ids)
+
+ warnings.warn("annotation and image ids do not match", stacklevel=2)
+
+ # This is constrain is too hard
+ # assert len(annotation_image_ids - image_ids) == 0, "You can't have annotation on non-existed images"
+
+ def _update_imgids(self):
+ """Update image ids for both image and annotation.
+
+ If datasets are merged, their image id, annotation id will conflict because they
+ are defined within their own local scope. Therefore, we will need to put these
+ ids in the global scope
+ """
+
+ from collections import defaultdict
+
+ dataset_id_pool = defaultdict(set)
+ all_datasets = self.name2genericdataset.values()
+
+ total_number_images = 0
+ total_number_annotations = 0
+ for dataset in all_datasets:
+ total_number_images += len(dataset.generic_train_images) + len(dataset.generic_test_images)
+ total_number_annotations += len(dataset.generic_train_annotations) + len(dataset.generic_test_annotations)
+
+ global_image_id_pool = set(range(total_number_images))
+ global_annotation_id_pool = set(range(total_number_annotations))
+
+ for dataset_name, dataset in self.name2genericdataset.items():
+ local_image_id_map = defaultdict(int)
+ local_anno_id_map = defaultdict(int)
+
+ traintest_images = dataset.generic_train_images + dataset.generic_test_images
+ traintest_annotations = dataset.generic_train_annotations + dataset.generic_test_annotations
+
+ for img in traintest_images:
+ new_image_id = global_image_id_pool.pop()
+ local_image_id_map[img["id"]] = new_image_id
+ img["id"] = new_image_id
+ dataset_id_pool[dataset_name].add(img["id"])
+
+ for anno in traintest_annotations:
+ anno["image_id"] = local_image_id_map[anno["image_id"]]
+ new_anno_id = global_annotation_id_pool.pop()
+ local_anno_id_map[anno["id"]] = new_anno_id
+ anno["id"] = new_anno_id
+
+ self.whether_anno_image_match(traintest_images, traintest_annotations)
+
+ from functools import reduce
+
+ count = 0
+ for _k, v in dataset_id_pool.items():
+ count += len(v)
+ print("size of the summation", count)
+ union = reduce(set.union, dataset_id_pool.values())
+ print("size of the union", len(union))
+
+ def _merge_datasets(self, name2dataset):
+ """Merged datasets into common list.
+
+ # only do this when iid/ood split is done
+ """
+
+ merged_train_images = []
+ merged_test_images = []
+ merged_train_annotations = []
+ merged_test_annotations = []
+
+ for _dataset_name, dataset in name2dataset.items():
+ train_images = dataset.generic_train_images
+ test_images = dataset.generic_test_images
+ train_annotations = dataset.generic_train_annotations
+ test_annotations = dataset.generic_test_annotations
+
+ merged_train_images.extend(train_images)
+ merged_test_images.extend(test_images)
+ merged_train_annotations.extend(train_annotations)
+ merged_test_annotations.extend(test_annotations)
+
+ print("Checking merged dataset")
+
+ merged_traintest_images = merged_train_images + merged_test_images
+ merged_traintest_annotations = merged_train_annotations + merged_test_annotations
+
+ self.whether_anno_image_match(merged_traintest_images, merged_traintest_annotations)
+
+ return (
+ merged_train_images,
+ merged_test_images,
+ merged_train_annotations,
+ merged_test_annotations,
+ )
+
+ def __eq__(self, other_dataset):
+
+ if isinstance(other_dataset, BasePoseDataset):
+ train_images1 = set(map(raw_2_imagename_with_id, self.train_images))
+ train_images2 = set(map(raw_2_imagename, other_dataset.generic_train_images))
+
+ test_images1 = set(map(raw_2_imagename_with_id, self.test_images))
+ test_images2 = set(map(raw_2_imagename, other_dataset.generic_test_images))
+ if train_images1 == train_images2 and test_images1 == test_images2:
+ print(f"dataset {self.meta['dataset_name']} and {other_dataset.meta['dataset_name']} are equivalent")
+ return True
+ else:
+ print(
+ f"dataset {self.meta['dataset_name']} and {other_dataset.meta['dataset_name']} are NOT equivalent"
+ )
+ return False
+
+ else:
+ return NotImplementedError("Not existed")
+
+ def materialize(
+ self,
+ proj_root,
+ framework="coco",
+ train_all=False,
+ deepcopy=False,
+ full_image_path=True,
+ ):
+
+ # can't be set to true at the same time. This will cause bugs
+ assert sum([train_all, full_image_path]) != 2
+
+ mat_func = mat_func_factory(framework)
+
+ self.meta["mat_datasets"] = self.name2genericdataset
+
+ if train_all:
+ # for pretrian phase, we can just train everything including the test part
+ self.train_images += self.test_images
+ self.train_annotations += self.test_annotations
+
+ mat_func(
+ proj_root,
+ self.train_images,
+ self.test_images,
+ self.train_annotations,
+ self.test_annotations,
+ self.meta,
+ deepcopy=deepcopy,
+ full_image_path=full_image_path,
+ )
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py
new file mode 100644
index 0000000000..405613362b
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.py
@@ -0,0 +1,130 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base_dlc import (
+ BaseDLCPoseDataset,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import (
+ calc_bboxes_from_keypoints,
+ read_image_shape_fast,
+)
+
+
+class SingleDLCPoseDataset(BaseDLCPoseDataset):
+ """The philosophy is to assume the dataset is already created so this class is not
+ responsible for creating training dataset."""
+
+ def __init__(self, proj_root, dataset_name, shuffle=1, modelprefix=""):
+ super().__init__(proj_root, dataset_name, shuffle=shuffle, modelprefix=modelprefix)
+
+ # overriding max_individuals
+ self.meta["max_individuals"] = 1
+
+ def _df2generic(self, df, image_id_offset=0):
+
+ bpts = df.columns.get_level_values("bodyparts").unique().tolist()
+
+ coco_categories = []
+
+ # single animal only has individual0
+
+ category = {
+ "name": "individual0",
+ "id": 0,
+ "supercategory": "animal",
+ }
+
+ category["keypoints"] = bpts
+
+ coco_categories.append(category)
+
+ coco_images = []
+ coco_annotations = []
+
+ annotation_id = 0
+ image_id = -1
+
+ for _, file_name in enumerate(df.index):
+ data = df.loc[file_name]
+
+ # skipping all nan
+
+ if np.isnan(data.to_numpy()).all():
+ continue
+
+ image_id += 1
+ category_id = 0
+ kpts = data.to_numpy().reshape(-1, 2)
+ keypoints = np.zeros((len(kpts), 3))
+
+ keypoints[:, :2] = kpts
+
+ is_visible = ~pd.isnull(kpts).all(axis=1)
+
+ keypoints[:, 2] = np.where(is_visible, 2, 0)
+
+ num_keypoints = is_visible.sum()
+
+ bbox_margin = 20
+
+ xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints(
+ [keypoints],
+ slack=bbox_margin,
+ clip=True,
+ )[0][:4]
+
+ w = xmax - xmin
+ h = ymax - ymin
+ area = w * h
+ bbox = np.nan_to_num([xmin, ymin, w, h])
+ keypoints = np.nan_to_num(keypoints.flatten())
+
+ annotation_id += 1
+ annotation = {
+ "image_id": image_id + image_id_offset,
+ "num_keypoints": num_keypoints,
+ "keypoints": keypoints,
+ "id": annotation_id,
+ "category_id": category_id,
+ "area": area,
+ "bbox": bbox,
+ "iscrowd": 0,
+ }
+ if np.sum(keypoints) != 0:
+ coco_annotations.append(annotation)
+
+ # I think width and height are important
+
+ if isinstance(file_name, tuple):
+ image_path = os.path.join(self.proj_root, *list(file_name))
+ else:
+ image_path = os.path.join(self.proj_root, file_name)
+
+ _, height, width = read_image_shape_fast(image_path)
+
+ image = {
+ "file_name": image_path,
+ "width": width,
+ "height": height,
+ "id": image_id + image_id_offset,
+ }
+ coco_images.append(image)
+
+ ret_obj = {
+ "images": coco_images,
+ "annotations": coco_annotations,
+ "categories": coco_categories,
+ }
+ return ret_obj
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py
new file mode 100644
index 0000000000..07896698a5
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py
@@ -0,0 +1,236 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import (
+ parse_video_filenames,
+)
+from deeplabcut.modelzoo.generalized_data_converter.datasets.base import BasePoseDataset
+from deeplabcut.modelzoo.generalized_data_converter.datasets.utils import (
+ calc_bboxes_from_keypoints,
+ read_image_shape_fast,
+)
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, conversioncode
+
+
+def merge_annotateddatasets(cfg):
+ """Merges all the h5 files for all labeled-datasets (from individual videos).
+
+ This is a bit of a mess because of cross platform compatibility.
+
+ Within platform comp. is straightforward. But if someone labels on windows and wants to train on a unix cluster or
+ colab...
+ """
+ AnnotationData = []
+ data_path = Path(os.path.join(cfg["project_path"], "labeled-data"))
+ videos = cfg["video_sets"].keys()
+ video_filenames = parse_video_filenames(videos)
+ for filename in video_filenames:
+ file_path = os.path.join(data_path / filename, f"CollectedData_{cfg['scorer']}.h5")
+ try:
+ data = pd.read_hdf(file_path)
+ conversioncode.guarantee_multiindex_rows(data)
+ if data.columns.levels[0][0] != cfg["scorer"]:
+ print(
+ f"{file_path} labeled by a different scorer. This data will not be utilized in training dataset"
+ f"creation. If you need to merge datasets across scorers, see"
+ f"https://github.com/DeepLabCut/DeepLabCut/wiki/Using-labeled-data-in-DeepLabCut-that-was-annotated-elsewhere-(or-merge-across-labelers)"
+ )
+ continue
+ AnnotationData.append(data)
+ except FileNotFoundError:
+ print(file_path, " not found (perhaps not annotated).")
+
+ if not len(AnnotationData):
+ print(
+ "Annotation data was not found by splitting video paths (from config['video_sets']). An alternative route"
+ "is taken..."
+ )
+ AnnotationData = conversioncode.merge_windowsannotationdataONlinuxsystem(cfg)
+ if not len(AnnotationData):
+ print("No data was found!")
+ return
+
+ AnnotationData = pd.concat(AnnotationData).sort_index()
+ # When concatenating DataFrames with misaligned column labels,
+ # all sorts of reordering may happen (mainly depending on 'sort' and 'join')
+ # Ensure the 'bodyparts' level agrees with the order in the config file.
+ if cfg.get("multianimalproject", False):
+ (
+ _,
+ uniquebodyparts,
+ multianimalbodyparts,
+ ) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
+ bodyparts = multianimalbodyparts + uniquebodyparts
+ else:
+ bodyparts = cfg["bodyparts"]
+ AnnotationData = AnnotationData.reindex(bodyparts, axis=1, level=AnnotationData.columns.names.index("bodyparts"))
+
+ return AnnotationData
+
+
+class SingleDLCDataFrame(BasePoseDataset):
+ def __init__(self, proj_root, dataset_name):
+ super().__init__()
+ self.meta["max_individuals"] = 1
+ assert proj_root is not None and dataset_name is not None
+ self.proj_root = proj_root
+ self.dataset_name = dataset_name
+ self.meta["dataset_name"] = dataset_name
+ self.meta["proj_root"] = proj_root
+ config_path = Path(proj_root) / "config.yaml"
+ # read config
+ cfg = auxiliaryfunctions.read_config(config_path)
+ # get the train folder
+
+ Data = merge_annotateddatasets(
+ cfg,
+ )
+
+ # now with this data, we construct necessary generic data
+
+ self.dlc_df = Data
+
+ images = self.dlc_df.index
+
+ ratio = 0.9
+
+ df_train = self.dlc_df.iloc[: int(len(images) * ratio)]
+ df_test = self.dlc_df.iloc[int(len(images) * ratio) :]
+
+ self.coco_train = self._df2generic(df_train)
+
+ offset = len(self.coco_train["images"])
+
+ self.coco_test = self._df2generic(df_test, image_id_offset=offset)
+
+ self.populate_generic()
+
+ def populate_generic(self):
+
+ self.generic_train_images = self.coco_train["images"]
+ self.generic_test_images = self.coco_test["images"]
+ self.generic_train_annotations = self.coco_train["annotations"]
+ self.generic_test_annotations = self.coco_test["annotations"]
+
+ self.meta["categories"] = self.coco_test["categories"][0]
+
+ # to build maps for later analysis
+ self._build_maps()
+
+ print(f"Before checking trainset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_train_images, self.generic_train_annotations)
+
+ print(f"Before checking testset {self.meta['dataset_name']}")
+
+ self.whether_anno_image_match(self.generic_test_images, self.generic_test_annotations)
+
+ def _df2generic(self, df, image_id_offset=0):
+
+ bpts = df.columns.get_level_values("bodyparts").unique().tolist()
+
+ coco_categories = []
+
+ # single animal only has individual0
+
+ category = {
+ "name": "individual0",
+ "id": 0,
+ "supercategory": "animal",
+ }
+
+ category["keypoints"] = bpts
+
+ coco_categories.append(category)
+
+ coco_images = []
+ coco_annotations = []
+
+ annotation_id = 0
+ image_id = -1
+
+ for _, file_name in enumerate(df.index):
+ data = df.loc[file_name]
+
+ # skipping all nan
+
+ if np.isnan(data.to_numpy()).all():
+ continue
+
+ image_id += 1
+ category_id = 0
+ kpts = data.to_numpy().reshape(-1, 2)
+ keypoints = np.zeros((len(kpts), 3))
+
+ keypoints[:, :2] = kpts
+
+ is_visible = ~pd.isnull(kpts).all(axis=1)
+
+ keypoints[:, 2] = np.where(is_visible, 2, 0)
+
+ num_keypoints = is_visible.sum()
+
+ bbox_margin = 20
+
+ xmin, ymin, xmax, ymax = calc_bboxes_from_keypoints(
+ [keypoints],
+ slack=bbox_margin,
+ clip=True,
+ )[0][:4]
+
+ w = xmax - xmin
+ h = ymax - ymin
+ area = w * h
+ bbox = np.nan_to_num([xmin, ymin, w, h])
+ keypoints = np.nan_to_num(keypoints.flatten())
+
+ annotation_id += 1
+ annotation = {
+ "image_id": image_id + image_id_offset,
+ "num_keypoints": num_keypoints,
+ "keypoints": keypoints,
+ "id": annotation_id,
+ "category_id": category_id,
+ "area": area,
+ "bbox": bbox,
+ "iscrowd": 0,
+ }
+ if np.sum(keypoints) != 0:
+ coco_annotations.append(annotation)
+
+ # I think width and height are important
+
+ if isinstance(file_name, tuple):
+ image_path = os.path.join(self.proj_root, *list(file_name))
+ else:
+ image_path = os.path.join(self.proj_root, file_name)
+
+ _, height, width = read_image_shape_fast(image_path)
+
+ image = {
+ "file_name": image_path,
+ "width": width,
+ "height": height,
+ "id": image_id + image_id_offset,
+ }
+ coco_images.append(image)
+
+ ret_obj = {
+ "images": coco_images,
+ "annotations": coco_annotations,
+ "categories": coco_categories,
+ }
+ return ret_obj
diff --git a/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py b/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py
new file mode 100644
index 0000000000..3bc48c63d2
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/utils.py
@@ -0,0 +1,40 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from functools import cache
+
+import numpy as np
+from PIL import Image
+
+
+def calc_bboxes_from_keypoints(data, slack=0, offset=0, clip=False):
+ data = np.asarray(data)
+ if data.shape[-1] < 3:
+ raise ValueError("Data should be of shape (n_animals, n_bodyparts, 3)")
+
+ if data.ndim != 3:
+ data = np.expand_dims(data, axis=0)
+ bboxes = np.full((data.shape[0], 5), np.nan)
+ bboxes[:, :2] = np.nanmin(data[..., :2], axis=1) - slack # X1, Y1
+ bboxes[:, 2:4] = np.nanmax(data[..., :2], axis=1) + slack # X2, Y2
+ bboxes[:, -1] = np.nanmean(data[..., 2]) # Average confidence
+ bboxes[:, [0, 2]] += offset
+ if clip:
+ coord = bboxes[:, :4]
+ coord[coord < 0] = 0
+ return bboxes
+
+
+@cache
+def read_image_shape_fast(path):
+ # Blazing fast and does not load the image into memory
+ with Image.open(path) as img:
+ width, height = img.size
+ return len(img.getbands()), height, width
diff --git a/deeplabcut/modelzoo/generalized_data_converter/utils.py b/deeplabcut/modelzoo/generalized_data_converter/utils.py
new file mode 100644
index 0000000000..fafe97e46a
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/utils.py
@@ -0,0 +1,299 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import glob
+import os
+import pickle
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import (
+ SingleDLC_config,
+)
+from deeplabcut.utils import auxiliaryfunctions
+
+
+def threshold_kpts(config_path, h5path, threshold_mean=0.9, threshold_min=0.1):
+
+ df = pd.read_hdf(h5path)
+
+ scorer = df.columns.get_level_values("scorer").unique()[0]
+ try:
+ data = df[scorer]["individual0"]
+ except Exception:
+ data = df[scorer]
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ bodyparts = cfg["multianimalbodyparts"]
+
+ thresholded_bpts = []
+
+ for bpt in bodyparts:
+ _mean = data[bpt]["likelihood"].mean()
+ _min = data[bpt]["likelihood"].min()
+ _var = data[bpt]["likelihood"].var()
+ if _mean > threshold_mean and _min > threshold_min:
+ thresholded_bpts.append(bpt)
+ print(bpt, "mean", _mean)
+ print(bpt, "min", _min)
+ print(bpt, "var", _var)
+
+ print("thresholded kpts", thresholded_bpts)
+ return thresholded_bpts
+ ret = []
+ print(ret)
+ return ret
+
+
+def create_dummy_config_file_from_h5(
+ proj_root, reference_h5, taskname="dummytask", scorer="dummyscorer", date="March30"
+):
+ """Assuming at least labeled-data folder is there."""
+
+ cfg_template = SingleDLC_config()
+
+ df = pd.read_hdf(reference_h5)
+
+ print(df)
+
+ pattern = glob.glob(os.path.join(proj_root, "labeled-data", "*"))
+
+ labeled_folders = [f.split("/")[-1] for f in pattern]
+
+ video_sets = {f"{folder}.mp4": {"crop": "0, 400, 0, 400"} for folder in labeled_folders}
+
+ # bodyparts = df[scorer]['bodyparts']
+
+ bodyparts = list(df.columns.get_level_values("bodyparts").unique())
+ scorer = df.columns.get_level_values("scorer").unique()[0]
+
+ modify_dict = dict(
+ Task=taskname,
+ project_path=proj_root,
+ scorer=scorer,
+ date=date,
+ video_sets=video_sets,
+ bodyparts=bodyparts,
+ TrainingFraction=[0.95],
+ )
+
+ cfg_template.create_cfg(proj_root, modify_dict)
+
+
+def create_dummy_config_file_from_pickle(
+ proj_root,
+ reference_pickle,
+ video_path,
+ taskname="dummytask",
+ scorer="dummyscorer",
+ date="March30",
+):
+ """Assuming at least labeled-data folder is there."""
+
+ cfg_template = SingleDLC_config()
+
+ with open(reference_pickle, "rb") as f:
+ pickle.load(f)
+
+ # bodyparts = pickle_obj['keypoint_names']
+ bodyparts = [
+ "tail",
+ "spine4",
+ "spine3",
+ "spine2",
+ "spine1",
+ "head",
+ "nose",
+ "right ear",
+ "left ear",
+ ]
+
+ video_path.split("/")[-1]
+
+ video_sets = {f"{video_path}": {"crop": "0, 400, 0, 400"}}
+
+ modify_dict = dict(
+ Task=taskname,
+ project_path=proj_root,
+ scorer=scorer,
+ date=date,
+ video_sets=video_sets,
+ bodyparts=bodyparts,
+ TrainingFraction=[0.95],
+ )
+
+ cfg_template.create_cfg(".", modify_dict)
+
+
+def create_video_h5_from_pickle(proj_root, cfg, reference_pickle, videopath):
+
+ with open(reference_pickle, "rb") as f:
+ pickle_obj = pickle.load(f)
+
+ # bodyparts = pickle_obj['keypoint_names']
+
+ video_name = videopath.split("/")[-1]
+
+ video_key = f"{video_name}" # .replace('.top.ir.mp4', '')
+
+ print("video_key", video_key)
+
+ print(list(pickle_obj.keys()))
+
+ detections = pickle_obj[video_key]
+
+ nframes = len(detections)
+
+ xyz_labs = ["x", "y", "likelihood"]
+
+ scorer = cfg["scorer"]
+
+ keypoint_names = cfg["bodyparts"]
+
+ product = [[scorer], keypoint_names, xyz_labs]
+
+ names = ["scorer", "bodyparts", "coords"]
+ columnindex = pd.MultiIndex.from_product(product, names=names)
+ imagenames = [f"frame{i}" for i in range(nframes)]
+ data = np.zeros((len(imagenames), len(columnindex))) * np.nan
+ df = pd.DataFrame(data, columns=columnindex, index=imagenames)
+
+ for imagename, kpts in zip(imagenames, detections, strict=False):
+ for kpt_id, kpt_name in enumerate(keypoint_names):
+ df.loc[imagename][scorer, kpt_name, "x"] = kpts[kpt_id, 0]
+ df.loc[imagename][scorer, kpt_name, "y"] = kpts[kpt_id, 1]
+ df.loc[imagename][scorer, kpt_name, "likelihood"] = kpts[kpt_id, 2]
+
+ vname = Path(videopath).stem
+ DLCscorer = ""
+
+ coords = [0, 400, 0, 400]
+ trainFraction = cfg["TrainingFraction"][0]
+ modelfolder = os.path.join(
+ cfg["project_path"],
+ str(auxiliaryfunctions.get_model_folder(trainFraction, 0, cfg)),
+ )
+
+ path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
+ test_cfg = auxiliaryfunctions.read_plainconfig(path_test_config)
+
+ start = 0
+ stop = 10
+ fps = 10
+ dictionary = {
+ "start": start,
+ "stop": stop,
+ "run_duration": stop - start,
+ "Scorer": DLCscorer,
+ "DLC-model-config file": test_cfg,
+ "fps": fps,
+ "batch_size": test_cfg["batch_size"],
+ "frame_dimensions": (400, 400),
+ "nframes": nframes,
+ "iteration (active-learning)": cfg["iteration"],
+ "cropping": cfg["cropping"],
+ "training set fraction": trainFraction,
+ "cropping_parameters": coords,
+ }
+ metadata = {"data": dictionary}
+
+ dataname = os.path.join(proj_root, vname + DLCscorer + ".h5")
+
+ metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
+
+ with open(metadata_path, "wb") as f:
+ pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
+
+ df.to_hdf(dataname, key="df_with_missing", format="table", mode="w")
+
+
+def add_skeleton(config_path, pretrain_model_name):
+
+ modelzoo_names = ["superquadruped", "supertopview"]
+
+ assert pretrain_model_name in modelzoo_names
+
+ super_quadruped = [
+ ("left_eye", "right_eye"),
+ ("left_eye", "left_earbase"),
+ ("right_eye", "right_earbase"),
+ ("left_eye", "nose"),
+ ("right_eye", "nose"),
+ ("nose", "throat_base"),
+ ("throat_base", "back_base"),
+ ("tail_base", "back_base"),
+ ("throat_base", "front_left_thai"),
+ ("front_left_thai", "front_left_knee"),
+ ("front_left_knee", "front_left_paw"),
+ ("throat_base", "front_right_thai"),
+ ("front_right_thai", "front_right_knee"),
+ ("front_right_knee", "front_right_paw"),
+ ("tail_base", "back_left_thai"),
+ ("back_left_thai", "back_left_knee"),
+ ("back_left_knee", "back_left_paw"),
+ ("tail_base", "back_right_thai"),
+ ("back_right_thai", "back_right_knee"),
+ ("back_right_knee", "back_right_paw"),
+ ]
+
+ skeleton_dict = {"superquadruped": super_quadruped, "supertopview": None}
+
+ skeleton = skeleton_dict[pretrain_model_name]
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+ cfg["skeleton"] = skeleton
+ print(f"overwriting skeleton for {config_path}")
+ auxiliaryfunctions.write_config(config_path, cfg)
+
+
+def customized_colormap(config_path):
+ # look for all symmetric keypoints
+ # make symmetric keypoints the same color
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+ bodyparts = cfg["multianimalbodyparts"]
+ n_bodyparts = len(cfg["multianimalbodyparts"])
+
+ import matplotlib.pyplot as plt
+
+ cmap = plt.cm.get_cmap("rainbow", n_bodyparts)
+
+ colors = [cmap(i) for i in range(n_bodyparts)]
+
+ for kpt_id in range(len(bodyparts)):
+ bodypart = bodyparts[kpt_id]
+ if "left" in bodypart:
+ ref_color = colors[kpt_id]
+ temp = bodypart.replace("left", "right")
+ if temp in bodyparts:
+ temp_id = bodyparts.index(temp)
+ colors[temp_id] = ref_color
+
+ def ret_function(i):
+ return colors[i]
+
+ return ret_function
+
+
+def create_modelprefix(modelprefix):
+ import shutil
+
+ shutil.copytree(
+ "template-dlc-models",
+ os.path.join(modelprefix, "dlc-models"),
+ dirs_exist_ok=True,
+ )
+
+
+if __name__ == "__main__":
+ customized_colormap("hei")
diff --git a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml
similarity index 53%
rename from deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml
rename to deeplabcut/modelzoo/model_configs/dlcrnet.yaml
index a0b2da3064..570c1a0aaa 100644
--- a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml
+++ b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml
@@ -1,62 +1,51 @@
-all_joints:
-- - 0
-- - 1
-- - 2
-- - 3
-- - 4
-- - 5
-- - 6
-- - 7
-- - 8
-- - 9
-- - 10
-- - 11
-- - 12
-- - 13
-- - 14
-- - 15
-- - 16
-- - 17
-- - 18
-- - 19
-- - 20
-- - 21
-- - 22
-- - 23
-- - 24
-- - 25
-- - 26
-all_joints_names:
-- nose
-- left_ear
-- right_ear
-- left_ear_tip
-- right_ear_tip
-- left_eye
-- right_eye
-- neck
-- mid_back
-- mouse_center
-- mid_backend
-- mid_backend2
-- mid_backend3
-- tail_base
-- tail1
-- tail2
-- tail3
-- tail4
-- tail5
-- left_shoulder
-- left_midside
-- left_hip
-- right_shoulder
-- right_midside
-- right_hip
-- tail_end
-- head_midpoint
+ # Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+ # Project path (change when moving around)
+project_path:
+
+ # Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+ # Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap:
+
+ # Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+batch_size: 1
+
+ # Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+ #if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+ # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
alpha_r: 0.02
apply_prob: 0.5
-batch_size: 1
clahe: true
claheratio: 0.1
crop_sampling: hybrid
@@ -64,7 +53,7 @@ crop_size:
- 400
- 400
cropratio: 0.4
-dataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_supertopviewMarch30/ma_supertopview_maDLC_scorer95shuffle1.pickle
+dataset:
dataset_type: multi-animal-imgaug
decay_steps: 30000
display_iters: 500
@@ -90,7 +79,11 @@ locref_stdev: 7.2801
lr_init: 0.0005
max_input_size: 1500
max_shift: 0.4
-metadataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_supertopviewMarch30/Documentation_data-ma_supertopview_95shuffle1.pickle
+mean_pixel:
+- 123.68
+- 116.779
+- 103.939
+metadataset:
min_input_size: 64
mirror: false
multi_stage: true
@@ -114,7 +107,6 @@ partaffinityfield_graph: []
partaffinityfield_predict: false
pos_dist_thresh: 17
pre_resize: []
-project_path:
rotation: 25
rotratio: 0.4
save_iters: 10000
@@ -122,5 +114,8 @@ scale_jitter_lo: 0.5
scale_jitter_up: 1.25
sharpen: false
sharpenratio: 0.3
+stride: 8.0
weigh_only_present_joints: false
gradient_masking: true
+weight_decay: 0.0001
+weigh_part_predictions: false
diff --git a/deeplabcut/modelzoo/model_configs/fasterrcnn_mobilenet_v3_large_fpn.yaml b/deeplabcut/modelzoo/model_configs/fasterrcnn_mobilenet_v3_large_fpn.yaml
new file mode 100644
index 0000000000..6d4e11b55a
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/fasterrcnn_mobilenet_v3_large_fpn.yaml
@@ -0,0 +1,51 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [ 1.0, 1.0 ]
+ translation: 40
+ collate:
+ type: ResizeFromDataSizeCollate
+ min_scale: 0.4
+ max_scale: 1.0
+ min_short_side: 128
+ max_short_side: 1152
+ multiple_of: 32
+ to_square: false
+ hflip: true
+ normalize_images: true
+device: auto
+model:
+ type: FasterRCNN
+ variant: fasterrcnn_mobilenet_v3_large_fpn
+ box_score_thresh: 0.6
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+runner:
+ type: DetectorTrainingRunner
+ key_metric: "test.mAP@50:95"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ milestones: [ 90 ]
+ lr_list: [ [ 1e-6 ] ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 250
diff --git a/deeplabcut/modelzoo/model_configs/fasterrcnn_resnet50_fpn_v2.yaml b/deeplabcut/modelzoo/model_configs/fasterrcnn_resnet50_fpn_v2.yaml
new file mode 100644
index 0000000000..a78d93eb48
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/fasterrcnn_resnet50_fpn_v2.yaml
@@ -0,0 +1,51 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [ 1.0, 1.0 ]
+ translation: 40
+ collate:
+ type: ResizeFromDataSizeCollate
+ min_scale: 0.4
+ max_scale: 1.0
+ min_short_side: 128
+ max_short_side: 1152
+ multiple_of: 32
+ to_square: false
+ hflip: true
+ normalize_images: true
+device: auto
+model:
+ type: FasterRCNN
+ variant: fasterrcnn_resnet50_fpn_v2
+ box_score_thresh: 0.6
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+runner:
+ type: DetectorTrainingRunner
+ key_metric: "test.mAP@50:95"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ milestones: [ 90 ]
+ lr_list: [ [ 1e-6 ] ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 250
diff --git a/deeplabcut/modelzoo/model_configs/hrnet_w32.yaml b/deeplabcut/modelzoo/model_configs/hrnet_w32.yaml
new file mode 100644
index 0000000000..011d7727c3
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/hrnet_w32.yaml
@@ -0,0 +1,81 @@
+data:
+ colormode: RGB
+ inference:
+ auto_padding:
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ scaling: [1.0, 1.0]
+ rotation: 30
+ translation: 0
+ gaussian_noise: 12.75
+ normalize_images: true
+ auto_padding:
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+device: auto
+method: td
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32
+ pretrained: false
+ freeze_bn_stats: True
+ freeze_bn_weights: False
+ interpolate_branches: false
+ increased_channel_count: false
+ backbone_output_channels: 32
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: "normal"
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: false
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: false
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ heatmap_config:
+ channels: [32, "num_bodyparts"]
+ kernel_size: [1]
+ strides: [1]
+net_type: hrnet_w32
+runner:
+ type: PoseTrainingRunner
+ key_metric: "test.mAP"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-6 ], [ 1e-7 ] ]
+ milestones: [ 160, 190 ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 200
+ seed: 42
diff --git a/deeplabcut/modelzoo/model_configs/resnet_50.yaml b/deeplabcut/modelzoo/model_configs/resnet_50.yaml
new file mode 100644
index 0000000000..994840c7d6
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/resnet_50.yaml
@@ -0,0 +1,88 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ scaling: [1.0, 1.0]
+ rotation: 30
+ translation: 0
+ gaussian_noise: 12.75
+ normalize_images: true
+device: auto
+method: td
+model:
+ backbone:
+ type: ResNet
+ model_name: resnet50_gn
+ output_stride: 16
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ backbone_output_channels: 2048
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 2048
+ - "num_bodyparts"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+ locref_config:
+ channels:
+ - 2048
+ - "num_bodyparts x 2"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+net_type: resnet_50
+runner:
+ type: PoseTrainingRunner
+ key_metric: "test.mAP"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-6 ], [ 1e-7 ] ]
+ milestones: [ 160, 190 ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 100
+ seed: 42
diff --git a/deeplabcut/modelzoo/model_configs/rtmpose_s.yaml b/deeplabcut/modelzoo/model_configs/rtmpose_s.yaml
new file mode 100644
index 0000000000..9c80b0583c
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/rtmpose_s.yaml
@@ -0,0 +1,102 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ scaling: [1.0, 1.0]
+ rotation: 30
+ translation: 0
+ gaussian_noise: 12.75
+ normalize_images: true
+device: auto
+method: td
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_s
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.33
+ widen_factor: 0.5
+ backbone_output_channels: 512
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size:
+ - 256
+ - 256
+ smoothing_type: gaussian
+ sigma:
+ - 5.66
+ - 5.66
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma:
+ - 5.66
+ - 5.66
+ decode_beta: 150.0
+ input_size:
+ - 256
+ - 256
+ in_channels: 512
+ out_channels: 39
+ in_featuremap_size:
+ - 8
+ - 8
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: SiLU
+ use_rel_bias: false
+ pos_enc: false
+net_type: rtmpose_s
+runner:
+ type: PoseTrainingRunner
+ key_metric: "test.mAP"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-6 ], [ 1e-7 ] ]
+ milestones: [ 160, 190 ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 100
+ seed: 42
diff --git a/deeplabcut/modelzoo/model_configs/rtmpose_x.yaml b/deeplabcut/modelzoo/model_configs/rtmpose_x.yaml
new file mode 100644
index 0000000000..0d1fb8a547
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/rtmpose_x.yaml
@@ -0,0 +1,163 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ top_down_crop:
+ width: 288
+ height: 384
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling:
+ - 1.0
+ - 1.0
+ translation: 0
+ collate:
+ covering: false
+ gaussian_noise: 12.75
+ hist_eq: false
+ motion_blur: false
+ normalize_images: true
+ top_down_crop:
+ width: 288
+ height: 384
+detector: null
+device: auto
+metadata:
+ project_path: null
+ pose_config_path: rtmpose_x_body7_pytorch_config.yaml
+ bodyparts:
+ - nose
+ - left_eye
+ - right_eye
+ - left_ear
+ - right_ear
+ - left_shoulder
+ - right_shoulder
+ - left_elbow
+ - right_elbow
+ - left_wrist
+ - right_wrist
+ - left_hip
+ - right_hip
+ - left_knee
+ - right_knee
+ - left_ankle
+ - right_ankle
+ unique_bodyparts: []
+ individuals:
+ - idv0
+ - idv1
+ - idv2
+ - idv3
+ - idv4
+ - idv5
+ - idv6
+ - idv7
+ - idv8
+ - idv9
+ with_identity: false
+method: td
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_p5
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ expand_ratio: 0.5
+ deepen_factor: 1.33
+ widen_factor: 1.25
+ channel_attention: true
+ norm_layer: SyncBN
+ activation_fn: SiLU
+ backbone_output_channels: 1280
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size:
+ - 288
+ - 384
+ smoothing_type: gaussian
+ sigma:
+ - 6.0
+ - 6.93
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma:
+ - 6.0
+ - 6.93
+ decode_beta: 150.0
+ input_size:
+ - 288
+ - 384
+ in_channels: 1280
+ out_channels: 17
+ in_featuremap_size:
+ - 9
+ - 12
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: SiLU
+ use_rel_bias: false
+ pos_enc: false
+net_type: rtmpose_x
+runner:
+ type: PoseTrainingRunner
+ gpus:
+ key_metric: test.mAP
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0005
+ scheduler:
+ type: SequentialLR
+ params:
+ schedulers:
+ - type: ConstantLR
+ params:
+ factor: 0.001
+ total_iters: 5
+ - type: CosineAnnealingLR
+ params:
+ T_max: 250
+ eta_min: 1e-05
+ milestones:
+ - 100
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 200
+ seed: 42
diff --git a/deeplabcut/modelzoo/model_configs/ssdlite.yaml b/deeplabcut/modelzoo/model_configs/ssdlite.yaml
new file mode 100644
index 0000000000..307bf92ea4
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/ssdlite.yaml
@@ -0,0 +1,50 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [ 1.0, 1.0 ]
+ translation: 40
+ collate:
+ type: ResizeFromDataSizeCollate
+ min_scale: 0.4
+ max_scale: 1.0
+ min_short_side: 128
+ max_short_side: 1152
+ multiple_of: 32
+ to_square: false
+ hflip: true
+ normalize_images: true
+device: auto
+model:
+ type: SSDLite
+ box_score_thresh: 0.6
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+runner:
+ type: DetectorTrainingRunner
+ key_metric: "test.mAP@50:95"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ milestones: [ 90 ]
+ lr_list: [ [ 1e-6 ] ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 8
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 250
diff --git a/deeplabcut/modelzoo/models.json b/deeplabcut/modelzoo/models.json
deleted file mode 100644
index f5e6ab25f9..0000000000
--- a/deeplabcut/modelzoo/models.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "superanimal_quadruped": "superquadruped.yaml",
- "superanimal_topviewmouse": "supertopview.yaml"
-}
diff --git a/deeplabcut/modelzoo/models_to_framework.json b/deeplabcut/modelzoo/models_to_framework.json
new file mode 100644
index 0000000000..d067486951
--- /dev/null
+++ b/deeplabcut/modelzoo/models_to_framework.json
@@ -0,0 +1,9 @@
+{
+ "dlcrnet": "tensorflow",
+ "hrnet_w32": "pytorch",
+ "resnet_50": "pytorch",
+ "rtmpose_s": "pytorch",
+ "rtmpose_x": "pytorch",
+ "fmpose3d_humans": "pytorch",
+ "fmpose3d_animals": "pytorch"
+}
diff --git a/deeplabcut/modelzoo/project_configs/superanimal_bird.yaml b/deeplabcut/modelzoo/project_configs/superanimal_bird.yaml
new file mode 100644
index 0000000000..3144433a14
--- /dev/null
+++ b/deeplabcut/modelzoo/project_configs/superanimal_bird.yaml
@@ -0,0 +1,105 @@
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+
+# Project path (change when moving around)
+project_path:
+
+
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+
+
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+- back
+- bill
+- belly
+- breast
+- crown
+- forehead
+- left_eye
+- left_leg
+- left_wing_tip
+- left_wrist
+- nape
+- right_eye
+- right_leg
+- right_wing_tip
+- right_wrist
+- tail_tip
+- throat
+- neck
+- tail_left
+- tail_right
+- upper_spine
+- upper_half_spine
+- lower_half_spine
+- right_foot
+- left_foot
+- left_half_chest
+- right_half_chest
+- chin
+- left_tibia
+- right_tibia
+- lower_spine
+- upper_half_neck
+- lower_half_neck
+- left_chest
+- right_chest
+- upper_neck
+- left_wing_shoulder
+- left_wing_elbow
+- right_wing_shoulder
+- right_wing_elbow
+- upper_cere
+- lower_cere
+
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+
+# Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap: rainbow
+
+
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+detector_snapshotindex: -1
+batch_size: 1
+detector_batch_size: 1
+
+
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
diff --git a/deeplabcut/modelzoo/project_configs/superanimal_humanbody.yaml b/deeplabcut/modelzoo/project_configs/superanimal_humanbody.yaml
new file mode 100644
index 0000000000..e4d891b2ba
--- /dev/null
+++ b/deeplabcut/modelzoo/project_configs/superanimal_humanbody.yaml
@@ -0,0 +1,90 @@
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+# Project path (change when moving around)
+project_path:
+
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+- nose
+- left_eye
+- right_eye
+- left_ear
+- right_ear
+- left_shoulder
+- right_shoulder
+- left_elbow
+- right_elbow
+- left_wrist
+- right_wrist
+- left_hip
+- right_hip
+- left_knee
+- right_knee
+- left_ankle
+- right_ankle
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+# Plotting configuration
+skeleton:
+ - [16, 14]
+ - [14, 12]
+ - [17, 15]
+ - [15, 13]
+ - [12, 13]
+ - [6, 12]
+ - [7, 13]
+ - [6, 7]
+ - [6, 8]
+ - [7, 9]
+ - [8, 10]
+ - [9, 11]
+ - [2, 3]
+ - [1, 2]
+ - [1, 3]
+ - [2, 4]
+ - [3, 5]
+ - [4, 6]
+ - [5, 7]
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap: rainbow
+
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+detector_snapshotindex: -1
+batch_size: 1
+detector_batch_size: 1
+
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
diff --git a/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml
new file mode 100644
index 0000000000..e06f82032f
--- /dev/null
+++ b/deeplabcut/modelzoo/project_configs/superanimal_quadruped.yaml
@@ -0,0 +1,102 @@
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+
+# Project path (change when moving around)
+project_path:
+
+
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+
+
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+- nose
+- upper_jaw
+- lower_jaw
+- mouth_end_right
+- mouth_end_left
+- right_eye
+- right_earbase
+- right_earend
+- right_antler_base
+- right_antler_end
+- left_eye
+- left_earbase
+- left_earend
+- left_antler_base
+- left_antler_end
+- neck_base
+- neck_end
+- throat_base
+- throat_end
+- back_base
+- back_end
+- back_middle
+- tail_base
+- tail_end
+- front_left_thai
+- front_left_knee
+- front_left_paw
+- front_right_thai
+- front_right_knee
+- front_right_paw
+- back_left_paw
+- back_left_thai
+- back_right_thai
+- back_left_knee
+- back_right_knee
+- back_right_paw
+- belly_bottom
+- body_middle_right
+- body_middle_left
+
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+
+# Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap: rainbow
+
+
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+detector_snapshotindex: -1
+batch_size: 1
+detector_batch_size: 1
+
+
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
diff --git a/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml
new file mode 100644
index 0000000000..bd09628322
--- /dev/null
+++ b/deeplabcut/modelzoo/project_configs/superanimal_topviewmouse.yaml
@@ -0,0 +1,90 @@
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+
+# Project path (change when moving around)
+project_path:
+
+
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+
+
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+- nose
+- left_ear
+- right_ear
+- left_ear_tip
+- right_ear_tip
+- left_eye
+- right_eye
+- neck
+- mid_back
+- mouse_center
+- mid_backend
+- mid_backend2
+- mid_backend3
+- tail_base
+- tail1
+- tail2
+- tail3
+- tail4
+- tail5
+- left_shoulder
+- left_midside
+- left_hip
+- right_shoulder
+- right_midside
+- right_hip
+- tail_end
+- head_midpoint
+
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+
+# Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap: rainbow
+
+
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+detector_snapshotindex: -1
+batch_size: 1
+detector_batch_size: 1
+
+
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
diff --git a/deeplabcut/modelzoo/utils.py b/deeplabcut/modelzoo/utils.py
index adc78170d7..e3bc96c6c7 100644
--- a/deeplabcut/modelzoo/utils.py
+++ b/deeplabcut/modelzoo/utils.py
@@ -4,18 +4,395 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
-import json
+from __future__ import annotations
+
import os
+import warnings
+from glob import glob
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+from matplotlib.colors import ListedColormap
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.core.conversion_table import ConversionTable
+from deeplabcut.utils.auxiliaryfunctions import (
+ get_bodyparts,
+ get_deeplabcut_path,
+ read_config,
+ write_config,
+)
+
+
+def dlc_modelzoo_path() -> Path:
+ """Returns: the path to the `modelzoo` folder in the DeepLabCut installation"""
+ dlc_root_path = Path(get_deeplabcut_path())
+ return dlc_root_path / "modelzoo"
+
+
+def get_super_animal_project_cfg(super_animal: str) -> dict:
+ """Gets the project configuration file for a SuperAnimal model.
+
+ Args:
+ super_animal: the name of the SuperAnimal model for which to load the project
+ configuration
+
+ Returns:
+ the project configuration for the given SuperAnimal model
+
+ Raises:
+ ValueError if no such SuperAnimal is found
+ """
+ project_configs_dir = dlc_modelzoo_path() / "project_configs"
+ super_animal_projects = {p.stem: p for p in project_configs_dir.iterdir()}
+ if super_animal not in super_animal_projects:
+ raise ValueError(
+ f"No such SuperAnimal model: {super_animal}. Available SuperAnimal models "
+ f"are {', '.join(super_animal_projects.keys())}."
+ )
+
+ return read_config_as_dict(super_animal_projects[super_animal])
+
+
+def get_super_animal_scorer(
+ super_animal: str,
+ model_snapshot_path: Path | str,
+ detector_snapshot_path: Path | str | None,
+ torchvision_detector_name: str | None = None,
+) -> str:
+ """
+ Args:
+ super_animal: The SuperAnimal dataset on which the models were trained
+ model_snapshot_path: The path for the SuperAnimal pose model snapshot
+ detector_snapshot_path: The path for the SuperAnimal detector snapshot, if a
+ detector is being used.
+ torchvision_detector_name: The name of a pretrained COCO detector from torchvision,
+ if such a detector is used instead of a snapshot.
+
+ Returns:
+ The DLC scorer name to use for the given SuperAnimal models.
+ """
+ if detector_snapshot_path is not None and torchvision_detector_name is not None:
+ raise ValueError("Provide only one of `detector_snapshot_path` or `torchvision_detector_name`, not both.")
+ super_animal_prefix = super_animal + "_"
+ # Always use model name first
+ model_name = Path(model_snapshot_path).stem
+ if model_name.startswith(super_animal_prefix):
+ model_name = model_name[len(super_animal_prefix) :]
+ dlc_scorer = f"{super_animal_prefix}{model_name}"
+
+ # Then add detector name if provided
+ if detector_snapshot_path is not None:
+ detector_name = Path(detector_snapshot_path).stem
+ if detector_name.startswith(super_animal_prefix):
+ detector_name = detector_name[len(super_animal_prefix) :]
+ dlc_scorer += f"_{detector_name}"
+ elif torchvision_detector_name is not None:
+ dlc_scorer += f"_{torchvision_detector_name}"
+
+ return dlc_scorer
+
+
+def create_conversion_table(
+ config: str | Path,
+ super_animal: str,
+ project_to_super_animal: dict[str, str],
+) -> ConversionTable:
+ """Creates a conversion table mapping bodyparts defined for a DeepLabCut project to
+ bodyparts defined for a SuperAnimal model. This allows to fine-tune SuperAnimal
+ weights instead of transfer learning from ImageNet. The conversion table is directly
+ added to the project's configuration file.
+
+ Args:
+ config: The path to the project configuration for which the conversion table
+ should be created.
+ super_animal: The SuperAnimal model for the conversion table
+ project_to_super_animal: The conversion table mapping each project bodypart
+ to the corresponding SuperAnimal bodypart.
+
+ Returns:
+ The conversion table that was added to the project config.
+
+ Raises:
+ ValueError: If the conversion table is misconfigured (e.g., if there are
+ misnamed bodyparts in the table). See ConversionTable for more.
+ """
+ cfg = read_config(str(config))
+ sa_cfg = get_super_animal_project_cfg(super_animal)
+ conversion_table = ConversionTable(
+ super_animal=super_animal,
+ project_bodyparts=get_bodyparts(cfg),
+ super_animal_bodyparts=sa_cfg["bodyparts"],
+ table=project_to_super_animal,
+ )
+
+ conversion_tables = cfg.get("SuperAnimalConversionTables")
+ if conversion_tables is None:
+ conversion_tables = {}
+
+ conversion_tables[super_animal] = conversion_table.table
+ cfg["SuperAnimalConversionTables"] = conversion_tables
+ write_config(str(config), cfg)
+ return conversion_table
+
+
+def get_conversion_table(cfg: dict | str | Path, super_animal: str) -> ConversionTable:
+ """Gets the conversion table from a project to a SuperAnimal model.
+
+ Args:
+ cfg: The path to a project configuration file, or directly the project config.
+ super_animal: The SuperAnimal for which to get the configuration file.
+
+ Returns:
+ A dictionary mapping {project_bodypart: super_animal_bodypart}
+
+ Raises:
+ ValueError: If the conversion table is misconfigured (e.g., if there are
+ misnamed bodyparts in the table). See ConversionTable for more.
+ """
+ if isinstance(cfg, (str, Path)):
+ cfg = read_config(str(cfg))
+
+ conversion_tables = cfg.get("SuperAnimalConversionTables", {})
+ if conversion_tables is None or super_animal not in conversion_tables:
+ raise ValueError(
+ f"No conversion table defined in the project config for {super_animal}."
+ "Call deeplabcut.modelzoo.create_conversion_table to create one."
+ )
+
+ sa_cfg = get_super_animal_project_cfg(super_animal)
+ conversion_table = ConversionTable(
+ super_animal=super_animal,
+ project_bodyparts=get_bodyparts(cfg),
+ super_animal_bodyparts=sa_cfg["bodyparts"],
+ table=conversion_tables[super_animal],
+ )
+ return conversion_table
+
+
+def read_conversion_table_from_csv(csv_path):
+ df = pd.read_csv(csv_path, skiprows=1, header=None)
+ df = df.dropna()
+ df[0] = df[0].str.replace(r"\s+", "", regex=True)
+ df[1] = df[1].str.replace(r"\s+", "", regex=True)
+ _map = dict(zip(df[0], df[1], strict=False))
+ return _map
+
+
+def parse_project_model_name(superanimal_name: str) -> tuple[str, str]:
+ """Parses model zoo model names for SuperAnimal models.
+
+ Args:
+ superanimal_name: the name of the SuperAnimal model name to parse
+
+ Returns:
+ project_name: the parsed SuperAnimal model name
+ model_name: the model architecture (e.g., dlcrnet, hrnetw32)
+ """
+
+ if superanimal_name == "superanimal_quadruped":
+ warnings.warn(
+ f"{superanimal_name} is deprecated and will be removed in a future version. Use"
+ f"{superanimal_name}_model_suffix instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ superanimal_name = "superanimal_quadruped_hrnetw32"
+
+ if superanimal_name == "superanimal_topviewmouse":
+ warnings.warn(
+ f"{superanimal_name} is deprecated and will be removed in a future version. Use"
+ f"{superanimal_name}_model_suffix instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ superanimal_name = "superanimal_topviewmouse_dlcrnet"
+
+ model_name = superanimal_name.split("_")[-1]
+ project_name = superanimal_name.replace(f"_{model_name}", "")
+
+ dlc_root_path = get_deeplabcut_path()
+ modelzoo_path = os.path.join(dlc_root_path, "modelzoo")
+
+ available_model_configs = glob(os.path.join(modelzoo_path, "model_configs", "*.yaml"))
+ available_models = [os.path.splitext(os.path.basename(path))[0] for path in available_model_configs]
+
+ if model_name not in available_models:
+ raise ValueError(f"Model {model_name} not found. Available models are: {available_models}")
+
+ available_project_configs = glob(os.path.join(modelzoo_path, "project_configs", "*.yaml"))
+ [os.path.splitext(os.path.basename(path))[0] for path in available_project_configs]
+
+ return project_name, model_name
-def parse_available_supermodels():
- import deeplabcut
+def get_superanimal_colormaps():
+ # FIXME(shaokai) - Add colormaps for the SuperBird dataset
+ superanimal_bird_colors = (
+ np.array(
+ [
+ (127, 0, 255),
+ (115, 18, 254),
+ (103, 37, 254),
+ (91, 56, 253),
+ (79, 74, 252),
+ (65, 95, 250),
+ (53, 112, 248),
+ (41, 128, 246),
+ (29, 144, 243),
+ (15, 162, 239),
+ (3, 176, 236),
+ (8, 189, 232),
+ (20, 201, 228),
+ (34, 214, 223),
+ (46, 223, 219),
+ (58, 232, 214),
+ (70, 239, 209),
+ (84, 246, 202),
+ (96, 250, 196),
+ (108, 253, 190),
+ (120, 254, 184),
+ (134, 254, 176),
+ (146, 253, 169),
+ (158, 250, 162),
+ (170, 246, 154),
+ (184, 239, 146),
+ (196, 232, 138),
+ (208, 223, 130),
+ (220, 214, 122),
+ (234, 201, 112),
+ (246, 189, 103),
+ (255, 176, 95),
+ (255, 162, 86),
+ (255, 144, 75),
+ (255, 128, 66),
+ (255, 112, 57),
+ (255, 95, 48),
+ (255, 74, 37),
+ (255, 56, 28),
+ (255, 37, 18),
+ (255, 18, 9),
+ (255, 0, 0),
+ ]
+ )
+ / 255
+ )
+ superanimal_topviewmouse_colors = (
+ np.array(
+ [
+ [127, 0, 255],
+ [109, 28, 254],
+ [91, 56, 253],
+ [71, 86, 251],
+ [53, 112, 248],
+ [33, 139, 244],
+ [15, 162, 239],
+ [4, 185, 234],
+ [22, 203, 228],
+ [42, 220, 220],
+ [60, 233, 213],
+ [80, 244, 204],
+ [98, 250, 195],
+ [118, 254, 185],
+ [136, 254, 175],
+ [156, 250, 163],
+ [174, 244, 152],
+ [194, 233, 139],
+ [212, 220, 127],
+ [232, 203, 113],
+ [250, 185, 100],
+ [255, 162, 86],
+ [255, 139, 72],
+ [255, 112, 57],
+ [255, 86, 43],
+ [255, 56, 28],
+ [255, 28, 14],
+ ]
+ )
+ / 255
+ )
+ superanimal_quadruped_colors = (
+ np.array(
+ [
+ [255.0, 0.0, 0.0],
+ [255.0, 39.63408568671726, 0.0],
+ [255.0, 79.26817137343453, 0.0],
+ [255.0, 118.9022570601518, 0.0],
+ [255.0, 158.53634274686905, 0.0],
+ [255.0, 198.17042843358632, 0.0],
+ [255.0, 237.8045141203036, 0.0],
+ [232.56140019297916, 255.0, 0.0],
+ [192.92731450626187, 255.0, 0.0],
+ [153.2932288195446, 255.0, 0.0],
+ [113.65914313282731, 255.0, 0.0],
+ [74.02505744611004, 255.0, 0.0],
+ [34.390971759392784, 255.0, 0.0],
+ [3.5647953575585385, 255.0, 8.807909284882923],
+ [0.0, 255.0, 44.87701729490043],
+ [0.0, 255.0, 84.51085328820125],
+ [0.0, 255.0, 124.14468928150207],
+ [0.0, 255.0, 163.77852527480275],
+ [0.0, 255.0, 203.4123612681037],
+ [0.0, 255.0, 243.04619726140453],
+ [0, 220, 255],
+ [0, 255, 255],
+ [0, 165, 255],
+ [0, 150, 255],
+ [0.0, 68.78344961404169, 255.0],
+ [0.0, 29.14936392732455, 255.0],
+ [10.484721759392611, 0.0, 255.0],
+ [50.11880744611004, 0.0, 255.0],
+ [89.75289313282732, 0.0, 255.0],
+ [129.38697881954448, 0.0, 255.0],
+ [169.02106450626192, 0.0, 255.0],
+ [169.02106450626192, 0.0, 255.0],
+ [255.0, 0.0, 142.80850706015173],
+ [169.02106450626192, 0.0, 255.0],
+ [255.0, 0.0, 142.80850706015173],
+ [255.0, 0.0, 142.80850706015173],
+ [255.0, 0.0, 103.17442137343447],
+ [255.0, 0.0, 63.54033568671722],
+ [255.0, 0.0, 23.90625],
+ ]
+ )
+ / 255
+ )
+ superanimal_humanbody_colors = (
+ np.array(
+ [
+ [255, 0, 0],
+ [255, 20, 0],
+ [255, 40, 0],
+ [255, 60, 0],
+ [255, 80, 0],
+ [255, 100, 0],
+ [255, 120, 0],
+ [255, 140, 0],
+ [255, 160, 0],
+ [255, 180, 0],
+ [255, 200, 0],
+ [255, 220, 0],
+ [255, 240, 0],
+ [255, 255, 0],
+ [220, 255, 0],
+ [180, 255, 0],
+ [140, 255, 0],
+ ]
+ )
+ / 255
+ )
- dlc_path = deeplabcut.utils.auxiliaryfunctions.get_deeplabcut_path()
- json_path = os.path.join(dlc_path, "modelzoo", "models.json")
- with open(json_path) as file:
- return json.load(file)
+ superanimal_colormaps = {
+ "superanimal_bird": ListedColormap(list(superanimal_bird_colors), name="superanimal_bird"),
+ "superanimal_topviewmouse": ListedColormap(
+ list(superanimal_topviewmouse_colors), name="superanimal_topviewmouse"
+ ),
+ "superanimal_quadruped": ListedColormap(list(superanimal_quadruped_colors), name="superanimal_quadruped"),
+ "superanimal_humanbody": ListedColormap(list(superanimal_humanbody_colors), name="superanimal_humanbody"),
+ }
+ return superanimal_colormaps
diff --git a/deeplabcut/modelzoo/video_inference.py b/deeplabcut/modelzoo/video_inference.py
new file mode 100644
index 0000000000..a516cc359a
--- /dev/null
+++ b/deeplabcut/modelzoo/video_inference.py
@@ -0,0 +1,647 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import json
+import logging
+import os
+import warnings
+from collections.abc import Sequence
+from pathlib import Path
+
+import torch
+from dlclibrary.dlcmodelzoo.modelzoo_download import download_huggingface_model
+from ruamel.yaml import YAML
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.modelzoo.utils import get_super_animal_scorer
+from deeplabcut.pose_estimation_pytorch.modelzoo.train_from_coco import adaptation_train
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ get_snapshot_folder_path,
+ get_super_animal_snapshot_path,
+ load_super_animal_config,
+ update_config,
+)
+from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path
+from deeplabcut.utils.deprecation import renamed_parameter
+from deeplabcut.utils.pseudo_label import (
+ dlc3predictions_2_annotation_from_video,
+ video_to_frames,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def get_checkpoint_epoch(checkpoint_path):
+ """Load a PyTorch checkpoint and return the current epoch number.
+
+ Args:
+ checkpoint_path (str): Path to the checkpoint file
+
+ Returns:
+ int: Current epoch number, or 0 if not found
+ """
+ # For reading metadata, it is recommended to load onto the CPU
+ checkpoint = torch.load(checkpoint_path, map_location="cpu")
+ if "metadata" in checkpoint and "epoch" in checkpoint["metadata"]:
+ return checkpoint["metadata"]["epoch"]
+ else:
+ return 0
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def video_inference_superanimal(
+ videos: str | list,
+ superanimal_name: str,
+ model_name: str,
+ detector_name: str | None = None,
+ scale_list: list | None = None,
+ video_extensions: str | Sequence[str] | None = None,
+ dest_folder: str | None = None,
+ cropping: list[int] | None = None,
+ video_adapt: bool = False,
+ plot_trajectories: bool = False,
+ batch_size: int = 1,
+ detector_batch_size: int = 1,
+ pcutoff: float = 0.1,
+ adapt_iterations: int = 1000,
+ pseudo_threshold: float = 0.1,
+ bbox_threshold: float = 0.9,
+ detector_epochs: int = 4,
+ pose_epochs: int = 4,
+ max_individuals: int = 10,
+ video_adapt_batch_size: int = 8,
+ device: str | None = "auto",
+ customized_pose_checkpoint: str | None = None,
+ customized_detector_checkpoint: str | None = None,
+ customized_model_config: str | None = None,
+ plot_bboxes: bool = True,
+ create_labeled_video: bool = True,
+ fmpose_return_3d: bool = False,
+):
+ """This function performs inference on videos using a pretrained SuperAnimal model.
+
+ IMPORTANT: Note that since we have both TensorFlow and PyTorch Engines, we will
+ route the engine based on the model you select:
+
+ * dlcrnet -> TensorFlow
+ * all others - > PyTorch
+
+ Parameters
+ ----------
+
+ videos (str or list):
+ The path to the video or a list of paths to videos.
+
+ superanimal_name (str):
+ The name of the SuperAnimal dataset for which to load a pre-trained model.
+
+ model_name (str):
+ The model architecture to use for inference.
+
+ detector_name (str):
+ For top-down models (only available with the PyTorch framework), the type of
+ object detector to use for inference.
+
+ scale_list (list):
+ A list of different resolutions for the spatial pyramid. Used only for bottom up models.
+
+ video_extensions (str | Sequence[str] | None, default=None):
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+
+ dest_folder (str): The path to the folder where the results should be saved.
+
+ cropping: list or None, optional, default=None
+ Only for SuperAnimal models running with the PyTorch engine.
+ List of cropping coordinates as [x1, x2, y1, y2].
+ Note that the same cropping parameters will then be used for all videos.
+ If different video crops are desired, run ``video_inference_superanimal`` on
+ individual videos with the corresponding cropping coordinates.
+
+ video_adapt (bool):
+ Whether to perform video adaptation. The default is False.
+ You only need to perform it on one video because the adaptation generalizes to all videos that are similar.
+
+ plot_trajectories (bool):
+ Whether to plot the trajectories. The default is False.
+
+ batch_size (int):
+ The batch size to use for video inference. Only for PyTorch models.
+
+ detector_batch_size (int):
+ The batch size to use for the detector during video inference. Only for PyTorch.
+
+ pcutoff (float):
+ The p-value cutoff for the confidence of the prediction. The default is 0.1.
+
+ adapt_iterations (int):
+ Number of iterations for adaptation training. Empirically 1000 is sufficient.
+
+ bbox_threshold (float):
+ The pseudo-label threshold for the confidence of the detector. The default is 0.9
+
+ detector_epochs (int):
+ Used in the PyTorch engine. The number of epochs for training the detector. The default is 4.
+
+ pose_epochs (int):
+ Used in the PyTorch engine. The number of epochs for training the pose estimator. The default is 4.
+
+ pseudo_threshold (float):
+ The pseudo-label threshold for the confidence of the prediction. The default is 0.1.
+
+ max_individuals (int):
+ The maximum number of individuals in the video. The default is 30. Used only for top down models.
+
+ video_adapt_batch_size (int):
+ The batch size to use for video adaptation.
+
+ device (str):
+ The device to use for inference. The default is None (CPU). Used only for PyTorch models.
+
+ customized_pose_checkpoint (str):
+ Used in the PyTorch engine. If specified, it replaces the default pose checkpoint.
+
+ customized_detector_checkpoint (str):
+ Used in the PyTorch engine. If specified, it replaces the default detector checkpoint.
+
+ customized_model_config (str):
+ Used for loading customized model config. Only supported in Pytorch
+
+ plot_bboxes (bool):
+ If using Top-Down approach, whether to plot the detector's bounding boxes. The default is True.
+
+ create_labeled_video (bool):
+ Specifies if a labeled video needs to be created, True by default.
+
+ fmpose_return_3d (bool):
+ Only used when ``model_name`` starts with ``"fmpose3d"``.
+ If True, include in-memory 3D poses in the return payload
+ (per video: ``{"df_2d": ..., "df_3d": ...}``).
+ If False (default), keep the legacy return payload with only
+ the 2D DataFrame per video.
+
+ Raises:
+ NotImplementedError:
+ If the model is not found in the modelzoo.
+ Warning: If the superanimal_name will be deprecated in the future.
+
+ FileNotFoundError:
+ If a non-existent path is passed to ``videos``.
+
+ (Model Explanation) SuperAnimal-Quadruped:
+ `superanimal_quadruped` models aim to work across a large range of quadruped
+ animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is
+ orthogonal to the animal ("side view"), and most of the data includes the animals
+ face (thus the front and side of the animal). You will note we have several variants
+ that differ in speed vs. performance, so please do test them out on your data to see
+ which is best suited for your application. Also note we have a "video adaptation"
+ feature, which lets you adapt your data to the model in a self-supervised way.
+ No labeling needed!
+
+ All model snapshots are automatically downloaded to modelzoo/checkpoints when used.
+
+ - PLEASE SEE THE FULL DATASHEET: https://zenodo.org/records/10619173
+ - MORE DETAILS ON THE MODELS (detector, pose estimators):
+ https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped
+ - We provide several models:
+ - `hrnet_w32` (Top-Down pose estimation model, PyTorch engine)
+ An `hrnet_w32` is a top-down model that is paired with a detector. That
+ means it takes a cropped image from an object detector and predicts the
+ keypoints. When selecting this variant, a `detector_name` must be set with
+ one of the provided object detectors.
+ - `dlcrnet` (TensorFlow engine)
+ This is a bottom-up model that predicts all keypoints then groups them into
+ individuals. This can be faster, but more error prone.
+ - We provide one object detector (only for the PyTorch engine):
+ - `fasterrcnn_resnet50_fpn_v2`
+ This is a FasterRCNN model with a ResNet backbone, see
+ https://pytorch.org/vision/stable/models/faster_rcnn.html
+
+ (Model Explanation) SuperAnimal-TopViewMouse:
+ `superanimal_topviewmouse` aims to work across lab mice in different lab settings
+ from a top-view perspective; this is very polar in many behavioral assays in freely
+ moving mice.
+
+ All model snapshots are automatically downloaded to modelzoo/checkpoints when used.
+
+ - [PLEASE SEE THE FULL DATASHEET HERE](https://zenodo.org/records/10618947)
+ - [MORE DETAILS ON THE MODELS (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-TopViewMouse)
+ - We provide several models:
+ - `hrnet_w32` (Top-Down pose estimation model, PyTorch engine)
+ An `hrnet_w32` is a top-down model that is paired with a detector. That
+ means it takes a cropped image from an object detector and predicts the
+ keypoints. When selecting this variant, a `detector_name` must be set with
+ one of the provided object detectors.
+ - `dlcrnet` (TensorFlow engine)
+ This is a bottom-up model that predicts all keypoints then groups them into
+ individuals. This can be faster, but more error prone.
+ - We provide one object detector (only for the PyTorch engine):
+ - `fasterrcnn_resnet50_fpn_v2`
+ This is a FasterRCNN model with a ResNet backbone, see
+ https://pytorch.org/vision/stable/models/faster_rcnn.html
+
+ (Model Explanation) SuperAnimal-Bird:
+ `superanimal_superbird` model aims to work on various bird species. It was developed
+ during the 2024 DLC AI Residency Program. More info can be
+ [found here](https://deeplabcut.medium.com/deeplabcut-ai-residency-2024-recap-working-with-the-superanimal-bird-model-and-dlc-3-0-live-e55807ca2c7c)
+
+ (Model Explanation) SuperAnimal-HumanBody:
+ `superanimal_humanbody` models aim to work across human body pose estimation
+ from various camera perspectives and environments. The models are designed to
+ handle different human poses, activities, and lighting conditions commonly
+ found in human motion analysis, sports analysis, and behavioral studies.
+
+ All model snapshots are automatically downloaded to modelzoo/checkpoints when used.
+
+ - We provide:
+ - `rtmpose_x` (Top-Down pose estimation model, PyTorch engine)
+ An `rtmpose_x` is a top-down model that is paired with a detector. That
+ means it takes a cropped image from an object detector and predicts the
+ keypoints. When selecting this variant, a `detector_name` must be set with
+ one of the provided object detectors. This model uses 17 body parts in
+ the COCO body7 format.
+ - The following object detectors can be used:
+ - `fasterrcnn_mobilenet_v3_large_fpn` (default)
+ This is a FasterRCNN model with a MobileNet backbone
+ - `fasterrcnn_resnet50_fpn`
+ - `fasterrcnn_resnet50_fpn_v2`
+ For more info, see https://pytorch.org/vision/stable/models/faster_rcnn.html
+
+ Examples (PyTorch Engine)
+ --------
+ >>> import deeplabcut.modelzoo.video_inference.video_inference_superanimal as video_inference_superanimal
+ >>> video_inference_superanimal(
+ videos=["/mnt/md0/shaokai/DLCdev/3mice_video1_short.mp4"],
+ superanimal_name="superanimal_topviewmouse",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_adapt=True,
+ max_individuals=3,
+ pseudo_threshold=0.1,
+ bbox_threshold=0.9,
+ detector_epochs=4,
+ pose_epochs=4,
+ )
+
+ Tips:
+ * max_individuals: make sure you correctly give the number of individuals. Our
+ inference api will only give up to max_individuals number of predictions.
+ * pseudo_threshold: the higher you set, the more aggressive you filter low
+ confidence predictions during video adaptation.
+ * bbox_threshold: the higher you set, the more aggressive you filter low confidence
+ bounding boxes during video adaptation. Different from our paper, we now add
+ video adaptation to the object detector as well.
+ * detector_epochs and pose_epochs do not need to be to high as video adaptation does
+ not require too much training. However, you can make them higher if you see a
+ substaintial gain in the training logs.
+
+ Examples
+ --------
+
+ >>> from deeplabcut.modelzoo.video_inference import video_inference_superanimal
+ >>> videos = ["/path/to/my/video.mp4"]
+ >>> superanimal_name = "superanimal_topviewmouse"
+ >>> video_extensions = "mp4"
+ >>> scale_list = [200, 300, 400]
+ >>> video_inference_superanimal(
+ videos,
+ superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ scale_list = scale_list,
+ video_extensions = video_extensions,
+ video_adapt = True,
+ )
+
+ Tips:
+ scale_list: it's recommended to leave this as empty list []. Empirically
+ [200, 300, 400] works well. We needed to do this as bottom-up models in TensorFlow
+ are sensitive to the scales of the image.
+ If you find your predictions not good without scale_list or it's too hard to find
+ the right scale_list, you can try to use the PyTorch engine.
+ """
+ if scale_list is None:
+ scale_list = []
+ if not model_name.startswith("fmpose3d"):
+ print(f"Running video inference on {videos} with {superanimal_name}_{model_name}")
+ dlc_root_path = get_deeplabcut_path()
+ modelzoo_path = os.path.join(dlc_root_path, "modelzoo")
+ available_architectures = json.load(open(os.path.join(modelzoo_path, "models_to_framework.json")))
+ framework = available_architectures[model_name]
+ print(f"Using {framework} for model {model_name}")
+ if framework == "tensorflow":
+ from deeplabcut.pose_estimation_tensorflow.modelzoo.api.superanimal_inference import (
+ _video_inference_superanimal,
+ )
+
+ weight_folder = get_snapshot_folder_path() / f"{superanimal_name}_{model_name}"
+ if not weight_folder.exists():
+ download_huggingface_model(superanimal_name, target_dir=str(weight_folder), rename_mapping=None)
+
+ if isinstance(videos, str):
+ videos = [videos]
+ _video_inference_superanimal(
+ videos,
+ superanimal_name,
+ model_name,
+ scale_list,
+ video_extensions,
+ video_adapt,
+ plot_trajectories,
+ pcutoff,
+ adapt_iterations,
+ pseudo_threshold,
+ create_labeled_video=create_labeled_video,
+ )
+ elif framework == "pytorch":
+ if model_name.startswith("fmpose3d"):
+ logger.info("Running video inference on %s using %s", videos, model_name)
+
+ recommended_superanimal_name = {
+ "fmpose3d_animals": "quadruped",
+ "fmpose3d_humans": "human",
+ }.get(model_name)
+
+ provided_superanimal_name = superanimal_name or ""
+ if superanimal_name != recommended_superanimal_name:
+ warnings.warn(
+ "For FMPose3D models, model selection is driven by 'model_name'. But for API "
+ "consistency, it is recommended to set 'superanimal_name' to the corresponding value."
+ f"Provided superanimal_name={provided_superanimal_name!r} differs from the "
+ f"recommended value for {model_name!r}: "
+ f"{recommended_superanimal_name!r}.",
+ stacklevel=2,
+ )
+
+ from deeplabcut.pose_estimation_pytorch.modelzoo.fmpose_3d.inference import (
+ _video_inference_fmpose3d,
+ )
+
+ return _video_inference_fmpose3d(
+ video_paths=videos,
+ model_name=model_name,
+ max_individuals=max_individuals,
+ pcutoff=pcutoff,
+ batch_size=batch_size,
+ dest_folder=dest_folder,
+ device=device,
+ create_labeled_video=create_labeled_video,
+ cropping=cropping,
+ include_3d_in_return=fmpose_return_3d,
+ )
+
+ torchvision_detector_name = None
+ if superanimal_name != "superanimal_humanbody" and detector_name is None:
+ raise ValueError("You have to specify a detector_name when using the Pytorch framework.")
+ elif superanimal_name == "superanimal_humanbody":
+ if detector_name:
+ torchvision_detector_name = detector_name
+ else:
+ torchvision_detector_name = "fasterrcnn_mobilenet_v3_large_fpn"
+
+ from deeplabcut.pose_estimation_pytorch.modelzoo.inference import (
+ _video_inference_superanimal,
+ )
+
+ if customized_model_config is not None:
+ config = read_config_as_dict(customized_model_config)
+ else:
+ config = load_super_animal_config(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=(detector_name if superanimal_name != "superanimal_humanbody" else None),
+ )
+
+ pose_model_path = customized_pose_checkpoint
+ if pose_model_path is None:
+ pose_model_path = get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=model_name,
+ )
+
+ detector_path = customized_detector_checkpoint
+ if detector_path is None and superanimal_name != "superanimal_humanbody":
+ detector_path = get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=detector_name,
+ )
+
+ dlc_scorer = get_super_animal_scorer(
+ superanimal_name, pose_model_path, detector_path, torchvision_detector_name
+ )
+
+ config = update_config(config, max_individuals, device)
+
+ output_suffix = "_before_adapt"
+
+ if video_adapt:
+ # the users can pass in many videos. For now, we only use one video for
+ # video adaptation. As reported in Ye et al. 2024, one video should be
+ # sufficient for video adaptation.
+ video_path = Path(videos[0])
+ print(f"Using {video_path} for video adaptation training")
+
+ # video inference to get pseudo label
+ _video_inference_superanimal(
+ [str(video_path)],
+ superanimal_name,
+ model_cfg=config,
+ model_snapshot_path=pose_model_path,
+ detector_snapshot_path=detector_path,
+ max_individuals=max_individuals,
+ pcutoff=pcutoff,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ cropping=cropping,
+ dest_folder=dest_folder,
+ output_suffix=output_suffix,
+ plot_bboxes=plot_bboxes,
+ bboxes_pcutoff=bbox_threshold,
+ create_labeled_video=create_labeled_video,
+ torchvision_detector_name=torchvision_detector_name,
+ )
+
+ # we prepare the pseudo dataset in the same folder of the target video
+ pseudo_dataset_folder = video_path.with_name(f"pseudo_{video_path.stem}")
+ pseudo_dataset_folder.mkdir(exist_ok=True)
+ model_folder = pseudo_dataset_folder / "checkpoints"
+ model_folder.mkdir(exist_ok=True)
+
+ image_folder = pseudo_dataset_folder / "images"
+ if image_folder.exists():
+ print(f"{image_folder} exists, skipping the frame extraction")
+ else:
+ image_folder.mkdir()
+ print(f"Video frames being extracted to {image_folder} for video adaptation.")
+ video_to_frames(video_path, pseudo_dataset_folder, cropping=cropping)
+
+ anno_folder = pseudo_dataset_folder / "annotations"
+ if (anno_folder / "train.json").exists() and (anno_folder / "test.json").exists():
+ print(
+ f"{anno_folder} exists, skipping the annotation construction. "
+ f"Delete the folder if you want to re-construct pseudo annotations"
+ )
+ else:
+ anno_folder.mkdir()
+
+ if dest_folder is None:
+ pseudo_anno_dir = video_path.parent
+ else:
+ pseudo_anno_dir = Path(dest_folder)
+
+ pseudo_anno_name = f"{video_path.stem}_{dlc_scorer}_before_adapt.json"
+ with open(pseudo_anno_dir / pseudo_anno_name) as f:
+ predictions = json.load(f)
+
+ # make sure we tune parameters inside this function such as pseudo
+ # threshold etc.
+ print(f"Constructing pseudo dataset at {pseudo_dataset_folder}")
+ dlc3predictions_2_annotation_from_video(
+ predictions,
+ pseudo_dataset_folder,
+ config["metadata"]["bodyparts"],
+ superanimal_name,
+ pose_threshold=pseudo_threshold,
+ bbox_threshold=bbox_threshold,
+ )
+
+ model_snapshot_prefix = f"snapshot-{model_name}"
+ config["runner"]["snapshot_prefix"] = model_snapshot_prefix
+
+ if superanimal_name != "superanimal_humanbody":
+ detector_snapshot_prefix = f"snapshot-{detector_name}"
+ config["detector"]["runner"]["snapshot_prefix"] = detector_snapshot_prefix
+
+ # the model config's parameters need to be updated for adaptation training
+ model_config_path = model_folder / "pytorch_config.yaml"
+ with open(model_config_path, "w") as f:
+ yaml = YAML()
+ yaml.dump(config, f)
+
+ # get the current epoch of the pose model
+ current_pose_epoch = get_checkpoint_epoch(pose_model_path)
+ # update the checkpoint path with the current epoch, if the checkpoint
+ # does not exist, use the best checkpoint
+ adapted_pose_checkpoint = model_folder / f"{model_snapshot_prefix}-{current_pose_epoch + pose_epochs:03}.pt"
+ if not Path(adapted_pose_checkpoint).exists():
+ adapted_pose_checkpoint = (
+ model_folder / f"{model_snapshot_prefix}-best-{current_pose_epoch + pose_epochs:03}.pt"
+ )
+
+ if superanimal_name != "superanimal_humanbody":
+ current_detector_epoch = get_checkpoint_epoch(detector_path)
+ adapted_detector_checkpoint = (
+ model_folder / f"{detector_snapshot_prefix}-{current_detector_epoch + detector_epochs:03}.pt"
+ )
+ if not Path(adapted_detector_checkpoint).exists():
+ adapted_detector_checkpoint = (
+ model_folder
+ / f"{detector_snapshot_prefix}-best-{current_detector_epoch + detector_epochs:03}.pt"
+ )
+
+ if (
+ superanimal_name == "superanimal_humanbody" or adapted_detector_checkpoint.exists()
+ ) and adapted_pose_checkpoint.exists():
+ snapshots_msg = f"pose ({adapted_pose_checkpoint})"
+ if superanimal_name != "superanimal_humanbody":
+ snapshots_msg += f" and detector ({adapted_detector_checkpoint})"
+ print(
+ f"Video adaptation already ran; {snapshots_msg} already exist. "
+ "To rerun video adaptation training, delete the checkpoints or select a different "
+ "number of adaptation epochs. Continuing with the existing checkpoints."
+ )
+ else:
+ params_msg = (
+ f" video adaptation batch size: {video_adapt_batch_size}\n"
+ f" (pose training) pose_epochs: {pose_epochs}\n"
+ " (pose) save_epochs: 1\n"
+ )
+ if superanimal_name != "superanimal_humanbody":
+ params_msg += f" detector_epochs: {detector_epochs}\n detector_save_epochs: 1\n"
+ print("Running video adaptation with following parameters:\n" + params_msg)
+
+ train_file = pseudo_dataset_folder / "annotations" / "train.json"
+ with open(train_file) as f:
+ temp_obj = json.load(f)
+
+ annotations = temp_obj["annotations"]
+ if len(annotations) == 0:
+ print(f"No valid predictions from {str(video_path)}. Check the quality of the video")
+ return
+
+ if superanimal_name == "superanimal_humanbody":
+ print("Warning, with the superanimal_humanbody type, only the pose model is adapted")
+
+ adaptation_train(
+ project_root=pseudo_dataset_folder,
+ model_folder=model_folder,
+ train_file="train.json",
+ test_file="test.json",
+ model_config_path=model_config_path,
+ device=device,
+ epochs=pose_epochs,
+ save_epochs=1,
+ detector_epochs=detector_epochs,
+ detector_save_epochs=1,
+ snapshot_path=pose_model_path,
+ detector_path=detector_path,
+ batch_size=video_adapt_batch_size,
+ detector_batch_size=video_adapt_batch_size,
+ skip_detector=(superanimal_name == "superanimal_humanbody"),
+ )
+
+ # after video adaptation, re-update the adapted checkpoint path, if the
+ # checkpoint does not exist, use the best checkpoint
+ adapted_pose_checkpoint = model_folder / f"{model_snapshot_prefix}-{current_pose_epoch + pose_epochs:03}.pt"
+ if not Path(adapted_pose_checkpoint).exists():
+ adapted_pose_checkpoint = (
+ model_folder / f"{model_snapshot_prefix}-best-{current_pose_epoch + pose_epochs:03}.pt"
+ )
+ pose_model_path = adapted_pose_checkpoint
+
+ if superanimal_name != "superanimal_humanbody":
+ adapted_detector_checkpoint = (
+ model_folder / f"{detector_snapshot_prefix}-{current_detector_epoch + detector_epochs:03}.pt"
+ )
+ if not Path(adapted_detector_checkpoint).exists():
+ adapted_detector_checkpoint = (
+ model_folder
+ / f"{detector_snapshot_prefix}-best-{current_detector_epoch + detector_epochs:03}.pt"
+ )
+ detector_path = adapted_detector_checkpoint
+
+ # Set the customized checkpoint paths and
+ output_suffix = "_after_adapt"
+
+ return _video_inference_superanimal(
+ videos,
+ superanimal_name,
+ model_cfg=config,
+ model_snapshot_path=pose_model_path,
+ detector_snapshot_path=detector_path,
+ max_individuals=max_individuals,
+ pcutoff=pcutoff,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ cropping=cropping,
+ dest_folder=dest_folder,
+ output_suffix=output_suffix,
+ plot_bboxes=plot_bboxes,
+ bboxes_pcutoff=bbox_threshold,
+ create_labeled_video=create_labeled_video,
+ torchvision_detector_name=torchvision_detector_name,
+ )
diff --git a/deeplabcut/modelzoo/webapp/__init__.py b/deeplabcut/modelzoo/webapp/__init__.py
new file mode 100644
index 0000000000..117d127147
--- /dev/null
+++ b/deeplabcut/modelzoo/webapp/__init__.py
@@ -0,0 +1,10 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/modelzoo/webapp/inference.py b/deeplabcut/modelzoo/webapp/inference.py
new file mode 100644
index 0000000000..8e586ba24a
--- /dev/null
+++ b/deeplabcut/modelzoo/webapp/inference.py
@@ -0,0 +1,112 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import numpy as np
+
+import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import update_config
+
+
+class SingletonTopDownRunners:
+ """Singleton class for topdown runners.
+
+ This class is a singleton class for topdown runners. It is used to
+ ensure that only one instance of the topdown runners is created.
+
+ Attrs:
+ config: Configuration dictionary
+ pose_model_path: Path to the pose model
+ detector_model_path: Path to the detector model
+ num_bodyparts: Number of bodyparts
+ max_individuals: Maximum number of individuals
+ """
+
+ _instance = None
+
+ def __new__(cls, *args, **kwargs):
+ if not cls._instance:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(
+ self,
+ config,
+ pose_model_path: str,
+ detector_model_path: str,
+ num_bodyparts: int,
+ max_individuals: int,
+ ):
+
+ pose_runner, detector_runner = get_inference_runners(
+ config,
+ snapshot_path=pose_model_path,
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ num_unique_bodyparts=0,
+ detector_path=detector_model_path,
+ )
+ self.pose_runner = pose_runner
+ self.detector_runner = detector_runner
+
+
+class SuperanimalPyTorchInference:
+ """Superanimal inference class.
+
+ This class is used to perform inference on a superanimal model from the DeepLabCut
+ model zoo website.
+ """
+
+ def __init__(
+ self,
+ project_name: str,
+ pose_model_type: str = "hrnet_w32",
+ detector_model_type: str = "fasterrcnn_resnet50_fpn_v2",
+ max_individuals: int = 30,
+ device: str = "cpu",
+ ):
+ self.max_individuals = max_individuals
+ config = modelzoo.load_super_animal_config(
+ super_animal=project_name,
+ model_name=pose_model_type,
+ detector_name=detector_model_type,
+ )
+ config = update_config(config, max_individuals, device)
+ self._config = config
+
+ def initialize_models(self, pose_model_path: str, detector_model_path: str):
+ self.models = SingletonTopDownRunners(
+ self.config,
+ pose_model_path,
+ detector_model_path,
+ len(self.config["bodyparts"]),
+ self.max_individuals,
+ )
+
+ @property
+ def config(self):
+ return self._config
+
+ def predict(self, frames: dict[str, np.array]):
+
+ input_images = np.array(list(frames.values()), dtype=float)
+
+ bbox_predictions = self.models.detector_runner.inference(images=input_images)
+ input_images = list(zip(input_images, bbox_predictions, strict=False))
+ predictions = self.models.pose_runner.inference(images=input_images)
+ predictions = [{("markers" if k == "bodyparts" else k): v for k, v in d.items()} for d in predictions]
+ predictions = [{**item[1], "image_path": item[0]} for item in zip(frames.keys(), predictions, strict=False)]
+ responses = {
+ "joint_names": self.config["bodyparts"],
+ "predictions": predictions,
+ }
+
+ return responses
diff --git a/deeplabcut/modelzoo/weight_initialization.py b/deeplabcut/modelzoo/weight_initialization.py
new file mode 100644
index 0000000000..bb36e49809
--- /dev/null
+++ b/deeplabcut/modelzoo/weight_initialization.py
@@ -0,0 +1,115 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Functions to build weight initialization parameters for SuperAnimal models."""
+
+from pathlib import Path
+
+import deeplabcut.modelzoo.utils as utils
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ get_super_animal_snapshot_path,
+)
+
+
+def build_weight_init(
+ cfg: dict | str | Path,
+ super_animal: str,
+ model_name: str,
+ detector_name: str | None,
+ with_decoder: bool = False,
+ memory_replay: bool = False,
+ customized_pose_checkpoint: str | Path | None = None,
+ customized_detector_checkpoint: str | Path | None = None,
+) -> WeightInitialization:
+ """Builds the WeightInitialization from a SuperAnimal model for a project.
+
+ Args:
+ cfg: The project's configuration, or the path to the project configuration file.
+ super_animal: The SuperAnimal model with which to initialize weights.
+ model_name: The type of the model architecture for which to load the weights.
+ detector_name: The type of detector architecture for which to load the weights.
+ with_decoder: Whether to load the decoder weights as well. If this is true,
+ a conversion table must be specified for the given SuperAnimal in the
+ project configuration file. See
+ ``deeplabcut.modelzoo.utils.create_conversion_table`` to create a
+ conversion table.
+ memory_replay: Only when ``with_decoder=True``. Whether to train the model
+ with memory replay, so that it predicts all SuperAnimal bodyparts.
+ customized_pose_checkpoint: A customized SuperAnimal pose checkpoint, as an
+ alternative to the Hugging Face one
+ customized_detector_checkpoint: A customized SuperAnimal detector checkpoint, as
+ an alternative to the Hugging Face one
+
+ To build a WeightInitialization instance for a project using the conversion table
+ specified in the project configuration file, use:
+
+ ```
+ from pathlib import Path
+ from deeplabcut.utils.auxiliaryfunctions import read_config
+ from deeplabcut.modelzoo import build_weight_init
+
+ project_cfg = read_config("/path/to/my/project/config.yaml")
+ super_animal = "superanimal_quadruped"
+ weight_init = build_weight_init(
+ cfg=project_cfg,
+ super_animal="superanimal_quadruped",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ with_decoder=True,
+ memory_replay=False,
+ )
+ ```
+
+ Returns:
+ The built WeightInitialization.
+ """
+ if super_animal == "superanimal_humanbody":
+ raise NotImplementedError(
+ "Weight Initialization, Transfer-Learning and Finetuning is currently not supported for"
+ "superanimal_humanbody"
+ )
+
+ if isinstance(cfg, (str, Path)):
+ cfg = read_config_as_dict(cfg)
+
+ conversion_array = None
+ bodyparts = None
+ if with_decoder:
+ conversion_table = utils.get_conversion_table(cfg, super_animal)
+ conversion_array = conversion_table.to_array()
+ bodyparts = conversion_table.converted_bodyparts()
+
+ snapshot_path = customized_pose_checkpoint
+ if snapshot_path is None:
+ snapshot_path = get_super_animal_snapshot_path(
+ dataset=super_animal,
+ model_name=model_name,
+ download=True,
+ )
+
+ detector_snapshot_path = customized_detector_checkpoint
+ if detector_snapshot_path is None and detector_name is not None:
+ detector_snapshot_path = get_super_animal_snapshot_path(
+ dataset=super_animal,
+ model_name=detector_name,
+ download=True,
+ )
+
+ return WeightInitialization(
+ snapshot_path=snapshot_path,
+ detector_snapshot_path=detector_snapshot_path,
+ dataset=super_animal,
+ with_decoder=with_decoder,
+ memory_replay=memory_replay,
+ conversion_array=conversion_array,
+ bodyparts=bodyparts,
+ )
diff --git a/deeplabcut/pose_cfg.yaml b/deeplabcut/pose_cfg.yaml
index 62e7681469..2142379da3 100644
--- a/deeplabcut/pose_cfg.yaml
+++ b/deeplabcut/pose_cfg.yaml
@@ -83,7 +83,7 @@ contrast:
claheratio: 0.1
histeq: True
histeqratio: 0.1
-
+
# dictionary with convolution parameters
convolution:
sharpen: False
diff --git a/deeplabcut/pose_estimation_3d/camera_calibration.py b/deeplabcut/pose_estimation_3d/camera_calibration.py
index 1fbb9d8b18..75e6752a64 100644
--- a/deeplabcut/pose_estimation_3d/camera_calibration.py
+++ b/deeplabcut/pose_estimation_3d/camera_calibration.py
@@ -20,21 +20,27 @@
import numpy as np
from matplotlib.axes._axes import _log as matplotlib_axes_logger
-from deeplabcut.utils import auxiliaryfunctions
-from deeplabcut.utils import auxiliaryfunctions_3d
+from deeplabcut.utils import auxiliaryfunctions, auxiliaryfunctions_3d
matplotlib_axes_logger.setLevel("ERROR")
def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, search_window_size=(11, 11)):
- """This function extracts the corners points from the calibration images, calibrates the camera and stores the calibration files in the project folder (defined in the config file).
+ """This function extracts the corners points from the calibration images, calibrates
+ the camera and stores the calibration files in the project folder (defined in the
+ config file).
- Make sure you have around 20-60 pairs of calibration images. The function should be used iteratively to select the right set of calibration images.
+ Make sure you have around 20-60 pairs of calibration images.
+ The function should be used iteratively to select the right set of calibration images.
- A pair of calibration image is considered "correct", if the corners are detected correctly in both the images. It may happen that during the first run of this function,
- the extracted corners are incorrect or the order of detected corners does not align for the corresponding views (i.e. camera-1 and camera-2 images).
+ A pair of calibration image is considered "correct",
+ if the corners are detected correctly in both the images.
+ It may happen that during the first run of this function,
+ the extracted corners are incorrect or the order of detected corners
+ does not align for the corresponding views (i.e. camera-1 and camera-2 images).
- In such a case, remove those pairs of images and re-run this function. Once the right number of calibration images are selected,
+ In such a case, remove those pairs of images and re-run this function.
+ Once the right number of calibration images are selected,
use the parameter ``calibrate=True`` to calibrate the cameras.
Parameters
@@ -49,11 +55,14 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
Integer specifying the number of columns in the calibration image.
calibrate : bool
- If this is set to True, the cameras are calibrated with the current set of calibration images. The default is ``False``
- Set it to True, only after checking the results of the corner detection method and removing dysfunctional images!
+ If this is set to True, the cameras are calibrated with the current set of calibration images.
+ The default is ``False``
+ Set it to True, only after checking the results of the corner detection method
+ and removing dysfunctional images!
alpha: float
- Floating point number between 0 and 1 specifying the free scaling parameter. When alpha = 0, the rectified images with only valid pixels are stored
+ Floating point number between 0 and 1 specifying the free scaling parameter.
+ When alpha = 0, the rectified images with only valid pixels are stored
i.e. the rectified images are zoomed in. When alpha = 1, all the pixels from the original images are retained.
For more details: https://docs.opencv.org/2.4/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html
@@ -67,7 +76,6 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
Once the right set of calibration images are selected,
>>> deeplabcut.calibrate_camera(config,calibrate=True)
-
"""
# Termination criteria
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
@@ -92,14 +100,10 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
# update the variable snapshot* in config file according to the name of the cameras
try:
for i in range(len(cam_names)):
- cfg_3d[str("config_file_" + cam_names[i])] = cfg_3d.pop(
- str("config_file_camera-" + str(i + 1))
- )
+ cfg_3d[str("config_file_" + cam_names[i])] = cfg_3d.pop(str("config_file_camera-" + str(i + 1)))
for i in range(len(cam_names)):
- cfg_3d[str("shuffle_" + cam_names[i])] = cfg_3d.pop(
- str("shuffle_camera-" + str(i + 1))
- )
- except:
+ cfg_3d[str("shuffle_" + cam_names[i])] = cfg_3d.pop(str("shuffle_camera-" + str(i + 1)))
+ except Exception:
pass
project_path = cfg_3d["project_path"]
@@ -121,7 +125,9 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
images.sort(key=lambda f: int("".join(filter(str.isdigit, f))))
if len(images) == 0:
raise Exception(
- "No calibration images found. Make sure the calibration images are saved as .jpg and with prefix as the camera name as specified in the config.yaml file."
+ "No calibration images found. "
+ "Make sure the calibration images are saved as .jpg and "
+ "with prefix as the camera name as specified in the config.yaml file."
)
skip_images = []
@@ -138,20 +144,16 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
) # (8,6) pattern (dimensions = common points of black squares)
# If found, add object points, image points (after refining them)
- if ret == True:
+ if ret:
img_shape[cam] = gray.shape[::-1]
objpoints[cam].append(objp)
- corners = cv2.cornerSubPix(
- gray, corners, search_window_size, (-1, -1), criteria
- )
+ corners = cv2.cornerSubPix(gray, corners, search_window_size, (-1, -1), criteria)
imgpoints[cam].append(corners)
# Draw the corners and store the images
img = cv2.drawChessboardCorners(img, (cbcol, cbrow), corners, ret)
- cv2.imwrite(
- os.path.join(str(path_corners), filename + "_corner.jpg"), img
- )
+ cv2.imwrite(os.path.join(str(path_corners), filename + "_corner.jpg"), img)
else:
- print("Corners not found for the image %s" % Path(fname).name)
+ print(f"Corners not found for the image {Path(fname).name}")
for new_cam in cam_names:
remove_fname = Path(fname).name.replace(cam, new_cam)
os.rename(
@@ -163,13 +165,16 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
try:
h, w = img.shape[:2]
- except:
+ except Exception as e:
raise Exception(
- "It seems that the name of calibration images does not match with the camera names in the config file. Please make sure that the calibration images are named with camera names as specified in the config.yaml file."
- )
+ "It seems that the name of calibration images does not match "
+ "with the camera names in the config file. "
+ "Please make sure that the calibration images are named"
+ " with camera names as specified in the config.yaml file."
+ ) from e
# Perform calibration for each cameras and store the matrices as a pickle file
- if calibrate == True:
+ if calibrate:
# Calibrating each camera
for cam in cam_names:
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
@@ -191,29 +196,22 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
),
)
print(
- "Saving intrinsic camera calibration matrices for %s as a pickle file in %s"
- % (cam, os.path.join(path_camera_matrix))
+ f"Saving intrinsic camera calibration matrices for {cam}"
+ f" as a pickle file in {os.path.join(path_camera_matrix)}"
)
# Compute mean re-projection errors for individual cameras
mean_error = 0
for i in range(len(objpoints[cam])):
- imgpoints_proj, _ = cv2.projectPoints(
- objpoints[cam][i], rvecs[i], tvecs[i], mtx, dist
- )
- error = cv2.norm(imgpoints[cam][i], imgpoints_proj, cv2.NORM_L2) / len(
- imgpoints_proj
- )
+ imgpoints_proj, _ = cv2.projectPoints(objpoints[cam][i], rvecs[i], tvecs[i], mtx, dist)
+ error = cv2.norm(imgpoints[cam][i], imgpoints_proj, cv2.NORM_L2) / len(imgpoints_proj)
mean_error += error
- print(
- "Mean re-projection error for %s images: %.3f pixels "
- % (cam, mean_error / len(objpoints[cam]))
- )
+ print(f"Mean re-projection error for {cam} images: {mean_error / len(objpoints[cam]):.3f} pixels ")
# Compute stereo calibration for each pair of cameras
camera_pair = [[cam_names[0], cam_names[1]]]
for pair in camera_pair:
- print("Computing stereo calibration for " % pair)
+ print("Computing stereo calibration for ")
(
retval,
cameraMatrix1,
@@ -269,27 +267,25 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4, sear
}
print(
- "Saving the stereo parameters for every pair of cameras as a pickle file in %s"
- % str(os.path.join(path_camera_matrix))
+ "Saving the stereo parameters for every "
+ f"pair of cameras as a pickle file in {str(os.path.join(path_camera_matrix))}"
)
- auxiliaryfunctions.write_pickle(
- os.path.join(path_camera_matrix, "stereo_params.pickle"), stereo_params
- )
- print(
- "Camera calibration done! Use the function ``check_undistortion`` to check the check the calibration"
- )
+ auxiliaryfunctions.write_pickle(os.path.join(path_camera_matrix, "stereo_params.pickle"), stereo_params)
+ print("Camera calibration done! Use the function ``check_undistortion`` to check the check the calibration")
else:
print(
- "Corners extracted! You may check for the extracted corners in the directory %s and remove the pair of images where the corners are incorrectly detected. If all the corners are detected correctly with right order, then re-run the same function and use the flag ``calibrate=True``, to calbrate the camera."
- % str(path_corners)
+ f"Corners extracted! You may check for the extracted corners in the directory {str(path_corners)}"
+ " and remove the pair of images where the corners are incorrectly detected. "
+ "If all the corners are detected correctly with right order, "
+ "then re-run the same function and use the flag ``calibrate=True``, to calbrate the camera."
)
def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
- """
- This function undistorts the calibration images based on the camera matrices and stores them in the project folder(defined in the config file)
- to visually check if the camera matrices are correct.
+ """This function undistorts the calibration images based on the camera matrices and
+ stores them in the project folder(defined in the config file) to visually check if
+ the camera matrices are correct.
Parameters
----------
@@ -303,13 +299,13 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
Int specifying the number of columns in the calibration image.
plot : bool
- If this is set to True, the results of undistortion are saved as plots. The default is ``True``; if provided it must be either ``True`` or ``False``.
+ If this is set to True, the results of undistortion are saved as plots.
+ The default is ``True``; if provided it must be either ``True`` or ``False``.
Example
--------
Linux/MacOs/Windows
>>> deeplabcut.check_undistortion(config, cbrow = 8,cbcol = 6)
-
"""
# Read the config file
@@ -344,9 +340,7 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
"""
camera_pair = [[cam_names[0], cam_names[1]]]
- stereo_params = auxiliaryfunctions.read_pickle(
- os.path.join(path_camera_matrix, "stereo_params.pickle")
- )
+ stereo_params = auxiliaryfunctions.read_pickle(os.path.join(path_camera_matrix, "stereo_params.pickle"))
for pair in camera_pair:
map1_x, map1_y = cv2.initUndistortRectifyMap(
@@ -375,17 +369,13 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
h, w = img1.shape[:2]
_, corners1 = cv2.findChessboardCorners(gray1, (cbcol, cbrow), None)
- corners_origin1 = cv2.cornerSubPix(
- gray1, corners1, (11, 11), (-1, -1), criteria
- )
+ corners_origin1 = cv2.cornerSubPix(gray1, corners1, (11, 11), (-1, -1), criteria)
# Remapping dataFrame_camera1_undistort
im_remapped1 = cv2.remap(img1, map1_x, map1_y, cv2.INTER_LANCZOS4)
imgpoints_proj_undistort = cv2.undistortPoints(
src=corners_origin1,
- cameraMatrix=stereo_params[pair[0] + "-" + pair[1]][
- "cameraMatrix1"
- ],
+ cameraMatrix=stereo_params[pair[0] + "-" + pair[1]]["cameraMatrix1"],
distCoeffs=stereo_params[pair[0] + "-" + pair[1]]["distCoeffs1"],
P=stereo_params[pair[0] + "-" + pair[1]]["P1"],
R=stereo_params[pair[0] + "-" + pair[1]]["R1"],
@@ -403,17 +393,13 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
h, w = img2.shape[:2]
_, corners2 = cv2.findChessboardCorners(gray2, (cbcol, cbrow), None)
- corners_origin2 = cv2.cornerSubPix(
- gray2, corners2, (11, 11), (-1, -1), criteria
- )
+ corners_origin2 = cv2.cornerSubPix(gray2, corners2, (11, 11), (-1, -1), criteria)
# Remapping
im_remapped2 = cv2.remap(img2, map2_x, map2_y, cv2.INTER_LANCZOS4)
imgpoints_proj_undistort2 = cv2.undistortPoints(
src=corners_origin2,
- cameraMatrix=stereo_params[pair[0] + "-" + pair[1]][
- "cameraMatrix2"
- ],
+ cameraMatrix=stereo_params[pair[0] + "-" + pair[1]]["cameraMatrix2"],
distCoeffs=stereo_params[pair[0] + "-" + pair[1]]["distCoeffs2"],
P=stereo_params[pair[0] + "-" + pair[1]]["P2"],
R=stereo_params[pair[0] + "-" + pair[1]]["R2"],
@@ -427,12 +413,10 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
cam1_undistort = np.array(cam1_undistort)
cam2_undistort = np.array(cam2_undistort)
- print("All images are undistorted and stored in %s" % str(path_undistort))
- print(
- "Use the function ``triangulate`` to undistort the dataframes and compute the triangulation"
- )
+ print(f"All images are undistorted and stored in {str(path_undistort)}")
+ print("Use the function ``triangulate`` to undistort the dataframes and compute the triangulation")
- if plot == True:
+ if plot:
f1, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 10))
f1.suptitle(
str("Original Image: Views from " + pair[0] + " and " + pair[1]),
@@ -443,14 +427,12 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
ax1.imshow(cv2.cvtColor(img1, cv2.COLOR_BGR2RGB))
ax2.imshow(cv2.cvtColor(img2, cv2.COLOR_BGR2RGB))
- norm = mcolors.Normalize(vmin=0.0, vmax=cam1_undistort.shape[1])
+ mcolors.Normalize(vmin=0.0, vmax=cam1_undistort.shape[1])
plt.savefig(os.path.join(str(path_undistort), "Original_Image.png"))
# Plot the undistorted corner points
f2, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 10))
- f2.suptitle(
- "Undistorted corner points on camera-1 and camera-2", fontsize=25
- )
+ f2.suptitle("Undistorted corner points on camera-1 and camera-2", fontsize=25)
ax1.imshow(cv2.cvtColor(im_remapped1, cv2.COLOR_BGR2RGB))
ax2.imshow(cv2.cvtColor(im_remapped2, cv2.COLOR_BGR2RGB))
for i in range(0, cam1_undistort.shape[1]):
@@ -473,14 +455,12 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
plt.savefig(os.path.join(str(path_undistort), "undistorted_points.png"))
# Triangulate
- triangulate = (
- auxiliaryfunctions_3d.compute_triangulation_calibration_images(
- stereo_params[pair[0] + "-" + pair[1]],
- cam1_undistort,
- cam2_undistort,
- path_undistort,
- cfg_3d,
- plot=True,
- )
+ triangulate = auxiliaryfunctions_3d.compute_triangulation_calibration_images(
+ stereo_params[pair[0] + "-" + pair[1]],
+ cam1_undistort,
+ cam2_undistort,
+ path_undistort,
+ cfg_3d,
+ plot=True,
)
auxiliaryfunctions.write_pickle("triangulate.pickle", triangulate)
diff --git a/deeplabcut/pose_estimation_3d/plotting3D.py b/deeplabcut/pose_estimation_3d/plotting3D.py
index 285806a945..5383401a9c 100644
--- a/deeplabcut/pose_estimation_3d/plotting3D.py
+++ b/deeplabcut/pose_estimation_3d/plotting3D.py
@@ -16,7 +16,12 @@
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
+from matplotlib import gridspec
+from matplotlib.animation import FFMpegWriter
from matplotlib.axes._axes import _log as matplotlib_axes_logger
+from matplotlib.collections import LineCollection
+from mpl_toolkits.mplot3d.art3d import Line3DCollection
+from tqdm import tqdm
from deeplabcut.utils import (
auxiliaryfunctions,
@@ -26,11 +31,6 @@
from deeplabcut.utils.auxfun_videos import VideoReader
matplotlib_axes_logger.setLevel("ERROR")
-from matplotlib import gridspec
-from matplotlib.animation import FFMpegWriter
-from matplotlib.collections import LineCollection
-from mpl_toolkits.mplot3d.art3d import Line3DCollection
-from tqdm import tqdm
def set_up_grid(figsize, xlim, ylim, zlim, view):
@@ -54,6 +54,9 @@ def set_up_grid(figsize, xlim, ylim, zlim, view):
return fig, axes1, axes2, axes3
+# TODO: @deruyter92 2026-05-20: the function signature could be updated to match
+# other API (i.e. videotype: str -> video_extensions: str | Sequence[str] | None)
+# this requires updating Get_list_of_triangulated_and_videoFiles.
def create_labeled_video_3d(
config,
path,
@@ -72,8 +75,8 @@ def create_labeled_video_3d(
fps=30,
dpi=300,
):
- """
- Creates a video with views from the two cameras and the 3d reconstruction for a selected number of frames.
+ """Creates a video with views from the two cameras and the 3d reconstruction for a
+ selected number of frames.
Parameters
----------
@@ -81,38 +84,56 @@ def create_labeled_video_3d(
Full path of the config.yaml file as a string.
path : list
- A list of strings containing the full paths to triangulated files for analysis or a path to the directory, where all the triangulated files are stored.
+ A list of strings containing the full paths to triangulated files for analysis or a path to the directory,
+ where all the triangulated files are stored.
videofolder: string
- Full path of the folder where the videos are stored. Use this if the vidoes are stored in a different location other than where the triangulation files are stored. By default is ``None`` and therefore looks for video files in the directory where the triangulation file is stored.
+ Full path of the folder where the videos are stored.
+ Use this if the videos are stored in a different location other than
+ where the triangulation files are stored.
+ By default is ``None`` and therefore looks for video files in the
+ directory where the triangulation file is stored.
start: int
- Integer specifying the start of frame index to select. Default is set to 0.
+ Integer specifying the start of frame index to select.
+ Default is set to 0.
end: int
- Integer specifying the end of frame index to select. Default is set to None, where all the frames of the video are used for creating the labeled video.
+ Integer specifying the end of frame index to select.
+ Default is set to None, where all the frames of the video are used for creating the labeled video.
trailpoints: int
- Number of revious frames whose body parts are plotted in a frame (for displaying history). Default is set to 0.
+ Number of revious frames whose body parts are plotted in a frame (for displaying history).
+ Default is set to 0.
videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
+ Checks for the extension of the video in case the input to the video is a directory.\n
+ Only videos with this extension are analyzed.
If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
view: list
- A list that sets the elevation angle in z plane and azimuthal angle in x,y plane of 3d view. Useful for rotating the axis for 3d view
+ A list that sets the elevation angle in z plane and azimuthal angle in x,y plane of 3d view.
+ Useful for rotating the axis for 3d view
xlim: list
- A list of integers specifying the limits for xaxis of 3d view. By default it is set to [None,None], where the x limit is set by taking the minimum and maximum value of the x coordinates for all the bodyparts.
+ A list of integers specifying the limits for xaxis of 3d view.
+ By default it is set to [None,None], where the x limit is set by t
+ aking the minimum and maximum value of the x coordinates for all the bodyparts.
ylim: list
- A list of integers specifying the limits for yaxis of 3d view. By default it is set to [None,None], where the y limit is set by taking the minimum and maximum value of the y coordinates for all the bodyparts.
+ A list of integers specifying the limits for yaxis of 3d view.
+ By default it is set to [None,None], where the y limit is set by
+ taking the minimum and maximum value of the y coordinates for all the bodyparts.
zlim: list
- A list of integers specifying the limits for zaxis of 3d view. By default it is set to [None,None], where the z limit is set by taking the minimum and maximum value of the z coordinates for all the bodyparts.
+ A list of integers specifying the limits for zaxis of 3d view.
+ By default it is set to [None,None], where the z limit is set by
+ taking the minimum and maximum value of the z coordinates for all the bodyparts.
draw_skeleton: bool
- If ``True`` adds a line connecting the body parts making a skeleton on on each frame. The body parts to be connected and the color of these connecting lines are specified in the config file. By default: ``True``
+ If ``True`` adds a line connecting the body parts making a skeleton on on each frame.
+ The body parts to be connected and the color of these connecting lines are specified in the config file.
+ By default: ``True``
color_by : string, optional (default='bodypart')
Coloring rule. By default, each bodypart is colored differently.
@@ -127,10 +148,10 @@ def create_labeled_video_3d(
>>> deeplabcut.create_labeled_video_3d(config,['/data/project1/videos'],start=100, end=500)
To set the xlim, ylim, zlim and rotate the view of the 3d axis
- >>> deeplabcut.create_labeled_video_3d(config,['/data/project1/videos'],start=100, end=500,view=[30,90],xlim=[-12,12],ylim=[15,25],zlim=[20,30])
-
+ >>> deeplabcut.create_labeled_video_3d(config,['/data/project1/videos'],start=100,
+ end=500,view=[30,90],xlim=[-12,12],ylim=[15,25],zlim=[20,30])
"""
- start_path = os.getcwd()
+ os.getcwd()
# Read the config file and related variables
cfg_3d = auxiliaryfunctions.read_config(config)
@@ -152,7 +173,9 @@ def create_labeled_video_3d(
print(file_list)
if file_list == []:
raise Exception(
- "No corresponding video file(s) found for the specified triangulated file or folder. Did you specify the video file type? If videos are stored in a different location, please use the ``videofolder`` argument to specify their path."
+ "No corresponding video file(s) found for the specified triangulated file or folder. "
+ "Did you specify the video file type? If videos are stored in a different location, "
+ "please use the ``videofolder`` argument to specify their path."
)
for file in file_list:
@@ -169,23 +192,15 @@ def create_labeled_video_3d(
pickle_file = triangulate_file.replace(string_to_remove, "_meta.pickle")
metadata_ = auxiliaryfunctions_3d.LoadMetadata3d(pickle_file)
- base_filename_cam1 = str(Path(file[1]).stem).split(videotype)[
- 0
- ] # required for searching the filtered file
- base_filename_cam2 = str(Path(file[2]).stem).split(videotype)[
- 0
- ] # required for searching the filtered file
+ base_filename_cam1 = str(Path(file[1]).stem).split(videotype)[0] # required for searching the filtered file
+ base_filename_cam2 = str(Path(file[2]).stem).split(videotype)[0] # required for searching the filtered file
cam1_view_video = file[1]
cam2_view_video = file[2]
cam1_scorer = metadata_["scorer_name"][cam_names[0]]
cam2_scorer = metadata_["scorer_name"][cam_names[1]]
print(
- "Creating 3D video from %s and %s using %s"
- % (
- Path(cam1_view_video).name,
- Path(cam2_view_video).name,
- Path(triangulate_file).name,
- )
+ f"Creating 3D video from {Path(cam1_view_video).name} "
+ f"and {Path(cam2_view_video).name} using {Path(triangulate_file).name}"
)
# Read the video files and corresponfing h5 files
@@ -199,9 +214,7 @@ def create_labeled_video_3d(
glob.glob(
os.path.join(
path_h5_file,
- str(
- "*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"
- ),
+ str("*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"),
)
)[0]
)
@@ -209,9 +222,7 @@ def create_labeled_video_3d(
glob.glob(
os.path.join(
path_h5_file,
- str(
- "*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"
- ),
+ str("*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"),
)
)[0]
)
@@ -228,29 +239,17 @@ def create_labeled_video_3d(
),
)
except IndexError:
- print(
- "No filtered predictions found, the unfiltered predictions will be used instead."
- )
+ print("No filtered predictions found, the unfiltered predictions will be used instead.")
df_cam1 = pd.read_hdf(
- glob.glob(
- os.path.join(
- path_h5_file, str(base_filename_cam1 + cam1_scorer + "*.h5")
- )
- )[0]
+ glob.glob(os.path.join(path_h5_file, str(base_filename_cam1 + cam1_scorer + "*.h5")))[0]
)
df_cam2 = pd.read_hdf(
- glob.glob(
- os.path.join(
- path_h5_file, str(base_filename_cam2 + cam2_scorer + "*.h5")
- )
- )[0]
+ glob.glob(os.path.join(path_h5_file, str(base_filename_cam2 + cam2_scorer + "*.h5")))[0]
)
df_3d = pd.read_hdf(triangulate_file)
try:
- num_animals = (
- df_3d.columns.get_level_values("individuals").unique().size
- )
+ num_animals = df_3d.columns.get_level_values("individuals").unique().size
except KeyError:
num_animals = 1
@@ -263,26 +262,14 @@ def create_labeled_video_3d(
output_folder.mkdir(parents=True, exist_ok=True)
# Flatten the list of bodyparts to connect
- bodyparts2plot = list(
- np.unique([val for sublist in bodyparts2connect for val in sublist])
- )
+ bodyparts2plot = list(np.unique([val for sublist in bodyparts2connect for val in sublist]))
# Format data
mask2d = df_cam1.columns.get_level_values("bodyparts").isin(bodyparts2plot)
- xy1 = (
- df_cam1.iloc[: len(df_3d)]
- .loc[:, mask2d]
- .to_numpy()
- .reshape((len(df_3d), -1, 3))
- )
+ xy1 = df_cam1.iloc[: len(df_3d)].loc[:, mask2d].to_numpy().reshape((len(df_3d), -1, 3))
visible1 = xy1[..., 2] >= pcutoff
xy1[~visible1] = np.nan
- xy2 = (
- df_cam2.iloc[: len(df_3d)]
- .loc[:, mask2d]
- .to_numpy()
- .reshape((len(df_3d), -1, 3))
- )
+ xy2 = df_cam2.iloc[: len(df_3d)].loc[:, mask2d].to_numpy().reshape((len(df_3d), -1, 3))
visible2 = xy2[..., 2] >= pcutoff
xy2[~visible2] = np.nan
mask = df_3d.columns.get_level_values("bodyparts").isin(bodyparts2plot)
@@ -294,7 +281,7 @@ def create_labeled_video_3d(
bodyparts2connect,
bpts,
)
- ind_links = tuple(zip(*links))
+ ind_links = tuple(zip(*links, strict=False))
if color_by == "bodypart":
color = plt.cm.get_cmap(cmap, len(bodyparts2plot))
@@ -357,7 +344,7 @@ def create_labeled_video_3d(
frame_cam1 = vid_cam1.read_frame()
frame_cam2 = vid_cam2.read_frame()
if frame_cam1 is None or frame_cam2 is None:
- raise IOError("A video frame is empty.")
+ raise OSError("A video frame is empty.")
im1.set_data(frame_cam1)
im2.set_data(frame_cam2)
diff --git a/deeplabcut/pose_estimation_3d/triangulation.py b/deeplabcut/pose_estimation_3d/triangulation.py
index 36d04c5b9c..ca90853ce8 100644
--- a/deeplabcut/pose_estimation_3d/triangulation.py
+++ b/deeplabcut/pose_estimation_3d/triangulation.py
@@ -4,26 +4,26 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
import os
+import warnings
from pathlib import Path
import cv2
import numpy as np
import pandas as pd
-from matplotlib.axes._axes import _log as matplotlib_axes_logger
-from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
-from deeplabcut.utils import auxiliaryfunctions_3d
-from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS
-
-matplotlib_axes_logger.setLevel("ERROR")
+from deeplabcut.core.trackingutils import TRACK_METHODS
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, auxiliaryfunctions_3d
+# TODO: @deruyter92 2026-05-20: the function signature could be updated to match
+# other API (i.e. videotype: str -> video_extensions: str | Sequence[str] | None)
+# this requires updating get_camerawise_videos (matching `collect_video_paths`)
def triangulate(
config,
video_path,
@@ -35,8 +35,7 @@ def triangulate(
save_as_csv=False,
track_method="",
):
- """
- This function triangulates the detected DLC-keypoints from the two camera views
+ """This function triangulates the detected DLC-keypoints from the two camera views
using the camera matrices (derived from calibration) to calculate 3D predictions.
Parameters
@@ -50,7 +49,8 @@ def triangulate(
i.e. [['video1-camera-1.avi','video1-camera-2.avi']]
videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
+ Checks for the extension of the video in case the input to the video is a directory.\n
+ Only videos with this extension are analyzed.
If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
@@ -78,7 +78,9 @@ def triangulate(
>>> deeplabcut.triangulate(config,'/data/project1/videos/')
To analyze only a few pairs of videos:
- >>> deeplabcut.triangulate(config,[['/data/project1/videos/video1-camera-1.avi','/data/project1/videos/video1-camera-2.avi'],['/data/project1/videos/video2-camera-1.avi','/data/project1/videos/video2-camera-2.avi']])
+ >>> deeplabcut.triangulate(config,[['/data/project1/videos/video1-camera-1.avi',
+ ... '/data/project1/videos/video1-camera-2.avi'],['/data/project1/videos/video2-camera-1.avi',
+ ... '/data/project1/videos/video2-camera-2.avi']])
Windows
@@ -86,9 +88,12 @@ def triangulate(
>>> deeplabcut.triangulate(config,'C:\\yourusername\\rig-95\\Videos')
To analyze only a few pair of videos:
- >>> deeplabcut.triangulate(config,[['C:\\yourusername\\rig-95\\Videos\\video1-camera-1.avi','C:\\yourusername\\rig-95\\Videos\\video1-camera-2.avi'],['C:\\yourusername\\rig-95\\Videos\\video2-camera-1.avi','C:\\yourusername\\rig-95\\Videos\\video2-camera-2.avi']])
+ >>> deeplabcut.triangulate(config,[['C:\\yourusername\\rig-95\\Videos\\video1-camera-1.avi',
+ ... 'C:\\yourusername\\rig-95\\Videos\\video1-camera-2.avi'],
+ ... ['C:\\yourusername\\rig-95\\Videos\\video2-camera-1.avi',
+ ... 'C:\\yourusername\\rig-95\\Videos\\video2-camera-2.avi']])
"""
- from deeplabcut.pose_estimation_tensorflow import predict_videos
+ from deeplabcut.compat import analyze_videos
from deeplabcut.post_processing import filtering
cfg_3d = auxiliaryfunctions.read_config(config)
@@ -102,27 +107,23 @@ def triangulate(
# Check if the config file exists
if not os.path.exists(snapshots[cam]):
raise Exception(
- str(
- "It seems the file specified in the variable config_file_"
- + str(cam)
- )
+ str("It seems the file specified in the variable config_file_" + str(cam))
+ " does not exist. Please edit the config file with correct file path and retry."
)
# flag to check if the video_path variable is a string or a list of list
flag = False # assumes that video path is a list
- if isinstance(video_path, str) == True:
+ if isinstance(video_path, str):
flag = True
- video_list = auxiliaryfunctions_3d.get_camerawise_videos(
- video_path, cam_names, videotype=videotype
- )
+ video_list = auxiliaryfunctions_3d.get_camerawise_videos(video_path, cam_names, videotype=videotype)
else:
video_list = video_path
if video_list == []:
print("No videos found in the specified video path.", video_path)
print(
- "Please make sure that the video names are specified with correct camera names as entered in the config file or"
+ "Please make sure that the video names are specified with"
+ " correct camera names as entered in the config file or"
)
print(
"perhaps the videotype is distinct from the videos in the path, I was looking for:",
@@ -136,30 +137,17 @@ def triangulate(
dataname = []
for j in range(len(video_list[i])): # looping over cameras
if cam_names[j] not in video_list[i][j]:
- raise ValueError(
- f"Camera name '{cam_names[j]}' "
- f"not found in video list '{video_list[i][j]}'."
- )
+ raise ValueError(f"Camera name '{cam_names[j]}' not found in video list '{video_list[i][j]}'.")
else:
- print(
- "Analyzing video %s using %s"
- % (video_list[i][j], str("config_file_" + cam_names[j]))
- )
+ print("Analyzing video {} using {}".format(video_list[i][j], str("config_file_" + cam_names[j])))
config_2d = snapshots[cam_names[j]]
cfg = auxiliaryfunctions.read_config(config_2d)
# Get track_method and do related checks
- track_method = auxfun_multianimal.get_track_method(
- cfg, track_method=track_method
- )
- if (
- len(cfg.get("multianimalbodyparts", [])) == 1
- and track_method != "box"
- ):
- warnings.warn(
- "Switching to `box` tracker for single point tracking..."
- )
+ track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+ if len(cfg.get("multianimalbodyparts", [])) == 1 and track_method != "box":
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
track_method = "box"
# Get track method suffix
@@ -168,7 +156,7 @@ def triangulate(
shuffle = cfg_3d[str("shuffle_" + cam_names[j])]
trainingsetindex = cfg_3d[str("trainingsetindex_" + cam_names[j])]
trainFraction = cfg["TrainingFraction"][trainingsetindex]
- if flag == True:
+ if flag:
video = os.path.join(video_path, video_list[i][j])
else:
video_path = str(Path(video_list[i][j]).parents[0])
@@ -202,16 +190,13 @@ def triangulate(
output_file + "_" + scorer_3d
) # Check if the videos are already analyzed for 3d
if os.path.isfile(output_filename + ".h5"):
- if save_as_csv is True and not os.path.exists(
- output_filename + ".csv"
- ):
+ if save_as_csv is True and not os.path.exists(output_filename + ".csv"):
# In case user adds save_as_csv is True after triangulating
- pd.read_hdf(output_filename + ".h5").to_csv(
- str(output_filename + ".csv")
- )
+ pd.read_hdf(output_filename + ".h5").to_csv(str(output_filename + ".csv"))
print(
- "Already analyzed...Checking the meta data for any change in the camera matrices and/or scorer names",
+ "Already analyzed..."
+ "Checking the meta data for any change in the camera matrices and/or scorer names",
vname,
)
pickle_file = str(output_filename + "_meta.pickle")
@@ -223,17 +208,13 @@ def triangulate(
path_undistort,
_,
) = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)
- path_stereo_file = os.path.join(
- path_camera_matrix, "stereo_params.pickle"
- )
+ path_stereo_file = os.path.join(path_camera_matrix, "stereo_params.pickle")
stereo_file = auxiliaryfunctions.read_pickle(path_stereo_file)
cam_pair = str(cam_names[0] + "-" + cam_names[1])
is_video_analyzed = False # variable to keep track if the video was already analyzed
# Check for the camera matrix
for k in metadata_["stereo_matrix"].keys():
- if np.all(
- metadata_["stereo_matrix"][k] == stereo_file[cam_pair][k]
- ):
+ if np.all(metadata_["stereo_matrix"][k] == stereo_file[cam_pair][k]):
pass
else:
run_triangulate = True
@@ -243,9 +224,7 @@ def triangulate(
cfg, shuffle, trainFraction, trainingsiterations="unknown"
)
- if (
- metadata_["scorer_name"][cam_names[j]] == DLCscorer
- ): # TODO: CHECK FOR BOTH?
+ if metadata_["scorer_name"][cam_names[j]] == DLCscorer: # TODO: CHECK FOR BOTH?
is_video_analyzed = True
elif metadata_["scorer_name"][cam_names[j]] == DLCscorerlegacy:
is_video_analyzed = True
@@ -255,18 +234,14 @@ def triangulate(
if is_video_analyzed:
print("This file is already analyzed!")
- dataname.append(
- os.path.join(
- destfolder, vname + DLCscorer + tr_method_suffix + ".h5"
- )
- )
+ dataname.append(os.path.join(destfolder, vname + DLCscorer + tr_method_suffix + ".h5"))
scorer_name[cam_names[j]] = DLCscorer
else:
# Analyze video if score name is different
- DLCscorer = predict_videos.analyze_videos(
+ DLCscorer = analyze_videos(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
gputouse=gputouse,
@@ -280,7 +255,7 @@ def triangulate(
filtering.filterpredictions(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
filtertype=filtertype,
@@ -288,15 +263,13 @@ def triangulate(
)
suffix += "_filtered"
- dataname.append(
- os.path.join(destfolder, vname + DLCscorer + suffix + ".h5")
- )
+ dataname.append(os.path.join(destfolder, vname + DLCscorer + suffix + ".h5"))
else: # need to do the whole jam.
- DLCscorer = predict_videos.analyze_videos(
+ DLCscorer = analyze_videos(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
gputouse=gputouse,
@@ -310,16 +283,14 @@ def triangulate(
filtering.filterpredictions(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
filtertype=filtertype,
destfolder=destfolder,
)
suffix += "_filtered"
- dataname.append(
- os.path.join(destfolder, vname + DLCscorer + suffix + ".h5")
- )
+ dataname.append(os.path.join(destfolder, vname + DLCscorer + suffix + ".h5"))
if run_triangulate:
# if len(dataname)>0:
@@ -330,30 +301,24 @@ def triangulate(
dataFrame_camera2_undistort,
stereomatrix,
path_stereo_file,
- ) = undistort_points(
- config, dataname, str(cam_names[0] + "-" + cam_names[1])
- )
+ ) = undistort_points(config, dataname, str(cam_names[0] + "-" + cam_names[1]))
if len(dataFrame_camera1_undistort) != len(dataFrame_camera2_undistort):
- import warnings
-
warnings.warn(
- "The number of frames do not match in the two videos. Please make sure that your videos have same number of frames and then retry! Excluding the extra frames from the longer video."
+ "The number of frames do not match in the two videos. "
+ "Please make sure that your videos have same number of frames and then retry! "
+ "Excluding the extra frames from the longer video.",
+ stacklevel=2,
)
if len(dataFrame_camera1_undistort) > len(dataFrame_camera2_undistort):
- dataFrame_camera1_undistort = dataFrame_camera1_undistort[
- : len(dataFrame_camera2_undistort)
- ]
+ dataFrame_camera1_undistort = dataFrame_camera1_undistort[: len(dataFrame_camera2_undistort)]
if len(dataFrame_camera2_undistort) > len(dataFrame_camera1_undistort):
- dataFrame_camera2_undistort = dataFrame_camera2_undistort[
- : len(dataFrame_camera1_undistort)
- ]
- # raise Exception("The number of frames do not match in the two videos. Please make sure that your videos have same number of frames and then retry!")
- scorer_cam1 = dataFrame_camera1_undistort.columns.get_level_values(0)[0]
- scorer_cam2 = dataFrame_camera2_undistort.columns.get_level_values(0)[0]
+ dataFrame_camera2_undistort = dataFrame_camera2_undistort[: len(dataFrame_camera1_undistort)]
+ # raise Exception("The number of frames do not match in the two videos.
+ # Please make sure that your videos have same number of frames and then retry!")
+ dataFrame_camera1_undistort.columns.get_level_values(0)[0]
+ dataFrame_camera2_undistort.columns.get_level_values(0)[0]
- bodyparts = dataFrame_camera1_undistort.columns.get_level_values(
- "bodyparts"
- ).unique()
+ dataFrame_camera1_undistort.columns.get_level_values("bodyparts").unique()
P1 = stereomatrix["P1"]
P2 = stereomatrix["P2"]
@@ -364,12 +329,8 @@ def triangulate(
num_frames = dataFrame_camera1_undistort.shape[0]
### Assign nan to [X,Y] of low likelihood predictions ###
# Convert the data to a np array to easily mask out the low likelihood predictions
- data_cam1_tmp = dataFrame_camera1_undistort.to_numpy().reshape(
- (num_frames, -1, 3)
- )
- data_cam2_tmp = dataFrame_camera2_undistort.to_numpy().reshape(
- (num_frames, -1, 3)
- )
+ data_cam1_tmp = dataFrame_camera1_undistort.to_numpy().reshape((num_frames, -1, 3))
+ data_cam2_tmp = dataFrame_camera2_undistort.to_numpy().reshape((num_frames, -1, 3))
# Assign [X,Y] = nan to low likelihood predictions
data_cam1_tmp[data_cam1_tmp[..., 2] < pcutoff, :2] = np.nan
data_cam2_tmp[data_cam2_tmp[..., 2] < pcutoff, :2] = np.nan
@@ -385,19 +346,13 @@ def triangulate(
if cfg.get("multianimalproject"):
# Check individuals are the same in both views
individuals_view1 = (
- dataFrame_camera1_undistort.columns.get_level_values("individuals")
- .unique()
- .to_list()
+ dataFrame_camera1_undistort.columns.get_level_values("individuals").unique().to_list()
)
individuals_view2 = (
- dataFrame_camera2_undistort.columns.get_level_values("individuals")
- .unique()
- .to_list()
+ dataFrame_camera2_undistort.columns.get_level_values("individuals").unique().to_list()
)
if individuals_view1 != individuals_view2:
- raise ValueError(
- "The individuals do not match between the two DataFrames"
- )
+ raise ValueError("The individuals do not match between the two DataFrames")
# Cross-view match individuals
_, voting = auxiliaryfunctions_3d.cross_view_match_dataframes(
@@ -412,12 +367,12 @@ def triangulate(
individuals = individuals_view1
# Reshape: (num_framex, num_individuals, num_bodyparts , 2)
- all_points_cam1 = dataFrame_camera1_undistort.to_numpy().reshape(
- (num_frames, len(individuals), -1, 3)
- )[..., :2]
- all_points_cam2 = dataFrame_camera2_undistort.to_numpy().reshape(
- (num_frames, len(individuals), -1, 3)
- )[..., :2]
+ all_points_cam1 = dataFrame_camera1_undistort.to_numpy().reshape((num_frames, len(individuals), -1, 3))[
+ ..., :2
+ ]
+ all_points_cam2 = dataFrame_camera2_undistort.to_numpy().reshape((num_frames, len(individuals), -1, 3))[
+ ..., :2
+ ]
# Triangulate data
triangulate = []
@@ -428,9 +383,7 @@ def triangulate(
pts_indv_cam1 = all_points_cam1[:, i].reshape((-1, 2)).T
pts_indv_cam2 = all_points_cam2[:, voting[i]].reshape((-1, 2)).T
- indv_points_3d = auxiliaryfunctions_3d.triangulatePoints(
- P1, P2, pts_indv_cam1, pts_indv_cam2
- )
+ indv_points_3d = auxiliaryfunctions_3d.triangulatePoints(P1, P2, pts_indv_cam1, pts_indv_cam2)
indv_points_3d = indv_points_3d[:3].T.reshape((num_frames, -1, 3))
@@ -446,18 +399,32 @@ def triangulate(
}
# Create 3D DataFrame column and row indices
- axis_labels = ("x", "y", "z")
+ cols = [
+ [scorer_3d],
+ list(auxiliaryfunctions.get_bodyparts(cfg)),
+ ["x", "y", "z"],
+ ]
+ cols_names = ["scorer", "bodyparts", "coords"]
+ flag_indiv_single = False
if cfg.get("multianimalproject"):
- columns = pd.MultiIndex.from_product(
- [[scorer_3d], individuals, bodyparts, axis_labels],
- names=["scorer", "individuals", "bodyparts", "coords"],
- )
-
- else:
- columns = pd.MultiIndex.from_product(
- [[scorer_3d], bodyparts, axis_labels],
- names=["scorer", "bodyparts", "coords"],
- )
+ cols_names.insert(1, "individuals")
+ if "single" == individuals[-1]:
+ individuals = individuals[:-1]
+ columns_unique = pd.MultiIndex.from_product(
+ [
+ [scorer_3d],
+ ["single"],
+ auxiliaryfunctions.get_unique_bodyparts(cfg),
+ ["x", "y", "z"],
+ ],
+ names=cols_names,
+ )
+ flag_indiv_single = True
+ cols.insert(1, individuals)
+ columns = pd.MultiIndex.from_product(cols, names=cols_names)
+ if flag_indiv_single:
+ columns = columns.append(columns_unique)
+ individuals.append("single")
inds = range(num_frames)
@@ -468,34 +435,30 @@ def triangulate(
df_3d = pd.DataFrame(triangulate, columns=columns, index=inds)
df_3d.to_hdf(
- str(output_filename + ".h5"),
- "df_with_missing",
- format="table",
+ str(output_filename) + ".h5",
+ key="df_with_missing",
mode="w",
+ format="table",
)
# Reorder 2D dataframe in view 2 to match order of view 1
if cfg.get("multianimalproject"):
df_2d_view2 = pd.read_hdf(dataname[1])
individuals_order = [individuals[i] for i in list(voting.values())]
- df_2d_view2 = auxfun_multianimal.reorder_individuals_in_df(
- df_2d_view2, individuals_order
- )
+ df_2d_view2 = auxfun_multianimal.reorder_individuals_in_df(df_2d_view2, individuals_order)
df_2d_view2.to_hdf(
dataname[1],
- "tracks",
+ key="tracks",
format="table",
mode="w",
)
- auxiliaryfunctions_3d.SaveMetadata3d(
- str(output_filename + "_meta.pickle"), metadata
- )
+ auxiliaryfunctions_3d.SaveMetadata3d(str(output_filename) + "_meta.pickle", metadata)
if save_as_csv:
- df_3d.to_csv(str(output_filename + ".csv"))
+ df_3d.to_csv(str(output_filename) + ".csv")
- print("Triangulated data for video", video_list[i])
+ print("Triangulated data for video", video)
print("Results are saved under: ", destfolder)
# have to make the dest folder none so that it can be updated for a new pair of videos
if destfolder == str(Path(video).parents[0]):
@@ -521,7 +484,7 @@ def _undistort_points(points, mat, coeffs, p, r):
def _undistort_views(df_view_pairs, stereo_params):
df_views_undist = []
- for df_view_pair, camera_pair in zip(df_view_pairs, stereo_params):
+ for df_view_pair, camera_pair in zip(df_view_pairs, stereo_params, strict=False):
params = stereo_params[camera_pair]
dfs = []
for i, df_view in enumerate(df_view_pair, start=1):
@@ -541,14 +504,13 @@ def _undistort_views(df_view_pairs, stereo_params):
def undistort_points(config, dataframe, camera_pair):
cfg_3d = auxiliaryfunctions.read_config(config)
path_camera_matrix = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)[2]
- """
- path_undistort = destfolder
- filename_cam1 = Path(dataframe[0]).stem
- filename_cam2 = Path(dataframe[1]).stem
+ """path_undistort = destfolder filename_cam1 = Path(dataframe[0]).stem filename_cam2
+ = Path(dataframe[1]).stem.
#currently no intermediate saving of this due to high speed.
# check if the undistorted files are already present
- if os.path.exists(os.path.join(path_undistort,filename_cam1 + '_undistort.h5')) and os.path.exists(os.path.join(path_undistort,filename_cam2 + '_undistort.h5')):
+ if os.path.exists(os.path.join(path_undistort,filename_cam1 + \
+ '_undistort.h5')) and os.path.exists(os.path.join(path_undistort,filename_cam2 + '_undistort.h5')):
print("The undistorted files are already present at %s" % os.path.join(path_undistort,filename_cam1))
dataFrame_cam1_undistort = pd.read_hdf(os.path.join(path_undistort,filename_cam1 + '_undistort.h5'))
dataFrame_cam2_undistort = pd.read_hdf(os.path.join(path_undistort,filename_cam2 + '_undistort.h5'))
@@ -556,17 +518,14 @@ def undistort_points(config, dataframe, camera_pair):
"""
if len(dataframe) != 2:
raise ValueError(
- f"undistort_points(config, dataframe, camera_pair) needs filenames to two data frames, but got dataframe={dataframe}."
+ "undistort_points(config, dataframe, camera_pair) "
+ f"needs filenames to two data frames, but got dataframe={dataframe}."
)
for filename in dataframe:
if not os.path.exists(filename):
- raise FileNotFoundError(
- f"Dataframe path '{filename}' could not be found in the filesystem."
- )
+ raise FileNotFoundError(f"Dataframe path '{filename}' could not be found in the filesystem.")
if not os.path.exists(path_camera_matrix):
- raise FileNotFoundError(
- f"Camera matrix file '{path_camera_matrix}' could not be found in the filesystem."
- )
+ raise FileNotFoundError(f"Camera matrix file '{path_camera_matrix}' could not be found in the filesystem.")
# Create an empty dataFrame to store the undistorted 2d coordinates and likelihood
dataframe_cam1 = pd.read_hdf(dataframe[0])
dataframe_cam2 = pd.read_hdf(dataframe[1])
diff --git a/deeplabcut/pose_estimation_pytorch/README.md b/deeplabcut/pose_estimation_pytorch/README.md
new file mode 100644
index 0000000000..dc1cd27bf3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/README.md
@@ -0,0 +1,510 @@
+# PyTorch DeepLabCut API
+
+This overview is primarily written for maintainers and expert users.
+
+Here we detail the logic and structure for the DLC3.* PyTorch code. Furthermore, we
+provide many practical examples to illustrate the usage of the code for developers.
+
+## Structure of the PyTorch DLC code
+
+[API](#API)
+
+[Models](#models)
+
+[Data](#data)
+
+[Runners](#runners)
+
+### API
+
+High-level API methods are implemented in `deeplabcut.pose_estimations_pytorch.apis`.
+This folder includes methods to train and evaluate models on DeepLabCut projects, and
+analyze videos or folders (of images). While some of the methods are implemented to work
+directly from DeepLabCut projects (i.e. by specifying the path to the project config
+file and the shuffle number), internally they call methods that allow more flexibility.
+Thus, they are also ideally suited for developers.
+
+### Models
+
+We provide state-of-the-art pose estimation models such as DLCRNet, HRNet, DEKR, BUCTD
+and more are coming! Object detection models are also available (and implemented in
+`deeplabcut.pose_estimations_pytorch.models.detectors`).
+
+The `deeplabcut.pose_estimations_pytorch.models` package contains all components related
+to building a model. Models are flexibly build from modular components: `backbone`,
+`neck` (optional) and `head` (as discussed below).
+
+You can check available models by running:
+
+```python
+import deeplabcut.pose_estimation_pytorch
+
+# Available pose estimation models
+print(deeplabcut.pose_estimation_pytorch.available_models())
+
+# Available object detection models
+print(deeplabcut.pose_estimation_pytorch.available_detectors())
+```
+
+### Model Configuration Files
+
+Model architectures are built according to a configuration specified in a `yaml` file.
+This file (named `pytorch_cfg.yaml`) describes the architecture of the model you want to
+train (but also hyperparameters, optimizer, ...). All code to manipulate PyTorch
+configuration files is in `deeplabcut.pose_estimations_pytorch.config`.
+
+To generate a model configuration, you can call `make_pytorch_pose_config`. Note that
+this does not save the configuration to a given filepath - it just returns it as a
+dictionary. However, you can save it with `write_config`.
+
+During a typical DeepLabCut project management workflow, these methods don't need to be
+called, as `create_training_dataset` will create this configuration file and save it to
+disk.
+
+```python
+from pathlib import Path
+
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+project_cfg = { "Task": "mice", ... } # the configuration for your DLC project
+pose_config_path = Path("/path/to/my/config/pytorch_cfg.yaml")
+model_cfg = dlc_torch.config.make_pytorch_pose_config(
+ project_config=project_cfg,
+ pose_config_path=pose_config_path,
+ net_type="hrnet_w32",
+ top_down=True,
+ save=True,
+)
+```
+
+### Adding Models
+
+If you want to add a novel model, you'll ideally build them from the following
+implemented parts:
+
+- a backbone (such as a ResNet or HRNet)
+- a head (such as a HeatmapHead)
+- a predictor (transforming model outputs into keypoint locations)
+- a target generator (creating the targets for your head outputs from your labels)
+
+Some models can also define a neck (model components between the backbone and the head).
+You'll also need some loss criterions, but usually you'll be able to use existing ones.
+
+You can either use existing classes and only replace some elements, or rewrite
+everything you need for your model. We use Model Registries to simplify the process of
+adding models.
+
+### Model Registry
+
+Registries are created for all model building blocks to make it easy to add new models.
+All you need to do is add the decorator `REGISTRY.register_module` to be able to load
+your model from a configuration file. Available registries are `BACKBONES`, `NECKS`,
+`HEADS`, `PREDICTORS` and `TARGET_GENERATORS`. Each building block has a base class
+that should be inherited by the class added to the model registry (`BaseBackbone`,
+`BaseNeck`, `BaseHead`, `BasePredictor` and `BaseGenerator` respectively).
+
+Let's illustrate that with a small example. We'll create a dummy backbone, which simply
+applies a max-pool to the input:
+
+```python
+import torch
+import torch.nn.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone
+
+
+@BACKBONES.register_module
+class DummyBackbone(BaseBackbone):
+ """A dummy backbone, simply max-pooling the input"""
+
+ def __init__(self, kernel_size: int = 2):
+ super().__init__(stride=kernel_size)
+ self.kernel_size = kernel_size
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return F.max_pool2d(x, kernel_size=self.kernel_size)
+
+
+backbone_config = dict(type="DummyBackbone", kernel_size=3)
+backbone = BACKBONES.build(backbone_config) # will create a DummyBackbone
+```
+
+Another example would be creating a custom head for our model. In this case, let's make
+a head which takes as input the output of a backbone (which has shape `(num_channels,
+H', W')`) and put it through a kernel-size 1 convolution, simply changing the number of
+channels.
+
+Heads can output multiple tensors (such as heatmaps and location refinement fields).
+Therefore, their `forward(...)` method outputs a dictionary mapping strings to tensors.
+Here, we return the `heatmap` and `locref` tensors.
+
+A head must contain different: a `target_generator` to generate targets for
+its outputs and a `predictor` to convert model outputs to pose. Make sure that the keys
+output by the `target_generator` and the `head` match! Some `criterion` also needs to be
+defined to compute the loss between the outputs and targets. When more than one output
+is specified (such as in this case, where we're generating heatmaps and location
+refinement fields), a loss aggregator must also be given to combine all losses into one
+(this should simply be a `WeightedLossAggregator`, indicating the weight for each loss).
+
+```python
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+ WeightedHuberCriterion,
+ WeightedLossAggregator,
+ WeightedMSECriterion,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead
+from deeplabcut.pose_estimation_pytorch.models.predictors import (
+ BasePredictor,
+ HeatmapPredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.target_generators import (
+ BaseGenerator,
+ HeatmapGaussianGenerator,
+)
+
+
+@HEADS.register_module
+class DummyHead(BaseHead):
+ """A dummy backbone, simply max-pooling the input"""
+
+ def __init__(
+ self,
+ num_input_channels: int,
+ num_bodyparts: int,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion],
+ aggregator: BaseLossAggregator,
+ ):
+ super().__init__(
+ stride=1,
+ predictor=predictor,
+ target_generator=target_generator,
+ criterion=criterion,
+ aggregator=aggregator
+ )
+ self.conv_heatmap = nn.Conv2d(
+ in_channels=num_input_channels,
+ out_channels=num_bodyparts,
+ kernel_size=1,
+ stride=1,
+ )
+ self.locref_heatmap = nn.Conv2d(
+ in_channels=num_input_channels,
+ out_channels=2 * num_bodyparts,
+ kernel_size=1,
+ stride=1,
+ )
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ return {
+ "heatmap": self.conv_heatmap(x),
+ "locref": self.locref_heatmap(x),
+ }
+
+
+head_config = dict(
+ type="DummyHead",
+ num_input_channels=2048,
+ num_bodyparts=5,
+ predictor=HeatmapPredictor(location_refinement=True, locref_std= 7.2801),
+ target_generator=HeatmapGaussianGenerator(
+ num_heatmaps=5,
+ pos_dist_thresh=17,
+ heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT,
+ generate_locref=True,
+ ),
+ criterion={
+ "heatmap": WeightedMSECriterion(),
+ "locref": WeightedHuberCriterion(),
+ },
+ aggregator=WeightedLossAggregator(weights={"heatmap": 1, "locref": 0.05}),
+)
+head = HEADS.build(head_config)
+```
+
+### Data
+
+The `deeplabcut.pose_estimations_pytorch.data` package contains all code for PyTorch
+dataset creation and test/train splitting. The `DLCLoader` class is used to load the
+labeled data for a specific shuffle.
+
+```python3
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+loader = dlc_torch.DLCLoader(
+ config="/path/to/my/project/config.yaml",
+ trainset_index=0,
+ shuffle=1,
+)
+
+# print the path to the model folder (where the config file is stored)
+print(loader.model_folder)
+# print the path to the evaluation folder
+print(loader.evaluation_folder)
+
+# display the DataFrame containing the dataset
+print(loader.df)
+
+# display the DataFrames containing the train/test data respectively
+print(loader.df_train)
+print(loader.df_test)
+```
+
+The `PoseDataset` class is an instance of
+[torch.utils.Dataset](https://pytorch.org/docs/stable/data.html), which converts raw
+images and keypoints to a tensor dataset for training and evaluation. You can generate
+an instance of training/test dataset with your `DLCLoader`:
+
+```python3
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+loader = dlc_torch.DLCLoader(
+ config="/path/to/my/project/config.yaml",
+ trainset_index=0,
+ shuffle=1,
+)
+train_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["train"]),
+ mode="train",
+ task=loader.pose_task,
+)
+valid_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["inference"]),
+ mode="test",
+ task=loader.pose_task,
+)
+```
+
+A `COCOLoader` is also available, and allows you train models in DeepLabCut on
+[COCO-format](https://medium.com/@manuktiwary/coco-format-what-and-how-5c7d22cf5301)
+datasets. This essentially consists of having a folder containing your dataset in the
+format:
+
+```
+COCOProject
+└───annotations
+│ │ train.json
+│ │ test.json
+│
+└───images
+ │ img0000.png
+ │ img0001.png
+ │ ...
+```
+
+In your `train.json` and `test.json` files, you can either specify your image
+`"file_name"` with a relative path or with an absolute path. If a relative path is
+used (e.g. `img0000.png` or `subfolder/img0000.png`), it will be resolved to the
+`images` folder in your project (i.e. `/path/to/COCOProject/images/img0000.png` or
+`/path/to/COCOProject/images/subfolder/img0000.png`).
+
+If you specify an absolute path, the path to the image will not be resolved, and the
+image will be loaded from the specified path. This allows you to keep data on different
+disks, or reuse the same images in different projects without having to duplicate them.
+
+To train a DeepLabCut model on a COCO-format dataset, you'll need to specify a model
+configuration file (as described in [#model_configuration_files]).
+
+```python3
+from pathlib import Path
+
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# Specify project paths
+project_root = Path("/path/to/my/COCOProject")
+train_json_filename = "train.json"
+test_json_filename = "test.json"
+
+# Parse information about the project
+train_dict = dlc_torch.COCOLoader.load_json(project_root, filename=train_json_filename)
+max_num_individuals, bodyparts = dlc_torch.COCOLoader.get_project_parameters(train_dict)
+
+# Generate a configuration file for your PyTorch model
+# In this case, it's for a Top-Down HRNet_w32
+experiment_path = project_root / "experiments" / "hrnet_w32"
+model_cfg_path = experiment_path / "train" / "pytorch_cfg.yaml"
+model_cfg = dlc_torch.config.make_pytorch_pose_config(
+ project_config=dlc_torch.config.make_basic_project_config(
+ dataset_path=str(project_root.resolve()),
+ bodyparts=bodyparts,
+ max_individuals=max_num_individuals,
+ multi_animal=True,
+ ),
+ pose_config_path=experiment_path,
+ net_type="hrnet_w32",
+ top_down=True,
+ save=True,
+)
+
+# Create the loader for the COCO dataset
+loader = dlc_torch.COCOLoader(
+ project_root=project_root,
+ model_config_path="/path/to/my/project/experiments/pytorch_config.yaml",
+ train_json_filename=train_json_filename,
+ test_json_filename=test_json_filename,
+)
+train_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["train"]),
+ mode="train",
+ task=loader.pose_task,
+)
+valid_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["inference"]),
+ mode="test",
+ task=loader.pose_task,
+)
+```
+
+### Runners
+
+The `deeplabcut.pose_estimations_pytorch.runners` contains code to get models, load
+pretrained weights, and either train them or run inference with them.
+
+## Code Examples
+
+### Training a Model on a COCO Dataset
+
+```python
+from pathlib import Path
+
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# Specify project paths
+project_root = Path("/path/to/my/COCOProject")
+train_json_filename = "train.json"
+test_json_filename = "test.json"
+
+loader = dlc_torch.COCOLoader(
+ project_root=project_root,
+ model_config_path="/path/to/my/project/experiments/pytorch_config.yaml",
+ train_json_filename=train_json_filename,
+ test_json_filename=test_json_filename,
+)
+dlc_torch.train(
+ loader=loader,
+ run_config=loader.model_cfg,
+ task=dlc_torch.Task(loader.model_cfg["method"]),
+ device="cuda:2",
+ logger_config=dict(
+ type="WandbLogger",
+ project_name="MyWandbProject",
+ tags=["model=hrnet_w32"],
+ ),
+ snapshot_path=None,
+)
+```
+
+### Running Video Analysis outside a DeepLabCut Project
+
+DeepLabCut provides high-level APIs (via the GUI or the python package) to analyze your
+data. The usage of this API assumes the existence of a DLC project (with `config.yaml`
+file, etc.).
+
+Sometimes it might be more convenient to just run a model on your data via a low-level
+API. We also use this API under the hood, in particular for the Model Zoo. Check out the
+example below:
+
+```python
+from deeplabcut.core.config import read_config_as_dict
+from pathlib import Path
+
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+train_dir = Path("/Users/Jaylen/my-dlc-models/train")
+pytorch_config_path = train_dir / "pytorch_config.yaml"
+snapshot_path = train_dir / "snapshot-100.pt"
+
+# for top-down models, otherwise None
+detector_snapshot_path = train_dir / "detector-snapshot-100.pt"
+
+# video and inference parameters
+video_path = Path("/Users/Jaylen/my-dlc-models/videos/test-video.mp4")
+max_num_animals = 5
+batch_size = 16
+detector_batch_size = 8
+
+# read model configuration
+model_cfg = read_config_as_dict(pytorch_config_path)
+pose_task = dlc_torch.Task(model_cfg["method"])
+pose_runner = dlc_torch.get_pose_inference_runner(
+ model_config=model_cfg,
+ snapshot_path=snapshot_path,
+ max_individuals=max_num_animals,
+ batch_size=batch_size,
+)
+
+detector_runner = None
+if pose_task == dlc_torch.Task.TOP_DOWN:
+ detector_runner = dlc_torch.get_detector_inference_runner(
+ model_config=model_cfg,
+ snapshot_path=detector_snapshot_path,
+ max_individuals=max_num_animals,
+ batch_size=detector_batch_size,
+ )
+
+predictions = dlc_torch.video_inference(
+ video=video_path,
+ pose_runner=pose_runner,
+ detector_runner=detector_runner,
+)
+```
+
+
+### Running Top-Down Video Analysis with Existing Bounding Boxes
+
+When `deeplabcut.pose_estimation_pytorch.apis.videos.video_inference` is called
+with a top-down model, it is assumed that a detector snapshot is given as well to obtain
+bounding boxes with which to run pose estimation. It's possible that you've already
+obtained bounding boxes for your video (with another object detector or through some
+other means), and you want to reuse those bounding boxes instead of running an object
+detector again.
+
+You can easily do so by writing a bit of custom code, as shown in the example below:
+
+```python
+from deeplabcut.core.config import read_config_as_dict
+from pathlib import Path
+
+import numpy as np
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+from tqdm import tqdm
+
+# create an iterator for your video
+video = dlc_torch.VideoIterator("/Users/Jayson/my-cool-video.mp4")
+
+# dummy bboxes - you can load yours from a file or in another way
+# the bboxes should be in `xywh` format, i.e. (x_top_left, y_top_left, width, height)
+bounding_boxes = [
+ dict( # frame 0 bounding boxes
+ bboxes=np.array([[12, 37, 120, 78]]),
+ ),
+ dict( # frame 1 bounding boxes
+ bboxes=np.array([[17, 45, 128, 73], [532, 34, 117, 87]]),
+ ),
+ # ...
+ dict( # frame N bboxes -> must be equal to the number of frames in the video!
+ bboxes=np.array([[17, 45, 128, 73], [532, 34, 117, 87]]),
+ ),
+]
+video.set_context(bounding_boxes)
+max_individuals = np.max([len(context["bboxes"]) for context in bounding_boxes])
+
+# run inference!
+model_cfg = read_config_as_dict("/Users/Jayson/pytorch_config.yaml")
+pose_runner = dlc_torch.get_pose_inference_runner(
+ model_config=model_cfg,
+ snapshot_path=Path("/Users/Jayson/model-snapshot.pt"),
+ max_individuals=max_individuals,
+ batch_size=32,
+)
+
+# your predictions will be a list, containing the predictions made for each frame
+# as a dict (with keys for "bodyparts" but also "bboxes")!
+predictions = pose_runner.inference(images=tqdm(video))
+```
diff --git a/deeplabcut/pose_estimation_pytorch/__init__.py b/deeplabcut/pose_estimation_pytorch/__init__.py
new file mode 100644
index 0000000000..09d8c1f839
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/__init__.py
@@ -0,0 +1,69 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import deeplabcut.pose_estimation_pytorch.config as config
+from deeplabcut.pose_estimation_pytorch.apis import (
+ VideoIterator,
+ analyze_image_folder,
+ analyze_images,
+ analyze_videos,
+ build_predictions_dataframe,
+ convert_detections2tracklets,
+ create_labeled_images,
+ create_tracking_dataset,
+ evaluate,
+ evaluate_network,
+ extract_maps,
+ extract_save_all_maps,
+ get_detector_inference_runner,
+ get_pose_inference_runner,
+ predict,
+ superanimal_analyze_images,
+ train,
+ train_network,
+ video_inference,
+ visualize_predictions,
+)
+from deeplabcut.pose_estimation_pytorch.config import (
+ available_detectors,
+ available_models,
+ is_model_cond_top_down,
+ is_model_top_down,
+)
+from deeplabcut.pose_estimation_pytorch.data import (
+ COLLATE_FUNCTIONS,
+ COCOLoader,
+ DLCLoader,
+ GenerativeSampler,
+ GenSamplingConfig,
+ Loader,
+ PoseDataset,
+ PoseDatasetParameters,
+ Snapshot,
+ build_transforms,
+ list_snapshots,
+)
+from deeplabcut.pose_estimation_pytorch.runners import (
+ DetectorInferenceRunner,
+ DetectorTrainingRunner,
+ DynamicCropper,
+ InferenceRunner,
+ PoseInferenceRunner,
+ PoseTrainingRunner,
+ TopDownDynamicCropper,
+ TorchSnapshotManager,
+ TrainingRunner,
+ build_inference_runner,
+ build_training_runner,
+ get_load_weights_only,
+ set_load_weights_only,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.pose_estimation_pytorch.utils import fix_seeds
diff --git a/deeplabcut/pose_estimation_pytorch/apis/__init__.py b/deeplabcut/pose_estimation_pytorch/apis/__init__.py
new file mode 100644
index 0000000000..e33c98629b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/__init__.py
@@ -0,0 +1,49 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from deeplabcut.pose_estimation_pytorch.apis.analyze_images import (
+ analyze_image_folder,
+ analyze_images,
+ superanimal_analyze_images,
+)
+from deeplabcut.pose_estimation_pytorch.apis.evaluation import (
+ evaluate,
+ evaluate_network,
+ predict,
+ visualize_predictions,
+)
+from deeplabcut.pose_estimation_pytorch.apis.export import export_model
+from deeplabcut.pose_estimation_pytorch.apis.tracking_dataset import (
+ create_tracking_dataset,
+)
+from deeplabcut.pose_estimation_pytorch.apis.tracklets import (
+ convert_detections2tracklets,
+)
+from deeplabcut.pose_estimation_pytorch.apis.training import (
+ train,
+ train_network,
+)
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ build_predictions_dataframe,
+ get_detector_inference_runner,
+ get_inference_runners,
+ get_pose_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.apis.videos import (
+ VideoIterator,
+ analyze_videos,
+ video_inference,
+)
+from deeplabcut.pose_estimation_pytorch.apis.visualization import (
+ create_labeled_images,
+ extract_maps,
+ extract_save_all_maps,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py
new file mode 100644
index 0000000000..25d415848d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py
@@ -0,0 +1,689 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import copy
+import glob
+import json
+import logging
+import os
+from collections import defaultdict
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+from tqdm import tqdm
+
+import deeplabcut.core.config as config_utils
+import deeplabcut.pose_estimation_pytorch.apis.visualization as visualization
+import deeplabcut.pose_estimation_pytorch.data as data
+import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+from deeplabcut.core.engine import Engine
+from deeplabcut.modelzoo.utils import get_superanimal_colormaps
+from deeplabcut.pose_estimation_pytorch.apis.ctd import get_condition_provider
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ build_predictions_dataframe,
+ get_detector_inference_runner,
+ get_filtered_coco_detector_inference_runner,
+ get_model_snapshots,
+ get_pose_inference_runner,
+ get_scorer_name,
+ get_scorer_uid,
+ parse_snapshot_index_for_analysis,
+)
+from deeplabcut.pose_estimation_pytorch.data.ctd import CondFromModel
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ COCO_PERSON_CATEGORY_ID,
+ update_config,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.pose_estimation_pytorch.utils import resolve_device
+from deeplabcut.utils import auxfun_videos, auxiliaryfunctions
+
+
+def superanimal_analyze_images(
+ superanimal_name: str,
+ model_name: str,
+ detector_name: str,
+ images: str | Path | list[str] | list[Path],
+ max_individuals: int,
+ out_folder: str | Path,
+ progress_bar: bool = True,
+ device: str | None = None,
+ pose_threshold: float = 0.4,
+ bbox_threshold: float = 0.6,
+ plot_skeleton: bool = True,
+ customized_model_config: str | Path | dict | None = None,
+ customized_pose_checkpoint: str | Path | None = None,
+ customized_detector_checkpoint: str | Path | None = None,
+ close_figure_after_save=True,
+) -> dict[str, dict]:
+ """This function inferences a superanimal model on a set of images and saves the
+ results as labeled images.
+
+ Args:
+ superanimal_name: str
+ The name of the SuperAnimal to analyze. Supported list:
+ - "superanimal_bird"
+ - "superanimal_topviewmouse"
+ - "superanimal_quadruped"
+ - "superanimal_superbird"
+ - "superanimal_humanbody"
+
+ model_name: str
+ The name of the pose model architecture to use for inference. To get a list
+ of available models for a SuperAnimal, call:
+ >>> import dlclibrary
+ >>> superanimal_name = "superanimal_topviewmouse"
+ >>> dlclibrary.get_available_models(superanimal_name)
+
+ detector_name: str
+ The name of the detector architecture to use for inference. To get a list
+ of available detectors for a SuperAnimal, call:
+ >>> import dlclibrary
+ >>> superanimal_name = "superanimal_topviewmouse"
+ >>> dlclibrary.get_available_detectors(superanimal_name)
+
+ images: str, Path, list[str], list[Path]
+ The images to analyze. Can either be a directory containing images, or
+ a list of paths of images.
+
+ max_individuals: int
+ The maximum number of individuals to detect in each image.
+
+ out_folder: str | Path
+ The directory where the labeled images will be saved.
+
+ progress_bar: bool, default=True
+ Whether to display a progress bar when running inference.
+
+ device: str | None, default=None
+ The device to use to run image analysis.
+
+ pose_threshold: float, default=0.4
+ The cutoff score when plotting pose predictions. To note, this is called
+ pcutoff in other parts of the code. Must be in (0, 1).
+
+ bbox_threshold: float, default=0.1
+ The minimum confidence score to keep bounding box detections. Must be in
+ (0, 1).
+
+ plot_skeleton: bool, default=True
+ If a skeleton is defined in the model configuration file, whether to plot
+ the skeleton connecting the predicted bodyparts on the images.
+
+ customized_model_config: str | Path | dict | None
+ A customized SuperAnimal model config, as an alternative to the default
+ SuperAnimal model config. You can get the default SuperAnimal config with:
+ >>> import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+ >>> config = modelzoo.load_super_animal_config(
+ >>> super_animal, model_name, detector_name,
+ >>> )
+
+ customized_pose_checkpoint: str | None
+ A customized SuperAnimal pose checkpoint, as an alternative to the
+ HuggingFace SuperAnimal models.
+
+ customized_detector_checkpoint: str | None
+ A customized SuperAnimal detector checkpoint, as an alternative to the
+ HuggingFace SuperAnimal models.
+
+ Returns:
+ The predictions made by the model for each image.
+
+ Examples:
+ >>> from deeplabcut.pose_estimation_pytorch.apis import (
+ >>> superanimal_analyze_images
+ >>> )
+ >>> predictions = superanimal_analyze_images(
+ >>> superanimal_name="superanimal_topviewmouse",
+ >>> model_name="resnet_50",
+ >>> detector_name="fasterrcnn_mobilenet_v3_large_fpn",
+ >>> images="test_mouse_images",
+ >>> max_individuals=3,
+ >>> out_folder="test_mouse_images_labeled",
+ >>> device="cuda:0",
+ >>> pose_threshold=0.1,
+ >>> )
+ """
+ out_folder = Path(out_folder)
+ out_folder.mkdir(exist_ok=True, parents=True)
+
+ if customized_pose_checkpoint is None:
+ snapshot_path = modelzoo.get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=model_name,
+ )
+ else:
+ snapshot_path = Path(customized_pose_checkpoint)
+
+ detector_path = customized_detector_checkpoint
+ if detector_path is None and superanimal_name != "superanimal_humanbody":
+ detector_path = modelzoo.get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=detector_name,
+ )
+
+ filtered_detector_config = None
+ if superanimal_name == "superanimal_humanbody":
+ if detector_name is not None:
+ torchvision_detector_name = detector_name
+ else:
+ torchvision_detector_name = "fasterrcnn_mobilenet_v3_large_fpn"
+ filtered_detector_config = {
+ "torchvision_detector_name": torchvision_detector_name,
+ "category_id": COCO_PERSON_CATEGORY_ID,
+ }
+
+ if customized_model_config is None:
+ config = modelzoo.load_super_animal_config(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=(detector_name if superanimal_name != "superanimal_humanbody" else None),
+ )
+ elif isinstance(customized_model_config, (str, Path)):
+ config = config_utils.read_config_as_dict(customized_model_config)
+ else:
+ config = copy.deepcopy(customized_model_config)
+
+ config = update_config(config, max_individuals, device)
+ config["metadata"]["individuals"] = [f"animal{i}" for i in range(max_individuals)]
+ if config.get("detector") is not None:
+ config["detector"]["model"]["box_score_thresh"] = bbox_threshold
+
+ predictions = analyze_image_folder(
+ model_cfg=config,
+ images=images,
+ snapshot_path=snapshot_path,
+ detector_path=detector_path,
+ max_individuals=max_individuals,
+ device=device,
+ progress_bar=progress_bar,
+ filtered_detector_config=filtered_detector_config,
+ # TODO: when COND_TOP_DOWN SuperAnimal models will be released - create & pass a conditions provider
+ )
+
+ skeleton_bodyparts = config.get("skeleton", [])
+ skeleton = None
+ if plot_skeleton and len(skeleton_bodyparts) > 0:
+ skeleton = []
+ bodyparts = config["metadata"]["bodyparts"]
+ for bpt_0, bpt_1 in skeleton_bodyparts:
+ skeleton.append((bodyparts.index(bpt_0), bodyparts.index(bpt_1)))
+
+ visualization.create_labeled_images(
+ predictions=predictions,
+ out_folder=out_folder,
+ pcutoff=pose_threshold,
+ bboxes_pcutoff=bbox_threshold,
+ cmap=get_superanimal_colormaps()[superanimal_name],
+ skeleton=skeleton,
+ skeleton_color=config.get("skeleton_color", "black"),
+ close_figure_after_save=close_figure_after_save,
+ )
+
+ return predictions
+
+
+def analyze_images(
+ config: str | Path,
+ images: str | Path | list[str] | list[Path],
+ frame_type: str | None = None,
+ output_dir: str | Path | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ snapshot_index: int | None = None,
+ detector_snapshot_index: int | None = None,
+ modelprefix: str = "",
+ device: str | None = None,
+ max_individuals: int | None = None,
+ save_as_csv: bool = False,
+ progress_bar: bool = True,
+ plotting: bool | str = False,
+ pcutoff: float | None = None,
+ bbox_pcutoff: float | None = None,
+ plot_skeleton: bool = True,
+ ctd_conditions: dict | CondFromModel | None = None,
+) -> dict[str, dict]:
+ """Runs analysis on images using a pose model.
+
+ Args:
+ config: The project configuration file.
+ images: The image(s) to run inference on. Can be the path to an image, the path
+ to a directory containing images, or a list of image paths or directories
+ containing images.
+ frame_type: Filters the images to analyze to only the ones with the given suffix
+ (e.g. setting `frame_type`=".png" will only analyze ".png" images). The
+ default behavior analyzes all ".jpg", ".jpeg" and ".png" images.
+ output_dir: The directory where the predictions will be stored.
+ shuffle: The shuffle for which to run image analysis.
+ trainingsetindex: The trainingsetindex for which to run image analysis.
+ snapshot_index: The index of the snapshot to use. Loaded from the project
+ configuration file if None.
+ detector_snapshot_index: For top-down models only. The index of the detector
+ snapshot to use. Loaded from the project configuration file if None.
+ modelprefix: The model prefix used for the shuffle.
+ device: The device to use to run image analysis.
+ max_individuals: The maximum number of individuals to detect in each image. Set
+ to the number of individuals in the project if None.
+ save_as_csv: Whether to also save the predictions as a CSV file.
+ progress_bar: Whether to display a progress bar when running inference.
+ plotting: Whether to plot predictions on images.
+ pcutoff: The cutoff score when plotting pose predictions. Must be None or in
+ (0, 1). If None, the pcutoff is read from the project configuration file.
+ bbox_pcutoff: The cutoff score when plotting bounding box predictions. Must be
+ None or in (0, 1). If None, it is read from the project configuration file.
+ plot_skeleton: If a skeleton is defined in the model configuration file, whether
+ to plot the skeleton connecting the predicted bodyparts on the images.
+ ctd_conditions: Only for CTD models. If None, the configuration for the
+ condition provider will be loaded from the pytorch_config file (under the
+ "inference": "conditions"). If the ctd_conditions is given as a dict, creates a
+ CondFromModel from the dict. Otherwise, a CondFromModel can be given
+ directly. Example configuration:
+ ```
+ ctd_conditions = {"shuffle": 17, "snapshot": "snapshot-best-190.pt"}
+ ```
+
+ Returns:
+ A dictionary mapping each image filename to the different types of predictions
+ for it (e.g. "bodyparts", "unique_bodyparts", "bboxes", "bbox_scores")
+ """
+ cfg = auxiliaryfunctions.read_config(config)
+ train_frac = cfg["TrainingFraction"][trainingsetindex]
+ model_folder = Path(cfg["project_path"]) / auxiliaryfunctions.get_model_folder(
+ train_frac,
+ shuffle,
+ cfg,
+ engine=Engine.PYTORCH,
+ modelprefix=modelprefix,
+ )
+ train_folder = model_folder / "train"
+
+ model_cfg_path = train_folder / Engine.PYTORCH.pose_cfg_name
+ model_cfg = config_utils.read_config_as_dict(model_cfg_path)
+ pose_task = Task(model_cfg["method"])
+
+ # get the snapshots to analyze images with
+ snapshot_index, detector_snapshot_index = parse_snapshot_index_for_analysis(
+ cfg, model_cfg, snapshot_index, detector_snapshot_index
+ )
+ snapshot = get_model_snapshots(snapshot_index, train_folder, pose_task)[0]
+ detector_snapshot = None
+ if detector_snapshot_index is not None:
+ detector_snapshot = get_model_snapshots(detector_snapshot_index, train_folder, Task.DETECT)[0]
+
+ # Load the BU model for the conditions provider
+ cond_provider = None
+ if pose_task == Task.COND_TOP_DOWN:
+ if ctd_conditions is None:
+ cond_provider = get_condition_provider(
+ condition_cfg=model_cfg["inference"]["conditions"],
+ config=config,
+ )
+ elif isinstance(ctd_conditions, dict):
+ cond_provider = get_condition_provider(
+ condition_cfg=ctd_conditions,
+ config=config,
+ )
+ else:
+ cond_provider = ctd_conditions
+
+ predictions = analyze_image_folder(
+ model_cfg=model_cfg,
+ images=images,
+ snapshot_path=snapshot.path,
+ detector_path=None if detector_snapshot is None else detector_snapshot.path,
+ frame_type=frame_type,
+ device=device,
+ max_individuals=max_individuals,
+ progress_bar=progress_bar,
+ cond_provider=cond_provider,
+ )
+
+ if not predictions:
+ logging.info(f"No predictions made for images {images}.")
+ return {}
+
+ if output_dir is None:
+ images = list(predictions.keys())
+ output_dir = Path(images[0]).parent.resolve()
+ print(f"Setting output directory to {output_dir}")
+
+ output_dir = Path(output_dir)
+ output_dir.mkdir(exist_ok=True)
+
+ scorer = get_scorer_name(
+ cfg,
+ shuffle=shuffle,
+ train_fraction=train_frac,
+ snapshot_uid=get_scorer_uid(snapshot, detector_snapshot),
+ modelprefix=modelprefix,
+ )
+ individuals = model_cfg["metadata"]["individuals"]
+ if max_individuals is not None:
+ individuals = [f"individual{i}" for i in range(max_individuals)]
+
+ df_predictions = build_predictions_dataframe(
+ scorer=scorer,
+ predictions=predictions,
+ parameters=data.PoseDatasetParameters(
+ bodyparts=model_cfg["metadata"]["bodyparts"],
+ unique_bpts=model_cfg["metadata"]["unique_bodyparts"],
+ individuals=individuals,
+ ),
+ image_name_to_index=None,
+ )
+
+ output_filepath = output_dir / f"image_predictions_{scorer}.h5"
+ print(f"Saving predictions to {output_filepath}")
+
+ df_predictions.to_hdf(output_filepath, key="predictions")
+ if save_as_csv:
+ print(f"Saving CSV as {output_filepath}")
+ df_predictions.to_csv(output_filepath.with_suffix(".csv"))
+
+ if plotting:
+ plot_dir = output_dir / f"LabeledImages_{scorer}"
+ plot_dir.mkdir(exist_ok=True)
+
+ mode = plotting if isinstance(plotting, str) else "bodypart"
+
+ bodyparts = model_cfg["metadata"]["bodyparts"]
+ skeleton = None
+ if plot_skeleton and len(cfg.get("skeleton", [])) > 0:
+ skeleton = [(bodyparts.index(bpt_0), bodyparts.index(bpt_1)) for bpt_0, bpt_1 in cfg["skeleton"]]
+
+ if pcutoff is None:
+ pcutoff = cfg.get("pcutoff", 0.6)
+ if bbox_pcutoff is None:
+ bbox_pcutoff = cfg.get("bbox_pcutoff", 0.6)
+
+ visualization.create_labeled_images(
+ predictions=predictions,
+ out_folder=plot_dir,
+ pcutoff=pcutoff,
+ bboxes_pcutoff=bbox_pcutoff,
+ mode=mode,
+ cmap=cfg.get("colormap", "rainbow"),
+ dot_size=cfg.get("dotsize", 12),
+ alpha_value=cfg.get("alphavalue", 12),
+ skeleton=skeleton,
+ skeleton_color=cfg.get("skeleton_color"),
+ )
+
+ return predictions
+
+
+def analyze_image_folder(
+ model_cfg: str | Path | dict,
+ images: str | Path | list[str] | list[Path],
+ snapshot_path: str | Path,
+ detector_path: str | Path | None = None,
+ frame_type: str | None = None,
+ device: str | None = None,
+ max_individuals: int | None = None,
+ progress_bar: bool = True,
+ filtered_detector_config: dict | None = None,
+ cond_provider: CondFromModel | None = None,
+) -> dict[str, dict[str, np.ndarray | np.ndarray]]:
+ """Runs pose inference on a folder of images and returns the predictions.
+
+ Args:
+ model_cfg: The model config (or its path) used to analyze the images.
+ images: The images to analyze. Can either be a directory containing images, or
+ a list of paths of images.
+ snapshot_path: The path of the snapshot to use to analyze the images.
+ detector_path: The path of the detector snapshot to use to analyze the images,
+ if a top-down model was used.
+ frame_type: Filters the images to analyze to only the ones with the given suffix
+ (e.g. setting `frame_type`=".png" will only analyze ".png" images). The
+ default behavior analyzes all ".jpg", ".jpeg" and ".png" images.
+ device: The device to use to run image analysis.
+ max_individuals: The maximum number of individuals to detect in each image. Set
+ to the number of individuals in the project if None.
+ progress_bar: Whether to display a progress bar when running inference.
+ filtered_detector_config: If using a filtered torchvision detector instead of a saved detector snapshot,
+ specify the filtered detector configuration
+ cond_provider: If using a CTD model - this parameter is needed to provide the conditions
+
+ Returns:
+ A dictionary mapping each image filename to the different types of predictions
+ for it (e.g. "bodyparts", "unique_bodyparts", "bboxes", "bbox_scores")
+
+ Raises:
+ ValueError: if the pose model is a top-down model but no detector path is given
+ """
+ if not isinstance(model_cfg, dict):
+ model_cfg = config_utils.read_config_as_dict(model_cfg)
+
+ pose_task = Task(model_cfg["method"])
+ if pose_task == Task.TOP_DOWN and detector_path is None and filtered_detector_config is None:
+ raise ValueError(
+ "A detector path or filtered_detector_config must be specified for image analysis using top-down models"
+ " Please specify the `detector_path` parameter or the `filtered_detector_config` parameter."
+ )
+
+ if max_individuals is None:
+ max_individuals = len(model_cfg["metadata"]["individuals"])
+
+ if device is None:
+ device = resolve_device(model_cfg)
+
+ if pose_task == Task.COND_TOP_DOWN and cond_provider is None:
+ raise ValueError(
+ "A conditions provider must be specified for image analysis when using cond-top-down models"
+ " Please specify the `cond_provider` parameter."
+ )
+
+ pose_runner = get_pose_inference_runner(
+ model_config=model_cfg,
+ snapshot_path=snapshot_path,
+ device=device,
+ max_individuals=max_individuals,
+ cond_provider=cond_provider,
+ )
+
+ image_suffixes = ".png", ".jpg", ".jpeg"
+ if frame_type is not None:
+ image_suffixes = (frame_type,)
+
+ image_paths = parse_images_and_image_folders(images, image_suffixes)
+ if not image_paths:
+ logging.info(f"No images found searching {images} for extensions {image_suffixes}. Skipping analysis.")
+ return {}
+ pose_inputs = image_paths
+
+ detector_runner = None
+ if detector_path is not None:
+ logging.info(f"Running object detection with {detector_path}")
+ detector_runner = get_detector_inference_runner(
+ model_config=model_cfg,
+ snapshot_path=detector_path,
+ device=device,
+ max_individuals=max_individuals,
+ )
+ elif filtered_detector_config is not None:
+ model_name = filtered_detector_config["torchvision_detector_name"]
+ category_id = filtered_detector_config["category_id"]
+
+ logging.info(
+ f"Running object detection with filtered torchvision detector '{model_name}', category_id={category_id}"
+ )
+ detector_runner = get_filtered_coco_detector_inference_runner(
+ model_name=model_name,
+ category_id=category_id,
+ batch_size=1,
+ device=device,
+ max_individuals=max_individuals,
+ color_mode=model_cfg["data"]["colormode"],
+ model_config=model_cfg,
+ )
+
+ if detector_runner is not None:
+ detector_image_paths = tqdm(image_paths) if progress_bar else image_paths
+ bbox_predictions = detector_runner.inference(images=detector_image_paths)
+ pose_inputs = list(zip(image_paths, bbox_predictions, strict=False))
+
+ logging.info(f"Running pose estimation with {snapshot_path}")
+
+ if progress_bar:
+ pose_inputs = tqdm(pose_inputs)
+
+ predictions = pose_runner.inference(pose_inputs)
+
+ return {
+ image_path: image_predictions for image_path, image_predictions in zip(image_paths, predictions, strict=False)
+ }
+
+
+def plot_images_coco(
+ model_cfg: str | Path | dict,
+ image_folder: str | Path,
+ snapshot_path: str | Path,
+ out_path: str = "test_images",
+ data_json_path: str = "",
+ detector_path: str | Path | None = None,
+ device: str | None = None,
+ max_individuals: int | None = None,
+ cond_provider: CondFromModel | None = None,
+) -> list[dict]:
+ """Runs pose inference on a folder of images from a COCO dataset, and plots all
+ predicted keypoints and bounding boxes.
+
+ Args:
+ model_cfg: The model config (or its path) used to analyze the images.
+ image_folder: The path to the folder containing the images to analyze.
+ snapshot_path: The path of the snapshot to use to analyze the images.
+ out_path: The path of the folder where images should be output.
+ data_json_path: The path to the JSON file containing ground truth data.
+ detector_path: The path of the detector snapshot to use to analyze the images,
+ if a top-down model was used.
+ device: The device on which to run image inference
+ max_individuals: The maximum number of individuals to detect in an image.
+ cond_provider: If using a CTD model - this parameter is needed to provide the conditions
+
+ Returns:
+ A list of dictionaries containing predictions made on each image.
+
+ Raises:
+ ValueError: if a top-down model configuration is given but detector_path is None
+ """
+ with open(data_json_path) as f:
+ obj = json.load(f)
+
+ coco_images = obj["images"]
+ coco_annotations = obj["annotations"]
+
+ image_name_to_id = {}
+ for image in coco_images:
+ # only works with relative path as a test image can be in a different folder
+ image_name = image["file_name"].split(os.sep)[-1]
+ image_name_to_id[image_name] = image["id"]
+
+ image_id_to_annotations = defaultdict(list)
+ image_ids = list(image_name_to_id.values())
+ for annotation in coco_annotations:
+ image_id = annotation["image_id"]
+ if annotation["image_id"] in image_ids:
+ image_id_to_annotations[image_id].append(annotation)
+
+ # need to support more image types
+ images_in_folder = glob.glob(str(Path(image_folder) / "*.png"))
+ corresponded_images = []
+ for image in images_in_folder:
+ image_path = image
+ image_name = image.split(os.sep)[-1]
+ if image_name in image_name_to_id:
+ corresponded_images.append(image_path)
+
+ images = corresponded_images
+
+ predictions = analyze_image_folder(
+ model_cfg=model_cfg,
+ images=images,
+ snapshot_path=snapshot_path,
+ detector_path=detector_path,
+ device=device,
+ max_individuals=max_individuals,
+ progress_bar=True,
+ cond_provider=cond_provider,
+ )
+
+ os.makedirs(out_path, exist_ok=True)
+
+ coco_format_predictions = []
+ for image_path, prediction in predictions.items():
+ image_name = image_path.split(os.sep)[-1]
+ coco_prediction = dict(
+ image_id=image_name_to_id[image_name],
+ gt_annotations=image_id_to_annotations[image_name_to_id[image_name]],
+ file_name=image_path,
+ bodyparts=prediction["bodyparts"],
+ )
+ if "unique_bodyparts" in prediction:
+ coco_prediction["unique_bodyparts"] = prediction["unique_bodyparts"]
+ if "bboxes" in prediction:
+ coco_prediction["bboxes"] = prediction["bboxes"]
+ if "bbox_scores" in prediction:
+ coco_prediction["bbox_scores"] = prediction["bbox_scores"]
+
+ coco_format_predictions.append(coco_prediction)
+
+ frame = auxfun_videos.imread(str(image_path), mode="skimage")
+ fig, ax = plt.subplots()
+ ax.imshow(frame)
+
+ # TODO: color of keypoints are all red. Need to change to a different colormap
+ for pose in prediction["bodyparts"]:
+ x, y, confidence = pose[:, 0], pose[:, 1], pose[:, 2]
+ mask = confidence > 0.0
+ x = x[mask]
+ y = y[mask]
+ ax.scatter(x, y, color="red")
+
+ bboxes = prediction["bboxes"]
+ for bbox in bboxes:
+ # Draw bounding boxes around detected objects
+ xmin, ymin, w, h = bbox
+ rect = plt.Rectangle((xmin, ymin), w, h, fill=False, edgecolor="blue", linewidth=2)
+
+ ax.add_patch(rect)
+ image_name = image_path.split("/")[-1]
+ fig.savefig(os.path.join(out_path, image_name))
+
+ return coco_format_predictions
+
+
+def parse_images_and_image_folders(
+ images: str | Path | list[str] | list[Path],
+ image_suffixes: tuple[str] = (".png", ".jpg", ".jpeg"),
+) -> list[str]:
+ """Parses image paths or directory paths into a single list of image paths.
+
+ Args:
+ images: Paths of images or folders containing images.
+ image_suffixes: Suffixes used for images.
+
+ Returns:
+ The images contained in the folders or directly the paths given as input
+ """
+ if isinstance(images, (str, Path)):
+ path = Path(images)
+ if path.is_dir():
+ return [str(img) for img in path.iterdir() if img.suffix in image_suffixes]
+
+ return [str(path)]
+
+ image_to_analyze = []
+ for file in images:
+ image_to_analyze += parse_images_and_image_folders(file)
+
+ return image_to_analyze
diff --git a/deeplabcut/pose_estimation_pytorch/apis/ctd.py b/deeplabcut/pose_estimation_pytorch/apis/ctd.py
new file mode 100644
index 0000000000..8f2ee0d8ef
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/ctd.py
@@ -0,0 +1,164 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Methods to help with conditional top-down models."""
+
+from pathlib import Path
+
+import numpy as np
+
+import deeplabcut.pose_estimation_pytorch.data as data
+from deeplabcut.pose_estimation_pytorch.data.ctd import (
+ CondFromFile,
+ CondFromModel,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def get_condition_provider(
+ condition_cfg: dict,
+ config: str | Path | None = None,
+) -> CondFromModel:
+ """Creates a CondFromModel conditions provider for a CTD model.
+
+ Args:
+ condition_cfg: The configuration for the condition provider. This is the
+ content of "inference": "conditions" in the pytorch_config
+ config: The path to the project config file, if the condition provider is
+ given as a snapshot from a DeepLabCut shuffle.
+
+ Returns:
+ The CondFromModel provider that can be used to generate conditions from a BU
+ model for a CTD model.
+ """
+ error_message = (
+ f"Misconfigured conditions in the pytorch_config: {condition_cfg}. Valid "
+ f"examples:\n" + _CONDITION_EXAMPLES_INFERENCE
+ )
+
+ if isinstance(condition_cfg, (str, Path)):
+ error_message = (
+ "To run inference with CTD models, you must specify the BU model you want to use to generate conditions.\n"
+ ) + error_message
+ raise ValueError(error_message)
+ elif not isinstance(condition_cfg, dict):
+ raise ValueError(error_message)
+
+ if config is not None:
+ condition_cfg["config"] = Path(config)
+
+ return CondFromModel(**condition_cfg)
+
+
+def get_conditions_provider_for_video(
+ cond_provider: CondFromModel,
+ video: str | Path,
+) -> CondFromFile | None:
+ """Tries to create a conditions loader.
+
+ Args:
+ cond_provider: The CondFromModel condition provider that will be used. The
+ scorer must be set, or potential conditions files for the video cannot be
+ found.
+ video: The path to the video file for which to look for the conditions.
+
+ Returns:
+ None if no condition files for this BU model and video can be found.
+ The CondFromFile provider to load the conditions for the video from a file.
+ """
+ if cond_provider.scorer is None:
+ return None
+
+ video = Path(video)
+
+ # Load pickle for multi-animal projects
+ cond_file = video.parent / f"{video.stem}{cond_provider.scorer}_assemblies.pickle"
+ if not cond_file.exists():
+ # Load h5 for single-animal projects
+ cond_file = video.parent / f"{video.stem}{cond_provider.scorer}.h5"
+ if not cond_file.exists():
+ return None
+
+ return CondFromFile(filepath=cond_file)
+
+
+def load_conditions_for_evaluation(loader: data.Loader, images: list[str]) -> dict[str, np.ndarray]:
+ """Loads the conditions needed to evaluate a CTD model.
+
+ Args:
+ loader: The Loader for the CTD model to evaluate.
+ images: A list of image paths to load conditions for.
+
+ Returns:
+ The conditions for the images.
+ """
+ if loader.pose_task != Task.COND_TOP_DOWN:
+ raise ValueError("Conditions can only be loaded for CTD models")
+
+ # load the conditions config
+ condition_cfg = loader.model_cfg["inference"].get("conditions")
+
+ # prepare error message
+ error_message = (
+ f"Misconfigured conditions in the pytorch_config: {condition_cfg}. Valid "
+ f"examples:\n" + _CONDITION_EXAMPLES_INFERENCE + _CONDITION_EXAMPLES_FROM_FILE
+ )
+
+ if isinstance(condition_cfg, (str, Path)):
+ condition_filepath = Path(condition_cfg)
+ cond_provider = CondFromFile(filepath=condition_filepath)
+ elif isinstance(condition_cfg, dict):
+ if isinstance(loader, data.DLCLoader) and "config" not in condition_cfg:
+ condition_cfg["config"] = loader.project_root / "config.yaml"
+
+ cond_provider = CondFromFile(**condition_cfg)
+ else:
+ raise ValueError(error_message)
+
+ return cond_provider.load_conditions(images, path_prefix=loader.image_root)
+
+
+_CONDITION_EXAMPLES_INFERENCE = """
+Example: Using a bottom-up model for conditions
+ ```
+ inference:
+ conditions:
+ config_path: /path/to/model-dir/pytorch_config.yaml
+ snapshot_path: /path/to/model-dir/snapshot-best-150.pth
+ ```
+Example: Loading the predictions for snapshot-250.pt of shuffle 1.
+ ```
+ inference:
+ conditions:
+ shuffle: 1
+ snapshot: snapshot-250.pt
+ ```
+Example: Loading the predictions for the snapshot with index 2 of shuffle 1.
+ ```
+ inference:
+ conditions:
+ shuffle: 1
+ snapshot_index: 2
+ ```
+"""
+
+
+_CONDITION_EXAMPLES_FROM_FILE = """
+Example: Loading the predictions contained in an h5 file.
+ ```
+ inference:
+ conditions: /path/to/bu_predictions.h5
+ ```
+Example: Loading the predictions contained in an json file.
+ ```
+ inference:
+ conditions: /path/to/bu_predictions.json
+ ```
+"""
diff --git a/deeplabcut/pose_estimation_pytorch/apis/evaluation.py b/deeplabcut/pose_estimation_pytorch/apis/evaluation.py
new file mode 100755
index 0000000000..9a15085ae7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/evaluation.py
@@ -0,0 +1,1007 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import argparse
+from collections.abc import Iterable
+from pathlib import Path
+
+import albumentations as A
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from tqdm import tqdm
+
+import deeplabcut.core.metrics as metrics
+import deeplabcut.pose_estimation_pytorch.apis.ctd as ctd
+import deeplabcut.pose_estimation_pytorch.apis.prune_paf_graph as prune_paf_graph
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch import utils
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ build_bboxes_dict_for_dataframe,
+ build_predictions_dataframe,
+ ensure_multianimal_df_format,
+ get_inference_runners,
+ get_model_snapshots,
+ get_scorer_name,
+ get_scorer_uid,
+)
+from deeplabcut.pose_estimation_pytorch.data import DLCLoader, Loader
+from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters
+from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.utils import auxfun_videos, auxiliaryfunctions
+from deeplabcut.utils.visualization import (
+ create_minimal_figure,
+ erase_artists,
+ get_cmap,
+ make_multianimal_labeled_image,
+ plot_evaluation_results,
+ save_labeled_frame,
+)
+
+
+def predict(
+ pose_runner: InferenceRunner,
+ loader: Loader,
+ mode: str,
+ detector_runner: InferenceRunner | None = None,
+) -> dict[str, dict[str, np.ndarray]]:
+ """Predicts poses on data contained in a loader.
+
+ Args:
+ pose_runner: The runner to use for pose estimation
+ loader: The loader containing the data to predict poses on
+ mode: {"train", "test"} The mode to predict on
+ detector_runner: If the loader's `pose_task` is "TD", a detector runner can be
+ given to detect individuals in the images. If no detector is given, ground
+ truth bounding boxes will be used to crop individuals before pose estimation
+
+ Returns:
+ The paths of images for which predictions were computed mapping to the
+ different predictions made by each model head
+ """
+ image_paths = loader.image_filenames(mode)
+ context = None
+
+ if loader.pose_task == Task.TOP_DOWN:
+ # Get bounding boxes for context
+ if detector_runner is not None:
+ bbox_predictions = detector_runner.inference(images=tqdm(image_paths))
+ context = bbox_predictions
+ else:
+ ground_truth_bboxes = loader.ground_truth_bboxes(mode=mode)
+ context = [{"bboxes": ground_truth_bboxes[image]["bboxes"]} for image in image_paths]
+
+ elif loader.pose_task == Task.COND_TOP_DOWN:
+ # Load conditions for context
+ conditions = ctd.load_conditions_for_evaluation(loader, image_paths)
+ context = [{"cond_kpts": conditions[image]} for image in image_paths]
+
+ images_with_context = image_paths
+ if context is not None:
+ if len(context) != len(image_paths):
+ raise ValueError(f"Missing context for some images: {len(context)} != {len(image_paths)}")
+ images_with_context = list(zip(image_paths, context, strict=False))
+
+ predictions = pose_runner.inference(images=tqdm(images_with_context))
+ return {
+ image_path: image_predictions for image_path, image_predictions in zip(image_paths, predictions, strict=False)
+ }
+
+
+def evaluate(
+ pose_runner: InferenceRunner,
+ loader: Loader,
+ mode: str,
+ detector_runner: InferenceRunner | None = None,
+ parameters: PoseDatasetParameters | None = None,
+ comparison_bodyparts: str | list[str] | None = None,
+ per_keypoint_evaluation: bool = False,
+ pcutoff: float | list[float] = 0.6,
+ force_multi_animal: bool = False,
+) -> tuple[dict[str, float], dict[str, dict[str, np.ndarray]]]:
+ """
+ Args:
+ pose_runner: The runner for pose estimation
+ loader: The loader containing the data to evaluate
+ mode: Either 'train' or 'test'
+ detector_runner: If the loader's `pose_task` is "TD", a detector can be given to
+ compute bounding boxes for pose estimation. If no detector is given, ground
+ truth bounding boxes are used.
+ parameters: PoseDatasetParameters to use. If None, the parameters will be
+ obtained from the given Loader. This can be used to change the names of
+ bodyparts, e.g. when a model is trained with memory replay.
+ comparison_bodyparts: A subset of the bodyparts for which to compute the
+ evaluation metrics. Passing "all" or None evaluates on all bodyparts.
+ per_keypoint_evaluation: Compute the train and test RMSE for each keypoint, and
+ save the results to a {model_name}-keypoint-results.csv in the
+ evaluation-results-pytorch folder.
+ pcutoff: Confidence threshold for RMSE computation. If a list is provided,
+ there should be one value for each bodypart and one value for each unique
+ bodypart (if there are any).
+ force_multi_animal: If False - the scenario (single- or multi-animal) is inferred from the loader.
+ If True - the multi-animal is used during evaluation, even if the loader contains only a single animal.
+
+
+ Returns:
+ A dict containing the evaluation results
+ A dict mapping the paths of images for which predictions were computed to the
+ different predictions made by each model head
+ """
+ predictions = predict(pose_runner, loader, mode, detector_runner=detector_runner)
+
+ # For models trained with memory-replay from SuperAnimal, keep project bodyparts
+ if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init"):
+ weight_init = WeightInitialization.from_dict(weight_init_cfg)
+ if weight_init.memory_replay:
+ for _, pred in predictions.items():
+ pred["bodyparts"] = pred["bodyparts"][:, weight_init.conversion_array]
+
+ if parameters is None:
+ parameters = loader.get_dataset_parameters()
+
+ gt_pose = loader.ground_truth_keypoints(mode)
+ pred_pose = {filename: pred["bodyparts"] for filename, pred in predictions.items()}
+ kpt_idx = _get_keypoints_to_use(parameters.bodyparts, comparison_bodyparts)
+
+ gt_unique, pred_unique, unique_idx = None, None, None
+ if parameters.num_unique_bpts >= 1:
+ gt_unique = loader.ground_truth_keypoints(mode, unique_bodypart=True)
+ pred_unique = {filename: pred["unique_bodyparts"] for filename, pred in predictions.items()}
+ unique_idx = _get_keypoints_to_use(parameters.unique_bpts, comparison_bodyparts)
+
+ # When `comparison_bodyparts` is used, check that the bodyparts used for evaluation
+ # make sense; If only unique bodyparts are being evaluated, set them as bodyparts
+ if kpt_idx is not None and unique_idx is not None:
+ if len(kpt_idx) == 0 and len(unique_idx) == 0:
+ unique_err = ""
+ if len(parameters.unique_bpts) > 0:
+ unique_err = f" and the unique_bodyparts are {parameters.unique_bpts}"
+ raise ValueError(
+ f"No bodyparts left when comparison_bodyparts={comparison_bodyparts}! "
+ f"The project bodyparts are {parameters.bodyparts}{unique_err}! Set "
+ f"comparison_bodyparts to `None` or `'all'` to evaluate on all of them,"
+ f" or select a subset of them to evaluate."
+ )
+ elif len(kpt_idx) == 0 and len(unique_idx) > 0:
+ gt_pose, pred_pose, kpt_idx = gt_unique, pred_unique, unique_idx
+ parameters = PoseDatasetParameters(
+ bodyparts=parameters.unique_bpts,
+ unique_bpts=[],
+ individuals=["animal"],
+ )
+ gt_unique, pred_unique, unique_idx = None, None, None
+
+ if kpt_idx is not None:
+ gt_pose = {img: kpts[:, kpt_idx] for img, kpts in gt_pose.items()}
+ pred_pose = {img: kpts[:, kpt_idx] for img, kpts in pred_pose.items()}
+
+ if unique_idx is not None:
+ gt_unique = {img: kpts[:, unique_idx] for img, kpts in gt_unique.items()}
+ pred_unique = {img: kpts[:, unique_idx] for img, kpts in pred_unique.items()}
+
+ bodyparts = _get_subset_bodyparts(parameters.bodyparts, comparison_bodyparts)
+ unique_bpts = _get_subset_bodyparts(parameters.unique_bpts, comparison_bodyparts)
+ _validate_pcutoff(bodyparts, unique_bpts, pcutoff)
+
+ results = metrics.compute_metrics(
+ gt_pose,
+ pred_pose,
+ single_animal=False if force_multi_animal else parameters.max_num_animals == 1,
+ pcutoff=pcutoff,
+ unique_bodypart_poses=pred_unique,
+ unique_bodypart_gt=gt_unique,
+ per_keypoint_rmse=per_keypoint_evaluation,
+ compute_detection_rmse=False,
+ )
+
+ if loader.model_cfg["metadata"]["with_identity"]:
+ pred_id_scores = {filename: pred["identity_scores"] for filename, pred in predictions.items()}
+ id_scores = metrics.compute_identity_scores(
+ individuals=parameters.individuals,
+ bodyparts=parameters.bodyparts,
+ predictions=pred_pose,
+ identity_scores=pred_id_scores,
+ ground_truth=gt_pose,
+ )
+ for name, score in id_scores.items():
+ results[f"id_head_{name}"] = score
+
+ # Updating poses to be aligned and padded
+ for image, pose in pred_pose.items():
+ predictions[image]["bodyparts"] = pose
+
+ return results, predictions
+
+
+def visualize_predictions(
+ predictions: dict,
+ ground_truth: dict,
+ output_dir: str | Path | None = None,
+ num_samples: int | None = None,
+ random_select: bool = False,
+ show_ground_truth: bool = True,
+ plot_bboxes: bool = True,
+) -> None:
+ """Visualize model predictions alongside ground truth keypoints.
+
+ This function processes keypoint predictions and ground truth data, applies
+ visibility masks, and generates visualization plots. It supports random or
+ sequential sampling of images for visualization.
+
+ Args:
+ predictions: Dictionary mapping image paths to prediction data.
+ Each prediction contains:
+ - bodyparts: array of shape [N, num_keypoints, 3] where 3 represents
+ (x, y, confidence)
+ - bboxes: array of shape [N, 4] for bounding boxes (optional)
+ - bbox_scores: array of shape [N,] for bbox confidences (optional)
+ ground_truth: Dictionary mapping image paths to ground truth keypoints.
+ Each value has shape [N, num_keypoints, 3] where 3 represents
+ (x, y, visibility)
+ output_dir: Path to save visualization outputs.
+ Defaults to "predictions_visualizations"
+ num_samples: Number of images to visualize. If None, processes all images
+ random_select: If True, randomly samples images; if False, uses first N images
+ show_ground_truth: If True, displays ground truth poses alongside predictions.
+ If False, only shows predictions but uses GT visibility mask
+ plot_bboxes: If True and the model is a top-down model, predicted bboxes will
+ be shown in the images as well
+ """
+ # Setup output directory
+ output_dir = Path(output_dir or "predictions_visualizations")
+ output_dir.mkdir(exist_ok=True)
+
+ # Select images to process
+ image_paths = list(predictions.keys())
+ if num_samples and num_samples < len(image_paths):
+ if random_select:
+ image_paths = np.random.choice(image_paths, num_samples, replace=False).tolist()
+ else:
+ image_paths = image_paths[:num_samples]
+
+ # Process each selected image
+ for image_path in image_paths:
+ # Get prediction and ground truth data
+ pred_data = predictions[image_path]
+ gt_keypoints = ground_truth[image_path] # Shape: [N, num_keypoints, 3]
+
+ # Create visibility mask from first GT sample. This mask will be applied to all samples for consistency
+ vis_mask = gt_keypoints[0, :, 2] > 0
+
+ # Process ground truth keypoints if showing GT
+ if show_ground_truth:
+ visible_gt = []
+ for gt in gt_keypoints:
+ visible_points = gt[vis_mask, :2] # Keep only x,y for visible joints
+ visible_gt.append(visible_points)
+ visible_gt = np.stack(visible_gt) # Shape: [N, num_visible_joints, 2]
+ else:
+ visible_gt = None
+
+ # Process predicted keypoints
+ pred_keypoints = pred_data["bodyparts"] # Shape: [N, num_keypoints, 3]
+ visible_pred = []
+ for pred in pred_keypoints:
+ visible_points = pred[vis_mask] # Keep only visible joint predictions
+ visible_pred.append(visible_points)
+ visible_pred = np.stack(visible_pred) # Shape: [N, num_visible_joints, 3]
+
+ if plot_bboxes:
+ bboxes = predictions[image_path].get("bboxes", None)
+ bbox_scores = predictions[image_path].get("bbox_scores", None)
+ bounding_boxes = (bboxes, bbox_scores) if bboxes is not None and bbox_scores is not None else None
+ else:
+ bounding_boxes = None
+
+ # Generate and save visualization
+ try:
+ plot_gt_and_predictions(
+ image_path=image_path,
+ output_dir=output_dir,
+ gt_bodyparts=visible_gt,
+ pred_bodyparts=visible_pred,
+ bounding_boxes=bounding_boxes,
+ )
+ print(f"Successfully plotted predictions for {image_path}")
+ except Exception as e:
+ print(f"Error plotting predictions for {image_path}: {str(e)}")
+
+
+def plot_gt_and_predictions(
+ image_path: str | Path,
+ output_dir: str | Path,
+ gt_bodyparts: np.ndarray,
+ pred_bodyparts: np.ndarray,
+ gt_unique_bodyparts: np.ndarray | None = None,
+ pred_unique_bodyparts: np.ndarray | None = None,
+ mode: str = "bodypart",
+ colormap: str = "rainbow",
+ dot_size: int = 12,
+ alpha_value: float = 0.7,
+ p_cutoff: float | list[float] = 0.6,
+ bounding_boxes: tuple[np.ndarray, np.ndarray] | None = None,
+ bboxes_pcutoff: float = 0.6,
+ bounding_boxes_color: str = "auto",
+):
+ """Plot ground truth and predictions on an image.
+
+ Args:
+ image_path: Path to the image
+ gt_bodyparts: Ground truth keypoints array (num_animals, num_keypoints, 3)
+ pred_bodyparts: Predicted keypoints array (num_animals, num_keypoints, 3)
+ output_dir: Directory where labeled images will be saved
+ gt_unique_bodyparts: Ground truth unique bodyparts if any
+ pred_unique_bodyparts: Predicted unique bodyparts if any
+ mode: How to color the points ("bodypart" or "individual")
+ colormap: Matplotlib colormap name
+ dot_size: Size of the plotted points
+ alpha_value: Transparency of the points
+ p_cutoff: Confidence threshold for showing predictions. If a list is provided,
+ there should be one value for each bodypart and one value for each unique
+ bodypart (if there are any).
+ bounding_boxes: bounding boxes (top-left corner, size) and their respective
+ confidence levels,
+ bboxes_pcutoff: bounding boxes confidence cutoff threshold.
+ bounding_boxes_color: If plotting bounding boxes, this is the color that will be
+ used for bounding boxes. If set to "auto" (default value):
+ - if mode is "bodypart", the bbox color will be a default color
+ - if mode is "individual", each individual's color will be used for its
+ bounding box
+ """
+ # Ensure output directory exists
+ output_dir = Path(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Read the image
+ frame = auxfun_videos.imread(str(image_path), mode="skimage")
+ num_pred, num_keypoints = pred_bodyparts.shape[:2]
+
+ # Create figure and set dimensions
+ fig, ax = create_minimal_figure()
+ h, w, _ = np.shape(frame)
+ fig.set_size_inches(w / 100, h / 100)
+ ax.set_xlim(0, w)
+ ax.set_ylim(0, h)
+ ax.invert_yaxis()
+ ax.imshow(frame, "gray")
+
+ # Set up colors based on mode
+ if mode == "bodypart":
+ num_colors = num_keypoints
+ if pred_unique_bodyparts is not None:
+ num_colors += pred_unique_bodyparts.shape[1]
+ colors = get_cmap(num_colors, name=colormap)
+
+ predictions = pred_bodyparts.swapaxes(0, 1)
+ ground_truth = gt_bodyparts.swapaxes(0, 1)
+ elif mode == "individual":
+ colors = get_cmap(num_pred + 1, name=colormap)
+ predictions = pred_bodyparts
+ ground_truth = gt_bodyparts
+ else:
+ raise ValueError(f"Invalid mode: {mode}")
+
+ if bounding_boxes_color == "auto":
+ if mode == "bodypart":
+ bboxes_color = None
+ elif mode == "individual":
+ bboxes_color = get_cmap(num_pred + 1, name=colormap)
+ else:
+ raise ValueError(f"Invalid mode: {mode}")
+ else:
+ bboxes_color = bounding_boxes_color
+
+ # Plot regular bodyparts
+ ax = make_multianimal_labeled_image(
+ frame,
+ ground_truth,
+ predictions[:, :, :2],
+ predictions[:, :, 2:],
+ colors,
+ dot_size,
+ alpha_value,
+ p_cutoff,
+ ax=ax,
+ bounding_boxes=bounding_boxes,
+ bboxes_cutoff=bboxes_pcutoff,
+ bboxes_color=bboxes_color,
+ )
+
+ # Plot unique bodyparts if present
+ if pred_unique_bodyparts is not None and gt_unique_bodyparts is not None:
+ if mode == "bodypart":
+ unique_predictions = pred_unique_bodyparts.swapaxes(0, 1)
+ unique_ground_truth = gt_unique_bodyparts.swapaxes(0, 1)
+ else:
+ unique_predictions = pred_unique_bodyparts
+ unique_ground_truth = gt_unique_bodyparts
+
+ ax = make_multianimal_labeled_image(
+ frame,
+ unique_ground_truth,
+ unique_predictions[:, :, :2],
+ unique_predictions[:, :, 2:],
+ colors[num_keypoints:],
+ dot_size,
+ alpha_value,
+ p_cutoff,
+ ax=ax,
+ )
+
+ # Save the labeled image
+ save_labeled_frame(
+ fig,
+ str(image_path),
+ str(output_dir),
+ belongs_to_train=False,
+ )
+ erase_artists(ax)
+ plt.close()
+
+
+def evaluate_snapshot(
+ cfg: dict,
+ loader: DLCLoader,
+ snapshot: Snapshot,
+ scorer: str,
+ transform: A.Compose | None = None,
+ plotting: bool | str = False,
+ show_errors: bool = True,
+ comparison_bodyparts: str | list[str] | None = None,
+ per_keypoint_evaluation: bool = False,
+ detector_snapshot: Snapshot | None = None,
+ pcutoff: float | list[float] | dict[str, float] | None = None,
+) -> pd.DataFrame:
+ """Evaluates a snapshot. The evaluation results are stored in the .h5 and .csv file
+ under the subdirectory 'evaluation_results'.
+
+ Args:
+ cfg: the content of the project's config file
+ loader: the loader for the shuffle to evaluate
+ snapshot: the snapshot to evaluate
+ scorer: the scorer name to use for the snapshot
+ transform: transformation pipeline for evaluation
+ ** Should normalise the data the same way it was normalised during training **
+ plotting: Plots the predictions on the train and test images. If provided it must
+ be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting
+ to ``True`` defaults as ``"bodypart"`` for multi-animal projects.
+ show_errors: whether to compare predictions and ground truth
+ comparison_bodyparts: A subset of the bodyparts for which to compute the
+ evaluation metrics.
+ per_keypoint_evaluation: Compute the train and test RMSE for each keypoint, and
+ save the results to a {model_name}-keypoint-results.csv in the
+ evaluation-results-pytorch folder.
+ detector_snapshot: Only for TD models. If defined, evaluation metrics are
+ computed using the detections made by this snapshot
+ pcutoff: The cutoff to use for computing evaluation metrics. When `None`, the
+ cutoff will be loaded from the project config. If a list is provided, there
+ should be one value for each bodypart and one value for each unique bodypart
+ (if there are any). If a dict is provided, the keys should be bodyparts
+ mapping to pcutoff values for each bodypart. Bodyparts that are not defined
+ in the dict will have pcutoff set to 0.6.
+ """
+ head_type = loader.model_cfg["model"]["heads"]["bodypart"]["type"]
+ if head_type == "DLCRNetHead":
+ prune_paf_graph.benchmark_paf_graphs(
+ loader=loader,
+ snapshot_path=snapshot.path,
+ verbose=False,
+ )
+
+ parameters = loader.get_dataset_parameters()
+
+ detector_path = None
+ if detector_snapshot is not None:
+ detector_path = detector_snapshot.path
+
+ pose_runner, detector_runner = get_inference_runners(
+ model_config=loader.model_cfg,
+ snapshot_path=snapshot.path,
+ max_individuals=parameters.max_num_animals,
+ num_bodyparts=parameters.num_joints,
+ num_unique_bodyparts=parameters.num_unique_bpts,
+ with_identity=loader.model_cfg["metadata"]["with_identity"],
+ transform=transform,
+ detector_path=detector_path,
+ )
+
+ # For memory-replay SuperAnimal models, convert bodyparts to project bodyparts
+ if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init", None):
+ weight_init = WeightInitialization.from_dict(weight_init_cfg)
+ if weight_init.memory_replay:
+ bodyparts = weight_init.bodyparts
+ if bodyparts is None:
+ bodyparts = auxiliaryfunctions.get_bodyparts(cfg)
+
+ parameters = PoseDatasetParameters(
+ bodyparts=bodyparts,
+ unique_bpts=parameters.unique_bpts,
+ individuals=parameters.individuals,
+ )
+
+ # get the names of bodyparts on which the model is evaluated
+ eval_parameters = PoseDatasetParameters(
+ bodyparts=_get_subset_bodyparts(parameters.bodyparts, comparison_bodyparts),
+ unique_bpts=_get_subset_bodyparts(parameters.unique_bpts, comparison_bodyparts),
+ individuals=parameters.individuals,
+ )
+
+ if pcutoff is None:
+ pcutoff = cfg.get("pcutoff", 0.6)
+ elif isinstance(pcutoff, dict):
+ pcutoff = [pcutoff.get(bpt, 0.6) for bpt in eval_parameters.bodyparts + eval_parameters.unique_bpts]
+ _validate_pcutoff(parameters.bodyparts, parameters.unique_bpts, pcutoff)
+
+ predictions = {}
+ rmse_per_bodypart = {}
+ bounding_boxes = {}
+ scores = {
+ "%Training dataset": loader.train_fraction,
+ "Shuffle number": loader.shuffle,
+ "Training epochs": snapshot.epochs,
+ "Detector epochs (TD only)": (-1 if detector_snapshot is None else detector_snapshot.epochs),
+ "pcutoff": (", ".join([str(v) for v in pcutoff]) if isinstance(pcutoff, list) else pcutoff),
+ }
+ for split in ["train", "test"]:
+ results, predictions_for_split = evaluate(
+ pose_runner=pose_runner,
+ loader=loader,
+ mode=split,
+ pcutoff=pcutoff,
+ detector_runner=detector_runner,
+ comparison_bodyparts=comparison_bodyparts,
+ per_keypoint_evaluation=per_keypoint_evaluation,
+ parameters=parameters,
+ )
+ if per_keypoint_evaluation:
+ rmse_per_bodypart[split] = _extract_rmse_per_bodypart(
+ results,
+ eval_parameters.bodyparts,
+ eval_parameters.unique_bpts,
+ )
+
+ df_split_predictions = build_predictions_dataframe(
+ scorer=scorer,
+ predictions=predictions_for_split,
+ parameters=eval_parameters,
+ image_name_to_index=image_to_dlc_df_index,
+ )
+ split_bounding_boxes = build_bboxes_dict_for_dataframe(
+ predictions=predictions_for_split,
+ image_name_to_index=image_to_dlc_df_index,
+ )
+ predictions[split] = df_split_predictions
+ bounding_boxes[split] = split_bounding_boxes
+ for k, v in results.items():
+ scores[f"{split} {k}"] = round(v, 2)
+
+ results_filename = f"{scorer}.h5"
+ df_predictions = pd.concat(predictions.values(), axis=0)
+ df_predictions = df_predictions.reindex(loader.df.index)
+ output_filename = loader.evaluation_folder / results_filename
+ output_filename.parent.mkdir(parents=True, exist_ok=True)
+ df_predictions.to_hdf(output_filename, key="df_with_missing")
+
+ df_scores = pd.DataFrame([scores]).set_index(
+ [
+ "%Training dataset",
+ "Shuffle number",
+ "Training epochs",
+ "Detector epochs (TD only)",
+ "pcutoff",
+ ]
+ )
+ scores_filepath = output_filename.with_suffix(".csv")
+ scores_filepath = scores_filepath.with_stem(scores_filepath.stem + "-results")
+ print(f"Evaluation results file: {scores_filepath.name}")
+ save_evaluation_results(df_scores, scores_filepath, show_errors, pcutoff)
+
+ if per_keypoint_evaluation:
+ rmse_per_bpt_path = output_filename.with_name(output_filename.stem + "-keypoint-results.csv")
+ save_rmse_per_bodypart(rmse_per_bodypart, rmse_per_bpt_path, show_errors)
+
+ if plotting:
+ folder_name = f"LabeledImages_{scorer}"
+ folder_path = loader.evaluation_folder / folder_name
+ folder_path.mkdir(parents=True, exist_ok=True)
+ if isinstance(plotting, str):
+ plot_mode = plotting
+ else:
+ plot_mode = "bodypart"
+
+ df_ground_truth = ensure_multianimal_df_format(loader.df)
+
+ bboxes_cutoff = loader.model_cfg.get("detector", {}).get("model", {}).get("box_score_thresh", 0.6)
+
+ for mode in ["train", "test"]:
+ df_combined = predictions[mode].merge(df_ground_truth, left_index=True, right_index=True)
+ bboxes_split = bounding_boxes[mode]
+
+ plot_evaluation_results(
+ df_combined=df_combined,
+ project_root=cfg["project_path"],
+ scorer=cfg["scorer"],
+ model_name=scorer,
+ output_folder=str(folder_path),
+ in_train_set=mode == "train",
+ plot_unique_bodyparts=eval_parameters.num_unique_bpts > 0,
+ mode=plot_mode,
+ colormap=cfg["colormap"],
+ dot_size=cfg["dotsize"],
+ alpha_value=cfg["alphavalue"],
+ p_cutoff=cfg["pcutoff"],
+ bounding_boxes=bboxes_split,
+ bboxes_cutoff=bboxes_cutoff,
+ )
+
+ return df_predictions
+
+
+def evaluate_network(
+ config: str | Path,
+ shuffles: Iterable[int] = (1,),
+ trainingsetindex: int | str = 0,
+ snapshotindex: int | str | None = None,
+ device: str | None = None,
+ plotting: bool | str = False,
+ show_errors: bool = True,
+ transform: A.Compose = None,
+ snapshots_to_evaluate: list[str] | None = None,
+ comparison_bodyparts: str | list[str] | None = None,
+ per_keypoint_evaluation: bool = False,
+ modelprefix: str = "",
+ detector_snapshot_index: int | None = None,
+ pcutoff: float | list[float] | dict[str, float] | None = None,
+) -> None:
+ """Evaluates a snapshot.
+
+ The evaluation results are stored in the .h5 and .csv file under the subdirectory
+ 'evaluation_results'.
+
+ Args:
+ config: path to the project's config file
+ shuffles: Iterable of integers specifying the shuffle indices to evaluate.
+ trainingsetindex: Integer specifying which training set fraction to use.
+ Evaluates all fractions if set to "all"
+ snapshotindex: index (starting at 0) of the snapshot we want to load. To
+ evaluate the last one, use -1. To evaluate all snapshots, use "all". For
+ example if we have 3 models saved
+ - snapshot-0.pt
+ - snapshot-50.pt
+ - snapshot-100.pt
+ and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None,
+ the snapshotindex is loaded from the project configuration.
+ device: the device to run evaluation on
+ plotting: Plots the predictions on the train and test images. If provided it must
+ be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting
+ to ``True`` defaults as ``"bodypart"`` for multi-animal projects.
+ show_errors: display train and test errors.
+ transform: transformation pipeline for evaluation
+ ** Should normalise the data the same way it was normalised during training **
+ snapshots_to_evaluate: List of snapshot names to evaluate (e.g. ["snapshot-50",
+ "snapshot-75"]). If defined, `snapshotindex` will be ignored.
+ comparison_bodyparts: A subset of the bodyparts for which to compute the
+ evaluation metrics.
+ per_keypoint_evaluation: Compute the train and test RMSE for each keypoint, and
+ save the results to a {model_name}-keypoint-results.csv in the
+ evaluation-results-pytorch folder.
+ modelprefix: directory containing the deeplabcut models to use when evaluating
+ the network. By default, they are assumed to exist in the project folder.
+ detector_snapshot_index: Only for TD models. If defined, uses the detector with
+ the given index for pose estimation.
+ pcutoff: The cutoff to use for computing evaluation metrics. When `None`, the
+ cutoff will be loaded from the project config. If a list is provided, there
+ should be one value for each bodypart and one value for each unique bodypart
+ (if there are any). If a dict is provided, the keys should be bodyparts
+ mapping to pcutoff values for each bodypart. Bodyparts that are not defined
+ in the dict will have pcutoff set to 0.6.
+
+ Examples:
+ If you want to evaluate on shuffle 1 without plotting predictions.
+
+ >>> import deeplabcut
+ >>> deeplabcut.evaluate_network(
+ >>> '/analysis/project/reaching-task/config.yaml', shuffles=[1],
+ >>> )
+
+ If you want to evaluate shuffles 0 and 1 and plot the predictions.
+
+ >>> deeplabcut.evaluate_network(
+ >>> '/analysis/project/reaching-task/config.yaml',
+ >>> shuffles=[0, 1],
+ >>> plotting=True,
+ >>> )
+
+ If you want to plot assemblies for a maDLC project
+
+ >>> deeplabcut.evaluate_network(
+ >>> '/analysis/project/reaching-task/config.yaml',
+ >>> shuffles=[1],
+ >>> plotting="individual",
+ >>> )
+ """
+ cfg = auxiliaryfunctions.read_config(config)
+
+ if isinstance(trainingsetindex, int):
+ train_set_indices = [trainingsetindex]
+ elif isinstance(trainingsetindex, str) and trainingsetindex.lower() == "all":
+ train_set_indices = list(range(len(cfg["TrainingFraction"])))
+ else:
+ raise ValueError(f"Invalid trainingsetindex: {trainingsetindex}")
+
+ if snapshotindex is None:
+ snapshotindex = cfg["snapshotindex"]
+
+ if detector_snapshot_index is None:
+ detector_snapshot_index = cfg["detector_snapshotindex"]
+
+ for train_set_index in train_set_indices:
+ for shuffle in shuffles:
+ loader = DLCLoader(
+ config=config,
+ shuffle=shuffle,
+ trainset_index=train_set_index,
+ modelprefix=modelprefix,
+ )
+ loader.evaluation_folder.mkdir(exist_ok=True, parents=True)
+
+ if device is not None:
+ loader.model_cfg["device"] = device
+ loader.model_cfg["device"] = utils.resolve_device(loader.model_cfg)
+
+ snapshots = get_model_snapshots(
+ snapshotindex,
+ model_folder=loader.model_folder,
+ task=loader.pose_task,
+ snapshot_filter=snapshots_to_evaluate,
+ )
+
+ detector_snapshots = [None]
+ if loader.pose_task == Task.TOP_DOWN:
+ if detector_snapshot_index is not None:
+ det_snapshots = get_model_snapshots("all", loader.model_folder, Task.DETECT)
+ if len(det_snapshots) == 0:
+ print(
+ "The detector_snapshot_index was set to "
+ f"{detector_snapshot_index} but no detector snapshots were "
+ f"found in {loader.model_folder}. Using ground truth "
+ "bounding boxes to compute metrics.\n"
+ "To analyze videos with a top-down model, you'll need to "
+ "train a detector!"
+ )
+ else:
+ detector_snapshots = get_model_snapshots(
+ detector_snapshot_index,
+ loader.model_folder,
+ Task.DETECT,
+ )
+ else:
+ print("Using GT bounding boxes to compute evaluation metrics")
+
+ for detector_snapshot in detector_snapshots:
+ for snapshot in snapshots:
+ scorer = get_scorer_name(
+ cfg=cfg,
+ shuffle=shuffle,
+ train_fraction=loader.train_fraction,
+ snapshot_uid=get_scorer_uid(snapshot, detector_snapshot),
+ modelprefix=modelprefix,
+ )
+ print(f"Evaluation scorer: {scorer}")
+ evaluate_snapshot(
+ loader=loader,
+ cfg=cfg,
+ scorer=scorer,
+ snapshot=snapshot,
+ transform=transform,
+ plotting=plotting,
+ show_errors=show_errors,
+ comparison_bodyparts=comparison_bodyparts,
+ per_keypoint_evaluation=per_keypoint_evaluation,
+ detector_snapshot=detector_snapshot,
+ pcutoff=pcutoff,
+ )
+
+
+def image_to_dlc_df_index(image: str) -> tuple[str, ...]:
+ """
+ Args:
+ image: the path of the image to map to a DLC index
+
+ Returns:
+ the image index to create a multi-animal DLC dataframe:
+ ("labeled-data", video_name, image_name)
+ """
+ image_path = Path(image)
+ if len(image_path.parts) >= 3 and image_path.parts[-3] == "labeled-data":
+ return Path(image_path).parts[-3:]
+
+ raise ValueError("Unexpected image filepath for a DLC project")
+
+
+def save_evaluation_results(df_scores: pd.DataFrame, scores_path: Path, print_results: bool, pcutoff: float) -> None:
+ """Saves the evaluation results to a CSV file. Adds the evaluation results for the
+ model to the combined results file, or creates it if it does not yet exist.
+
+ Args:
+ df_scores: the scores dataframe for a snapshot
+ scores_path: the path where the model scores CSV should be saved
+ print_results: whether to print evaluation results to the console
+ pcutoff: the pcutoff used to get the evaluation results
+ """
+ if print_results:
+ print(f"Evaluation results for {scores_path.name} (pcutoff: {pcutoff}):")
+ print(df_scores.iloc[0])
+
+ # Save scores file
+ df_scores.to_csv(scores_path)
+
+ # Update combined results
+ combined_scores_path = scores_path.parent.parent / "CombinedEvaluation-results.csv"
+ if combined_scores_path.exists():
+ df_existing_results = pd.read_csv(combined_scores_path, index_col=[0, 1, 2, 3, 4])
+ df_scores = df_scores.combine_first(df_existing_results)
+
+ df_scores = df_scores.sort_index()
+ df_scores.to_csv(combined_scores_path)
+
+
+def save_rmse_per_bodypart(
+ rmse_per_bodypart: dict[str, dict[str, float]],
+ output_path: Path,
+ print_results: bool,
+) -> None:
+ """Saves the evaluation results per bodypart to a CSV file.
+
+ Args:
+ rmse_per_bodypart: The scores dataframe for a snapshot
+ output_path: The path of the file where
+ print_results: Whether to print results to the console
+ """
+ index, data = [], []
+ if print_results:
+ print(f"Per-bodypart evaluation results ({output_path.stem}):")
+
+ for split, rmse_results in rmse_per_bodypart.items():
+ key = split.capitalize() + " error (px)"
+ index.append(key)
+ data.append(rmse_results)
+
+ if print_results:
+ print(f" {key}")
+ bpt_key_length = max([len(k) for k in rmse_results.keys()]) + 4
+ for k, v in rmse_results.items():
+ key = (k + ":").ljust(bpt_key_length)
+ print(f" {key}{v:3>.2f}px")
+
+ # Save scores file
+ df_rmse_per_bodypart = pd.DataFrame(data, index=index)
+ df_rmse_per_bodypart.to_csv(output_path)
+
+
+def _validate_pcutoff(
+ bodyparts: list[str],
+ unique_bpts: list[str],
+ pcutoff: float | list[float],
+) -> None:
+ """Checks that the given `pcutoff` value has the correct number of elements."""
+ if isinstance(pcutoff, (int, float)):
+ return
+
+ total_bodyparts = len(bodyparts) + len(unique_bpts)
+ if len(pcutoff) != total_bodyparts:
+ raise ValueError(
+ "When passing the pcutoff as a list, the length of the list should be "
+ "equal to the number of bodyparts and the number of unique bpts. "
+ f"Found a list containing {len(pcutoff)} elements, but there are "
+ f"{total_bodyparts} total bodyparts, which are {bodyparts + unique_bpts}."
+ )
+
+
+def _get_keypoints_to_use(
+ bodyparts: list[str],
+ bodypart_subset: str | list[str] | None,
+) -> list[int] | None:
+ """Computes the indices of the keypoints indices to keep based on the given subset.
+
+ Args:
+ bodyparts: The bodyparts predicted by the model.
+ bodypart_subset: The subset of bodyparts to keep. If None or "all", all
+ bodyparts are kept.
+
+ Returns:
+ None if all bodyparts should be kept, or bodyparts is an empty list. Otherwise,
+ returns a list containing the indices of the bodyparts to keep. If no bodyparts
+ should be kept, returns an empty list.
+ """
+ if len(bodyparts) == 0 or bodypart_subset is None or bodypart_subset == "all":
+ return None
+
+ if isinstance(bodypart_subset, str):
+ bodypart_subset = [bodypart_subset]
+
+ to_keep = set(bodypart_subset)
+ return [i for i, b in enumerate(bodyparts) if b in to_keep]
+
+
+def _get_subset_bodyparts(
+ bodyparts: list[str],
+ subset: str | list[str] | None,
+) -> list[str]:
+ """Gets a subset of bodyparts that were used.
+
+ Args:
+ bodyparts: The bodyparts output by the model.
+ subset: The subset of bodyparts to keep.
+
+ Returns:
+ The bodyparts that were used to evaluate the model.
+ """
+ if subset is None or subset == "all":
+ return bodyparts
+
+ if isinstance(subset, str):
+ subset = [subset]
+
+ to_keep = set(subset)
+ return [b for b in bodyparts if b in to_keep]
+
+
+def _extract_rmse_per_bodypart(
+ results: dict[str, float],
+ bodyparts: list[str],
+ unique_bodyparts: list[str],
+) -> dict[str, float]:
+ """Extracts the RMSE per bodypart metrics from the results dict.
+
+ This method modifies the given dict in-place, removing all keys for RMSE per
+ bodypart or unique bodypart.
+
+ Args:
+ results: The results returned by the evaluation method.
+ bodyparts: The bodyparts defined for the project.
+ unique_bodyparts: The unique bodyparts defined for the project.
+
+ Returns:
+ The per-bodypart RMSE.
+ """
+ rmse_per_bodypart = {}
+ for bpt_idx, bpt in enumerate(bodyparts):
+ rmse = results.pop(f"rmse_keypoint_{bpt_idx}", None)
+ if rmse is not None:
+ rmse_per_bodypart[bpt] = rmse
+
+ for bpt_idx, bpt in enumerate(unique_bodyparts):
+ rmse = results.pop(f"rmse_unique_keypoint_{bpt_idx}", None)
+ if rmse is not None:
+ rmse_per_bodypart[bpt] = rmse
+
+ return rmse_per_bodypart
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config", type=str)
+ parser.add_argument("--modelprefix", type=str, default="")
+ parser.add_argument("--snapshotindex", type=int, default=49)
+ parser.add_argument("--plotting", type=bool, default=False)
+ parser.add_argument("--show_errors", type=bool, default=True)
+ args = parser.parse_args()
+ evaluate_network(
+ config=args.config,
+ modelprefix=args.modelprefix,
+ snapshotindex=args.snapshotindex,
+ plotting=args.plotting,
+ show_errors=args.show_errors,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/apis/export.py b/deeplabcut/pose_estimation_pytorch/apis/export.py
new file mode 100644
index 0000000000..3a061fc15f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/export.py
@@ -0,0 +1,190 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Code to export DeepLabCut models for DLCLive inference."""
+
+import copy
+from pathlib import Path
+
+import torch
+
+import deeplabcut.pose_estimation_pytorch.apis.utils as utils
+import deeplabcut.pose_estimation_pytorch.data as dlc3_data
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def export_model(
+ config: str | Path,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ snapshotindex: int | None = None,
+ detector_snapshot_index: int | None = None,
+ iteration: int | None = None,
+ overwrite: bool = False,
+ wipe_paths: bool = False,
+ without_detector: bool = False,
+ modelprefix: str | None = None,
+) -> None:
+ """Export DeepLabCut models for live inference.
+
+ Saves the pytorch_config.yaml configuration, snapshot files, of the model to a
+ directory named exported-models-pytorch within the project directory.
+
+ Args:
+ config: Path of the project configuration file
+ shuffle : The shuffle of the model to export.
+ trainingsetindex: The index of the training fraction for the model you wish to
+ export.
+ snapshotindex: The snapshot index for the weights you wish to export. If None,
+ uses the snapshotindex as defined in ``config.yaml``.
+ detector_snapshot_index: Only for TD models. If defined, uses the detector with
+ the given index for pose estimation. If None, uses the snapshotindex as
+ defined in the project ``config.yaml``.
+ iteration: The project iteration (active learning loop) you wish to export. If
+ None, the iteration listed in the project config file is used.
+ overwrite : bool, optional
+ If the model you wish to export has already been exported, whether to
+ overwrite. default = False
+ wipe_paths : bool, optional
+ Removes the actual path of your project and the init_weights from the
+ ``pytorch_config.yaml``.
+ without_detector: bool, optional
+ Exports top-down models without the detector.
+ modelprefix: Directory containing the deeplabcut models to use when evaluating
+ the network. By default, the models are assumed to exist in the project
+ folder.
+
+ Raises:
+ ValueError: If no snapshots could be found for the shuffle.
+ ValueError: If a top-down model is exported but no detector snapshots are found.
+
+ Examples:
+ Export the last stored snapshot for model trained with shuffle 3:
+ >>> import deeplabcut
+ >>> deeplabcut.export_model(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> shuffle=3,
+ >>> snapshotindex=-1,
+ >>> )
+ """
+ cfg = af.read_config(str(config))
+ if iteration is not None:
+ cfg["iteration"] = iteration
+
+ loader = dlc3_data.DLCLoader(
+ config=cfg,
+ trainset_index=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix="" if modelprefix is None else modelprefix,
+ )
+
+ if snapshotindex is None:
+ snapshotindex = loader.project_cfg["snapshotindex"]
+ snapshots = utils.get_model_snapshots(snapshotindex, loader.model_folder, loader.pose_task)
+
+ if len(snapshots) == 0:
+ raise ValueError(
+ f"Could not find any snapshots to export in ``{loader.model_folder}`` for "
+ f"``snapshotindex={snapshotindex}``."
+ )
+
+ detector_snapshots = [None]
+ if loader.pose_task == Task.TOP_DOWN and not without_detector:
+ if detector_snapshot_index is None:
+ detector_snapshot_index = loader.project_cfg["detector_snapshotindex"]
+ detector_snapshots = utils.get_model_snapshots(detector_snapshot_index, loader.model_folder, Task.DETECT)
+
+ if len(detector_snapshots) == 0:
+ raise ValueError(
+ "Attempting to export a top-down pose estimation model but no detector "
+ f"snapshots were found in ``{loader.model_folder}`` for "
+ f"``detector_snapshot_index={detector_snapshot_index}``. You must "
+ f"export a detector snapshot with a top-down pose estimation model."
+ )
+
+ export_folder_name = get_export_folder_name(loader)
+ export_dir = loader.project_path / "exported-models-pytorch" / export_folder_name
+ export_dir.mkdir(exist_ok=True, parents=True)
+
+ load_kwargs = dict(map_location="cpu", weights_only=True)
+ for det_snapshot in detector_snapshots:
+ detector_weights = None
+ if det_snapshot is not None:
+ detector_weights = torch.load(det_snapshot.path, **load_kwargs)["model"]
+
+ for snapshot in snapshots:
+ export_filename = get_export_filename(loader, snapshot, det_snapshot)
+ export_path = export_dir / export_filename
+ if export_path.exists() and not overwrite:
+ continue
+
+ model_cfg = copy.deepcopy(loader.model_cfg)
+ if wipe_paths:
+ wipe_paths_from_model_config(model_cfg)
+
+ pose_weights = torch.load(snapshot.path, **load_kwargs)["model"]
+ export_dict = dict(config=model_cfg, pose=pose_weights)
+ if detector_weights is not None:
+ export_dict["detector"] = detector_weights
+
+ torch.save(export_dict, export_path)
+
+
+def get_export_folder_name(loader: dlc3_data.DLCLoader) -> str:
+ """
+ Args:
+ loader: The loader for the shuffle for which we want to export models.
+
+ Returns:
+ The name of the folder in which exported models should be placed for a shuffle.
+ """
+ return (
+ f"DLC_{loader.project_cfg['Task']}_{loader.model_cfg['net_type']}_"
+ f"iteration-{loader.project_cfg['iteration']}_shuffle-{loader.shuffle}"
+ )
+
+
+def get_export_filename(
+ loader: dlc3_data.DLCLoader,
+ snapshot: Snapshot,
+ detector_snapshot: Snapshot | None = None,
+) -> str:
+ """
+ Args:
+ loader: The loader for the shuffle for which we want to export models.
+ snapshot: The pose model snapshot to export.
+ detector_snapshot: The detector snapshot to export, for top-down models.
+
+ Returns:
+ The name of the file in which the exported model should be stored.
+ """
+ export_filename = get_export_folder_name(loader)
+ if detector_snapshot is not None:
+ export_filename += "_snapshot-detector" + detector_snapshot.uid()
+ export_filename += "_snapshot-" + snapshot.uid()
+ return export_filename + ".pt"
+
+
+def wipe_paths_from_model_config(model_cfg: dict) -> None:
+ """Removes all paths from the contents of the ``pytorch_config`` file.
+
+ Args:
+ model_cfg: The model configuration to wipe.
+ """
+ model_cfg["metadata"]["project_path"] = ""
+ model_cfg["metadata"]["pose_config_path"] = ""
+ if "weight_init" in model_cfg["train_settings"]:
+ model_cfg["train_settings"]["weight_init"] = None
+ if "resume_training_from" in model_cfg:
+ model_cfg["resume_training_from"] = None
+ if "resume_training_from" in model_cfg.get("detector", {}):
+ model_cfg["detector"]["resume_training_from"] = None
diff --git a/deeplabcut/pose_estimation_pytorch/apis/prune_paf_graph.py b/deeplabcut/pose_estimation_pytorch/apis/prune_paf_graph.py
new file mode 100644
index 0000000000..fe96e5f100
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/prune_paf_graph.py
@@ -0,0 +1,283 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from collections import defaultdict
+from pathlib import Path
+
+import networkx as nx
+import numpy as np
+import torch
+from tqdm import tqdm
+
+import deeplabcut.core.metrics as metrics
+import deeplabcut.pose_estimation_pytorch.apis.utils as utils
+import deeplabcut.pose_estimation_pytorch.data as data
+import deeplabcut.pose_estimation_pytorch.models.predictors as predictors
+import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions
+from deeplabcut.core.crossvalutils import find_closest_neighbors
+from deeplabcut.pose_estimation_pytorch.models import PoseModel
+from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import Graph
+
+
+@torch.no_grad()
+def benchmark_paf_graphs(
+ loader: data.Loader,
+ snapshot_path: Path,
+ verbose: bool = False,
+ overwrite: bool = False,
+ update_config: bool = True,
+) -> list[dict]:
+ """Prunes the PAF graph to maximize performance.
+
+ Args:
+ loader: The loader for the model to prune.
+ snapshot_path: The path to the snapshot with which to prune the model.
+ verbose: Verbose pruning of the model.
+ overwrite: Whether to overwrite the graph if it was already pruned.
+ update_config: Whether to update the model configuration with the pruned graph.
+
+ Returns:
+ A list of dictionaries containing results for each pruned graph.
+
+ If the graph was already pruned, a single element is returned with an
+ "edges_to_keep" key, containing the indices of edges to keep in the graph.
+
+ Otherwise, a list of graphs that were evaluated is returned, with "key_metric",
+ "edges_to_keep" and "metrics" keys. The list is sorted by "key_metric" (which
+ is pose mAP).
+ """
+ runner = utils.get_pose_inference_runner(loader.model_cfg, snapshot_path)
+ device = runner.device
+ preprocessor = runner.preprocessor
+ model = runner.model
+ predictor = model.heads.bodypart.predictor
+
+ # only benchmark the PAF graph if the PAF indices contain all edges
+ if not overwrite and len(predictor.edges_to_keep) < len(predictor.graph):
+ return [dict(edges_to_keep=predictor.edges_to_keep)]
+
+ model.to(device)
+ model.eval()
+
+ if not isinstance(predictor, predictors.PartAffinityFieldPredictor):
+ raise ValueError("Predictor should be a PartAffinityFieldPredictor.")
+
+ if verbose:
+ print("-------------------------------------------------")
+ print("Benchmarking different Part-Affinity Field Graphs")
+ print(" (1/3) Obtaining the best graph candidates")
+
+ gt_train = loader.ground_truth_keypoints("train")
+ best_paf_edges, _ = get_n_best_paf_graphs(
+ model,
+ gt_train,
+ preprocessor,
+ device,
+ predictor.graph,
+ n_graphs=10,
+ )
+
+ if verbose:
+ print(" (2/3) Running test inference")
+
+ gt_test = loader.ground_truth_keypoints("test")
+ images_test = [img_path for img_path in gt_test]
+
+ predictions = {graph_id: {} for graph_id in range(len(best_paf_edges))}
+ with torch.no_grad():
+ for image_path in tqdm(images_test):
+ image, _ = preprocessor(image_path, {})
+ outputs = model(image.to(device))
+ for graph_id, edges in enumerate(best_paf_edges):
+ predictor.set_paf_edges_to_keep(edges)
+ pred_pose = model.get_predictions(outputs)["bodypart"]["poses"]
+ predictions[graph_id][image_path] = pred_pose.cpu().numpy()[0]
+
+ if verbose:
+ print(" (3/3) Evaluating Graphs")
+
+ results = []
+ for graph_id, pred_pose in predictions.items():
+ edges_to_keep = [int(i) for i in best_paf_edges[graph_id]]
+ graph_metrics = metrics.compute_metrics(
+ gt_test,
+ pred_pose,
+ single_animal=False,
+ pcutoff=0.6,
+ )
+ results.append(
+ dict(
+ edges_to_keep=edges_to_keep,
+ key_metric=graph_metrics["mAP"],
+ metrics=graph_metrics,
+ )
+ )
+
+ if verbose:
+ print(" ---")
+ print(f" |Graph {graph_id}: {len(edges_to_keep)} edges")
+ print(f" | mAP: {graph_metrics['mAP']}")
+ print(f" | mAR: {graph_metrics['mAR']}")
+ print(f" | edges: {edges_to_keep}")
+ print()
+
+ results = list(sorted(results, key=lambda r: 1 - r["key_metric"]))
+
+ if update_config and len(results) > 0:
+ best_results = results[0]
+ best_edges = best_results["edges_to_keep"]
+ graph_metrics = best_results["metrics"]
+
+ if verbose:
+ print("Selecting the following Graph")
+ print(60 * "-")
+ print(f"|Graph with {len(best_edges)} edges")
+ print(f"| mAP: {graph_metrics['mAP']}")
+ print(f"| mAR: {graph_metrics['mAR']}")
+ print(f"| edges: {best_edges}")
+ print()
+
+ # update the edges to keep in the PyTorch configuration file
+ loader.update_model_cfg({"model.heads.bodypart.predictor.edges_to_keep": best_edges})
+
+ # update the edges indices
+ test_config = loader.model_folder.parent / "test" / "pose_cfg.yaml"
+ auxiliaryfunctions.edit_config(str(test_config), dict(paf_best=best_edges))
+
+ return results
+
+
+def _calc_separability(
+ vals_left: np.ndarray,
+ vals_right: np.ndarray,
+ n_bins: int = 101,
+ metric: str = "jeffries",
+ max_sensitivity: bool = False,
+) -> tuple[float, float]:
+ if metric not in ("jeffries", "auc"):
+ raise ValueError("`metric` should be either 'jeffries' or 'auc'.")
+
+ bins = np.linspace(0, 1, n_bins)
+ hist_left = np.histogram(vals_left, bins=bins)[0]
+ hist_left = hist_left / hist_left.sum()
+ hist_right = np.histogram(vals_right, bins=bins)[0]
+ hist_right = hist_right / hist_right.sum()
+ tpr = np.cumsum(hist_right)
+ if metric == "jeffries":
+ sep = np.sqrt(2 * (1 - np.sum(np.sqrt(hist_left * hist_right)))) # Jeffries-Matusita distance
+ else:
+ sep = np.trapz(np.cumsum(hist_left), tpr)
+ if max_sensitivity:
+ threshold = bins[max(1, np.argmax(tpr > 0))]
+ else:
+ threshold = bins[np.argmin(1 - np.cumsum(hist_left) + tpr)]
+ return sep, threshold
+
+
+@torch.no_grad()
+def compute_within_between_paf_costs(
+ model: PoseModel,
+ ground_truth: dict[str, np.ndarray],
+ preprocessor: data.Preprocessor,
+ device: str,
+) -> tuple[defaultdict, defaultdict]:
+ predictor = model.heads.bodypart.predictor
+ images = [img_path for img_path in ground_truth]
+
+ within = defaultdict(list)
+ between = defaultdict(list)
+ for image_path in tqdm(images):
+ image, _ = preprocessor(image_path, {})
+ outputs = model(image.to(device))
+ preds = model.get_predictions(outputs)["bodypart"]["preds"][0]
+ gt_pose_with_vis = ground_truth[image_path].transpose((1, 0, 2))
+
+ # mask non-visible keypoints
+ gt_pose = gt_pose_with_vis[..., :2].copy()
+ gt_pose[gt_pose_with_vis[..., 2] <= 0] = np.nan
+
+ if np.isnan(gt_pose).all():
+ continue
+
+ coords_pred = preds["coordinates"][0]
+ costs_pred = preds["costs"]
+
+ # Get animal IDs and corresponding indices in the arrays of detections
+ lookup = dict()
+ for i, (coord_pred, coord_gt) in enumerate(zip(coords_pred, gt_pose, strict=False)):
+ inds = np.flatnonzero(np.all(~np.isnan(coord_pred), axis=1))
+ inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1))
+ if inds.size and inds_gt.size:
+ neighbors = find_closest_neighbors(coord_gt[inds_gt], coord_pred[inds], k=3)
+ found = neighbors != -1
+ lookup[i] = dict(zip(inds_gt[found], inds[neighbors[found]], strict=False))
+
+ for k, v in costs_pred.items():
+ paf = v["m1"]
+ mask_within = np.zeros(paf.shape, dtype=bool)
+ s, t = predictor.graph[k]
+ if s not in lookup or t not in lookup:
+ continue
+ lu_s = lookup[s]
+ lu_t = lookup[t]
+ common_id = set(lu_s).intersection(lu_t)
+ for id_ in common_id:
+ mask_within[lu_s[id_], lu_t[id_]] = True
+ within_vals = paf[mask_within]
+ between_vals = paf[~mask_within]
+ within[k].extend(within_vals)
+ between[k].extend(between_vals)
+
+ return within, between
+
+
+def get_n_best_paf_graphs(
+ model: PoseModel,
+ ground_truth: dict[str, np.ndarray],
+ preprocessor: data.Preprocessor,
+ device: str,
+ full_graph: Graph,
+ root_edges: list[int] | None = None,
+ n_graphs: int = 10,
+ metric: str = "auc",
+) -> tuple[list[list[int]], dict[int, float]]:
+ return_preds = model.heads.bodypart.predictor.return_preds
+ model.heads.bodypart.predictor.return_preds = True
+
+ within_train, between_train = compute_within_between_paf_costs(model, ground_truth, preprocessor, device)
+ existing_edges = list(set(k for k, v in within_train.items() if v))
+
+ scores, _ = zip(
+ *[_calc_separability(between_train[n], within_train[n], metric=metric) for n in existing_edges], strict=False
+ )
+
+ # Find minimal skeleton
+ G = nx.Graph()
+ for edge, score in zip(existing_edges, scores, strict=False):
+ if np.isfinite(score):
+ G.add_edge(*full_graph[edge], weight=score)
+
+ order = np.asarray(existing_edges)[np.argsort(scores)[::-1]]
+ if root_edges is None:
+ root_edges = []
+ for edge in nx.maximum_spanning_edges(G, data=False):
+ root_edges.append(full_graph.index(sorted(edge)))
+
+ n_edges = len(existing_edges) - len(root_edges)
+ lengths = np.linspace(0, n_edges, min(n_graphs, n_edges + 1), dtype=int)[1:]
+ order = order[np.isin(order, root_edges, invert=True)]
+ best_edges = [root_edges]
+ for length in lengths:
+ best_edges.append(root_edges + list(order[:length]))
+
+ model.heads.bodypart.predictor.return_preds = return_preds
+ return best_edges, dict(zip(existing_edges, scores, strict=False))
diff --git a/deeplabcut/pose_estimation_pytorch/apis/tracking_dataset.py b/deeplabcut/pose_estimation_pytorch/apis/tracking_dataset.py
new file mode 100644
index 0000000000..97b3c8fadf
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/tracking_dataset.py
@@ -0,0 +1,286 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Code to create tracking datasets for ReID model training."""
+
+from collections.abc import Sequence
+from pathlib import Path
+
+from tqdm import tqdm
+
+import deeplabcut.pose_estimation_pytorch.apis.utils as utils
+import deeplabcut.pose_estimation_pytorch.data as data
+import deeplabcut.pose_estimation_pytorch.data.postprocessor as postprocessing
+import deeplabcut.pose_estimation_pytorch.models as models
+import deeplabcut.pose_estimation_pytorch.runners as runners
+import deeplabcut.pose_estimation_pytorch.runners.shelving as shelving
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.pose_estimation_pytorch.apis.videos import VideoIterator
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.pose_tracking_pytorch import create_triplets_dataset
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+
+
+def build_feature_extraction_runner(
+ loader: data.Loader,
+ snapshot_path: str | Path,
+ device: str,
+ batch_size: int = 1,
+) -> runners.PoseInferenceRunner:
+ """Builds a runner to extract backbone features for poses of individuals.
+
+ Args:
+ loader: The loader for the model to use.
+ snapshot_path: The path of the snapshot to use.
+ device: The device on which to run pose estimation.
+ batch_size: The batch size to run pose estimation with.
+
+ Returns:
+ A PoseInferenceRunner that will return features for extracted pose.
+ """
+ num_features = loader.model_cfg["model"]["backbone_output_channels"]
+ num_bodyparts = len(loader.model_cfg["metadata"]["bodyparts"])
+ top_down = loader.pose_task != Task.BOTTOM_UP
+ rescale_mode = postprocessing.RescaleAndOffset.Mode.KEYPOINT
+ if top_down:
+ rescale_mode = postprocessing.RescaleAndOffset.Mode.KEYPOINT_TD
+ data_cfg = loader.model_cfg["data"]["inference"]
+ crop_cfg = data_cfg.get("top_down_crop", {})
+ width, height = crop_cfg.get("width", 256), crop_cfg.get("height", 256)
+ preprocessor = data.build_top_down_preprocessor(
+ color_mode=loader.model_cfg["data"]["colormode"],
+ transform=data.build_transforms(data_cfg),
+ top_down_crop_size=(width, height),
+ top_down_crop_margin=crop_cfg.get("margin", 0),
+ )
+ else:
+ preprocessor = data.build_bottom_up_preprocessor(
+ loader.model_cfg["data"]["colormode"], data.build_transforms(loader.model_cfg["data"]["inference"])
+ )
+
+ postprocessor = postprocessing.ComposePostprocessor(
+ [
+ postprocessing.PrepareBackboneFeatures(top_down=top_down),
+ postprocessing.ConcatenateOutputs(
+ keys_to_concatenate={
+ "bodyparts": ("bodypart", "poses"),
+ "features": ("backbone", "bodypart_features"),
+ },
+ empty_shapes={
+ "bodyparts": (num_bodyparts, 3),
+ "features": (num_bodyparts, num_features),
+ },
+ create_empty_outputs=True,
+ ),
+ postprocessing.RescaleAndOffset(["bodyparts"], rescale_mode),
+ ]
+ )
+
+ runner = runners.build_inference_runner(
+ task=loader.pose_task,
+ model=models.PoseModel.build(loader.model_cfg["model"]),
+ device=device,
+ snapshot_path=snapshot_path,
+ batch_size=batch_size,
+ preprocessor=preprocessor,
+ postprocessor=postprocessor,
+ load_weights_only=loader.model_cfg["runner"].get("load_weights_only", None),
+ )
+ assert isinstance(runner, runners.PoseInferenceRunner), f"Failed to build inference runner: got type {type(runner)}"
+
+ # Set the model to output backbone features
+ runner.model.output_features = True
+
+ return runner
+
+
+def extract_features_for_video(
+ runner: runners.PoseInferenceRunner,
+ video: VideoIterator,
+ shelf_writer: shelving.FeatureShelfWriter,
+ detector_runner: runners.DetectorInferenceRunner | None = None,
+) -> None:
+ """Extracts backbone features for predicted keypoints in a video.
+
+ Args:
+ video: The video for which to extract backbone features.
+ runner: The inference runner with which to extract backbone features.
+ shelf_writer: The ShelfWriter used to extract features.
+ detector_runner: For top-down models, the detector to use to predict bboxes.
+ """
+ if detector_runner is not None:
+ print(f"Running detector with batch size {detector_runner.batch_size}")
+ bbox_predictions = detector_runner.inference(images=tqdm(video))
+ video.set_context(bbox_predictions)
+
+ shelf_writer.open()
+ runner.inference(tqdm(video), shelf_writer=shelf_writer)
+ shelf_writer.close()
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def create_tracking_dataset(
+ config: str,
+ videos: list[str] | list[Path],
+ track_method: str,
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ destfolder: str | None = None,
+ batch_size: int | None = None,
+ detector_batch_size: int | None = None,
+ cropping: list[int] | None = None,
+ modelprefix: str = "",
+ robust_nframes: bool = False,
+ n_triplets: int = 1000,
+) -> str:
+ """Creates a tracking dataset to train a ReID tracklet stitcher.
+
+ Args:
+ config: Full path of the config.yaml file for the project
+ videos: A str (or list of strings) containing the full paths to videos from
+ which to create the tracking dataset or a path to the directory, where all
+ the videos with same extension are stored.
+ track_method: Specifies the tracker used to generate the pose estimation data.
+ Must be either 'box', 'skeleton', or 'ellipse'.
+ video_extensions: Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+ shuffle: An integer specifying the shuffle index of the training dataset used
+ for training the network.
+ trainingsetindex: Integer specifying which TrainingsetFraction to use.
+ destfolder: Specifies the destination folder for the tracking data. If ``None``,
+ the path of the video is used. Note that for subsequent analysis this
+ folder also needs to be passed.
+ batch_size: The batch size to use for inference. Takes the value from the
+ project config as a default.
+ detector_batch_size: The batch size to use for detector inference. Takes the
+ value from the project config as a default.
+ cropping: List of cropping coordinates as [x1, x2, y1, y2]. Note that the same
+ cropping parameters will then be used for all videos. If different video
+ crops are desired, run ``analyze_videos`` on individual videos with the
+ corresponding cropping coordinates.
+ modelprefix: Directory containing the deeplabcut models to use when evaluating
+ the network. By default, they are assumed to exist in the project folder.
+ robust_nframes: Evaluate a video's number of frames in a robust manner. This
+ option is slower (as the whole video is read frame-by-frame), but does not
+ rely on metadata, hence its robustness against file corruption.
+ n_triplets: The number of triplets to extract for the dataset.
+
+ Returns:
+ The scorer used to analyze the videos.
+ """
+ loader = data.DLCLoader(
+ config,
+ trainset_index=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+ test_cfg_path = loader.model_folder.parent / "test" / "pose_cfg.yaml"
+ test_cfg = read_config_as_dict(test_cfg_path)
+
+ snapshot_index, detector_snapshot_index = utils.parse_snapshot_index_for_analysis(
+ loader.project_cfg,
+ loader.model_cfg,
+ None,
+ None,
+ )
+ snapshot = utils.get_model_snapshots(
+ snapshot_index,
+ loader.model_folder,
+ loader.pose_task,
+ )[0]
+
+ if cropping is None and loader.project_cfg.get("cropping", False):
+ cropping = (
+ loader.project_cfg["x1"],
+ loader.project_cfg["x2"],
+ loader.project_cfg["y1"],
+ loader.project_cfg["y2"],
+ )
+
+ output_folder = None
+ if destfolder is not None and destfolder != "":
+ output_folder = Path(destfolder)
+
+ if batch_size is None:
+ batch_size = loader.project_cfg["batch_size"]
+
+ device = utils.resolve_device(loader.model_cfg)
+ runner = build_feature_extraction_runner(loader, snapshot.path, device, batch_size=batch_size)
+
+ detector_runner = None
+ detector_snapshot = None
+ if loader.pose_task == Task.TOP_DOWN:
+ if detector_batch_size is None:
+ detector_batch_size = loader.project_cfg.get("detector_batch_size", 1)
+
+ detector_snapshot = utils.get_model_snapshots(
+ detector_snapshot_index,
+ loader.model_folder,
+ Task.DETECT,
+ )[0]
+ detector_runner = utils.get_detector_inference_runner(
+ model_config=loader.model_cfg,
+ snapshot_path=detector_snapshot.path,
+ batch_size=detector_batch_size,
+ device=device,
+ )
+
+ dlc_scorer = utils.get_scorer_name(
+ loader.project_cfg,
+ shuffle,
+ loader.train_fraction,
+ snapshot_uid=utils.get_scorer_uid(snapshot, detector_snapshot),
+ modelprefix=modelprefix,
+ )
+
+ videos = collect_video_paths(videos, extensions=video_extensions)
+ for video_path in videos:
+ print(f"Loading {video_path}")
+ video = VideoIterator(video_path, cropping=cropping)
+
+ nx, ny = video.dimensions
+ nframes = video.get_n_frames(robust=robust_nframes)
+ duration = video.calc_duration(robust=robust_nframes)
+ fps = video.fps
+ if robust_nframes:
+ fps = nframes / duration
+
+ print(f"Duration of video [s]: {duration:.2f}, recorded with {fps:.2f} fps!")
+ print(f"Overall # of frames: {nframes} found with (before cropping)")
+ print(f"Frame dimensions: {nx} x {ny}")
+
+ if output_folder is None:
+ output_folder = Path(video.video_path).parent
+ output_folder.mkdir(parents=True, exist_ok=True)
+ output_prefix = Path(video_path).stem + dlc_scorer
+ output_filepath = output_folder / f"{output_prefix}_bpt_features.pickle"
+
+ shelf_writer = shelving.FeatureShelfWriter(
+ test_cfg,
+ output_filepath,
+ num_frames=video.get_n_frames(robust=robust_nframes),
+ )
+ extract_features_for_video(runner, video, shelf_writer, detector_runner=detector_runner)
+
+ create_triplets_dataset(
+ videos,
+ dlc_scorer,
+ track_method,
+ n_triplets=n_triplets,
+ destfolder=destfolder,
+ )
+ return dlc_scorer
diff --git a/deeplabcut/pose_estimation_pytorch/apis/tracklets.py b/deeplabcut/pose_estimation_pytorch/apis/tracklets.py
new file mode 100644
index 0000000000..ea7bf881c2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/tracklets.py
@@ -0,0 +1,336 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+import pickle
+import warnings
+from collections.abc import Sequence
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+from scipy.optimize import linear_sum_assignment
+from scipy.special import softmax
+from tqdm import tqdm
+
+import deeplabcut.utils.auxfun_multianimal as auxfun_multianimal
+import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions
+from deeplabcut.core import trackingutils
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.inferenceutils import Assembly
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ get_scorer_name,
+ parse_snapshot_index_for_analysis,
+)
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def convert_detections2tracklets(
+ config: str,
+ videos: str | list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ overwrite: bool = False,
+ destfolder: str | None = None,
+ ignore_bodyparts: list[str] | None = None,
+ inferencecfg: dict | None = None,
+ modelprefix="",
+ greedy: bool = False, # TODO(niels): implement greedy assembly during video analysis
+ calibrate: bool = False, # TODO(niels): implement assembly calibration during video analysis
+ window_size: int = 0, # TODO(niels): implement window size selection for assembly during video analysis
+ identity_only=False,
+ track_method="",
+ snapshot_index: int | str | None = None,
+ detector_snapshot_index: int | str | None = None,
+):
+ """TODO: Documentation, clean & remove code duplication (with analyze video)"""
+ cfg = auxiliaryfunctions.read_config(config)
+ inference_cfg = inferencecfg
+ track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+
+ if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box":
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
+ track_method = "box"
+ cfg["default_track_method"] = track_method
+ auxiliaryfunctions.write_config(config, cfg)
+
+ train_fraction = cfg["TrainingFraction"][trainingsetindex]
+ start_path = os.getcwd() # record cwd to return to this directory in the end
+
+ # TODO: add cropping as in video analysis!
+ # if cropping is not None:
+ # cfg['cropping']=True
+ # cfg['x1'],cfg['x2'],cfg['y1'],cfg['y2']=cropping
+ # print("Overwriting cropping parameters:", cropping)
+ # print("These are used for all videos, but won't be save to the cfg file.")
+
+ rel_model_dir = auxiliaryfunctions.get_model_folder(
+ train_fraction,
+ shuffle,
+ cfg,
+ modelprefix=modelprefix,
+ engine=Engine.PYTORCH,
+ )
+ model_dir = Path(cfg["project_path"]) / rel_model_dir
+ path_test_config = model_dir / "test" / "pose_cfg.yaml"
+ dlc_cfg = auxiliaryfunctions.read_plainconfig(str(path_test_config))
+
+ if "multi-animal" not in dlc_cfg["dataset_type"]:
+ raise ValueError("This function is only required for multianimal projects!")
+
+ if track_method == "ctd":
+ raise ValueError(
+ "CTD tracking occurs directly during video analysis. No need to call "
+ "`convert_detections2tracklets` with `track_method=='ctd'`."
+ )
+
+ if inference_cfg is None:
+ inference_cfg = auxfun_multianimal.read_inferencecfg(model_dir / "test" / "inference_cfg.yaml", cfg)
+ auxfun_multianimal.check_inferencecfg_sanity(cfg, inference_cfg)
+
+ if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box":
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
+ track_method = "box"
+ # Also ensure `boundingboxslack` is greater than zero, otherwise overlap
+ # between trackers cannot be evaluated, resulting in empty tracklets.
+ inference_cfg["boundingboxslack"] = max(inference_cfg["boundingboxslack"], 40)
+
+ loader = DLCLoader(
+ config,
+ trainset_index=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+ snapshot_index, detector_snapshot_index = parse_snapshot_index_for_analysis(
+ loader.project_cfg,
+ loader.model_cfg,
+ snapshot_index,
+ detector_snapshot_index,
+ )
+ dlc_scorer = get_scorer_name(
+ cfg,
+ shuffle,
+ train_fraction,
+ snapshot_index=snapshot_index,
+ detector_index=detector_snapshot_index,
+ modelprefix=modelprefix,
+ )
+
+ paths_input = videos
+ videos = collect_video_paths(videos, extensions=video_extensions)
+ if len(videos) == 0:
+ print(f"No videos were found in {paths_input}")
+ return
+
+ for video in videos:
+ print("Processing... ", video)
+ if destfolder is None:
+ output_path = video.parent
+ else:
+ output_path = Path(destfolder)
+ output_path.mkdir(exist_ok=True, parents=True)
+
+ video_name = video.stem
+
+ data_prefix = video_name + dlc_scorer
+ data_filename = output_path / (data_prefix + ".h5")
+ print(f"Loading From {data_filename}")
+ data, metadata = auxfun_multianimal.LoadFullMultiAnimalData(str(data_filename))
+ if track_method == "ellipse":
+ method = "el"
+ elif track_method == "box":
+ method = "bx"
+ else:
+ method = "sk"
+
+ track_filename = output_path / (data_prefix + f"_{method}.pickle")
+ if not overwrite and track_filename.exists():
+ # TODO: check if metadata are identical (same parameters!)
+ print(f"Tracklets already computed at {track_filename}")
+ print("Set overwrite = True to overwrite.")
+ else:
+ assemblies_path = data_filename.with_stem(data_filename.stem + "_assemblies").with_suffix(".pickle")
+ if not assemblies_path.exists():
+ raise FileNotFoundError(
+ f"Could not find the assembles file {assemblies_path}. You're "
+ f"converting detections to tracklets using PyTorch, which "
+ "means the assemblies file must be created by the model when "
+ "analyzing the video!"
+ )
+ assemblies_data = auxiliaryfunctions.read_pickle(assemblies_path)
+
+ tracklets = build_tracklets(
+ assemblies_data=assemblies_data,
+ track_method=track_method,
+ inference_cfg=inference_cfg,
+ joints=data["metadata"]["all_joints_names"],
+ scorer=metadata["data"]["Scorer"],
+ num_frames=data["metadata"]["nframes"],
+ ignore_bodyparts=ignore_bodyparts,
+ unique_bodyparts=cfg["uniquebodyparts"],
+ identity_only=identity_only,
+ )
+
+ with open(track_filename, "wb") as f:
+ pickle.dump(tracklets, f, pickle.HIGHEST_PROTOCOL)
+
+ os.chdir(str(start_path))
+ print(
+ "The tracklets were created (i.e., under the hood "
+ "deeplabcut.convert_detections2tracklets was run). Now you can "
+ "'refine_tracklets' in the GUI, or run 'deeplabcut.stitch_tracklets'."
+ )
+
+
+def build_tracklets(
+ assemblies_data: dict,
+ track_method: str,
+ inference_cfg: dict,
+ joints: list[str],
+ scorer: str,
+ num_frames: int,
+ ignore_bodyparts: list[str] | None = None,
+ unique_bodyparts: list | None = None,
+ identity_only: bool = False,
+) -> dict:
+
+ if track_method == "box":
+ mot_tracker = trackingutils.SORTBox(
+ inference_cfg["max_age"],
+ inference_cfg["min_hits"],
+ inference_cfg.get("iou_threshold", 0.3),
+ )
+ elif track_method == "skeleton":
+ mot_tracker = trackingutils.SORTSkeleton(
+ len(joints),
+ inference_cfg["max_age"],
+ inference_cfg["min_hits"],
+ inference_cfg.get("oks_threshold", 0.5),
+ )
+ else:
+ mot_tracker = trackingutils.SORTEllipse(
+ inference_cfg.get("max_age", 1),
+ inference_cfg.get("min_hits", 1),
+ inference_cfg.get("iou_threshold", 0.6),
+ )
+
+ tracklets = {}
+
+ df_index = _create_tracklets_header(joints, scorer)
+ tracklets["header"] = df_index
+
+ # Initialize storage of the 'single' individual track
+ if unique_bodyparts:
+ tracklets["single"] = {}
+ _single = {}
+ for index in range(num_frames):
+ single_detection = assemblies_data["single"].get(index)
+ if single_detection is None:
+ continue
+ _single[index] = np.asarray(single_detection)
+ tracklets["single"].update(_single)
+
+ pcutoff = inference_cfg.get("pcutoff")
+ if inference_cfg["topktoretain"] == 1:
+ tracklets[0] = {}
+ for index in tqdm(range(num_frames)):
+ assemblies = assemblies_data.get(index)
+ if assemblies is None or len(assemblies) == 0:
+ continue
+
+ assembly = np.asarray(assemblies[0].data)
+ assembly[assembly[..., 2] < pcutoff] = np.nan
+ tracklets[0][index] = assembly
+ else:
+ multi_bpts = list(set(joints).difference(unique_bodyparts or []))
+ keep = set(multi_bpts).difference(ignore_bodyparts or [])
+ keep_inds = sorted(multi_bpts.index(bpt) for bpt in keep)
+ for index in tqdm(range(num_frames)):
+ assemblies = assemblies_data.get(index)
+ if assemblies is None or len(assemblies) == 0:
+ continue
+
+ animals = np.stack([a for a in assemblies])
+ animals[np.any(animals[..., :3] < 0, axis=-1), :2] = np.nan
+ animals[animals[..., 2] < pcutoff, :2] = np.nan
+ animal_mask = ~np.all(np.isnan(animals[:, :, :2]), axis=(1, 2))
+ if ~np.any(animal_mask):
+ continue
+ animals = animals[animal_mask]
+
+ if identity_only:
+ # Optimal identity assignment based on soft voting
+ mat = np.zeros((len(animals), inference_cfg["topktoretain"]))
+ for row, animal_pose in enumerate(animals):
+ animal_pose = animal_pose[~np.isnan(animal_pose).any(axis=1)]
+ unique_ids, idx = np.unique(animal_pose[:, 3], return_inverse=True)
+ total_scores = np.bincount(idx, weights=animal_pose[:, 2])
+ softmax_id_scores = softmax(total_scores)
+ for pred_id, softmax_score in zip(unique_ids.astype(int), softmax_id_scores, strict=False):
+ mat[row, pred_id] = softmax_score
+
+ inds = linear_sum_assignment(mat, maximize=True)
+ trackers = np.c_[inds][:, ::-1]
+ else:
+ if track_method == "box":
+ xy = trackingutils.calc_bboxes_from_keypoints(
+ animals[:, keep_inds], inference_cfg["boundingboxslack"]
+ ) # TODO: get cropping parameters and utilize!
+ else:
+ xy = animals[:, keep_inds, :2]
+ trackers = mot_tracker.track(xy)
+
+ strwidth = int(np.ceil(np.log10(num_frames)))
+ imname = "frame" + str(index).zfill(strwidth)
+ trackingutils.fill_tracklets(tracklets, trackers, animals, imname)
+
+ return tracklets
+
+
+def _create_tracklets_header(joints, dlc_scorer):
+ bodypart_labels = [bpt for bpt in joints for _ in range(3)]
+ scorers = len(bodypart_labels) * [dlc_scorer]
+ xyl_value = int(len(bodypart_labels) / 3) * ["x", "y", "likelihood"]
+ return pd.MultiIndex.from_arrays(
+ np.vstack([scorers, bodypart_labels, xyl_value]),
+ names=["scorer", "bodyparts", "coords"],
+ )
+
+
+def _conv_predictions_to_assemblies(
+ image_names: list[str], predictions: dict[str, np.ndarray]
+) -> dict[int, list[Assembly]]:
+ """Converts predictions to an assemblies dictionary predictions shape (num_animals,
+ num_keypoints, 2 or 3)"""
+ assemblies = {}
+ if len(predictions) == 0:
+ return assemblies
+
+ for image_index, image_name in enumerate(image_names):
+ frame_predictions = predictions.get(image_name)
+ if frame_predictions is not None:
+ num_kpts, num_animals, pred_shape = frame_predictions.shape
+ kpt_lst = []
+ for i in range(num_animals):
+ animal_prediction = frame_predictions[:, i, :]
+ ass_prediction = np.ones((num_kpts, 4), dtype=frame_predictions.dtype)
+ ass_prediction[:, 3] = -ass_prediction[:, 3]
+ ass_prediction[:, :pred_shape] = animal_prediction.copy()
+ ass = Assembly.from_array(ass_prediction)
+ if len(ass) > 0:
+ kpt_lst.append(ass)
+
+ assemblies[image_index] = kpt_lst
+
+ return assemblies
diff --git a/deeplabcut/pose_estimation_pytorch/apis/training.py b/deeplabcut/pose_estimation_pytorch/apis/training.py
new file mode 100644
index 0000000000..dc636bc9f1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/training.py
@@ -0,0 +1,389 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import argparse
+import copy
+import logging
+from pathlib import Path
+
+import albumentations as A
+from torch.utils.data import DataLoader
+
+import deeplabcut.core.config as config_utils
+import deeplabcut.pose_estimation_pytorch.utils as utils
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.data import (
+ COCOLoader,
+ DLCLoader,
+ Loader,
+ build_transforms,
+)
+from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS
+from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel
+from deeplabcut.pose_estimation_pytorch.modelzoo.memory_replay import (
+ prepare_memory_replay,
+)
+from deeplabcut.pose_estimation_pytorch.runners import build_training_runner
+from deeplabcut.pose_estimation_pytorch.runners.logger import (
+ LOGGER,
+ destroy_file_logging,
+ setup_file_logging,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def train(
+ loader: Loader,
+ run_config: dict,
+ task: Task,
+ device: str | None = "cpu",
+ gpus: list[int] | None = None,
+ logger_config: dict | None = None,
+ snapshot_path: str | Path | None = None,
+ transform: A.BaseCompose | None = None,
+ inference_transform: A.BaseCompose | None = None,
+ max_snapshots_to_keep: int | None = None,
+ load_head_weights: bool = True,
+) -> None:
+ """Builds a model from a configuration and fits it to a dataset.
+
+ Args:
+ loader: the loader containing the data to train on/validate with
+ run_config: the model and run configuration
+ task: the task to train the model for
+ device: the torch device to train on (such as "cpu", "cuda", "mps")
+ gpus: the list of GPU indices to use for multi-GPU training
+ logger_config: the configuration of a logger to use
+ snapshot_path: if continuing to train from a snapshot, the path containing the
+ weights to load
+ transform: if defined, overwrites the transform defined in the model config
+ inference_transform: if defined, overwrites the inference transform defined in
+ the model config
+ max_snapshots_to_keep: the maximum number of snapshots to store for each model
+ load_head_weights: When `snapshot_path` is not None and a pose model is being
+ trained, whether to load the head weights from the saved snapshot.
+ """
+ weight_init = None
+ pretrained = True
+
+ if weight_init_cfg := run_config["train_settings"].get("weight_init"):
+ weight_init = WeightInitialization.from_dict(weight_init_cfg)
+ pretrained = False
+ elif snapshot_path is not None:
+ # If we're loading from a snapshot, don't use pretrained backbone weights
+ # since the weights will be loaded from the snapshot
+ pretrained = False
+
+ if task == Task.DETECT:
+ model = DETECTORS.build(
+ run_config["model"],
+ weight_init=weight_init,
+ pretrained=pretrained,
+ )
+
+ else:
+ model = PoseModel.build(
+ run_config["model"],
+ weight_init=weight_init,
+ pretrained_backbone=pretrained,
+ )
+
+ if max_snapshots_to_keep is not None:
+ run_config["runner"]["snapshots"]["max_snapshots"] = max_snapshots_to_keep
+
+ logger = None
+ if logger_config is not None:
+ logger = LOGGER.build({**logger_config, "model": model, "train_folder": loader.model_folder})
+ logger.log_config(run_config)
+
+ if device is None:
+ device = utils.resolve_device(run_config)
+ elif device == "auto":
+ run_config["device"] = device
+ device = utils.resolve_device(run_config)
+
+ if gpus is None:
+ gpus = run_config["runner"].get("gpus")
+
+ if device == "mps" and task == Task.DETECT:
+ device = "cpu" # FIXME: Cannot train detectors on MPS
+
+ if snapshot_path is None:
+ snapshot_path = run_config.get("resume_training_from")
+
+ model.to(device) # Move model before giving its parameters to the optimizer
+ runner = build_training_runner(
+ runner_config=run_config["runner"],
+ model_folder=loader.model_folder,
+ task=task,
+ model=model,
+ device=device,
+ gpus=gpus,
+ snapshot_path=snapshot_path,
+ load_head_weights=load_head_weights,
+ logger=logger,
+ )
+
+ if transform is None:
+ transform = build_transforms(run_config["data"]["train"])
+ if inference_transform is None:
+ inference_transform = build_transforms(run_config["data"]["inference"])
+
+ logging.info("Data Transforms:")
+ logging.info(f" Training: {transform}")
+ logging.info(f" Validation: {inference_transform}")
+
+ train_dataset = loader.create_dataset(transform=transform, mode="train", task=task)
+ valid_dataset = loader.create_dataset(transform=inference_transform, mode="test", task=task)
+
+ collate_fn = None
+ if collate_fn_cfg := run_config["data"]["train"].get("collate"):
+ collate_fn = COLLATE_FUNCTIONS.build(collate_fn_cfg)
+ logging.info(f"Using custom collate function: {collate_fn_cfg}")
+
+ batch_size = run_config["train_settings"]["batch_size"]
+ num_workers = run_config["train_settings"]["dataloader_workers"]
+ pin_memory = run_config["train_settings"]["dataloader_pin_memory"]
+ train_dataloader = DataLoader(
+ train_dataset,
+ batch_size=batch_size,
+ shuffle=True,
+ collate_fn=collate_fn,
+ num_workers=num_workers,
+ pin_memory=pin_memory,
+ )
+ valid_dataloader = DataLoader(valid_dataset, batch_size=1, shuffle=False)
+
+ if (
+ loader.model_cfg["model"].get("freeze_bn_stats", False)
+ or loader.model_cfg["model"].get("backbone", {}).get("freeze_bn_stats", False)
+ or batch_size == 1
+ ):
+ logging.info(
+ "\nNote: According to your model configuration, you're training with batch "
+ "size 1 and/or ``freeze_bn_stats=true``. This is not an optimal setting "
+ "if you have powerful GPUs.\n"
+ "This is good for small batch sizes (e.g., when training on a CPU), where "
+ "you should keep ``freeze_bn_stats=true``.\n"
+ "If you're using a GPU to train, you can obtain faster performance by "
+ "setting a larger batch size (the biggest power of 2 where you don't get"
+ "a CUDA out-of-memory error, such as 8, 16, 32 or 64 depending on the "
+ "model, size of your images, and GPU memory) and ``freeze_bn_stats=false`` "
+ "for the backbone of your model. \n"
+ "This also allows you to increase the learning rate (empirically you can "
+ "scale the learning rate by sqrt(batch_size) times).\n"
+ )
+
+ logging.info(f"Using {len(train_dataset)} images and {len(valid_dataset)} for testing")
+ if task == task.DETECT:
+ logging.info("\nStarting object detector training...\n" + (50 * "-"))
+ else:
+ logging.info("\nStarting pose model training...\n" + (50 * "-"))
+
+ runner.fit(
+ train_dataloader,
+ valid_dataloader,
+ epochs=run_config["train_settings"]["epochs"],
+ display_iters=run_config["train_settings"]["display_iters"],
+ )
+
+
+def train_network(
+ config: str | Path,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ modelprefix: str = "",
+ device: str | None = None,
+ snapshot_path: str | Path | None = None,
+ detector_path: str | Path | None = None,
+ load_head_weights: bool = True,
+ batch_size: int | None = None,
+ epochs: int | None = None,
+ save_epochs: int | None = None,
+ detector_batch_size: int | None = None,
+ detector_epochs: int | None = None,
+ detector_save_epochs: int | None = None,
+ display_iters: int | None = None,
+ max_snapshots_to_keep: int | None = None,
+ pose_threshold: float | None = 0.1,
+ pytorch_cfg_updates: dict | None = None,
+) -> None:
+ """Trains a network for a project.
+
+ Args:
+ config : path to the yaml config file of the project
+ shuffle : index of the shuffle we want to train on
+ trainingsetindex : training set index
+ modelprefix: directory containing the deeplabcut configuration files to use
+ to train the network (and where snapshots will be saved). By default, they
+ are assumed to exist in the project folder.
+ device: the torch device to train on (such as "cpu", "cuda", "mps")
+ snapshot_path: if resuming training, the snapshot from which to resume
+ detector_path: if resuming training of a top-down model, used to specify the
+ detector snapshot from which to resume
+ load_head_weights: if resuming training of a pose estimation model (either
+ through the `snapshot_path` attribute or the `resume_training_from` key in
+ the `pytorch_config.yaml` file), setting this to True also loads the weights
+ for the model head (equivalent to the `keepdeconvweights` for TensorFlow
+ models). Note that if you change the number of bodyparts, you need to set
+ this to false for re-training.
+ batch_size: overrides the batch size to train with
+ epochs: overrides the maximum number of epochs to train the model for
+ save_epochs: overrides the number of epochs between each snapshot save
+ detector_batch_size: Only for top-down models. Overrides the batch size with
+ which to train the detector.
+ detector_epochs: Only for top-down models. Overrides the maximum number of
+ epochs to train the model for. Setting to 0 means the detector will not be
+ trained.
+ detector_save_epochs: Only for top-down models. Overrides the number of epochs
+ between each snapshot of the detector is saved.
+ display_iters: overrides the number of iterations between each log of the loss
+ within an epoch
+ max_snapshots_to_keep: the maximum number of snapshots to save for each model
+ pose_threshold: Used for memory-replay. Pseudo-predictions with confidence lower
+ than this threshold are discarded for memory-replay
+ pytorch_cfg_updates: dict, optional, default = None.
+ A dictionary of updates to the pytorch config. The keys are the dot-separated
+ paths to the values to update in the config.
+ For example, to update the gpus to run the training on, you can use:
+ ```
+ pytorch_cfg_updates={"runner.gpus": [0,1,2,3]}
+ ```
+ To see the full list - check the pytorch_cfg.yaml file in your project folder
+ """
+ loader = DLCLoader(
+ config=config,
+ shuffle=shuffle,
+ trainset_index=trainingsetindex,
+ modelprefix=modelprefix,
+ )
+
+ if weight_init_cfg := loader.model_cfg["train_settings"].get("weight_init"):
+ weight_init = WeightInitialization.from_dict(weight_init_cfg)
+ if weight_init.memory_replay:
+ if weight_init.detector_snapshot_path is None:
+ raise ValueError(
+ "When fine-tuning a SuperAnimal model with memory replay, a "
+ "detector must be given as well so animals can be detected in "
+ "images to obtain pseudo-labels. Please update your weight "
+ "initialization so that `detector_snapshot_path` is not None."
+ )
+
+ print("Preparing data for memory replay (this can take some time)")
+ dataset_params = loader.get_dataset_parameters()
+ prepare_memory_replay(
+ config,
+ loader,
+ weight_init.dataset,
+ weight_init.snapshot_path,
+ weight_init.detector_snapshot_path,
+ device,
+ train_file="train.json",
+ max_individuals=dataset_params.max_num_animals,
+ pose_threshold=pose_threshold,
+ )
+
+ print("Loading memory replay data")
+ loader = COCOLoader(
+ project_root=loader.model_folder / "memory_replay",
+ model_config_path=loader.model_config_path,
+ train_json_filename="memory_replay_train.json",
+ )
+
+ cfg_updates = {}
+
+ # Pose model training settings
+ if batch_size is not None:
+ cfg_updates["train_settings.batch_size"] = batch_size
+ if epochs is not None:
+ cfg_updates["train_settings.epochs"] = epochs
+ if save_epochs is not None:
+ cfg_updates["runner.snapshots.save_epochs"] = save_epochs
+ if display_iters is not None:
+ cfg_updates["train_settings.display_iters"] = display_iters
+
+ # Detector config settings (if exists)
+ if loader.model_cfg.get("detector") is not None:
+ if detector_batch_size is not None:
+ cfg_updates["detector.train_settings.batch_size"] = detector_batch_size
+ if detector_epochs is not None:
+ cfg_updates["detector.train_settings.epochs"] = detector_epochs
+ if detector_save_epochs is not None:
+ cfg_updates["detector.runner.snapshots.save_epochs"] = detector_save_epochs
+ if display_iters is not None:
+ cfg_updates["detector.train_settings.display_iters"] = display_iters
+
+ # Optional generic overrides
+ if pytorch_cfg_updates is not None:
+ cfg_updates.update(pytorch_cfg_updates)
+
+ # Only call update if anything changed
+ if cfg_updates:
+ loader.update_model_cfg(cfg_updates)
+
+ setup_file_logging(loader.model_folder / "train.txt")
+
+ logging.info("Training with configuration:")
+ config_utils.pretty_print(loader.model_cfg, print_fn=logging.info)
+
+ # fix seed for reproducibility
+ utils.fix_seeds(loader.model_cfg["train_settings"]["seed"])
+
+ # get the pose task
+ pose_task = Task(loader.model_cfg.get("method", "bu"))
+ if pose_task == Task.TOP_DOWN and loader.model_cfg["detector"]["train_settings"]["epochs"] > 0:
+ logger_config = None
+ if loader.model_cfg.get("logger"):
+ logger_config = copy.deepcopy(loader.model_cfg["logger"])
+ logger_config["run_name"] += "-detector"
+
+ detector_run_config = loader.model_cfg["detector"]
+ detector_run_config["device"] = loader.model_cfg["device"]
+ detector_run_config["train_settings"]["weight_init"] = loader.model_cfg["train_settings"].get("weight_init")
+ train(
+ loader=loader,
+ run_config=detector_run_config,
+ task=Task.DETECT,
+ device=device,
+ logger_config=logger_config,
+ snapshot_path=detector_path,
+ max_snapshots_to_keep=max_snapshots_to_keep,
+ )
+
+ if loader.model_cfg["train_settings"]["epochs"] > 0:
+ train(
+ loader=loader,
+ run_config=loader.model_cfg,
+ task=pose_task,
+ device=device,
+ logger_config=loader.model_cfg.get("logger"),
+ snapshot_path=snapshot_path,
+ max_snapshots_to_keep=max_snapshots_to_keep,
+ load_head_weights=load_head_weights,
+ )
+
+ destroy_file_logging()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--config-path", type=str)
+ parser.add_argument("--shuffle", type=int, default=1)
+ parser.add_argument("--train-ind", type=int, default=0)
+ parser.add_argument("--modelprefix", type=str, default="")
+ args = parser.parse_args()
+ train_network(
+ config=args.config_path,
+ shuffle=args.shuffle,
+ trainingsetindex=args.train_ind,
+ modelprefix=args.modelprefix,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/apis/utils.py b/deeplabcut/pose_estimation_pytorch/apis/utils.py
new file mode 100644
index 0000000000..879e449e3a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py
@@ -0,0 +1,914 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable, Sequence
+from pathlib import Path
+
+import albumentations as A
+import numpy as np
+import pandas as pd
+from torchvision.models import detection
+from torchvision.models.detection import (
+ FasterRCNN_MobileNet_V3_Large_FPN_Weights,
+ FasterRCNN_ResNet50_FPN_V2_Weights,
+ FasterRCNN_ResNet50_FPN_Weights,
+ fasterrcnn_mobilenet_v3_large_fpn,
+ fasterrcnn_resnet50_fpn,
+)
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.core.engine import Engine
+from deeplabcut.pose_estimation_pytorch.data.ctd import CondFromModel
+from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import (
+ build_dlc_dataframe_columns,
+)
+from deeplabcut.pose_estimation_pytorch.data.postprocessor import (
+ build_bottom_up_postprocessor,
+ build_detector_postprocessor,
+ build_top_down_postprocessor,
+)
+from deeplabcut.pose_estimation_pytorch.data.preprocessor import (
+ build_bottom_up_preprocessor,
+ build_conditional_top_down_preprocessor,
+ build_top_down_preprocessor,
+)
+from deeplabcut.pose_estimation_pytorch.data.transforms import build_transforms
+from deeplabcut.pose_estimation_pytorch.models import DETECTORS, PoseModel
+from deeplabcut.pose_estimation_pytorch.models.detectors.filtered_detector import (
+ FilteredDetector,
+)
+from deeplabcut.pose_estimation_pytorch.runners import (
+ CTDTrackingConfig,
+ DetectorInferenceRunner,
+ DynamicCropper,
+ InferenceRunner,
+ PoseInferenceRunner,
+ TopDownDynamicCropper,
+ build_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.runners.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import (
+ Snapshot,
+ TorchSnapshotManager,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.pose_estimation_pytorch.utils import resolve_device
+from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS, collect_video_paths
+from deeplabcut.utils.deprecation import deprecated
+
+
+def parse_snapshot_index_for_analysis(
+ cfg: dict,
+ model_cfg: dict,
+ snapshot_index: int | str | None,
+ detector_snapshot_index: int | str | None,
+) -> tuple[int, int | None]:
+ """Gets the index of the snapshots to use for data analysis (e.g. video analysis)
+
+ Args:
+ cfg: The project configuration.
+ model_cfg: The model configuration.
+ snapshot_index: The index of the snapshot to use, if one was given by the user.
+ detector_snapshot_index: The index of the detector snapshot to use, if one
+ was given by the user.
+
+ Returns:
+ snapshot_index: the snapshot index to use for analysis
+ detector_snapshot_index: the detector index to use for analysis, or None if no
+ detector should be used
+ """
+ if snapshot_index is None:
+ snapshot_index = cfg["snapshotindex"]
+ if snapshot_index == "all":
+ logging.warning(
+ "snapshotindex is set to 'all' (in the config.yaml file or as given to "
+ "`analyze_...`). Running data analysis with all snapshots is very "
+ "costly! Use the function 'evaluate_network' to choose the best the "
+ "snapshot. For now, changing snapshot index to -1. To evaluate another "
+ "snapshot, you can change the value in the config file or call "
+ "`analyze_videos` or `analyze_images` with your desired snapshot index."
+ )
+ snapshot_index = -1
+
+ pose_task = Task(model_cfg["method"])
+ if pose_task == Task.TOP_DOWN:
+ if detector_snapshot_index is None:
+ detector_snapshot_index = cfg.get("detector_snapshotindex", -1)
+
+ if detector_snapshot_index == "all":
+ logging.warning(
+ f"detector_snapshotindex is set to '{detector_snapshot_index}' (in the "
+ "config.yaml file or as given to `analyze_...`). Running data analysis "
+ "with all snapshots is very costly! Use 'evaluate_network' to choose "
+ "the best detector snapshot. For now, changing the detector snapshot "
+ "index to -1. To evaluate another detector snapshot, you can change "
+ "the value in the config file or call `analyze_videos` or "
+ "`analyze_images` with your desired detector snapshot index."
+ )
+ detector_snapshot_index = -1
+
+ else:
+ detector_snapshot_index = None
+
+ return snapshot_index, detector_snapshot_index
+
+
+def return_train_network_path(
+ config: str, shuffle: int = 1, trainingsetindex: int = 0, modelprefix: str = ""
+) -> tuple[Path, Path, Path]:
+ """
+ Args:
+ config: Full path of the config.yaml file as a string.
+ shuffle: The shuffle index to select for training
+ trainingsetindex: Which TrainingsetFraction to use (note that TrainingFraction
+ is a list in config.yaml)
+ modelprefix: the modelprefix for the model
+
+ Returns:
+ the path to the training pytorch pose configuration file
+ the path to the test pytorch pose configuration file
+ the path to the folder containing the snapshots
+ """
+ cfg = auxiliaryfunctions.read_config(config)
+ project_path = Path(cfg["project_path"])
+ train_frac = cfg["TrainingFraction"][trainingsetindex]
+ model_folder = auxiliaryfunctions.get_model_folder(
+ train_frac, shuffle, cfg, engine=Engine.PYTORCH, modelprefix=modelprefix
+ )
+ return (
+ project_path / model_folder / "train" / "pytorch_config.yaml",
+ project_path / model_folder / "test" / "pose_cfg.yaml",
+ project_path / model_folder / "train",
+ )
+
+
+def get_model_snapshots(
+ index: int | str,
+ model_folder: Path,
+ task: Task,
+ snapshot_filter: list[str] | None = None,
+) -> list[Snapshot]:
+ """
+ Args:
+ index: Passing an index returns the snapshot with that index (where snapshots
+ based on their number of training epochs, and the last snapshot is the
+ "best" model based on validation metrics if one exists). Passing "best"
+ returns the best snapshot from the training run. Passing "all" returns all
+ snapshots.
+ model_folder: The path to the folder containing the snapshots
+ task: The task for which to return the snapshot
+ snapshot_filter: List of snapshot names to return (e.g. ["snapshot-50",
+ "snapshot-75"]). If defined, `index` will be ignored.
+
+ Returns:
+ If index=="all", returns all snapshots. Otherwise, returns a list containing a
+ single snapshot, with the desired index.
+
+ Raises:
+ ValueError: If the index given is not valid
+ ValueError: If index=="best" but there is no saved best model
+ """
+ snapshot_manager = TorchSnapshotManager(model_folder=model_folder, snapshot_prefix=task.snapshot_prefix)
+ if snapshot_filter is not None:
+ all_snapshots = snapshot_manager.snapshots()
+ snapshots = [s for s in all_snapshots if s.path.stem in snapshot_filter]
+ if len(snapshots) != len(snapshot_filter):
+ print("Warning: could not find all `snapshots_to_evaluate`.")
+ print(f" Requested snapshots: {snapshot_filter}")
+ print(f" Found snapshots: {[s.path.stem for s in all_snapshots]}")
+ print(f" Snapshots returned: {[s.path.stem for s in snapshots]}")
+ return snapshots
+
+ if isinstance(index, str) and index.lower() == "best":
+ best_snapshot = snapshot_manager.best()
+ if best_snapshot is None:
+ raise ValueError(f"No best snapshot found in {model_folder}")
+ snapshots = [best_snapshot]
+ elif isinstance(index, str) and index.lower() == "all":
+ snapshots = snapshot_manager.snapshots()
+ elif isinstance(index, int):
+ all_snapshots = snapshot_manager.snapshots()
+ if len(all_snapshots) == 0 or len(all_snapshots) <= index or (index < 0 and len(all_snapshots) < -index):
+ names = [s.path.name for s in all_snapshots]
+ raise ValueError(
+ f"Found {len(all_snapshots)} snapshots in {model_folder} (with names "
+ f"{names}) with prefix {snapshot_manager.snapshot_prefix}. Could "
+ f"not return snapshot with index {index}."
+ )
+
+ snapshots = [all_snapshots[index]]
+ else:
+ raise ValueError(f"Invalid snapshotindex: {index}")
+
+ return snapshots
+
+
+def get_scorer_uid(snapshot: Snapshot, detector_snapshot: Snapshot | None) -> str:
+ """
+ Args:
+ snapshot: the snapshot for which to get the scorer UID
+ detector_snapshot: if a top-down model is used with a detector, the detector
+ snapshot for which to get the scorer UID
+
+ Returns:
+ the uid to use for the scorer
+ """
+ snapshot_id = f"snapshot_{snapshot.uid()}"
+ if detector_snapshot is not None:
+ detect_id = detector_snapshot.uid()
+ snapshot_id = f"detector_{detect_id}_{snapshot_id}"
+ return snapshot_id
+
+
+def get_scorer_name(
+ cfg: dict,
+ shuffle: int,
+ train_fraction: float,
+ snapshot_index: int | None = None,
+ detector_index: int | None = None,
+ snapshot_uid: str | None = None,
+ modelprefix: str = "",
+) -> str:
+ """Get the scorer name for a particular PyTorch DeepLabCut shuffle.
+
+ Args:
+ cfg: The project configuration.
+ shuffle: The index of the shuffle for which to get the scorer
+ train_fraction: The training fraction for the shuffle.
+ snapshot_index: The index of the snapshot used. If None, the value is loaded
+ from the project's config.yaml file.
+ detector_index: For top-down models, the index of the detector used. If None,
+ the value is loaded from the project's config.yaml file.
+ snapshot_uid: If the snapshot_uid is not None, this value will be used instead
+ of loading the snapshot and detector with given indices and calling
+ utils.get_scorer_uid.
+ modelprefix: The model prefix, if one was used.
+
+ Returns:
+ the scorer name
+ """
+ model_dir = Path(cfg["project_path"]) / auxiliaryfunctions.get_model_folder(
+ train_fraction,
+ shuffle,
+ cfg,
+ engine=Engine.PYTORCH,
+ modelprefix=modelprefix,
+ )
+ train_dir = model_dir / "train"
+ model_cfg = read_config_as_dict(str(train_dir / Engine.PYTORCH.pose_cfg_name))
+ net_type = model_cfg["net_type"]
+ pose_task = Task(model_cfg["method"])
+
+ if snapshot_uid is None:
+ if snapshot_index is None:
+ snapshot_index = auxiliaryfunctions.get_snapshot_index_for_scorer("snapshotindex", cfg["snapshotindex"])
+ if detector_index is None:
+ detector_index = auxiliaryfunctions.get_snapshot_index_for_scorer(
+ "detector_snapshotindex", cfg["detector_snapshotindex"]
+ )
+
+ snapshot = get_model_snapshots(snapshot_index, train_dir, pose_task)[0]
+ detector_snapshot = None
+ if detector_index is not None and pose_task == Task.TOP_DOWN:
+ try:
+ detector_snapshot = get_model_snapshots(detector_index, train_dir, Task.DETECT)[0]
+ except ValueError:
+ detector_snapshot = None
+
+ snapshot_uid = get_scorer_uid(snapshot, detector_snapshot)
+
+ task, date = cfg["Task"], cfg["date"]
+ name = "".join([p.capitalize() for p in net_type.split("_")])
+ return f"DLC_{name}_{task}{date}shuffle{shuffle}_{snapshot_uid}"
+
+
+@deprecated(replacement="deeplabcut.collect_video_paths", since="3.0.0")
+def list_videos_in_folder(
+ data_path: str | Path | list[str | Path],
+ video_type: str | Sequence[str] | None = SUPPORTED_VIDEOS,
+ shuffle: bool = False,
+) -> list[Path]:
+ return collect_video_paths(
+ data_path=data_path,
+ extensions=video_type,
+ shuffle=shuffle,
+ )
+
+
+def ensure_multianimal_df_format(df_predictions: pd.DataFrame) -> pd.DataFrame:
+ """Convert dataframe to 'multianimal' format (with an "individuals" columns index)
+
+ Args:
+ df_predictions: the dataframe to convert
+
+ Returns:
+ the dataframe in MA format
+ """
+ df_predictions_ma = df_predictions.copy()
+ try:
+ df_predictions_ma.columns.get_level_values("individuals").unique().tolist()
+ except KeyError:
+ new_cols = pd.MultiIndex.from_tuples(
+ [(col[0], "animal", col[1], col[2]) for col in df_predictions_ma.columns],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ df_predictions_ma.columns = new_cols
+ return df_predictions_ma
+
+
+def _image_names_to_df_index(
+ image_names: list[str],
+ image_name_to_index: Callable[[str], tuple[str, ...]] | None = None,
+) -> pd.MultiIndex | list[str]:
+ """Creates index for predictions dataframe. This method is used in
+ build_predictions_dataframe, but also in build_bboxes_dict_for_dataframe. It is
+ important that these two methods return objects with the same index / keys.
+
+ Args:
+ image_names: list of image names
+ image_name_to_index, optional: a transform to apply on each image_name
+ """
+
+ if image_name_to_index is not None:
+ return pd.MultiIndex.from_tuples([image_name_to_index(image_name) for image_name in image_names])
+ else:
+ return image_names
+
+
+def build_predictions_dataframe(
+ scorer: str,
+ predictions: dict[str, dict[str, np.ndarray]],
+ parameters: PoseDatasetParameters,
+ image_name_to_index: Callable[[str], tuple[str, ...]] | None = None,
+) -> pd.DataFrame:
+ """Builds a pandas DataFrame from pose prediction data. The resulting DataFrame
+ includes properly formatted indices and column names for compatibility with
+ DeepLabCut workflows.
+
+ Args:
+ scorer: The name of the scorer used to generate the predictions.
+ predictions: A dictionary where each key is an image name and its value is
+ another dictionary. The inner dictionary contains prediction data for
+ "bodyparts" and optionally "unique_bodyparts". The "bodyparts" and
+ "unique_bodyparts" data arrays are expected to be 3-dimensional, containing
+ pose predictions in format (num_predicted_individuals, num_bodyparts, 3).
+ parameters: Dataset-specific parameters required for constructing DataFrame
+ columns.
+ image_name_to_index: A callable function that takes an image name and returns
+ a tuple representing the DataFrame index. If None, indices will be
+ generated without transformation.
+
+ Returns:
+ A pandas DataFrame containing the processed prediction data for all provided
+ images. The DataFrame index corresponds to the image names or their
+ transformed values (if `image_name_to_index` is provided). The DataFrame
+ columns are constructed using the provided scorer and parameters.
+ """
+ image_names = []
+ prediction_data = []
+ for image_name, image_predictions in predictions.items():
+ image_data = image_predictions["bodyparts"][..., :3].reshape(-1)
+ if "unique_bodyparts" in image_predictions:
+ image_data = np.concatenate([image_data, image_predictions["unique_bodyparts"][..., :3].reshape(-1)])
+ image_names.append(image_name)
+ prediction_data.append(image_data)
+
+ index = _image_names_to_df_index(image_names, image_name_to_index)
+
+ return pd.DataFrame(
+ prediction_data,
+ index=index,
+ columns=build_dlc_dataframe_columns(
+ scorer=scorer,
+ parameters=parameters,
+ with_likelihood=True,
+ ),
+ )
+
+
+def build_bboxes_dict_for_dataframe(
+ predictions: dict[str, dict[str, np.ndarray]],
+ image_name_to_index: Callable[[str], tuple[str, ...]] | None = None,
+) -> dict:
+ """Creates a dictionary with bounding boxes from predictions.
+
+ The keys of the dictionary are the same as the index of the dataframe created by
+ build_predictions_dataframe. Therefore, the structures returned by
+ build_predictions_dataframe and by build_bboxes_dict_for_dataframe can be accessed
+ with the same keys.
+
+ Args:
+ predictions: Dictionary containing the evaluation results
+ image_name_to_index: a transform to apply on each image_name
+
+ Returns:
+ Dictionary with sames keys as in the dataframe returned by
+ build_predictions_dataframe, and respective bounding boxes and scores, if any.
+ """
+
+ image_names = []
+ bboxes_data = []
+ for image_name, image_predictions in predictions.items():
+ image_names.append(image_name)
+ if "bboxes" in image_predictions and "bbox_scores" in image_predictions:
+ bboxes_data.append((image_predictions["bboxes"], image_predictions["bbox_scores"]))
+
+ index = _image_names_to_df_index(image_names, image_name_to_index)
+
+ return dict(zip(index, bboxes_data, strict=False))
+
+
+def get_inference_runners(
+ model_config: dict,
+ snapshot_path: str | Path,
+ max_individuals: int | None = None,
+ num_bodyparts: int | None = None,
+ num_unique_bodyparts: int | None = None,
+ batch_size: int = 1,
+ device: str | None = None,
+ with_identity: bool = False,
+ transform: A.BaseCompose | None = None,
+ detector_batch_size: int = 1,
+ detector_path: str | Path | None = None,
+ detector_transform: A.BaseCompose | None = None,
+ dynamic: DynamicCropper | None = None,
+ inference_cfg: InferenceConfig | dict | None = None,
+ min_bbox_score: float | None = None,
+) -> tuple[InferenceRunner, InferenceRunner | None]:
+ """Builds the runners for pose estimation.
+
+ Args:
+ model_config: the pytorch configuration file
+ snapshot_path: the path of the snapshot from which to load the weights
+ max_individuals: the maximum number of individuals per image (if None, uses the
+ individuals defined in the model_config metadata)
+ num_bodyparts: the number of bodyparts predicted by the model (if None, uses the
+ bodyparts defined in the model_config metadata)
+ num_unique_bodyparts: the number of unique_bodyparts predicted by the model (if
+ None, uses the unique bodyparts defined in the model_config metadata)
+ batch_size: the batch size to use for the pose model.
+ with_identity: whether the pose model has an identity head
+ device: if defined, overwrites the device selection from the model config
+ transform: the transform for pose estimation. if None, uses the transform
+ defined in the config.
+ detector_batch_size: the batch size to use for the detector
+ detector_path: the path to the detector snapshot from which to load weights,
+ for top-down models (if a detector runner is needed)
+ detector_transform: the transform for object detection. if None, uses the
+ transform defined in the config.
+ dynamic: The DynamicCropper used for video inference, or None if dynamic
+ cropping should not be used. Only for bottom-up pose estimation models.
+ Should only be used when creating inference runners for video pose
+ estimation with batch size 1.
+ inference_cfg: Configuration for the InferenceRunner. If None - uses the
+ inference config defined in the model_config
+ min_bbox_score: Minimum score threshold for filtering bounding boxes from the
+ detector. Only bounding boxes with scores higher than this threshold are
+ kept. If None, no filtering is applied.
+
+ Returns:
+ a runner for pose estimation
+ a runner for detection, if detector_path is not None
+ """
+ if max_individuals is None:
+ max_individuals = len(model_config["metadata"]["individuals"])
+ if num_bodyparts is None:
+ num_bodyparts = len(model_config["metadata"]["bodyparts"])
+ if num_unique_bodyparts is None:
+ num_unique_bodyparts = len(model_config["metadata"]["unique_bodyparts"])
+
+ pose_task = Task(model_config["method"])
+ if device is None:
+ device = resolve_device(model_config)
+
+ if transform is None:
+ transform = build_transforms(model_config["data"]["inference"])
+
+ if inference_cfg is None:
+ inference_cfg = model_config.get("inference")
+
+ detector_runner = None
+ if pose_task == Task.BOTTOM_UP:
+ pose_preprocessor = build_bottom_up_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ )
+ pose_postprocessor = build_bottom_up_postprocessor(
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ num_unique_bodyparts=num_unique_bodyparts,
+ with_identity=with_identity,
+ )
+ else:
+ crop_cfg = model_config["data"]["inference"].get("top_down_crop", {})
+ width, height = crop_cfg.get("width", 256), crop_cfg.get("height", 256)
+ margin = crop_cfg.get("margin", 0)
+ if pose_task == Task.COND_TOP_DOWN:
+ pose_preprocessor = build_conditional_top_down_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ bbox_margin=model_config["data"].get("bbox_margin", 20),
+ top_down_crop_size=(width, height),
+ top_down_crop_margin=margin,
+ top_down_crop_with_context=crop_cfg.get("crop_with_context", False),
+ )
+ else: # Top-Down
+ pose_preprocessor = build_top_down_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ top_down_crop_size=(width, height),
+ top_down_crop_margin=margin,
+ top_down_crop_with_context=crop_cfg.get("crop_with_context", True),
+ )
+
+ pose_postprocessor = build_top_down_postprocessor(
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ num_unique_bodyparts=num_unique_bodyparts,
+ )
+
+ # FIXME: Cannot run detectors on MPS
+ detector_device = device
+ if device == "mps":
+ detector_device = "cpu"
+
+ if detector_path is not None:
+ detector_path = str(detector_path)
+ if detector_transform is None:
+ detector_transform = build_transforms(model_config["detector"]["data"]["inference"])
+
+ detector_config = model_config["detector"]["model"]
+ if "pretrained" in detector_config:
+ detector_config["pretrained"] = False
+
+ detector_runner = build_inference_runner(
+ task=Task.DETECT,
+ model=DETECTORS.build(detector_config),
+ device=detector_device,
+ snapshot_path=detector_path,
+ batch_size=detector_batch_size,
+ preprocessor=build_bottom_up_preprocessor(
+ color_mode=model_config["detector"]["data"]["colormode"],
+ transform=detector_transform,
+ ),
+ postprocessor=build_detector_postprocessor(
+ max_individuals=max_individuals,
+ min_bbox_score=min_bbox_score,
+ ),
+ load_weights_only=model_config["detector"]["runner"].get(
+ "load_weights_only",
+ None,
+ ),
+ inference_cfg=inference_cfg,
+ )
+
+ pose_runner = build_inference_runner(
+ task=pose_task,
+ model=PoseModel.build(model_config["model"]),
+ device=device,
+ snapshot_path=snapshot_path,
+ batch_size=batch_size,
+ preprocessor=pose_preprocessor,
+ postprocessor=pose_postprocessor,
+ dynamic=dynamic,
+ load_weights_only=model_config["runner"].get("load_weights_only", None),
+ inference_cfg=inference_cfg,
+ )
+ return pose_runner, detector_runner
+
+
+def get_detector_inference_runner(
+ model_config: dict,
+ snapshot_path: str | Path,
+ batch_size: int = 1,
+ device: str | None = None,
+ max_individuals: int | None = None,
+ transform: A.BaseCompose | None = None,
+ inference_cfg: InferenceConfig | dict | None = None,
+ min_bbox_score: float | None = None,
+) -> DetectorInferenceRunner:
+ """Builds an inference runner for object detection.
+
+ Args:
+ model_config: the pytorch configuration file
+ snapshot_path: the path of the snapshot from which to load the weights
+ max_individuals: the maximum number of individuals per image
+ batch_size: the batch size to use for the pose model.
+ device: if defined, overwrites the device selection from the model config
+ transform: the transform for pose estimation. if None, uses the transform
+ defined in the config.
+ inference_cfg: Configuration for the InferenceRunner. If None - uses the
+ inference config defined in the model_config
+ min_bbox_score: Minimum score threshold for filtering bounding boxes from the
+ detector. Only bounding boxes with scores higher than this threshold are
+ kept. If None, no filtering is applied.
+
+ Returns:
+ an inference runner for object detection
+ """
+ if device is None:
+ device = resolve_device(model_config)
+ elif device == "mps": # FIXME(niels): Cannot run detectors on MPS
+ device = "cpu"
+
+ if max_individuals is None:
+ max_individuals = len(model_config["metadata"]["individuals"])
+
+ det_cfg = model_config["detector"]
+ if transform is None:
+ transform = build_transforms(det_cfg["data"]["inference"])
+
+ if inference_cfg is None:
+ inference_cfg = model_config.get("inference")
+
+ if "pretrained" in det_cfg["model"]:
+ det_cfg["model"]["pretrained"] = False
+
+ preprocessor = build_bottom_up_preprocessor(det_cfg["data"]["colormode"], transform)
+ postprocessor = build_detector_postprocessor(
+ max_individuals=max_individuals,
+ min_bbox_score=min_bbox_score,
+ )
+ runner = build_inference_runner(
+ task=Task.DETECT,
+ model=DETECTORS.build(det_cfg["model"]),
+ device=device,
+ snapshot_path=snapshot_path,
+ batch_size=batch_size,
+ preprocessor=preprocessor,
+ postprocessor=postprocessor,
+ load_weights_only=det_cfg["runner"].get("load_weights_only", None),
+ inference_cfg=inference_cfg,
+ )
+
+ if not isinstance(runner, DetectorInferenceRunner):
+ raise RuntimeError(f"Failed to build DetectorInferenceRunner: {model_config}")
+
+ return runner
+
+
+TORCHVISION_DETECTORS = {
+ "fasterrcnn_resnet50_fpn": {
+ "fn": fasterrcnn_resnet50_fpn,
+ "weights": FasterRCNN_ResNet50_FPN_Weights.DEFAULT,
+ },
+ "fasterrcnn_resnet50_fpn_v2": {
+ "fn": detection.fasterrcnn_resnet50_fpn_v2,
+ "weights": FasterRCNN_ResNet50_FPN_V2_Weights.DEFAULT,
+ },
+ "fasterrcnn_mobilenet_v3_large_fpn": {
+ "fn": fasterrcnn_mobilenet_v3_large_fpn,
+ "weights": FasterRCNN_MobileNet_V3_Large_FPN_Weights.DEFAULT,
+ },
+}
+
+
+def get_filtered_coco_detector_inference_runner(
+ model_name: str,
+ category_id: int,
+ batch_size: int = 1,
+ device: str | None = None,
+ box_score_thresh: float = 0.6,
+ max_individuals: int | None = None,
+ color_mode: str | None = None,
+ model_config: dict | None = None,
+ transform: A.BaseCompose | None = None,
+ inference_cfg: InferenceConfig | dict | None = None,
+ min_bbox_score: float | None = None,
+) -> DetectorInferenceRunner:
+ """Builds a detector inference runner using a pretrained COCO detector from
+ torchvision.
+
+ This function loads a pretrained object detection model from `torchvision.models.detection`,
+ wraps it in a `FilteredDetector` that keeps only detections for a specified COCO category,
+ and packages it into a `DetectorInferenceRunner` ready for inference.
+
+ You can optionally provide a model configuration dictionary to resolve `device`, `max_individuals`,
+ and `color_mode`. If no `model_config` is given, these must be specified explicitly.
+
+ Args:
+ model_name (str): Name of the torchvision detection model to load.
+ Supported values include:
+ "fasterrcnn_resnet50_fpn",
+ "fasterrcnn_resnet50_fpn_v2",
+ "fasterrcnn_mobilenet_v3_large_fpn".
+ category_id (int): The COCO category ID to retain in the detections.
+ batch_size (int, optional): Batch size for inference. Defaults to 1.
+ device (str or None, optional): Device to run the model on (e.g., "cuda", "cpu", or "mps").
+ If None, resolved from model_config or defaults to CUDA.
+ box_score_thresh (float, optional): Confidence threshold for filtering bounding boxes.
+ Defaults to 0.6.
+ max_individuals (int or None, optional): Maximum number of individuals to retain per image.
+ If None, resolved from model_config.
+ color_mode (str or None, optional): Color mode used for preprocessing (e.g., "RGB").
+ If None, resolved from model_config.
+ model_config (dict or None, optional): Optional configuration dictionary used to resolve
+ `device`, `max_individuals`, and `color_mode`.
+ transform (A.BaseCompose or None, optional): Optional preprocessing pipeline.
+ If None, uses the model's default transform.
+ inference_cfg: Configuration for the InferenceRunner. If None - uses the
+ inference config defined in the model_config
+ min_bbox_score (float or None, optional): Minimum score threshold for filtering
+ bounding boxes from the detector. Only
+ bounding boxes with scores higher than
+ this threshold are kept. If None, no
+ filtering is applied.
+
+ Returns:
+ DetectorInferenceRunner: A configured detector inference runner.
+
+ Raises:
+ ValueError: If `model_config` is not provided and required fields are missing.
+ """
+ if model_name not in TORCHVISION_DETECTORS:
+ raise ValueError(f"Unsupported model: {model_name}")
+
+ if model_config is not None:
+ if device is None:
+ device = resolve_device(model_config)
+ if max_individuals is None:
+ max_individuals = len(model_config["metadata"]["individuals"])
+ if color_mode is None:
+ color_mode = model_config["data"]["colormode"]
+ else:
+ missing = []
+ if device is None:
+ missing.append("device")
+ if max_individuals is None:
+ missing.append("max_individuals")
+ if color_mode is None:
+ missing.append("color_mode")
+ if missing:
+ raise ValueError(f"If `model_config` is not provided, you must explicitly specify: {', '.join(missing)}.")
+ if device == "mps":
+ device = "cpu"
+
+ if transform is None:
+ transform = build_transforms({"scale_to_unit_range": True})
+
+ if inference_cfg is None:
+ inference_cfg = model_config.get("inference")
+
+ entry = TORCHVISION_DETECTORS[model_name]
+ weights = entry["weights"]
+ detector = entry["fn"](weights=weights, box_score_thresh=box_score_thresh)
+
+ detector.eval().to(device)
+ filtered_detector = FilteredDetector(detector, class_id=category_id).to(device)
+ detector_runner = build_inference_runner(
+ task=Task.DETECT,
+ model=filtered_detector,
+ device=device,
+ snapshot_path=None,
+ batch_size=batch_size,
+ preprocessor=build_bottom_up_preprocessor(
+ color_mode=color_mode,
+ transform=transform,
+ ),
+ postprocessor=build_detector_postprocessor(
+ max_individuals=max_individuals,
+ min_bbox_score=min_bbox_score,
+ ),
+ inference_cfg=inference_cfg,
+ )
+ return detector_runner
+
+
+def get_pose_inference_runner(
+ model_config: dict,
+ snapshot_path: str | Path,
+ batch_size: int = 1,
+ device: str | None = None,
+ max_individuals: int | None = None,
+ transform: A.BaseCompose | None = None,
+ dynamic: DynamicCropper | None = None,
+ cond_provider: CondFromModel | None = None,
+ ctd_tracking: bool | CTDTrackingConfig = False,
+ inference_cfg: InferenceConfig | dict | None = None,
+) -> PoseInferenceRunner:
+ """Builds an inference runner for pose estimation.
+
+ Args:
+ model_config: the pytorch configuration file
+ snapshot_path: the path of the snapshot from which to load the weights
+ max_individuals: the maximum number of individuals per image
+ batch_size: the batch size to use for the pose model.
+ device: if defined, overwrites the device selection from the model config
+ transform: the transform for pose estimation. if None, uses the transform
+ defined in the config.
+ dynamic: The DynamicCropper used for video inference, or None if dynamic
+ cropping should not be used. Should only be used when creating inference
+ runners for video pose estimation with batch size 1. For top-down pose
+ estimation models, a `TopDownDynamicCropper` must be used.
+ cond_provider: Only for CTD models. If None, the CondProvider is created from
+ the pytorch_cfg.
+ ctd_tracking: Only for CTD models. Conditional top-down models can be used
+ to directly track individuals. Poses from frame T are given as conditions
+ for frame T+1. This also means a BU model is only needed to "initialize" the
+ pose in the first frame, and for the remaining frames only the CTD model is
+ needed. To configure conditional pose tracking differently, you can pass a
+ CTDTrackingConfig instance.
+ inference_cfg: Configuration for the InferenceRunner. If None - uses the
+ inference config defined in the model_config
+
+ Returns:
+ an inference runner for pose estimation
+ """
+ pose_task = Task(model_config["method"])
+ metadata = model_config["metadata"]
+ num_bodyparts = len(metadata["bodyparts"])
+ num_unique = len(metadata["unique_bodyparts"])
+ with_identity = bool(metadata["with_identity"])
+ if max_individuals is None:
+ max_individuals = len(metadata["individuals"])
+
+ if device is None:
+ device = resolve_device(model_config)
+
+ if transform is None:
+ transform = build_transforms(model_config["data"]["inference"])
+
+ if inference_cfg is None:
+ inference_cfg = model_config.get("inference")
+
+ kwargs = {}
+ if pose_task == Task.BOTTOM_UP or isinstance(dynamic, TopDownDynamicCropper):
+ pose_preprocessor = build_bottom_up_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ )
+ pose_postprocessor = build_bottom_up_postprocessor(
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ num_unique_bodyparts=num_unique,
+ with_identity=with_identity,
+ )
+ else:
+ crop_cfg = model_config["data"]["inference"].get("top_down_crop", {})
+ width, height = crop_cfg.get("width", 256), crop_cfg.get("height", 256)
+ margin = crop_cfg.get("margin", 0)
+
+ if pose_task == Task.COND_TOP_DOWN:
+ if cond_provider is not None:
+ kwargs["bu_runner"] = get_pose_inference_runner(
+ model_config=read_config_as_dict(cond_provider.config_path),
+ snapshot_path=cond_provider.snapshot_path,
+ batch_size=1,
+ device=device,
+ max_individuals=max_individuals,
+ )
+
+ kwargs["ctd_tracking"] = ctd_tracking
+
+ pose_preprocessor = build_conditional_top_down_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ bbox_margin=model_config["data"].get("bbox_margin", 20),
+ top_down_crop_size=(width, height),
+ top_down_crop_margin=margin,
+ top_down_crop_with_context=crop_cfg.get("crop_with_context", False),
+ )
+ else: # Top-Down
+ pose_preprocessor = build_top_down_preprocessor(
+ color_mode=model_config["data"]["colormode"],
+ transform=transform,
+ top_down_crop_size=(width, height),
+ top_down_crop_margin=margin,
+ top_down_crop_with_context=crop_cfg.get("crop_with_context", True),
+ )
+
+ pose_postprocessor = build_top_down_postprocessor(
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ num_unique_bodyparts=num_unique,
+ )
+
+ runner = build_inference_runner(
+ task=pose_task,
+ model=PoseModel.build(model_config["model"]),
+ device=device,
+ snapshot_path=snapshot_path,
+ batch_size=batch_size,
+ preprocessor=pose_preprocessor,
+ postprocessor=pose_postprocessor,
+ dynamic=dynamic,
+ load_weights_only=model_config["runner"].get("load_weights_only", None),
+ inference_cfg=inference_cfg,
+ **kwargs,
+ )
+ if not isinstance(runner, PoseInferenceRunner):
+ raise RuntimeError(f"Failed to build PoseInferenceRunner for {model_config}")
+
+ return runner
diff --git a/deeplabcut/pose_estimation_pytorch/apis/videos.py b/deeplabcut/pose_estimation_pytorch/apis/videos.py
new file mode 100644
index 0000000000..823674be7d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/videos.py
@@ -0,0 +1,956 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import copy
+import logging
+import pickle
+import time
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+
+import albumentations as A
+import numpy as np
+import pandas as pd
+import torch
+from tqdm import tqdm
+
+import deeplabcut.pose_estimation_pytorch.apis.utils as utils
+import deeplabcut.pose_estimation_pytorch.runners.shelving as shelving
+from deeplabcut.pose_estimation_pytorch.apis.ctd import (
+ get_condition_provider,
+ get_conditions_provider_for_video,
+)
+from deeplabcut.pose_estimation_pytorch.apis.tracklets import (
+ convert_detections2tracklets,
+)
+from deeplabcut.pose_estimation_pytorch.data import DLCLoader
+from deeplabcut.pose_estimation_pytorch.data.ctd import CondFromModel
+from deeplabcut.pose_estimation_pytorch.runners import (
+ CTDTrackingConfig,
+ DynamicCropper,
+ InferenceRunner,
+ TopDownDynamicCropper,
+)
+from deeplabcut.pose_estimation_pytorch.runners.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.refine_training_dataset.stitch import stitch_tracklets
+from deeplabcut.utils import VideoReader, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+
+
+class VideoIterator(VideoReader):
+ """A class to iterate over videos, with possible added context."""
+
+ def __init__(
+ self,
+ video_path: str | Path,
+ context: list[dict[str, Any]] | None = None,
+ cropping: list[int] | None = None,
+ ) -> None:
+ super().__init__(str(video_path))
+ self._context = context
+ self._index = 0
+ self._crop = cropping is not None
+ if self._crop:
+ self.set_bbox(*cropping)
+
+ def set_crop(self, cropping: list[int] | None = None) -> None:
+ """Sets the cropping parameters for the video."""
+ self._crop = cropping is not None
+ if self._crop:
+ self.set_bbox(*cropping)
+ else:
+ self.set_bbox(0, 1, 0, 1, relative=True)
+
+ def get_context(self) -> list[dict[str, Any]] | None:
+ if self._context is None:
+ return None
+
+ return copy.deepcopy(self._context)
+
+ def set_context(self, context: list[dict[str, Any]] | None) -> None:
+ if context is None:
+ self._context = None
+ return
+
+ self._context = copy.deepcopy(context)
+
+ def __iter__(self):
+ return self
+
+ def __next__(self) -> np.ndarray | tuple[str, dict[str, Any]]:
+ frame = self.read_frame(crop=self._crop)
+ if frame is None:
+ self._index = 0
+ self.reset()
+ raise StopIteration
+
+ # Otherwise ValueError: At least one stride in the given numpy array is negative,
+ # and tensors with negative strides are not currently supported. (You can probably
+ # work around this by making a copy of your array with array.copy().)
+ frame = frame.copy()
+ if self._context is None:
+ self._index += 1
+ return frame
+
+ context = copy.deepcopy(self._context[self._index])
+ self._index += 1
+ return frame, context
+
+
+class GpuTqdm(tqdm):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._cuda_available = torch.cuda.is_available()
+
+ def __iter__(self):
+ for obj in super().__iter__():
+ if self._cuda_available:
+ used = torch.cuda.memory_reserved() / 1024**2
+ total = torch.cuda.get_device_properties(0).total_memory / 1024**2
+ self.set_postfix({"GPU": f"{used:.1f}/{total:.1f} MiB"})
+ yield obj
+
+
+def video_inference(
+ video: str | Path | VideoIterator,
+ pose_runner: InferenceRunner,
+ detector_runner: InferenceRunner | None = None,
+ cropping: list[int] | None = None,
+ shelf_writer: shelving.ShelfWriter | None = None,
+ robust_nframes: bool = False,
+ show_gpu_memory: bool = False,
+) -> list[dict[str, np.ndarray]]:
+ """Runs inference on a video.
+
+ Args:
+ video: The video to analyze
+ pose_runner: The pose runner to run inference with
+ detector_runner: When the pose model is a top-down model, a detector runner can
+ be given to obtain bounding boxes for the video. If the pose model is a
+ top-down model and no detector_runner is given, the bounding boxes must
+ already be set in the VideoIterator (see examples).
+ cropping: Optionally, video inference can be run on a cropped version of the
+ video. To do so, pass a list containing 4 elements to specify which area
+ of the video should be analyzed: ``[xmin, xmax, ymin, ymax]``.
+ shelf_writer: By default, data are dumped in a pickle file at the end of the
+ video analysis. Passing a shelf manager writes data to disk on-the-fly
+ using a "shelf" (a pickle-based, persistent, database-like object by
+ default, resulting in constant memory footprint). The returned list is
+ then empty.
+ robust_nframes: Evaluate a video's number of frames in a robust manner. This
+ option is slower (as the whole video is read frame-by-frame), but does not
+ rely on metadata, hence its robustness against file corruption.
+ show_gpu_memory: When true, the tqdm progress bar shows the gpu memory usage
+ of the current process.
+
+ Returns:
+ Predictions for each frame in the video. If a shelf_manager is given, this list
+ will be empty and the predictions will exclusively be stored in the file written
+ by the shelf.
+
+ Examples:
+ Bottom-up video analysis:
+ >>> import deeplabcut.pose_estimation_pytorch as pep
+ >>> from deeplabcut.core.config import read_config_as_dict
+ >>> model_cfg = read_config_as_dict("pytorch_config.yaml")
+ >>> runner = pep.get_pose_inference_runner(model_cfg, "snapshot.pt")
+ >>> video_predictions = pep.video_inference("video.mp4", runner)
+ >>>
+
+ Top-down video analysis:
+ >>> import deeplabcut.pose_estimation_pytorch as pep
+ >>> from deeplabcut.core.config import read_config_as_dict
+ >>> model_cfg = read_config_as_dict("pytorch_config.yaml")
+ >>> runner = pep.get_pose_inference_runner(model_cfg, "snapshot.pt")
+ >>> d_runner = pep.get_pose_inference_runner(model_cfg, "snapshot-detector.pt")
+ >>> video_predictions = pep.video_inference("video.mp4", runner, d_runner)
+ >>>
+
+ Top-Down pose estimation with pre-computed bounding boxes:
+ >>> import numpy as np
+ >>> import deeplabcut.pose_estimation_pytorch as pep
+ >>> from deeplabcut.core.config import read_config_as_dict
+ >>>
+ >>> video_iterator = pep.VideoIterator("video.mp4")
+ >>> video_iterator.set_context([
+ >>> { # frame 1 context
+ >>> "bboxes": np.array([[12, 17, 4, 5]]), # format (x0, y0, w, h)
+ >>> },
+ >>> { # frame 1 context
+ >>> "bboxes": np.array([[12, 17, 4, 5], [18, 92, 54, 32]]),
+ >>> },
+ >>> ...
+ >>> ])
+ >>> model_cfg = read_config_as_dict("pytorch_config.yaml")
+ >>> runner = pep.get_pose_inference_runner(model_cfg, "snapshot.pt")
+ >>> video_predictions = pep.video_inference(video_iterator, runner)
+ >>>
+ """
+ if not isinstance(video, VideoIterator):
+ video = VideoIterator(str(video), cropping=cropping)
+ elif cropping is not None:
+ video.set_crop(cropping)
+
+ n_frames = video.get_n_frames(robust=robust_nframes)
+ vid_w, vid_h = video.dimensions
+ print(f"Starting to analyze {video.video_path}")
+ print(
+ f"Video metadata: \n"
+ f" Overall # of frames: {n_frames}\n"
+ f" Duration of video [s]: {n_frames / max(1, video.fps):.2f}\n"
+ f" fps: {video.fps}\n"
+ f" resolution: w={vid_w}, h={vid_h}\n"
+ )
+
+ if detector_runner is not None:
+ print(f"Running detector with batch size {detector_runner.batch_size}")
+ bbox_predictions = detector_runner.inference(images=GpuTqdm(video) if show_gpu_memory else tqdm(video))
+ video.set_context(bbox_predictions)
+
+ print(f"Running pose prediction with batch size {pose_runner.batch_size}")
+ if shelf_writer is not None:
+ shelf_writer.open()
+
+ predictions = pose_runner.inference(
+ images=GpuTqdm(video) if show_gpu_memory else tqdm(video), shelf_writer=shelf_writer
+ )
+ if shelf_writer is not None:
+ shelf_writer.close()
+
+ if shelf_writer is None and len(predictions) != n_frames:
+ tip_url = "https://deeplabcut.github.io/DeepLabCut/docs/recipes/io.html"
+ header = "#tips-on-video-re-encoding-and-preprocessing"
+ logging.warning(
+ f"The video metadata indicates that there {n_frames} in the video, but "
+ f"only {len(predictions)} were able to be processed. This can happen if "
+ "the video is corrupted. You can try to fix the issue by re-encoding your "
+ f"video (tips on how to do that: {tip_url}{header})"
+ )
+
+ return predictions
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def analyze_videos(
+ config: str,
+ videos: str | list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ save_as_csv: bool = False,
+ in_random_order: bool = False,
+ snapshot_index: int | str | None = None,
+ detector_snapshot_index: int | str | None = None,
+ device: str | None = None,
+ destfolder: str | None = None,
+ batch_size: int | None = None,
+ detector_batch_size: int | None = None,
+ dynamic: tuple[bool, float, int] = (False, 0.5, 10),
+ ctd_conditions: dict | CondFromModel | None = None,
+ ctd_tracking: bool | dict | CTDTrackingConfig = False,
+ top_down_dynamic: dict | None = None,
+ modelprefix: str = "",
+ use_shelve: bool = False,
+ robust_nframes: bool = False,
+ transform: A.Compose | None = None,
+ auto_track: bool | None = True,
+ n_tracks: int | None = None,
+ animal_names: list[str] | None = None,
+ calibrate: bool = False,
+ identity_only: bool | None = False,
+ overwrite: bool = False,
+ cropping: list[int] | None = None,
+ save_as_df: bool = False,
+ show_gpu_memory: bool = False,
+ inference_cfg: InferenceConfig | dict | None = None,
+) -> str:
+ """Makes prediction based on a trained network.
+
+ The index of the trained network is specified by parameters in the config file
+ (in particular the variable 'snapshot_index').
+
+ Args:
+ config: full path of the config.yaml file for the project
+ videos: a str (or list of strings) containing the full paths to videos for
+ analysis or a path to the directory, where all the videos with same
+ extension are stored.
+ video_extensions: Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+ shuffle: An integer specifying the shuffle index of the training dataset used for
+ training the network.
+ trainingsetindex: Integer specifying which TrainingsetFraction to use.
+ save_as_csv: For multi-animal projects and when `auto_track=True`, passed
+ along to the `stitch_tracklets` method to save tracks as CSV.
+ in_random_order: Whether or not to analyze videos in a random order. This is
+ only relevant when specifying a video directory in `videos`.
+ device: the device to use for video analysis
+ destfolder: specifies the destination folder for analysis data. If ``None``,
+ the path of the video is used. Note that for subsequent analysis this
+ folder also needs to be passed
+ snapshot_index: index (starting at 0) of the snapshot to use to analyze the
+ videos. To evaluate the last one, use -1. For example if we have
+ - snapshot-0.pt
+ - snapshot-50.pt
+ - snapshot-100.pt
+ - snapshot-best.pt
+ and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None,
+ the snapshot index is loaded from the project configuration.
+ detector_snapshot_index: (only for top-down models) index of the detector
+ snapshot to use, used in the same way as ``snapshot_index``
+ dynamic: (state, detection threshold, margin) triplet. If the state is true,
+ then dynamic cropping will be performed. That means that if an object is
+ detected (i.e. any body part > detection threshold), then object boundaries
+ are computed according to the smallest/largest x position and
+ smallest/largest y position of all body parts. This window is expanded by
+ the margin and from then on only the posture within this crop is analyzed
+ (until the object is lost, i.e. < detection threshold). The current position
+ is utilized for updating the crop window for the next frame (this is why the
+ margin is important and should be set large enough given the movement of the
+ animal).
+ ctd_conditions: Only for CTD models. If None, the configuration for the
+ condition provider will be loaded from the pytorch_config file (under the
+ "inference": "conditions"). If the ctd_conditions is given as a dict, creates a
+ CondFromModel from the dict. Otherwise, a CondFromModel can be given
+ directly. Example configuration:
+ ```
+ ctd_conditions = {"shuffle": 17, "snapshot": "snapshot-best-190.pt"}
+ ```
+ ctd_tracking: Only for CTD models. Conditional top-down models can be used
+ to directly track individuals. Poses from frame T are given as conditions
+ for frame T+1. This also means a BU model is only needed to "initialize" the
+ pose in the first frame, and for the remaining frames only the CTD model is
+ needed. To configure conditional pose tracking differently, you can pass a
+ CTDTrackingConfig instance.
+ top_down_dynamic: Configuration for a top-down dynamic cropper. If None,
+ top-down dynamic cropping is not used. Can only be used when running
+ inference on a single animal. If an empty dict is given, default parameters
+ are used. This is not recommended, as parameters should be customized for
+ your data. Possible parameters are:
+ "top_down_crop_size": tuple[int, int]
+ The (width, height) to resize the crop to. If not specified, will
+ be loaded from the `pytorch_cfg.yaml` for your top-down model. If
+ your model is not a top-down model, must be given.
+ "patch_counts": tuple[int, int] (default: (3, 2))
+ The number of patches along the (width, height) of the images when
+ no crop is found.
+ "patch_overlap": int (default: 50)
+ The amount of overlapping pixels between adjacent patches.
+ "min_bbox_size": tuple[int, int] (default: (50, 50))
+ The minimum (width, height) for a detected bounding box.
+ "threshold": float (default: 0.6)
+ The threshold score for bodyparts above which an individual is
+ considered to be detected.
+ "margin": int (default: 25)
+ The margin to add around keypoints when generating bounding boxes.
+ "min_hq_keypoints": int (default: 2)
+ The minimum number of keypoints above the threshold required for the
+ individual to be considered detected and a bbox to be computed.
+ "bbox_from_hq": bool (default: False)
+ If True, only keypoints above the score threshold will be used to
+ compute the bounding boxes.
+ modelprefix: directory containing the deeplabcut models to use when evaluating
+ the network. By default, they are assumed to exist in the project folder.
+ batch_size: the batch size to use for inference. Takes the value from the
+ project config as a default.
+ detector_batch_size: the batch size to use for detector inference. Takes the
+ value from the project config as a default.
+ transform: Optional custom transforms to apply to the video
+ overwrite: Overwrite any existing videos
+ use_shelve: By default, data are dumped in a pickle file at the end of the video
+ analysis. Otherwise, data are written to disk on the fly using a "shelf";
+ i.e., a pickle-based, persistent, database-like object by default, resulting
+ in constant memory footprint.
+ robust_nframes: Evaluate a video's number of frames in a robust manner. This
+ option is slower (as the whole video is read frame-by-frame), but does not
+ rely on metadata, hence its robustness against file corruption.
+ auto_track: By default, tracking and stitching are automatically performed,
+ producing the final h5 data file. This is equivalent to the behavior for
+ single-animal projects.
+
+ If ``False``, one must run ``convert_detections2tracklets`` and
+ ``stitch_tracklets`` afterwards, in order to obtain the h5 file.
+ n_tracks: Number of tracks to reconstruct. By default, taken as the number of
+ individuals defined in the config.yaml. Another number can be passed if the
+ number of animals in the video is different from the number of animals the
+ model was trained on.
+ animal_names: If you want the names given to individuals in the labeled data
+ file, you can specify those names as a list here. If given and `n_tracks`
+ is None, `n_tracks` will be set to `len(animal_names)`. If `n_tracks` is not
+ None, then it must be equal to `len(animal_names)`. If it is not given, then
+ `animal_names` will be loaded from the `individuals` in the project
+ `config.yaml` file.
+ identity_only: sub-call for auto_track. If ``True`` and animal identity was
+ learned by the model, assembly and tracking rely exclusively on identity
+ prediction.
+ cropping: List of cropping coordinates as [x1, x2, y1, y2]. Note that the same
+ cropping parameters will then be used for all videos. If different video
+ crops are desired, run ``analyze_videos`` on individual videos with the
+ corresponding cropping coordinates.
+ save_as_df: Cannot be used when `use_shelve` is True. Saves the video
+ predictions (before tracking results) to an H5 file containing a pandas
+ DataFrame. If ``save_as_csv==True`` than the full predictions will also be
+ saved in a CSV file.
+ show_gpu_memory: When true, the tqdm progress bar shows the gpu memory usage
+ of the current process.
+ inference_cfg: InferenceConfig to use
+ If None, the configuration from the `pytorch_cfg.yaml` will be used
+
+ Returns:
+ The scorer used to analyze the videos
+ """
+ # Create the output folder
+ _validate_destfolder(destfolder)
+
+ # Load the project configuration
+ loader = DLCLoader(
+ config,
+ trainset_index=trainingsetindex,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ train_fraction = loader.project_cfg["TrainingFraction"][trainingsetindex]
+ pose_cfg_path = loader.model_folder.parent / "test" / "pose_cfg.yaml"
+ pose_cfg = auxiliaryfunctions.read_plainconfig(pose_cfg_path)
+
+ snapshot_index, detector_snapshot_index = utils.parse_snapshot_index_for_analysis(
+ loader.project_cfg,
+ loader.model_cfg,
+ snapshot_index,
+ detector_snapshot_index,
+ )
+
+ if cropping is None and loader.project_cfg.get("cropping", False):
+ cropping = (
+ loader.project_cfg["x1"],
+ loader.project_cfg["x2"],
+ loader.project_cfg["y1"],
+ loader.project_cfg["y2"],
+ )
+
+ # Get general project parameters
+ multi_animal = loader.project_cfg["multianimalproject"]
+ bodyparts = loader.model_cfg["metadata"]["bodyparts"]
+ unique_bodyparts = loader.model_cfg["metadata"]["unique_bodyparts"]
+ individuals = loader.model_cfg["metadata"]["individuals"]
+ max_num_animals = len(individuals)
+
+ if device is not None:
+ loader.model_cfg["device"] = device
+
+ if batch_size is None:
+ batch_size = loader.project_cfg.get("batch_size", 1)
+
+ if not multi_animal:
+ save_as_df = True
+ if use_shelve:
+ print(
+ "The ``use_shelve`` parameter cannot be used for single animal projects. Setting ``use_shelve=False``."
+ )
+ use_shelve = False
+
+ dynamic = DynamicCropper.build(*dynamic)
+ if loader.pose_task != Task.BOTTOM_UP and dynamic is not None:
+ print(
+ "Turning off dynamic cropping. It should only be used for bottom-up "
+ "pose estimation models, but you are using a top-down model. For top-down "
+ "models, use the TopDownDynamicCropper with the `top_down_dynamic` arg."
+ )
+ dynamic = None
+
+ if top_down_dynamic is not None:
+ if loader.pose_task == Task.TOP_DOWN:
+ td_cfg = loader.model_cfg["data"]["inference"].get(
+ "top_down_crop",
+ {"width": 256, "height": 256},
+ )
+ top_down_dynamic["top_down_crop_size"] = td_cfg["width"], td_cfg["height"]
+
+ print(f"Creating a TopDownDynamicCropper with configuration {top_down_dynamic}")
+ dynamic = TopDownDynamicCropper(**top_down_dynamic)
+
+ snapshot = utils.get_model_snapshots(snapshot_index, loader.model_folder, loader.pose_task)[0]
+
+ # Load the BU model for the conditions provider
+ cond_provider = None
+ if loader.pose_task == Task.COND_TOP_DOWN:
+ if ctd_conditions is None:
+ cond_provider = get_condition_provider(
+ condition_cfg=loader.model_cfg["inference"]["conditions"],
+ config=config,
+ )
+ elif isinstance(ctd_conditions, dict):
+ cond_provider = get_condition_provider(
+ condition_cfg=ctd_conditions,
+ config=config,
+ )
+ else:
+ cond_provider = ctd_conditions
+
+ if isinstance(ctd_tracking, dict):
+ # FIXME(niels) - add video FPS setting
+ ctd_tracking = CTDTrackingConfig.build(ctd_tracking)
+
+ print(f"Analyzing videos with {snapshot.path}")
+ pose_runner = utils.get_pose_inference_runner(
+ model_config=loader.model_cfg,
+ snapshot_path=snapshot.path,
+ max_individuals=max_num_animals,
+ batch_size=batch_size,
+ transform=transform,
+ dynamic=dynamic,
+ cond_provider=cond_provider,
+ ctd_tracking=ctd_tracking,
+ inference_cfg=inference_cfg,
+ )
+
+ detector_runner = None
+ _detector_path, detector_snapshot = None, None
+ if loader.pose_task == Task.TOP_DOWN and dynamic is None:
+ if detector_snapshot_index is None:
+ raise ValueError(
+ "Cannot run videos analysis for top-down models without a detector "
+ "snapshot! Please specify your desired detector_snapshotindex in your "
+ "project's configuration file."
+ )
+
+ if detector_batch_size is None:
+ detector_batch_size = loader.project_cfg.get("detector_batch_size", 1)
+
+ detector_snapshot = utils.get_model_snapshots(detector_snapshot_index, loader.model_folder, Task.DETECT)[0]
+ print(f" -> Using detector {detector_snapshot.path}")
+ detector_runner = utils.get_detector_inference_runner(
+ model_config=loader.model_cfg,
+ snapshot_path=detector_snapshot.path,
+ max_individuals=max_num_animals,
+ batch_size=detector_batch_size,
+ inference_cfg=inference_cfg,
+ )
+
+ dlc_scorer = loader.scorer(snapshot, detector_snapshot)
+ print(f"Using scorer: {dlc_scorer}")
+
+ # Reading video and init variables
+ videos = collect_video_paths(videos, extensions=video_extensions, shuffle=in_random_order)
+ h5_files_created = False # Track if any .h5 files were created
+
+ for video in videos:
+ if destfolder is None:
+ output_path = video.parent
+ else:
+ output_path = Path(destfolder)
+
+ output_prefix = video.stem + dlc_scorer
+ output_pkl = output_path / f"{output_prefix}_full.pickle"
+
+ video_iterator = VideoIterator(video, cropping=cropping)
+
+ # Check if BU model pose predictions exist so the model does not need to be run
+ if loader.pose_task == Task.COND_TOP_DOWN:
+ vid_cond_provider = get_conditions_provider_for_video(cond_provider, video)
+ if vid_cond_provider is not None:
+ video_cond = vid_cond_provider.load_conditions()
+ video_iterator.set_context([dict(cond_kpts=c) for c in video_cond])
+
+ shelf_writer = None
+ if use_shelve:
+ shelf_writer = shelving.ShelfWriter(
+ pose_cfg=pose_cfg,
+ filepath=output_pkl,
+ num_frames=video_iterator.get_n_frames(robust=robust_nframes),
+ )
+
+ if not overwrite and output_pkl.exists():
+ print(f"Video {video} already analyzed at {output_pkl}!")
+ else:
+ runtime = [time.time()]
+ predictions = video_inference(
+ video=video_iterator,
+ pose_runner=pose_runner,
+ detector_runner=detector_runner,
+ shelf_writer=shelf_writer,
+ robust_nframes=robust_nframes,
+ show_gpu_memory=show_gpu_memory,
+ )
+ runtime.append(time.time())
+ metadata = _generate_metadata(
+ cfg=loader.project_cfg,
+ pytorch_config=loader.model_cfg,
+ dlc_scorer=dlc_scorer,
+ train_fraction=train_fraction,
+ batch_size=batch_size,
+ cropping=cropping,
+ runtime=(runtime[0], runtime[1]),
+ video=video_iterator,
+ robust_nframes=robust_nframes,
+ )
+
+ with open(output_path / f"{output_prefix}_meta.pickle", "wb") as f:
+ pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
+
+ if use_shelve and save_as_df:
+ print("Can't ``save_as_df`` as ``use_shelve=True``. Skipping.")
+
+ if not use_shelve:
+ output_data = _generate_output_data(pose_cfg, predictions)
+ with open(output_pkl, "wb") as f:
+ pickle.dump(output_data, f, pickle.HIGHEST_PROTOCOL)
+
+ if save_as_df:
+ create_df_from_prediction(
+ predictions=predictions,
+ multi_animal=multi_animal,
+ model_cfg=loader.model_cfg,
+ dlc_scorer=dlc_scorer,
+ output_path=output_path,
+ output_prefix=output_prefix,
+ save_as_csv=save_as_csv,
+ )
+ h5_files_created = True # .h5 file was created
+
+ if multi_animal:
+ assemblies_path = output_path / f"{output_prefix}_assemblies.pickle"
+ _generate_assemblies_file(
+ full_data_path=output_pkl,
+ output_path=assemblies_path,
+ num_bodyparts=len(bodyparts),
+ num_unique_bodyparts=len(unique_bodyparts),
+ )
+
+ # when running CTD tracking, don't auto-track as CTD did the tracking
+ # for us!
+ if ctd_tracking:
+ full_data = auxiliaryfunctions.read_pickle(output_pkl)
+ full_data_meta = full_data.pop("metadata")
+
+ num_frames = full_data_meta["nframes"]
+ str_width = full_data_meta["key_str_width"]
+
+ ctd_predictions = []
+ for i in range(num_frames):
+ frame_data = full_data.get("frame" + str(i).zfill(str_width))
+ if frame_data is None:
+ pose = np.full((len(individuals), len(bodyparts), 3), np.nan)
+ ctd_predictions.append(dict(bodyparts=pose))
+ continue
+
+ # there can't be unique bodyparts for CTD models
+ # -> so coords has shape (num_bodyparts, num_idv, _)
+ coords = np.stack(frame_data["coordinates"][0], axis=0)
+ scores = np.stack(frame_data["confidence"], axis=0)
+ pose = np.concatenate([coords, scores], axis=-1)
+
+ # transpose to (num_idv, num_bodyparts, _)
+ pose = pose.transpose((1, 0, 2))
+
+ # add poses to the predictions
+ ctd_predictions.append(dict(bodyparts=pose))
+
+ create_df_from_prediction(
+ predictions=predictions,
+ multi_animal=multi_animal,
+ model_cfg=loader.model_cfg,
+ dlc_scorer=dlc_scorer,
+ output_path=output_path,
+ output_prefix=output_prefix + "_ctd",
+ save_as_csv=save_as_csv,
+ )
+ h5_files_created = True # .h5 file was created for CTD tracking
+
+ elif auto_track:
+ convert_detections2tracklets(
+ config=config,
+ videos=str(video),
+ video_extensions=video_extensions,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ overwrite=False,
+ identity_only=identity_only,
+ destfolder=str(output_path),
+ snapshot_index=snapshot_index,
+ detector_snapshot_index=detector_snapshot_index,
+ )
+ stitch_tracklets(
+ config,
+ [str(video)],
+ video_extensions,
+ shuffle,
+ trainingsetindex,
+ n_tracks=n_tracks,
+ animal_names=animal_names,
+ destfolder=str(output_path),
+ save_as_csv=save_as_csv,
+ snapshot_index=snapshot_index,
+ detector_snapshot_index=detector_snapshot_index,
+ )
+ h5_files_created = True # .h5 file was created by stitch_tracklets
+
+ if h5_files_created:
+ print(
+ "The videos are analyzed. Now your research can truly start!\n"
+ "You can create labeled videos with 'create_labeled_video'.\n"
+ "If the tracking is not satisfactory for some videos, consider expanding the "
+ "training set. You can use the function 'extract_outlier_frames' to extract a "
+ "few representative outlier frames.\n"
+ )
+ else:
+ print(
+ "No .h5 files were created during video analysis. Please check your code and "
+ "ensure that the video inference and output generation are correct.\n"
+ )
+
+ return dlc_scorer
+
+
+def create_df_from_prediction(
+ predictions: list[dict[str, np.ndarray]],
+ dlc_scorer: str,
+ multi_animal: bool,
+ model_cfg: dict,
+ output_path: str | Path,
+ output_prefix: str | Path,
+ save_as_csv: bool = False,
+) -> pd.DataFrame:
+ pred_bodyparts = np.stack([p["bodyparts"][..., :3] for p in predictions])
+ pred_unique_bodyparts = None
+ if len(predictions) > 0 and "unique_bodyparts" in predictions[0]:
+ pred_unique_bodyparts = np.stack([p["unique_bodyparts"] for p in predictions])
+
+ output_h5 = Path(output_path) / f"{output_prefix}.h5"
+ output_pkl = Path(output_path) / f"{output_prefix}_full.pickle"
+
+ bodyparts = model_cfg["metadata"]["bodyparts"]
+ unique_bodyparts = model_cfg["metadata"]["unique_bodyparts"]
+ individuals = model_cfg["metadata"]["individuals"]
+ n_individuals = len(individuals)
+
+ print(f"Saving results in {output_h5} and {output_pkl}")
+ coords = ["x", "y", "likelihood"]
+ cols = [[dlc_scorer], bodyparts, coords]
+ cols_names = ["scorer", "bodyparts", "coords"]
+
+ if multi_animal:
+ cols.insert(1, individuals)
+ cols_names.insert(1, "individuals")
+
+ results_df_index = pd.MultiIndex.from_product(cols, names=cols_names)
+ pred_bodyparts = pred_bodyparts[:, :n_individuals]
+ df = pd.DataFrame(
+ pred_bodyparts.reshape((len(pred_bodyparts), -1)),
+ columns=results_df_index,
+ index=range(len(pred_bodyparts)),
+ )
+ if pred_unique_bodyparts is not None:
+ unique_columns = [dlc_scorer], ["single"], unique_bodyparts, coords
+ df_u = pd.DataFrame(
+ pred_unique_bodyparts.reshape((len(pred_unique_bodyparts), -1)),
+ columns=pd.MultiIndex.from_product(unique_columns, names=cols_names),
+ index=range(len(pred_unique_bodyparts)),
+ )
+ df = df.join(df_u, how="outer")
+
+ df.to_hdf(output_h5, key="df_with_missing", format="table", mode="w")
+ if save_as_csv:
+ df.to_csv(output_h5.with_suffix(".csv"))
+ return df
+
+
+def _generate_assemblies_file(
+ full_data_path: Path,
+ output_path: Path,
+ num_bodyparts: int,
+ num_unique_bodyparts: int,
+) -> None:
+ """Generates the assemblies file from predictions."""
+ if full_data_path.exists():
+ with open(full_data_path, "rb") as f:
+ data = pickle.load(f)
+
+ else:
+ data = shelving.ShelfReader(full_data_path)
+ data.open()
+
+ num_frames = data["metadata"]["nframes"]
+ str_width = data["metadata"].get("key_str_width")
+ if str_width is None:
+ keys = [k for k in data.keys() if k != "metadata"]
+ str_width = len(keys[0]) - len("frame")
+
+ assemblies = dict(single=dict())
+ for frame_index in range(num_frames):
+ frame_key = "frame" + str(frame_index).zfill(str_width)
+ predictions = data[frame_key]
+
+ keypoint_preds = predictions["coordinates"][0]
+ keypoint_scores = predictions["confidence"]
+
+ bpts = np.stack(keypoint_preds[:num_bodyparts])
+ scores = np.stack(keypoint_scores[:num_bodyparts])
+ preds = np.concatenate([bpts, scores], axis=-1)
+
+ keypoint_id_scores = predictions.get("identity")
+ if keypoint_id_scores is not None:
+ keypoint_id_scores = np.stack(keypoint_id_scores[:num_bodyparts])
+ keypoint_pred_ids = np.argmax(keypoint_id_scores, axis=2)
+ keypoint_pred_ids = np.expand_dims(keypoint_pred_ids, axis=-1)
+ else:
+ num_bpts, num_preds = preds.shape[:2]
+ keypoint_pred_ids = -np.ones((num_bpts, num_preds, 1))
+
+ # reshape to (num_preds, num_bpts, 4)
+ preds = np.concatenate([preds, keypoint_pred_ids], axis=-1)
+ preds = preds.transpose((1, 0, 2))
+
+ # remove all-missing predictions
+ mask = ~np.all(preds < 0, axis=(1, 2))
+ preds = preds[mask]
+
+ assemblies[frame_index] = preds
+
+ if num_unique_bodyparts > 0:
+ unique_bpts = np.stack(keypoint_preds[num_bodyparts:])
+ unique_scores = np.stack(keypoint_scores[num_bodyparts:])
+ unique_preds = np.concatenate([unique_bpts, unique_scores], axis=-1)
+ unique_preds = unique_preds.transpose((1, 0, 2))
+ assemblies["single"][frame_index] = unique_preds[0] # single prediction
+
+ with open(output_path, "wb") as file:
+ pickle.dump(assemblies, file, pickle.HIGHEST_PROTOCOL)
+
+ if isinstance(data, shelving.ShelfReader):
+ data.close()
+
+
+def _validate_destfolder(destfolder: str | None) -> None:
+ """Checks that the destfolder for video analysis is valid."""
+ if destfolder is not None and destfolder != "":
+ output_folder = Path(destfolder)
+ if not output_folder.exists():
+ print(f"Creating the output folder {output_folder}")
+ output_folder.mkdir(parents=True)
+
+ assert Path(output_folder).is_dir(), f"Output folder must be a directory: you passed '{output_folder}'"
+
+
+def _generate_metadata(
+ cfg: dict,
+ pytorch_config: dict,
+ dlc_scorer: str,
+ train_fraction: int,
+ batch_size: int,
+ cropping: list[int] | None,
+ runtime: tuple[float, float],
+ video: VideoIterator,
+ robust_nframes: bool = False,
+) -> dict:
+ w, h = video.dimensions
+ if cropping is None:
+ cropping_parameters = [0, w, 0, h]
+ else:
+ if not len(cropping) == 4:
+ raise ValueError(
+ f"The cropping parameters should be exactly 4 values: [x_min, x_max, y_min, y_max]. Found {cropping}"
+ )
+ cropping_parameters = cropping
+
+ metadata = {
+ "start": runtime[0],
+ "stop": runtime[1],
+ "run_duration": runtime[1] - runtime[0],
+ "Scorer": dlc_scorer,
+ "pytorch-config": pytorch_config,
+ "fps": video.fps,
+ "batch_size": batch_size,
+ "frame_dimensions": (w, h),
+ "nframes": video.get_n_frames(robust=robust_nframes),
+ "iteration (active-learning)": cfg["iteration"],
+ "training set fraction": train_fraction,
+ "cropping": cropping is not None,
+ "cropping_parameters": cropping_parameters,
+ "individuals": pytorch_config["metadata"]["individuals"],
+ "bodyparts": pytorch_config["metadata"]["bodyparts"],
+ "unique_bodyparts": pytorch_config["metadata"]["unique_bodyparts"],
+ }
+ return {"data": metadata}
+
+
+def _generate_output_data(
+ pose_config: dict,
+ predictions: list[dict[str, np.ndarray]],
+) -> dict:
+ str_width = int(np.ceil(np.log10(len(predictions))))
+ output = {
+ "metadata": {
+ "nms radius": pose_config.get("nmsradius"),
+ "minimal confidence": pose_config.get("minconfidence"),
+ "sigma": pose_config.get("sigma", 1),
+ "PAFgraph": pose_config.get("partaffinityfield_graph"),
+ "PAFinds": pose_config.get(
+ "paf_best",
+ np.arange(len(pose_config.get("partaffinityfield_graph", []))),
+ ),
+ "all_joints": [[i] for i in range(len(pose_config["all_joints"]))],
+ "all_joints_names": [pose_config["all_joints_names"][i] for i in range(len(pose_config["all_joints"]))],
+ "nframes": len(predictions),
+ "key_str_width": str_width,
+ }
+ }
+
+ for frame_num, frame_predictions in enumerate(predictions):
+ key = "frame" + str(frame_num).zfill(str_width)
+ # shape (num_assemblies, num_bpts, 3)
+ bodyparts = frame_predictions["bodyparts"]
+ # shape (num_bpts, num_assemblies, 3)
+ bodyparts = bodyparts.transpose((1, 0, 2))
+ coordinates = [bpt[:, :2] for bpt in bodyparts]
+ scores = [bpt[:, 2:3] for bpt in bodyparts]
+
+ # full pickle has bodyparts and unique bodyparts in same array
+ num_unique = 0
+ if "unique_bodyparts" in frame_predictions:
+ unique_bpts = frame_predictions["unique_bodyparts"].transpose((1, 0, 2))
+ coordinates += [bpt[:, :2] for bpt in unique_bpts]
+ scores += [bpt[:, 2:] for bpt in unique_bpts]
+ num_unique = len(unique_bpts)
+
+ output[key] = {
+ "coordinates": (coordinates,),
+ "confidence": scores,
+ "costs": None,
+ }
+
+ if "bboxes" in frame_predictions:
+ output[key]["bboxes"] = frame_predictions["bboxes"]
+ if "bbox_scores" in frame_predictions:
+ output[key]["bbox_scores"] = frame_predictions["bbox_scores"]
+
+ if "identity_scores" in frame_predictions:
+ # Reshape id scores from (num_assemblies, num_bpts, num_individuals)
+ # to the original DLC full pickle format: (num_bpts, num_assem, num_ind)
+ id_scores = frame_predictions["identity_scores"]
+ id_scores = id_scores.transpose((1, 0, 2))
+ output[key]["identity"] = [bpt_id_scores for bpt_id_scores in id_scores]
+
+ if num_unique > 0:
+ # needed for create_video_with_all_detections to display unique bpts
+ num_assem, num_ind = id_scores.shape[1:]
+ output[key]["identity"] += [-1 * np.ones((num_assem, num_ind)) for i in range(num_unique)]
+
+ return output
diff --git a/deeplabcut/pose_estimation_pytorch/apis/visualization.py b/deeplabcut/pose_estimation_pytorch/apis/visualization.py
new file mode 100644
index 0000000000..c58a8a3cac
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/visualization.py
@@ -0,0 +1,657 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Methods to help with visualization of model outputs."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import cv2
+import matplotlib.collections as collections
+import matplotlib.colors as colors
+import matplotlib.pyplot as plt
+import numpy as np
+import torch
+import torch.nn.functional as F
+from PIL import Image
+from tqdm import tqdm
+
+import deeplabcut.core.visualization as visualization
+import deeplabcut.pose_estimation_pytorch.apis.utils as utils
+import deeplabcut.pose_estimation_pytorch.data as data
+import deeplabcut.pose_estimation_pytorch.data.preprocessor as preprocessor
+import deeplabcut.pose_estimation_pytorch.models as models
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.core.engine import Engine
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.utils import auxiliaryfunctions
+
+
+def create_labeled_images(
+ predictions: dict[str, dict[str, np.ndarray | np.ndarray]],
+ out_folder: str | Path,
+ pcutoff: float = 0.6,
+ bboxes_pcutoff: float = 0.6,
+ mode: str = "bodypart",
+ cmap: str | colors.Colormap = "rainbow",
+ dot_size: int = 12,
+ alpha_value: float = 0.7,
+ skeleton: list[tuple[int, int]] | None = None,
+ skeleton_color: str = "k",
+ close_figure_after_save: bool = True,
+):
+ """Plots model predictions on images.
+
+ Args:
+ predictions: The predictions to plot. A dictionary mapping image paths to
+ the predictions made by the model on that image. The predictions should
+ contain a "bodyparts" key, mapping to an array of shape (max_individuals,
+ num_bodyparts, 3) containing predicted bodyparts. If there are any unique
+ bodyparts predicted, then it should also contain a "unique_bodyparts" key,
+ mapping to an array of shape (1, num_bodyparts, 3) containing the predicted
+ unique bodyparts.
+ out_folder: The folder where model predictions should be saved.
+ pcutoff: The p-cutoff score above which predicted bodyparts are displayed with
+ a "⋅" marker, and below which they are displayed with a "X" marker.
+ bboxes_pcutoff: The bounding box cutoff score, below which predicted bounding
+ boxes are shown with a dashed line.
+ mode: One of "bodypart", "individual". Whether to color predictions by
+ bodypart or individual.
+ cmap: The colormap to use to plot predictions.
+ dot_size: The size of the bodypart prediction markers.
+ alpha_value: The transparency value of the bodypart prediction markers.
+ skeleton: If skeletons should be plotted, the list of bodyparts that constitute
+ the skeletons.
+ skeleton_color: The color with which to plot the skeleton, if one is given.
+ close_figure_after_save: Whether to close figures after saving the labeled
+ images to disk.
+ """
+ out_folder = Path(out_folder)
+ out_folder.mkdir(exist_ok=True)
+
+ color_by_individual = mode == "individual"
+ if isinstance(cmap, str):
+ cmap = plt.cm.get_cmap(cmap)
+
+ for image_path, image_predictions in predictions.items():
+ # Load frame
+ frame = Image.open(str(image_path))
+
+ # get pose predictions
+ pred = image_predictions["bodyparts"]
+ total_idv, total_bodyparts = pred.shape[:2]
+ unique_pred = None
+ if "unique_bodyparts" in image_predictions:
+ unique_pred = image_predictions["unique_bodyparts"][0]
+ total_idv += 1
+ total_bodyparts += len(unique_pred)
+
+ # create plot
+ fig, ax = plt.subplots()
+ ax.imshow(frame)
+
+ # plot bodyparts
+ for idx, pose in enumerate(pred):
+ xy, scores = pose[:, :2], pose[:, 2]
+ mask = scores > pcutoff
+ if np.sum(pose) < 0 or np.sum(mask) <= 0:
+ continue
+
+ bones = []
+ if skeleton is not None:
+ for idx_1, idx_2 in skeleton:
+ if scores[idx_1] > pcutoff and scores[idx_2] > pcutoff:
+ bones.append(xy[[idx_1, idx_2]])
+
+ kwargs = dict(s=dot_size)
+ if color_by_individual:
+ kwargs["c"] = cmap(idx / total_idv)
+ else:
+ c = np.linspace(0, 1, total_bodyparts)[: len(pose)][mask]
+ kwargs["c"] = c
+ kwargs["cmap"] = cmap
+
+ xy = xy[mask]
+ ax.scatter(xy[:, 0], xy[:, 1], **kwargs)
+ if len(bones) > 0:
+ ax.add_collection(collections.LineCollection(bones, colors=skeleton_color, alpha=alpha_value))
+
+ # plot unique bodyparts
+ if unique_pred is not None:
+ xy, scores = unique_pred[:, :2], unique_pred[:, 2]
+ mask = scores > pcutoff
+ if np.sum(mask) <= 0:
+ continue
+
+ kwargs = dict(s=dot_size)
+ if color_by_individual:
+ kwargs["c"] = cmap(1)
+ else:
+ c = np.linspace(0, 1, total_bodyparts)
+ kwargs["c"] = c[-len(unique_pred) :][mask]
+ kwargs["cmap"] = cmap
+
+ xy = xy[mask]
+ ax.scatter(xy[:, 0], xy[:, 1], **kwargs)
+
+ # plot bounding boxes
+ if "bboxes" in image_predictions and "bbox_scores" in image_predictions:
+ bboxes = image_predictions["bboxes"]
+ bbox_scores = image_predictions["bbox_scores"]
+ for idx, (bbox, score) in enumerate(zip(bboxes, bbox_scores, strict=True)):
+ if score <= bboxes_pcutoff:
+ continue
+
+ xmin, ymin, w, h = bbox
+ rect = plt.Rectangle((xmin, ymin), w, h, fill=False, edgecolor="green", linewidth=2)
+ ax.add_patch(rect)
+
+ # save predictions
+ output_path = out_folder / f"predictions_{Path(image_path).stem}.png"
+ fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
+ fig.savefig(output_path)
+
+ if close_figure_after_save:
+ plt.close(fig)
+
+ if close_figure_after_save:
+ plt.close()
+
+
+@torch.no_grad()
+def extract_model_outputs(
+ images: list[str] | list[Path],
+ model: models.PoseModel,
+ pre_processor: preprocessor.Preprocessor,
+ device: str = "auto",
+ context: list[dict[str, np.ndarray]] | None = None,
+) -> list[dict[str, np.ndarray]]:
+ """Obtains the outputs for a model for a list of images.
+
+ Args:
+ images: List of image paths for which to get model outputs.
+ model: The model for which to get model outputs.
+ pre_processor: The pre-processor used to prepare the images before giving them
+ to the model.
+ device: The device on which to run inference.
+ context: The context for each image to give to the pre-processor. For top-down
+ models, this context should contain the bounding boxes to use for each
+ image. This should be in a format:
+ [
+ {"bboxes": array of shape (num_bboxes, 4)}, # image 1 bboxes,
+ {"bboxes": array of shape (num_bboxes, 4)}, # image 2 bboxes,
+ ...,
+ {"bboxes": array of shape (num_bboxes, 4)}, # image N bboxes,
+ ]
+
+ Returns:
+ A list containing a dict for each input image, in the format:
+ {
+ inputs: a numpy array containing the inputs given to the model for the image
+ context: the context given alongside the image
+ outputs: a dict containing the model outputs
+ }
+ """
+ if context is not None and len(context) != len(images):
+ raise ValueError(
+ "When passing context along with the images (e.g. bounding boxes for "
+ "top-down models), there should be the same number of elements in the "
+ f"context as the number of images. Received {len(images)} images but "
+ f"{len(context)} contexts."
+ )
+
+ model = model.to(device)
+ model = model.eval()
+
+ model_data = []
+ for idx, image in enumerate(images):
+ image_context = {}
+ if context is not None:
+ image_context = context[idx]
+
+ inputs, image_context = pre_processor(image, image_context)
+ output = model(inputs.to(device))
+
+ for head, head_cfg in model.cfg["heads"].items():
+ if (
+ head_cfg["predictor"].get("apply_sigmoid", False)
+ or head_cfg["predictor"]["type"] == "PartAffinityFieldPredictor"
+ ):
+ if "heatmap" in output[head]:
+ output[head]["heatmap"] = F.sigmoid(output[head]["heatmap"])
+
+ output = {
+ head: {name: output.cpu().numpy() for name, output in head_outputs.items()}
+ for head, head_outputs in output.items()
+ }
+ model_data.append(dict(inputs=inputs.cpu().numpy(), context=context, outputs=output))
+
+ return model_data
+
+
+def extract_maps(
+ config,
+ shuffle: int = 0,
+ trainingsetindex: int | str = 0,
+ device: str | None = None,
+ rescale: bool = False,
+ indices: list[int] | None = None,
+ extract_paf: bool = True,
+ modelprefix: str | None = "",
+ snapshot_index: int | str | None = None,
+ detector_snapshot_index: int | str | None = None,
+) -> dict:
+ """Extracts the different maps output by DeepLabCut models, such as scoremaps,
+ location refinement fields and part-affinity fields.
+
+ Args:
+ config: Full path of the config.yaml file as a string.
+ shuffle: Index of the shuffle for which to extract maps
+ trainingset_index: Integer specifying which TrainingsetFraction to use. This
+ variable can also be set to "all".
+ rescale: Evaluate the model at the 'global_scale' variable (as set in the
+ test/pose_config.yaml file for a particular project). Every image will be
+ resized according to that scale and prediction will be compared to the
+ resized ground truth. The error will be reported in pixels at rescaled to
+ the *original* size. Example:
+ For a [200, 200] pixel image evaluated at ``global_scale=0.5``,
+ predictions are calculated on [100, 100] pixel images, compared to
+ ``0.5*ground truth`` and this error is then multiplied by 2!. The
+ evaluation images are also shown for the original size!
+ indices: Optionally, you can only obtain maps for a subset of images in your
+ dataset. The indices given here are the indices of the images for which
+ maps will be extracted.
+ modelprefix: Directory containing the deeplabcut models to use when evaluating
+ the network. By default, the models are assumed to exist in the project
+ folder.
+ snapshot_index: Index (starting at 0) of the snapshot we want to extract maps
+ with. To evaluate the last one, use -1. To extract maps for all snapshots,
+ use "all".
+ detector_snapshot_index: Only for TD models. If defined, uses the detector with
+ the given index for pose estimation. To extract maps for all detector
+ snapshots, use "all".
+
+ Returns:
+ a dict indexed by (trainingset_fraction, snapshot_index, image_index). For each
+ key, the item contains a tuple of:
+ (img, scmap, locref, paf, bpt_names, paf_graph, img_name, is_train)
+
+ Examples
+ --------
+ If you want to extract the data for image 0 and 103 (of the training set) for
+ model trained with shuffle 0.
+
+ >>> deeplabcut.extract_maps(config, 0, indices=[0, 103])
+ """
+ cfg = read_config_as_dict(config)
+
+ trainset_indices = [trainingsetindex]
+ if trainingsetindex == "all":
+ trainset_indices = [i for i in range(len(cfg["TrainingFraction"]))]
+ if snapshot_index is None:
+ snapshot_index = cfg["snapshotindex"]
+ if detector_snapshot_index is None:
+ detector_snapshot_index = cfg["detector_snapshotindex"]
+
+ extracted_maps = {}
+ for trainset_index in trainset_indices:
+ loader = data.DLCLoader(
+ config=config,
+ shuffle=shuffle,
+ trainset_index=trainset_index,
+ modelprefix=modelprefix,
+ )
+ extracted_maps[loader.train_fraction] = {}
+
+ # (img, scmap, locref, paf, bpt_names, paf_graph, img_name, is_train)
+ metadata = loader.model_cfg["metadata"]
+ bpt_names = metadata["bodyparts"] + metadata["unique_bodyparts"]
+ paf_graph = []
+ bpt_head_cfg = loader.model_cfg["model"]["heads"]["bodypart"]
+ if bpt_head_cfg["type"] == "DLCRNetHead":
+ paf_graph = bpt_head_cfg.get("predictor", {}).get("graph")
+ paf_indices = bpt_head_cfg.get("predictor", {}).get("edges_to_keep")
+ if paf_indices is not None:
+ paf_graph = [paf_graph[i] for i in paf_indices]
+
+ if device is not None:
+ loader.model_cfg["device"] = device
+ loader.model_cfg["device"] = utils.resolve_device(loader.model_cfg)
+ device = loader.model_cfg["device"]
+
+ if snapshot_index is None:
+ snapshot_index = -1
+ snapshots = utils.get_model_snapshots(snapshot_index, loader.model_folder, loader.pose_task)
+
+ image_paths = loader.df.index
+ if indices is not None:
+ image_paths = [image_paths[idx] for idx in indices]
+ if len(image_paths) > 0 and isinstance(image_paths[0], tuple):
+ image_paths = [Path(*img_path) for img_path in image_paths]
+
+ image_paths = [(loader.project_path / img_path).resolve() for img_path in image_paths]
+
+ context = _get_context(image_paths, loader, detector_snapshot_index, device)
+ train_idx = set(loader.split["train"])
+ for snapshot in snapshots:
+ snapshot_id = snapshot.path.stem
+ extracted_maps[loader.train_fraction][snapshot_id] = {}
+ runner = utils.get_pose_inference_runner(
+ model_config=loader.model_cfg,
+ snapshot_path=snapshot.path,
+ )
+ results = extract_model_outputs(
+ image_paths,
+ runner.model,
+ runner.preprocessor,
+ runner.device,
+ context=context,
+ )
+ for idx, result in enumerate(results):
+ image_idx = idx
+ if indices is not None:
+ image_idx = indices[idx]
+
+ # key can be just image_idx, or (image_idx, bbox_idx) for TD models
+ keys, images, outputs = _collect_model_outputs(loader.pose_task, result, image_idx)
+ for key, image, output in zip(keys, images, outputs, strict=False):
+ parsed = _parse_model_outputs(
+ image,
+ output,
+ strides={k: runner.model.get_stride(k) for k in runner.model.heads.keys()},
+ denormalize_image=True,
+ )
+ img_name = image_paths[idx].stem
+ if isinstance(key, tuple):
+ bbox_id = key[1]
+ img_name += f"_bbox{bbox_id:03d}"
+
+ is_train = image_idx in train_idx
+ extracted_maps[loader.train_fraction][snapshot_id][key] = (
+ *parsed,
+ None,
+ bpt_names,
+ paf_graph,
+ img_name,
+ is_train,
+ )
+
+ # img, scmap, locref, paf, peaks, bpt_names, paf_graph, img_name, is_train
+ return extracted_maps
+
+
+def extract_save_all_maps(
+ config: str | Path,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ comparison_bodyparts: str | list[str] = "all",
+ extract_paf: bool = True,
+ all_paf_in_one: bool = True,
+ device: str | None = None,
+ rescale: bool = False,
+ indices: list[int] | None = None,
+ modelprefix: str | None = "",
+ snapshot_index: int | str | None = None,
+ detector_snapshot_index: int | str | None = None,
+ dest_folder: str | Path | None = None,
+):
+ """Extracts the scoremap, location refinement field and part affinity field
+ prediction of the model. The maps will be rescaled to the size of the input image
+ and stored in the corresponding model folder in /evaluation-results-pytorch.
+
+ Args:
+ config: Full path of the config.yaml file as a string.
+ shuffle: Index of the shuffle for which to extract maps
+ trainingset_index: Integer specifying which TrainingsetFraction to use. This
+ variable can also be set to "all".
+ comparison_bodyparts: The average error will be computed for those body parts
+ only (Has to be a subset of the body parts).
+ extract_paf: Extract part affinity fields by default. Note that turning it off
+ will make the function much faster.
+ all_paf_in_one: By default, all part affinity fields are displayed on a single
+ frame. If false, individual fields are shown on separate frames.
+ indices: Optionally, you can only obtain maps for a subset of images in your
+ dataset. The indices given here are the indices of the images for which
+ maps will be extracted.
+ modelprefix: Directory containing the deeplabcut models to use when evaluating
+ the network. By default, the models are assumed to exist in the project
+ folder.
+ snapshot_index: Index (starting at 0) of the snapshot we want to extract maps
+ with. To evaluate the last one, use -1. To extract maps for all snapshots,
+ use "all".
+ detector_snapshot_index: Only for TD models. If defined, uses the detector with
+ the given index for pose estimation. To extract maps for all detector
+ snapshots, use "all".
+
+ Examples
+ --------
+ Calculated maps for images 0, 1 and 33.
+ >>> deeplabcut.extract_save_all_maps(
+ >>> "/analysis/project/reaching-task/config.yaml",
+ >>> shuffle=1,
+ >>> indices=[0, 1, 33]
+ >>> )
+ """
+ cfg = read_config_as_dict(config)
+ maps = extract_maps(
+ config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ device=device,
+ rescale=rescale,
+ indices=indices,
+ snapshot_index=snapshot_index,
+ detector_snapshot_index=detector_snapshot_index,
+ modelprefix=modelprefix,
+ )
+ bpts_to_plot = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparison_bodyparts)
+
+ print("Saving plots...")
+ for frac, values in maps.items():
+ dest_folder = _get_maps_folder(cfg, frac, shuffle, modelprefix, dest_folder)
+ dest_folder.mkdir(exist_ok=True)
+ for snap, maps in values.items():
+ for image_idx, image_maps in tqdm(maps.items()):
+ (
+ image,
+ scmap,
+ locref,
+ paf,
+ peaks,
+ bpt_names,
+ paf_graph,
+ image_path,
+ training_image,
+ ) = image_maps
+
+ if not extract_paf:
+ paf = []
+
+ label = "train" if training_image else "test"
+ img_w, img_h = image.shape[1], image.shape[0]
+ scmap = _prepare_maps_for_plotting(scmap, (img_w, img_h))
+ if scmap is None:
+ raise ValueError("Cannot plot heatmaps - none output by the model")
+
+ locref = _prepare_maps_for_plotting(locref, (img_w, img_h))
+ if locref is not None:
+ locref = locref.reshape((img_h, img_w, -1, 2))
+ paf = _prepare_maps_for_plotting(paf, (img_w, img_h))
+
+ visualization.generate_model_output_plots(
+ output_folder=dest_folder,
+ image_name=Path(image_path).stem,
+ bodypart_names=bpt_names,
+ bodyparts_to_plot=bpts_to_plot,
+ image=image,
+ scmap=scmap,
+ locref=locref,
+ paf=paf,
+ paf_graph=paf_graph,
+ paf_all_in_one=all_paf_in_one,
+ paf_colormap=cfg["colormap"],
+ output_suffix=f"{label}_{shuffle}_{frac}_{snap}",
+ )
+
+
+def _get_context(
+ image_paths: list[Path],
+ loader: data.Loader,
+ detector_snapshot_index: int | str | None,
+ device: str,
+) -> list[dict] | None:
+ """Gets the context for top-down pose estimation models."""
+ if loader.pose_task != Task.TOP_DOWN:
+ return None
+
+ det_snapshots = []
+ if detector_snapshot_index is not None:
+ det_snapshots = utils.get_model_snapshots(detector_snapshot_index, loader.model_folder, Task.DETECT)
+
+ if detector_snapshot_index is None or len(det_snapshots) == 0:
+ if detector_snapshot_index is None:
+ print("No ``detector_snapshot_index`` given.")
+ else:
+ print(f"No detector snapshots found in {loader.model_folder}")
+ print("Using GT bboxes to extract maps for this top-down model")
+
+ bboxes_train = loader.ground_truth_bboxes(mode="train")
+ bboxes_test = loader.ground_truth_bboxes(mode="test")
+ bboxes = {**bboxes_train, **bboxes_test}
+ return [dict(bboxes=bboxes[str(img_path)]["bboxes"]) for img_path in image_paths]
+
+ detector_runner = utils.get_detector_inference_runner(
+ model_config=loader.model_cfg,
+ snapshot_path=det_snapshots[-1].path,
+ device=device,
+ )
+ return detector_runner.inference(image_paths)
+
+
+def _collect_model_outputs(
+ task: Task,
+ result: dict,
+ image_idx: int,
+) -> tuple[list, list, list]:
+ """Collects the model outputs into data that can be processed.
+
+ Args:
+ task: Whether the model is a bottom-up or top-down model.
+ result: A result output by ``extract_model_outputs``.
+ image_idx: The index of the image
+
+ Returns: keys, images, outputs
+ keys: The key for each image to plot.
+ images: The images to plot for this input image (a single image for bottom-up
+ models, and the number of bounding boxes for top-down models).
+ outputs: The model outputs for each image.
+ """
+ if task == Task.TOP_DOWN:
+ keys, images, outputs = [], [], []
+
+ # parse each input individually
+ num_bboxes = len(result["inputs"])
+ for bbox_idx in range(num_bboxes):
+ keys.append((image_idx, bbox_idx))
+ images.append(result["inputs"][bbox_idx])
+ outputs.append(
+ {
+ head: {k: v[bbox_idx] for k, v in head_outputs.items()}
+ for head, head_outputs in result["outputs"].items()
+ }
+ )
+ return keys, images, outputs
+
+ # remove batch dimension
+ return (
+ [image_idx],
+ [result["inputs"][0]],
+ [{head: {k: v[0] for k, v in head_outputs.items()} for head, head_outputs in result["outputs"].items()}],
+ )
+
+
+def _parse_model_outputs(
+ image: np.ndarray,
+ outputs: dict[str, dict[str, np.ndarray]],
+ strides: dict[str, int],
+ denormalize_image: bool = True,
+) -> tuple[np.ndarray, list[np.ndarray], list[np.ndarray], list[np.ndarray]]:
+ """Parses the model outputs into a format that can easily be plotted.
+
+ Args:
+ image: The image used to obtain the outputs.
+ outputs: The model outputs.
+ strides: The total stride for each model head.
+ denormalize_image: Whether the image was normalized and should be de-normalized.
+
+ Returns: (img, scmap, locref, paf)
+ img: The (de-normalized) image used as input.
+ scmap: The score maps output by the model.
+ locref: The locref fields output by the model.
+ paf: The part-affinity fields output by the model.
+ """
+ image = image.transpose((1, 2, 0))
+ if denormalize_image:
+ image = image * np.array([0.229, 0.224, 0.225])
+ image = image + np.array([0.485, 0.456, 0.406])
+ image = np.clip(image, 0, 1)
+
+ heatmaps = [h for h in outputs["bodypart"].get("heatmap", [])]
+ locrefs = [m * strides["bodypart"] for m in outputs["bodypart"].get("locref", [])]
+ paf = [p for p in outputs["bodypart"].get("paf", [])]
+
+ if "unique_bodypart" in outputs:
+ heatmaps += [h for h in outputs["unique_bodypart"].get("heatmap", [])]
+ locrefs += [strides["unique_bodypart"] * m for m in outputs["unique_bodypart"].get("locref", [])]
+
+ return image, heatmaps, locrefs, paf
+
+
+def _prepare_maps_for_plotting(maps: list[np.ndarray], image_size: tuple[int, int]) -> np.ndarray | None:
+ """Resizes all maps to the image size and concatenates them into a single array.
+
+ Args:
+ maps: The maps that will be shown on the image.
+ image_size: The (width, height) of the input image.
+
+ Returns:
+ The resized maps, or None if the list of maps was empty.
+ """
+ if len(maps) == 0:
+ return None
+
+ img_w, img_h = image_size
+ return np.stack(
+ [cv2.resize(map_, (img_w, img_h), interpolation=cv2.INTER_LINEAR) for map_ in maps],
+ axis=-1,
+ )
+
+
+def _get_maps_folder(
+ cfg: dict,
+ train_frac: float,
+ shuffle: int,
+ model_prefix: str | None,
+ dest_folder: str | Path | None,
+) -> Path:
+ """Gets the destination folder for output maps."""
+ if dest_folder is None:
+ project_path = Path(cfg["project_path"])
+ eval_folder = auxiliaryfunctions.get_evaluation_folder(
+ trainFraction=train_frac,
+ shuffle=shuffle,
+ cfg=cfg,
+ engine=Engine.PYTORCH,
+ modelprefix=model_prefix,
+ )
+ dest_folder = project_path / eval_folder / "maps"
+
+ return Path(dest_folder)
diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py b/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py
new file mode 100644
index 0000000000..117d127147
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/benchmark/__init__.py
@@ -0,0 +1,10 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py
new file mode 100644
index 0000000000..a2f8ad408b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/benchmark/profile_HRNetCoAM.py
@@ -0,0 +1,20 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+# Script for reproducing results in Zhou* & Stoffl* et al. for BUCTD with CoAM
+
+# path=datapath
+# results=resultspath or put numbers
+
+# train model
+
+# evaluate and
+# check if predicted is close to result
diff --git a/deeplabcut/pose_estimation_pytorch/config/__init__.py b/deeplabcut/pose_estimation_pytorch/config/__init__.py
new file mode 100644
index 0000000000..5ff726c436
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/__init__.py
@@ -0,0 +1,29 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+# For backwards compatibility
+from deeplabcut.core.config import (
+ pretty_print,
+ read_config_as_dict,
+ write_config,
+)
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ make_basic_project_config,
+ make_pytorch_pose_config,
+ make_pytorch_test_config,
+)
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ available_detectors,
+ available_models,
+ is_model_cond_top_down,
+ is_model_top_down,
+ update_config,
+ update_config_by_dotpath,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml b/deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml
new file mode 100644
index 0000000000..4c45a347d5
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/animaltokenpose/animaltokenpose_base.yaml
@@ -0,0 +1,49 @@
+# TODO: This default configuration file needs to be reviewed so it matches the original
+ # base TokenPose configuration, as defined in
+ # https://github.com/leeyegy/TokenPose/blob/main/experiments/coco/tokenpose/tokenpose_b_256_192_patch43_dim192_depth12_heads8.yaml
+method: td # Need to add a detector
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ backbone_output_channels: 32
+ neck:
+ type: Transformer
+ feature_size:
+ - 64
+ - 64
+ patch_size:
+ - 4
+ - 4
+ num_keypoints: "num_bodyparts"
+ channels: 32
+ dim: 192
+ heads: 8
+ depth: 6
+ heads:
+ bodypart:
+ type: TransformerHead
+ target_generator:
+ type: HeatmapPlateauGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: false
+ criterion:
+ type: WeightedBCECriterion
+ predictor:
+ type: HeatmapPredictor
+ location_refinement: false
+ dim: 192
+ hidden_heatmap_dim: 384
+ heatmap_dim: 4096
+ apply_multi: true
+ heatmap_size:
+ - 64
+ - 64
+ apply_init: true
+ head_stride: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_m.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_m.yaml
new file mode 100644
index 0000000000..bc2ce2591c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_m.yaml
@@ -0,0 +1,19 @@
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_m
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.67
+ widen_factor: 0.75
+ backbone_output_channels: 768
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 5e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_s.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_s.yaml
new file mode 100644
index 0000000000..f797246437
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_s.yaml
@@ -0,0 +1,19 @@
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_s
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.33
+ widen_factor: 0.5
+ backbone_output_channels: 512
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 5e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_x.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_x.yaml
new file mode 100644
index 0000000000..1ee65785a4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/cspnext_x.yaml
@@ -0,0 +1,19 @@
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_x
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 1.33
+ widen_factor: 1.25
+ backbone_output_channels: 1280
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 5e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml
new file mode 100644
index 0000000000..2bbb35ad76
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w18.yaml
@@ -0,0 +1,18 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+ train:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w18
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ backbone_output_channels: 18
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml
new file mode 100644
index 0000000000..a2e1a21cf6
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w32.yaml
@@ -0,0 +1,18 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+ train:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ backbone_output_channels: 32
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml
new file mode 100644
index 0000000000..9941090c53
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/hrnet_w48.yaml
@@ -0,0 +1,18 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+ train:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w48
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ backbone_output_channels: 48
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml
new file mode 100644
index 0000000000..de9ed663f3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_101.yaml
@@ -0,0 +1,18 @@
+model:
+ backbone:
+ type: ResNet
+ model_name: resnet101
+ output_stride: 16
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ backbone_output_channels: 2048
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 5e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
diff --git a/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml
new file mode 100644
index 0000000000..21298e6cbd
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/backbones/resnet_50.yaml
@@ -0,0 +1,18 @@
+model:
+ backbone:
+ type: ResNet
+ model_name: resnet50_gn
+ output_stride: 16
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ backbone_output_channels: 2048
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 5e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml b/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml
new file mode 100644
index 0000000000..4e8836c4ca
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/aug_default.yaml
@@ -0,0 +1,18 @@
+bbox_margin: 20
+colormode: RGB
+inference:
+ normalize_images: true
+train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [0.5, 1.25]
+ translation: 0
+ crop_sampling:
+ width: 448
+ height: 448
+ max_shift: 0.1
+ method: hybrid
+ gaussian_noise: 12.75
+ motion_blur: true
+ normalize_images: true
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml
new file mode 100644
index 0000000000..3030925d37
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/aug_top_down.yaml
@@ -0,0 +1,19 @@
+bbox_margin: 20
+colormode: RGB
+inference:
+ normalize_images: true
+ top_down_crop:
+ width: 256
+ height: 256
+train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [1.0, 1.0]
+ translation: 0
+ gaussian_noise: 12.75
+ motion_blur: true
+ normalize_images: true
+ top_down_crop:
+ width: 256
+ height: 256
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml
new file mode 100644
index 0000000000..93d751bcf9
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/base.yaml
@@ -0,0 +1,28 @@
+device: auto
+method: bu
+runner:
+ type: PoseTrainingRunner
+ gpus: null
+ key_metric: "test.mAP"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0001
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-5 ], [ 1e-6 ] ]
+ milestones: [ 160, 190 ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 8
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 200
+ seed: 42
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/base_detector.yaml b/deeplabcut/pose_estimation_pytorch/config/base/base_detector.yaml
new file mode 100644
index 0000000000..ef3fbf656c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/base_detector.yaml
@@ -0,0 +1,45 @@
+data:
+ colormode: RGB
+ inference:
+ normalize_images: true
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [ 1.0, 1.0 ]
+ translation: 40
+ collate:
+ type: ResizeFromDataSizeCollate
+ min_scale: 0.4
+ max_scale: 1.0
+ min_short_side: 128
+ max_short_side: 1152
+ multiple_of: 32
+ to_square: false
+ hflip: true
+ normalize_images: true
+device: auto
+runner:
+ type: DetectorTrainingRunner
+ key_metric: "test.mAP@50:95"
+ key_metric_asc: true
+ eval_interval: 10
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-4
+ scheduler:
+ type: LRListScheduler
+ params:
+ milestones: [ 160 ]
+ lr_list: [ [ 1e-5 ] ]
+ snapshots:
+ max_snapshots: 5
+ save_epochs: 25
+ save_optimizer_state: false
+train_settings:
+ batch_size: 1
+ dataloader_workers: 0
+ dataloader_pin_memory: false
+ display_iters: 500
+ epochs: 250
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml
new file mode 100644
index 0000000000..04501208b2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts.yaml
@@ -0,0 +1,39 @@
+type: HeatmapHead
+weight_init: normal
+predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ gradient_masking: false
+ generate_locref: true
+ locref_std: 7.2801
+criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+heatmap_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_bodyparts"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+locref_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_bodyparts x 2"
+ kernel_size:
+ - 3
+ strides:
+ - 2
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml
new file mode 100644
index 0000000000..183a8ad260
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/head_bodyparts_with_paf.yaml
@@ -0,0 +1,62 @@
+type: DLCRNetHead
+predictor:
+ type: PartAffinityFieldPredictor
+ num_animals: "num_individuals"
+ num_multibodyparts: "num_bodyparts"
+ num_uniquebodyparts: 0
+ nms_radius: 5
+ sigma: 1.0
+ locref_stdev: 7.2801
+ min_affinity: 0.05
+ graph: "paf_graph"
+ edges_to_keep: "paf_edges_to_keep"
+ apply_sigmoid: true
+ clip_scores: false
+target_generator:
+ type: SequentialGenerator
+ generators:
+ - type: HeatmapPlateauGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ gradient_masking: false
+ generate_locref: true
+ locref_std: 7.2801
+ - type: PartAffinityFieldGenerator
+ graph: "paf_graph"
+ width: 20
+criterion:
+ heatmap:
+ type: WeightedBCECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ paf:
+ type: WeightedHuberCriterion
+ weight: 0.1
+heatmap_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_bodyparts"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+locref_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_bodyparts x 2"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+paf_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_limbs x 2" # num_limbs = len(graph)
+ kernel_size:
+ - 3
+ strides:
+ - 2
+num_stages: 5
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml
new file mode 100644
index 0000000000..eb9c253929
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/head_identity.yaml
@@ -0,0 +1,23 @@
+type: HeatmapHead
+predictor:
+ type: IdentityPredictor
+ apply_sigmoid: true
+target_generator:
+ type: HeatmapPlateauGenerator
+ num_heatmaps: "num_individuals"
+ pos_dist_thresh: 17
+ heatmap_mode: INDIVIDUAL
+ gradient_masking: false
+ generate_locref: false
+criterion:
+ heatmap:
+ type: WeightedBCECriterion
+ weight: 1.0
+heatmap_config:
+ channels:
+ - "backbone_output_channels"
+ - "num_individuals"
+ kernel_size:
+ - 3
+ strides:
+ - 2
diff --git a/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml
new file mode 100644
index 0000000000..57d5fa483d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/base/head_topdown.yaml
@@ -0,0 +1,40 @@
+type: HeatmapHead
+weight_init: normal
+predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ gradient_masking: true
+ background_weight: 0.0
+ generate_locref: true
+ locref_std: 7.2801
+criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+heatmap_config:
+ channels:
+ - "backbone_output_channels"
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+locref_config:
+ channels:
+ - "backbone_output_channels"
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w32.yaml
new file mode 100644
index 0000000000..e19e774bec
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w32.yaml
@@ -0,0 +1,71 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: HRNetCoAM
+ base_model_name: hrnet_w32
+ pretrained: true
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ coam_modules: [2,]
+ channel_att_only: false
+ att_heads: 1
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 32
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 32
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+ locref_config:
+ channels:
+ - 32
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48.yaml
new file mode 100644
index 0000000000..b3b487390a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48.yaml
@@ -0,0 +1,71 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: HRNetCoAM
+ base_model_name: hrnet_w48
+ pretrained: true
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ coam_modules: [2,]
+ channel_att_only: false
+ att_heads: 1
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 48
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+ locref_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48_human.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48_human.yaml
new file mode 100644
index 0000000000..f43438a3e7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_coam_w48_human.yaml
@@ -0,0 +1,72 @@
+data:
+ bbox_margin: 5
+ gen_sampling:
+ keypoint_sigmas: [ .079, .079, .072, .072, .062, .062, .107, .107, .087, .087, .089, .089, .025, .025 ] # CrowdPose sigmas
+ keypoints_symmetry: [ [ 0, 1 ], [ 2, 3 ], [ 4, 5 ], [ 6, 7 ], [ 8, 9 ], [ 10, 11 ] ] # CrowdPose symmetries
+ inference:
+ top_down_crop:
+ width: 288
+ height: 384
+ crop_with_context: true
+ train:
+ top_down_crop:
+ width: 288
+ height: 384
+ crop_with_context: true
+method: ctd
+model:
+ backbone:
+ type: HRNetCoAM
+ base_model_name: hrnet_w48
+ pretrained: true
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ coam_modules: [2,]
+ channel_att_only: false
+ att_heads: 1
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [384, 288]
+ backbone_output_channels: 48
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+ locref_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w32.yaml
new file mode 100644
index 0000000000..1115877f48
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w32.yaml
@@ -0,0 +1,71 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 32
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 32
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+ locref_config:
+ channels:
+ - 32
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w48.yaml
new file mode 100644
index 0000000000..36cbaa4305
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_hrnet_w48.yaml
@@ -0,0 +1,71 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: HRNet
+ model_name: hrnet_w48
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ interpolate_branches: false
+ increased_channel_count: false # changes backbone_output_channels to 128 when true
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 48
+ heads:
+ bodypart:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ apply_sigmoid: false
+ clip_scores: true
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ heatmap_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts"
+ kernel_size: 1
+ locref_config:
+ channels:
+ - 48
+ kernel_size: []
+ strides: []
+ final_conv:
+ out_channels: "num_bodyparts x 2"
+ kernel_size: 1
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_m.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_m.yaml
new file mode 100644
index 0000000000..88b708cc39
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_m.yaml
@@ -0,0 +1,95 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_m
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.67
+ widen_factor: 0.75
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 768
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [256, 256]
+ smoothing_type: gaussian
+ sigma: [5.66, 5.66]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [5.66, 5.66]
+ decode_beta: 150.0
+ input_size: [256, 256]
+ in_channels: 768
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [8, 8] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list:
+ - - 1e-3
+ - - 1e-4
+ - - 1e-5
+ milestones:
+ - 5
+ - 300
+ - 360
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_s.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_s.yaml
new file mode 100644
index 0000000000..6ae8b5364a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_s.yaml
@@ -0,0 +1,95 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 256
+ height: 256
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_s
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.33
+ widen_factor: 0.5
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [256, 256]
+ backbone_output_channels: 512
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [256, 256]
+ smoothing_type: gaussian
+ sigma: [5.66, 5.66]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [5.66, 5.66]
+ decode_beta: 150.0
+ input_size: [256, 256]
+ in_channels: 512
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [8, 8] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list:
+ - - 1e-3
+ - - 1e-4
+ - - 1e-5
+ milestones:
+ - 5
+ - 300
+ - 360
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x.yaml
new file mode 100644
index 0000000000..f809a0c569
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x.yaml
@@ -0,0 +1,95 @@
+data:
+ bbox_margin: 25
+ gen_sampling:
+ keypoint_sigmas: 0.1
+ inference:
+ top_down_crop:
+ width: 384
+ height: 384
+ crop_with_context: false
+ train:
+ top_down_crop:
+ width: 384
+ height: 384
+ crop_with_context: false
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_x
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 1.33
+ widen_factor: 1.25
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [384, 384]
+ backbone_output_channels: 1280
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [384, 384]
+ smoothing_type: gaussian
+ sigma: [6.93, 6.93]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [6.93, 6.93]
+ decode_beta: 150.0
+ input_size: [384, 384]
+ in_channels: 1280
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [12, 12] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list:
+ - - 1e-3
+ - - 1e-4
+ - - 1e-5
+ milestones:
+ - 5
+ - 300
+ - 360
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x_human.yaml b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x_human.yaml
new file mode 100644
index 0000000000..1d47cb3306
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd/ctd_prenet_rtmpose_x_human.yaml
@@ -0,0 +1,96 @@
+data:
+ bbox_margin: 5
+ gen_sampling:
+ keypoint_sigmas: [ .079, .079, .072, .072, .062, .062, .107, .107, .087, .087, .089, .089, .025, .025 ] # CrowdPose sigmas
+ keypoints_symmetry: [ [ 0, 1 ], [ 2, 3 ], [ 4, 5 ], [ 6, 7 ], [ 8, 9 ], [ 10, 11 ] ] # CrowdPose symmetries
+ inference:
+ top_down_crop:
+ width: 288
+ height: 384
+ crop_with_context: true
+ train:
+ top_down_crop:
+ width: 288
+ height: 384
+ crop_with_context: true
+method: ctd
+model:
+ backbone:
+ type: CondPreNet
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_x
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 1.33
+ widen_factor: 1.25
+ kpt_encoder:
+ type: StackedKeypointEncoder
+ num_joints: "num_bodyparts"
+ kernel_size: [15, 15]
+ img_size: [384, 288]
+ backbone_output_channels: 1280
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [288, 384]
+ smoothing_type: gaussian
+ sigma: [6., 6.93]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [6., 6.93]
+ decode_beta: 150.0
+ input_size: [288, 384]
+ in_channels: 1280
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [9, 12] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-5
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list:
+ - - 1e-3
+ - - 1e-4
+ - - 1e-5
+ milestones:
+ - 5
+ - 300
+ - 360
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml
new file mode 100644
index 0000000000..f116963677
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w18.yaml
@@ -0,0 +1,68 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w18
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ interpolate_branches: true
+ increased_channel_count: false
+ backbone_output_channels: 270
+ heads:
+ bodypart:
+ type: DEKRHead
+ weight_init: dekr
+ target_generator:
+ type: DEKRGenerator
+ num_joints: "num_bodyparts"
+ pos_dist_thresh: 17
+ bg_weight: 0.1
+ criterion:
+ heatmap:
+ type: DEKRHeatmapLoss
+ weight: 1
+ offset:
+ type: DEKROffsetLoss
+ weight: 0.03
+ predictor:
+ type: DEKRPredictor
+ apply_sigmoid: false
+ use_heatmap: false
+ clip_scores: true
+ num_animals: "num_individuals"
+ keypoint_score_type: combined
+ max_absorb_distance: 75
+ nms_threshold: 0.05
+ apply_pose_nms: true
+ heatmap_config:
+ channels:
+ - 270
+ - 18
+ - "num_bodyparts + 1" # num_bodyparts + center keypoint
+ num_blocks: 1
+ dilation_rate: 1
+ final_conv_kernel: 1
+ offset_config:
+ channels:
+ - 270
+ - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt
+ - "num_bodyparts"
+ num_offset_per_kpt: 15
+ num_blocks: 2
+ dilation_rate: 1
+ final_conv_kernel: 1
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0005
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
+with_center_keypoints: true
diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml
new file mode 100644
index 0000000000..675347ac5b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w32.yaml
@@ -0,0 +1,68 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ interpolate_branches: true
+ increased_channel_count: false
+ backbone_output_channels: 480
+ heads:
+ bodypart:
+ type: DEKRHead
+ weight_init: dekr
+ target_generator:
+ type: DEKRGenerator
+ num_joints: "num_bodyparts"
+ pos_dist_thresh: 17
+ bg_weight: 0.1
+ criterion:
+ heatmap:
+ type: DEKRHeatmapLoss
+ weight: 1
+ offset:
+ type: DEKROffsetLoss
+ weight: 0.03
+ predictor:
+ type: DEKRPredictor
+ apply_sigmoid: false
+ use_heatmap: false
+ clip_scores: true
+ num_animals: "num_individuals"
+ keypoint_score_type: combined
+ max_absorb_distance: 75
+ nms_threshold: 0.05
+ apply_pose_nms: true
+ heatmap_config:
+ channels:
+ - 480
+ - 32
+ - "num_bodyparts + 1" # num_bodyparts + center keypoint
+ num_blocks: 1
+ dilation_rate: 1
+ final_conv_kernel: 1
+ offset_config:
+ channels:
+ - 480
+ - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt
+ - "num_bodyparts"
+ num_offset_per_kpt: 15
+ num_blocks: 2
+ dilation_rate: 1
+ final_conv_kernel: 1
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0005
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
+with_center_keypoints: true
diff --git a/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml
new file mode 100644
index 0000000000..789aee9f82
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/dekr/dekr_w48.yaml
@@ -0,0 +1,68 @@
+data:
+ inference:
+ auto_padding: # Required for HRNet backbones
+ pad_width_divisor: 32
+ pad_height_divisor: 32
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w48
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ interpolate_branches: true
+ increased_channel_count: false
+ backbone_output_channels: 720
+ heads:
+ bodypart:
+ type: DEKRHead
+ weight_init: dekr
+ target_generator:
+ type: DEKRGenerator
+ num_joints: "num_bodyparts"
+ pos_dist_thresh: 17
+ bg_weight: 0.1
+ criterion:
+ heatmap:
+ type: DEKRHeatmapLoss
+ weight: 1
+ offset:
+ type: DEKROffsetLoss
+ weight: 0.03
+ predictor:
+ type: DEKRPredictor
+ apply_sigmoid: false
+ use_heatmap: false
+ clip_scores: true
+ num_animals: "num_individuals"
+ keypoint_score_type: combined
+ max_absorb_distance: 75
+ nms_threshold: 0.05
+ apply_pose_nms: true
+ heatmap_config:
+ channels:
+ - 720
+ - 48
+ - "num_bodyparts + 1" # num_bodyparts + center keypoint
+ num_blocks: 1
+ dilation_rate: 1
+ final_conv_kernel: 1
+ offset_config:
+ channels:
+ - 720
+ - "num_bodyparts x 15" # num_bodyparts * num_offset_per_kpt
+ - "num_bodyparts"
+ num_offset_per_kpt: 15
+ num_blocks: 2
+ dilation_rate: 1
+ final_conv_kernel: 1
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0005
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-4 ], [ 1e-5 ] ]
+ milestones: [ 90, 120 ]
+with_center_keypoints: true
diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml
new file mode 100644
index 0000000000..3d07eb41c0
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_mobilenet_v3_large_fpn.yaml
@@ -0,0 +1,5 @@
+model:
+ type: FasterRCNN
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ variant: fasterrcnn_mobilenet_v3_large_fpn
diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml
new file mode 100644
index 0000000000..53711c6810
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/detectors/fasterrcnn_resnet50_fpn_v2.yaml
@@ -0,0 +1,5 @@
+model:
+ type: FasterRCNN
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+ variant: fasterrcnn_resnet50_fpn_v2
diff --git a/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml b/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml
new file mode 100644
index 0000000000..c0357b34a0
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/detectors/ssdlite.yaml
@@ -0,0 +1,6 @@
+model:
+ type: SSDLite
+ freeze_bn_stats: true
+ freeze_bn_weights: false
+train_settings:
+ batch_size: 16
diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml
new file mode 100644
index 0000000000..6b9036c6ce
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride16_ms5.yaml
@@ -0,0 +1,72 @@
+model:
+ backbone:
+ type: DLCRNet
+ model_name: resnet50
+ pretrained: true
+ output_stride: 16
+ backbone_output_channels: 2304
+ pose_model:
+ stride: 8
+ heads:
+ bodypart:
+ type: DLCRNetHead
+ predictor:
+ type: PartAffinityFieldPredictor
+ num_animals: "num_individuals"
+ num_multibodyparts: "num_bodyparts"
+ num_uniquebodyparts: 0
+ nms_radius: 5
+ sigma: 1.0
+ locref_stdev: 7.2801
+ min_affinity: 0.05
+ graph: "paf_graph"
+ edges_to_keep: "paf_edges_to_keep"
+ target_generator:
+ type: SequentialGenerator
+ generators:
+ - type: HeatmapPlateauGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ - type: PartAffinityFieldGenerator
+ graph: "paf_graph"
+ width: 20
+ criterion:
+ heatmap:
+ type: WeightedBCECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ paf:
+ type: WeightedHuberCriterion
+ weight: 0.1
+ heatmap_config:
+ channels:
+ - 2304
+ - "num_bodyparts"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+ locref_config:
+ channels:
+ - 2304
+ - "num_bodyparts x 2"
+ kernel_size:
+ - 3
+ strides:
+ - 2
+ paf_config:
+ channels:
+ - 2304
+ - "num_limbs x 2" # num_limbs = len(graph)
+ kernel_size:
+ - 3
+ strides:
+ - 2
+ num_stages: 5
+runner:
+ eval_interval: 25 # slow evaluation with poor Part-Affinity fields
diff --git a/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml
new file mode 100644
index 0000000000..26ec928ee7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/dlcrnet/dlcrnet_stride32_ms5.yaml
@@ -0,0 +1,81 @@
+model:
+ backbone:
+ type: DLCRNet
+ model_name: resnet50
+ pretrained: true
+ output_stride: 32
+ backbone_output_channels: 2304
+ pose_model:
+ stride: 8
+ heads:
+ bodypart:
+ type: DLCRNetHead
+ predictor:
+ type: PartAffinityFieldPredictor
+ num_animals: "num_individuals"
+ num_multibodyparts: "num_bodyparts"
+ num_uniquebodyparts: 0
+ nms_radius: 5
+ sigma: 1.0
+ locref_stdev: 7.2801
+ min_affinity: 0.05
+ graph: "paf_graph"
+ edges_to_keep: "paf_edges_to_keep"
+ target_generator:
+ type: SequentialGenerator
+ generators:
+ - type: HeatmapPlateauGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ heatmap_mode: KEYPOINT
+ generate_locref: true
+ locref_std: 7.2801
+ - type: PartAffinityFieldGenerator
+ graph: "paf_graph"
+ width: 20
+ criterion:
+ heatmap:
+ type: WeightedBCECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+ paf:
+ type: WeightedHuberCriterion
+ weight: 0.1
+ heatmap_config:
+ channels:
+ - 2304
+ - 1152
+ - "num_bodyparts"
+ kernel_size:
+ - 3
+ - 3
+ strides:
+ - 2
+ - 2
+ locref_config:
+ channels:
+ - 2304
+ - 1152
+ - "num_bodyparts x 2"
+ kernel_size:
+ - 3
+ - 3
+ strides:
+ - 2
+ - 2
+ paf_config:
+ channels:
+ - 2304
+ - 1152
+ - "num_limbs x 2" # num_limbs = len(graph)
+ kernel_size:
+ - 3
+ - 3
+ strides:
+ - 2
+ - 2
+ num_stages: 5
+runner:
+ eval_interval: 25 # slow evaluation with poor Part-Affinity fields
diff --git a/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py
new file mode 100644
index 0000000000..3cf316dd15
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py
@@ -0,0 +1,583 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Methods to create the configuration files for PyTorch DeepLabCut models."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+from deeplabcut.core.config import read_config_as_dict, write_config
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ get_config_folder_path,
+ load_backbones,
+ load_base_config,
+ replace_default_values,
+ update_config,
+)
+from deeplabcut.pose_estimation_pytorch.runners.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+
+
+def make_pytorch_pose_config(
+ project_config: dict,
+ pose_config_path: str | Path,
+ net_type: str | None = None,
+ top_down: bool = False,
+ detector_type: str | None = None,
+ weight_init: WeightInitialization | None = None,
+ save: bool = False,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
+) -> dict:
+ """Creates a PyTorch pose configuration file for a DeepLabCut project.
+
+ The base/ folder contains default configurations, such as data augmentations or
+ heatmap heads (that can be used to predict pose or identity based on visual
+ features). These files are used to create pose model configurations.
+
+ All available backbone configurations are stored in the backbones/ folder.
+ - any backbone can be a single animal model with a heatmap head added on top
+ - any backbone can be a top-down model with a detector and a heatmap head
+ - any backbone can be a bottom-up model with a detector and a heatmap + PAF head
+
+ All other model architectures have their own folders, with different variants
+ available. Top-down model architectures must specify `method: TD` in their
+ configuration files, from which this method adds a backbone configuration.
+
+ Placeholder values (such as `num_bodyparts` or `num_individuals`) are filled in
+ based on the project config file.
+
+ Args:
+ project_config: the DeepLabCut project config
+ pose_config_path: the path where the pytorch pose configuration will be saved
+ net_type: the architecture of the desired pose estimation model
+ top_down: when the net_type is a backbone, whether to create a top-down model
+ by associating a detector to the pose model. Required for multi-animal
+ projects when net_type is a backbone (as a backbone + heatmap head can only
+ predict pose for single individuals).
+ detector_type: for top-down pose models, the architecture of the desired object
+ detection model
+ weight_init: Specify how model weights should be initialized. If None, ImageNet
+ pretrained weights from Timm will be loaded when training.
+ save: Whether to save the model configuration file to the ``pose_config_path``.
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] , optional, default = None,
+ If using a conditional-top-down (CTD) net_type, this argument needs to be specified.
+ It defines the conditions that will be used with the CTD model.
+ It can be either:
+ * A shuffle number (ctd_conditions: int), which must correspond to a bottom-up (BU) network type.
+ * A predictions file path (ctd_conditions: string | Path), which must correspond to a .json or .h5
+ predictions file.
+ * A shuffle number and a particular snapshot (ctd_conditions: tuple[int, str] | tuple[int, int]), which
+ respectively correspond to a bottom-up (BU) network type and a particular snapshot name or index.
+
+
+ Returns:
+ the PyTorch pose configuration file
+ """
+ multianimal_project = project_config.get("multianimalproject", False)
+ individuals = project_config.get("individuals", ["single"])
+ with_identity = project_config.get("identity")
+ bodyparts = auxiliaryfunctions.get_bodyparts(project_config)
+ unique_bpts = auxiliaryfunctions.get_unique_bodyparts(project_config)
+
+ if net_type is None:
+ net_type = project_config.get("default_net_type", "resnet_50")
+
+ configs_dir = get_config_folder_path()
+ pose_config = load_base_config(configs_dir)
+ pose_config = add_metadata(project_config, pose_config, pose_config_path)
+ pose_config["net_type"] = net_type
+
+ backbones = load_backbones(configs_dir)
+ if net_type in backbones:
+ if not top_down and multianimal_project:
+ model_cfg = create_backbone_with_paf_model(
+ configs_dir=configs_dir,
+ net_type=net_type,
+ num_individuals=len(individuals),
+ bodyparts=bodyparts,
+ paf_parameters=_get_paf_parameters(project_config, bodyparts),
+ )
+ else:
+ model_cfg = create_backbone_with_heatmap_model(
+ configs_dir=configs_dir,
+ net_type=net_type,
+ multianimal_project=multianimal_project,
+ bodyparts=bodyparts,
+ top_down=top_down,
+ )
+ else:
+ architecture = net_type.split("_")[0]
+ default_value_kwargs = {}
+ if architecture == "dlcrnet":
+ default_value_kwargs.update(_get_paf_parameters(project_config, bodyparts))
+
+ cfg_path = configs_dir / architecture / f"{net_type}.yaml"
+ model_cfg = read_config_as_dict(cfg_path)
+ model_cfg = replace_default_values(
+ model_cfg,
+ num_bodyparts=len(bodyparts),
+ num_individuals=len(individuals),
+ **default_value_kwargs,
+ )
+
+ task = Task(model_cfg.get("method", "BU").upper())
+ if task == Task.TOP_DOWN:
+ model_cfg = add_detector(
+ configs_dir,
+ model_cfg,
+ len(individuals),
+ detector_type=detector_type,
+ )
+
+ # add the default augmentations to the config
+ aug_filename = "aug_default.yaml" if task == Task.BOTTOM_UP else "aug_top_down.yaml"
+ aug_cfg = {"data": read_config_as_dict(configs_dir / "base" / aug_filename)}
+
+ pose_config = update_config(pose_config, aug_cfg)
+
+ # add the model to the config
+ pose_config = update_config(pose_config, model_cfg)
+
+ # set the dataset from which to load weights
+ if weight_init is not None:
+ pose_config["train_settings"]["weight_init"] = weight_init.to_dict()
+
+ # add a unique bodypart head if needed
+ if len(unique_bpts) > 0:
+ if task != Task.BOTTOM_UP:
+ raise ValueError(
+ f"You selected a top-down model architecture ({net_type}), but you have"
+ f" unique bodyparts, which is not yet implemented for top-down models."
+ " Please select a bottom-up architecture such as `resnet_50` for single"
+ " animal projects or `dlcrnet_50` for multi-animal projects."
+ )
+
+ pose_config = add_unique_bodypart_head(
+ configs_dir,
+ pose_config,
+ num_unique_bodyparts=len(unique_bpts),
+ backbone_output_channels=pose_config["model"]["backbone_output_channels"],
+ )
+
+ # add an identity head if needed
+ if with_identity:
+ if task != Task.BOTTOM_UP:
+ raise ValueError(
+ f"You selected a top-down model architecture ({net_type}), but you have"
+ f" set `identity: true`, which is not yet implemented for top-down"
+ f" models. Please select a bottom-up architecture such as `dlcrnet_50`"
+ f" to train with identity, or set `identity: false`."
+ )
+
+ pose_config = add_identity_head(
+ configs_dir,
+ pose_config,
+ num_individuals=len(individuals),
+ backbone_output_channels=pose_config["model"]["backbone_output_channels"],
+ )
+
+ pose_config["inference"] = InferenceConfig().to_dict()
+ # Add conditions for CTD models if specified
+ if task == Task.COND_TOP_DOWN and ctd_conditions is not None:
+ _add_ctd_conditions(pose_config, ctd_conditions)
+
+ # sort first-level keys to make it prettier
+ pose_config = dict(sorted(pose_config.items()))
+
+ if save:
+ write_config(pose_config_path, pose_config, overwrite=True)
+
+ return pose_config
+
+
+def _add_ctd_conditions(model_cfg: dict, ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int]):
+ """
+ Args:
+ model_cfg: dict, contents of pytorch_config.yaml
+ ctd_conditions: Only for using conditional-top-down (CTD) models. It defines
+ the conditions that will be used with the CTD model. It can be:
+ * A shuffle number (ctd_conditions: int), which must correspond to a
+ bottom-up (BU) network type.
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file.
+ * A shuffle number and a particular snapshot (ctd_conditions:
+ tuple[int, str] | tuple[int, int]), which respectively correspond to a
+ bottom-up (BU) network type and a particular snapshot name or index.
+ """
+ if isinstance(ctd_conditions, int):
+ conditions = {"shuffle": ctd_conditions}
+
+ elif isinstance(ctd_conditions, str) or isinstance(ctd_conditions, Path):
+ ctd_conditions = Path(ctd_conditions)
+ if not ctd_conditions.exists():
+ raise FileNotFoundError(f"Invalid path: {ctd_conditions}")
+ if ctd_conditions.suffix not in (".h5", ".json"):
+ raise ValueError("Invalid conditions file extension.")
+ conditions = str(ctd_conditions.resolve())
+
+ elif isinstance(ctd_conditions, tuple):
+ if len(ctd_conditions) != 2:
+ raise ValueError("Invalid conditions tuple length.")
+ if not isinstance(ctd_conditions[0], int):
+ raise TypeError("Conditions shuffle number must be of type int.")
+ if isinstance(ctd_conditions[1], int):
+ conditions = {
+ "shuffle": ctd_conditions[0],
+ "snapshot_index": ctd_conditions[1],
+ }
+ elif isinstance(ctd_conditions[1], str):
+ conditions = {"shuffle": ctd_conditions[0], "snapshot": ctd_conditions[1]}
+ else:
+ raise TypeError("Conditions snapshot must be of type int (index) or string (snapshot name).")
+ else:
+ raise TypeError("Conditions ctd_conditions is of invalid type.")
+
+ model_cfg["inference"]["conditions"] = conditions
+
+
+def make_pytorch_test_config(
+ model_config: dict,
+ test_config_path: str | Path,
+ save: bool = False,
+) -> dict:
+ """Creates the test configuration for a model.
+
+ Args:
+ model_config: The PyTorch config for the model.
+ test_config_path: The path of the test config
+ save: Whether to save the test config to ``test_config_path``.
+
+ Returns:
+ The test configuration file.
+ """
+ bodyparts = model_config["metadata"]["bodyparts"]
+ unique_bodyparts = model_config["metadata"]["unique_bodyparts"]
+ all_joint_names = bodyparts + unique_bodyparts
+
+ test_config = dict(
+ dataset=model_config["metadata"]["project_path"],
+ dataset_type="multi-animal-imgaug", # required for downstream tracking
+ num_joints=len(all_joint_names),
+ all_joints=[[i] for i in range(len(all_joint_names))],
+ all_joints_names=all_joint_names,
+ net_type=model_config["net_type"],
+ global_scale=1,
+ scoremap_dir="test",
+ )
+ if save:
+ write_config(test_config_path, test_config)
+
+ return test_config
+
+
+def make_basic_project_config(
+ dataset_path: Path | str,
+ bodyparts: list[str],
+ max_individuals: int,
+ multi_animal: bool = True,
+) -> dict:
+ """Creates a basic configuration dict that can be used to create model configs.
+
+ This should be used to create the `project_config` given to
+ `make_pytorch_pose_config` for non-DeepLabCut projects (e.g. when creating a
+ configuration file for a model that will be trained on a COCO dataset).
+
+ Args:
+ dataset_path: The path to the dataset for which the config will be created.
+ bodyparts: The bodyparts labeled for individuals in the dataset.
+ max_individuals: The maximum number of individuals to detect in a single image.
+ multi_animal: Whether multiple animals can be present in an image.
+
+ Returns:
+ The created project configuration dict that can be given to
+ `make_pytorch_pose_config`.
+
+ Examples:
+ Creating a `pytorch_config` for a ResNet50 backbone with a part-affinity head (
+ as multi_animal=True and top_down=False)
+
+ >>> import deeplabcut.pose_estimation_pytorch as pep
+ >>> project_config = pep.config.make_basic_project_config(
+ >>> dataset_path="/path/coco",
+ >>> bodyparts=["nose", "left_eye", "right_eye"],
+ >>> max_individuals=12,
+ >>> multi_animal=True,
+ >>> )
+ >>> model_config = pep.config.make_pytorch_pose_config(
+ >>> project_config=project_config,
+ >>> pose_config_path="/path/coco/models/resnet50/pytorch_config.yaml",
+ >>> net_type="resnet_50",
+ >>> top_down=False,
+ >>> save=True,
+ >>> )
+
+ Creating a `pytorch_config` for a ResNet50 backbone with a simple heatmap head
+ (as the project is single-animal):
+
+ >>> import deeplabcut.pose_estimation_pytorch as pep
+ >>> project_config = pep.config.make_basic_project_config(
+ >>> dataset_path="/path/coco",
+ >>> bodyparts=["nose", "left_eye", "right_eye"],
+ >>> max_individuals=1,
+ >>> multi_animal=False,
+ >>> )
+ >>> model_config = pep.config.make_pytorch_pose_config(
+ >>> project_config=project_config,
+ >>> pose_config_path="/path/coco/models/resnet50/pytorch_config.yaml",
+ >>> net_type="resnet_50",
+ >>> top_down=False,
+ >>> save=True,
+ >>> )
+ """
+ return dict(
+ project_path=str(dataset_path),
+ multianimalproject=multi_animal,
+ bodyparts=bodyparts,
+ multianimalbodyparts=bodyparts,
+ uniquebodyparts=[],
+ individuals=[f"individual{i:03d}" for i in range(max_individuals)],
+ )
+
+
+def add_metadata(project_config: dict, config: dict, pose_config_path: str | Path) -> dict:
+ """Adds metadata to a pytorch pose configuration.
+
+ Args:
+ project_config: the project configuration
+ config: the pytorch pose configuration
+ pose_config_path: the path where the pytorch pose configuration will be saved
+
+ Returns:
+ the configuration with a `meta` key added
+ """
+ config = copy.deepcopy(config)
+ config["metadata"] = {
+ "project_path": project_config["project_path"],
+ "pose_config_path": str(pose_config_path),
+ "bodyparts": auxiliaryfunctions.get_bodyparts(project_config),
+ "unique_bodyparts": auxiliaryfunctions.get_unique_bodyparts(project_config),
+ "individuals": project_config.get("individuals", ["animal"]),
+ "with_identity": project_config.get("identity", False),
+ }
+ return config
+
+
+def create_backbone_with_heatmap_model(
+ configs_dir: Path,
+ net_type: str,
+ multianimal_project: bool,
+ bodyparts: list[str],
+ top_down: bool,
+) -> dict:
+ """Creates a simple heatmap pose estimation model, composed of a backbone and a head
+ predicting heatmaps and location refinement maps.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ net_type: the type of backbone to create the model with (e.g., resnet_50)
+ multianimal_project: whether this model is created for a multi-animal project
+ bodyparts: the bodyparts to detect
+ top_down: whether the model will be associated to a detector to form a top-down
+ pose estimation model
+
+ Returns:
+ the backbone + heatmap model configuration
+
+ Raises:
+ ValueError: if the model is being created for a multi-animal project but the
+ head won't be associated with a detector (heatmaps can only predict
+ bodyparts for a single individual).
+ """
+ if multianimal_project and not top_down:
+ raise ValueError(
+ "A pose model formed of a backbone and simple heatmap + location refinement"
+ " head can only be used for single animal projects. As you're working with"
+ " a multi-animal project, please select a multi-individual model instead of"
+ f" {net_type} or use a detector to create a top-down model (create your"
+ f" configuration with `make_pytorch_pose_config(..., top_down=True)`)."
+ )
+
+ # add the backbone to the config
+ model_config = read_config_as_dict(configs_dir / "backbones" / f"{net_type}.yaml")
+ backbone_output_channels = model_config["model"]["backbone_output_channels"]
+
+ model_config["method"] = "bu"
+ bodypart_head_name = "head_bodyparts.yaml"
+ if top_down:
+ model_config["method"] = "td"
+ bodypart_head_name = "head_topdown.yaml"
+
+ # add a bodypart head
+ bodypart_head_config = read_config_as_dict(configs_dir / "base" / bodypart_head_name)
+ model_config["model"]["heads"] = {
+ "bodypart": replace_default_values(
+ bodypart_head_config,
+ num_bodyparts=len(bodyparts),
+ backbone_output_channels=backbone_output_channels,
+ )
+ }
+ return model_config
+
+
+def create_backbone_with_paf_model(
+ configs_dir: Path,
+ net_type: str,
+ num_individuals: int,
+ bodyparts: list[str],
+ paf_parameters: dict,
+) -> dict:
+ """Creates a pose estimation model, composed of a backbone and a head predicting
+ heatmaps, location refinement maps and part affinity fields for multi-animal pose
+ estimation.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ net_type: the type of backbone to create the model with (e.g., resnet_50)
+ num_individuals: the maximum number of individuals in a frame
+ bodyparts: the bodyparts to detect
+ paf_parameters: the parameters for the PAF
+
+ Returns:
+ the backbone + heatmap, location refinement, PAF model configuration
+ """
+ # add the backbone to the config
+ model_config = read_config_as_dict(configs_dir / "backbones" / f"{net_type}.yaml")
+ backbone_output_channels = model_config["model"]["backbone_output_channels"]
+
+ # add a bodypart head
+ bodypart_head_config = read_config_as_dict(configs_dir / "base" / "head_bodyparts_with_paf.yaml")
+ model_config["model"]["heads"] = {
+ "bodypart": replace_default_values(
+ bodypart_head_config,
+ num_bodyparts=len(bodyparts),
+ num_individuals=num_individuals,
+ backbone_output_channels=backbone_output_channels,
+ **paf_parameters,
+ )
+ }
+ return model_config
+
+
+def add_detector(
+ configs_dir: Path,
+ config: dict,
+ num_individuals: int,
+ detector_type: str | None = None,
+) -> dict:
+ """Adds a detector to a model.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ config: model configuration to update
+ num_individuals: the maximum number of individuals the model should detect
+ detector_type: the type of detector to use (if None, uses ``ssdlite``)
+
+ Returns:
+ the model configuration with an added detector config
+ """
+ if detector_type is None:
+ detector_type = "ssdlite" # default detector
+
+ detector_type = detector_type.lower()
+ config = copy.deepcopy(config)
+ detector_config = update_config(
+ read_config_as_dict(configs_dir / "base" / "base_detector.yaml"),
+ read_config_as_dict(configs_dir / "detectors" / f"{detector_type}.yaml"),
+ )
+ detector_config = replace_default_values(
+ detector_config,
+ num_individuals=num_individuals,
+ )
+ config["detector"] = dict(sorted(detector_config.items()))
+ return config
+
+
+def add_unique_bodypart_head(
+ configs_dir: Path,
+ config: dict,
+ num_unique_bodyparts: int,
+ backbone_output_channels: int,
+) -> dict:
+ """Adds a unique bodypart head to a model.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ config: model configuration to update
+ num_unique_bodyparts: the number of unique bodyparts to detect
+ backbone_output_channels: the number of channels output by the model backbone
+
+ Returns:
+ the configuration with an added unique bodypart head
+ """
+ config = copy.deepcopy(config)
+ unique_head_config = replace_default_values(
+ read_config_as_dict(configs_dir / "base" / "head_bodyparts.yaml"),
+ num_bodyparts=num_unique_bodyparts,
+ backbone_output_channels=backbone_output_channels,
+ )
+ unique_head_config["target_generator"]["label_keypoint_key"] = "keypoints_unique"
+ config["model"]["heads"]["unique_bodypart"] = unique_head_config
+ return config
+
+
+def add_identity_head(
+ configs_dir: Path,
+ config: dict,
+ num_individuals: int,
+ backbone_output_channels: int,
+) -> dict:
+ """Adds an identity head to a model.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ config: model configuration to update
+ num_individuals: the number of individuals to re-identify
+ backbone_output_channels: the number of channels output by the model backbone
+
+ Returns:
+ the configuration with an added identity head
+ """
+ config = copy.deepcopy(config)
+ id_head_config = read_config_as_dict(configs_dir / "base" / "head_identity.yaml")
+ config["model"]["heads"]["identity"] = replace_default_values(
+ id_head_config,
+ num_individuals=num_individuals,
+ backbone_output_channels=backbone_output_channels,
+ )
+ return config
+
+
+def _get_paf_parameters(
+ project_config: dict,
+ bodyparts: list[str],
+ num_limbs_threshold: int = 105,
+ paf_graph_degree: int = 6,
+) -> dict:
+ """Gets values for PAF parameters from the project configuration."""
+ paf_graph = [[i, j] for i in range(len(bodyparts)) for j in range(i + 1, len(bodyparts))]
+ num_limbs = len(paf_graph)
+ # If the graph is unnecessarily large (with 15+ keypoints by default),
+ # we randomly prune it to a size guaranteeing an average node degree of 6;
+ # see Suppl. Fig S9c in Lauer et al., 2022.
+ if num_limbs >= num_limbs_threshold:
+ paf_graph = auxfun_multianimal.prune_paf_graph(
+ paf_graph,
+ average_degree=paf_graph_degree,
+ )
+ num_limbs = len(paf_graph)
+ return {
+ "paf_graph": paf_graph,
+ "num_limbs": num_limbs,
+ "paf_edges_to_keep": project_config.get("paf_best", list(range(num_limbs))),
+ }
diff --git a/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_m.yaml b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_m.yaml
new file mode 100644
index 0000000000..23424451e1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_m.yaml
@@ -0,0 +1,102 @@
+data:
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ train:
+ random_bbox_transform:
+ shift_factor: 0.16
+ shift_prob: 0.3
+ scale_factor: [0.75, 1.25]
+ scale_prob: 1.0
+ p: 1.0
+ top_down_crop:
+ width: 256
+ height: 256
+method: td # Need to add a detector
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_m
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.67
+ widen_factor: 0.75
+ backbone_output_channels: 768
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [256, 256]
+ smoothing_type: gaussian
+ sigma: [5.66, 5.66]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [5.66, 5.66]
+ decode_beta: 150.0
+ apply_softmax: true
+ normalize_outputs: false
+ input_size: [256, 256]
+ in_channels: 768
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [8, 8] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-3
+ scheduler:
+ type: SequentialLR
+ params:
+ schedulers:
+ - type: LinearLR
+ params:
+ start_factor: 0.001
+ end_factor: 1.0
+ total_iters: 5
+ - type: CosineAnnealingLR
+ params:
+ T_max: 200 # max_epochs // 2
+ eta_min: 5e-5 # ~base_lr / 20
+ - type: LRListScheduler
+ params:
+ milestones:
+ - 0
+ lr_list:
+ - - 5e-5
+ milestones:
+ - 200 # max_epochs // 2
+ - 400
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_s.yaml b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_s.yaml
new file mode 100644
index 0000000000..62c5fc1cac
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_s.yaml
@@ -0,0 +1,102 @@
+data:
+ inference:
+ top_down_crop:
+ width: 256
+ height: 256
+ train:
+ random_bbox_transform:
+ shift_factor: 0.16
+ shift_prob: 0.3
+ scale_factor: [0.75, 1.25]
+ scale_prob: 1.0
+ p: 1.0
+ top_down_crop:
+ width: 256
+ height: 256
+method: td # Need to add a detector
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_s
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 0.33
+ widen_factor: 0.5
+ backbone_output_channels: 512
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [256, 256]
+ smoothing_type: gaussian
+ sigma: [5.66, 5.66]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [5.66, 5.66]
+ decode_beta: 150.0
+ apply_softmax: true
+ normalize_outputs: false
+ input_size: [256, 256]
+ in_channels: 512
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [8, 8] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-3
+ scheduler:
+ type: SequentialLR
+ params:
+ schedulers:
+ - type: LinearLR
+ params:
+ start_factor: 0.001
+ end_factor: 1.0
+ total_iters: 5
+ - type: CosineAnnealingLR
+ params:
+ T_max: 200 # max_epochs // 2
+ eta_min: 5e-5 # ~base_lr / 20
+ - type: LRListScheduler
+ params:
+ milestones:
+ - 0
+ lr_list:
+ - - 5e-5
+ milestones:
+ - 200 # max_epochs // 2
+ - 400
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_x.yaml b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_x.yaml
new file mode 100644
index 0000000000..fde7a4da53
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/rtmpose/rtmpose_x.yaml
@@ -0,0 +1,102 @@
+data:
+ inference:
+ top_down_crop:
+ width: 384
+ height: 384
+ train:
+ random_bbox_transform:
+ shift_factor: 0.16
+ shift_prob: 0.3
+ scale_factor: [ 0.75, 1.25 ]
+ scale_prob: 1.0
+ p: 1.0
+ top_down_crop:
+ width: 384
+ height: 384
+method: td # Need to add a detector
+model:
+ backbone:
+ type: CSPNeXt
+ model_name: cspnext_x
+ freeze_bn_stats: false
+ freeze_bn_weights: false
+ deepen_factor: 1.33
+ widen_factor: 1.25
+ backbone_output_channels: 1280
+ heads:
+ bodypart:
+ type: RTMCCHead
+ weight_init: RTMPose
+ target_generator:
+ type: SimCCGenerator
+ input_size: [384, 384]
+ smoothing_type: gaussian
+ sigma: [6.93, 6.93]
+ simcc_split_ratio: 2.0
+ label_smooth_weight: 0.0
+ normalize: false
+ criterion:
+ x:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ y:
+ type: KLDiscreteLoss
+ use_target_weight: true
+ beta: 10.0
+ label_softmax: true
+ predictor:
+ type: SimCCPredictor
+ simcc_split_ratio: 2.0
+ sigma: [6.93, 6.93]
+ decode_beta: 150.0
+ apply_softmax: true
+ normalize_outputs: false
+ input_size: [384, 384]
+ in_channels: 1280
+ out_channels: "num_bodyparts"
+ in_featuremap_size: [12, 12] # input_size / backbone stride
+ simcc_split_ratio: 2.0
+ final_layer_kernel_size: 7
+ gau_cfg:
+ hidden_dims: 256
+ s: 128
+ expansion_factor: 2
+ dropout_rate: 0
+ drop_path: 0.0
+ act_fn: "SiLU"
+ use_rel_bias: false
+ pos_enc: false
+runner:
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-3
+ scheduler:
+ type: SequentialLR
+ params:
+ schedulers:
+ - type: LinearLR
+ params:
+ start_factor: 0.001
+ end_factor: 1.0
+ total_iters: 5
+ - type: CosineAnnealingLR
+ params:
+ T_max: 200 # max_epochs // 2
+ eta_min: 5e-5 # ~base_lr / 20
+ - type: LRListScheduler
+ params:
+ milestones:
+ - 0
+ lr_list:
+ - - 5e-5
+ milestones:
+ - 200 # max_epochs // 2
+ - 400
+train_settings:
+ batch_size: 32
+ dataloader_workers: 4
+ dataloader_pin_memory: false
+ epochs: 400
diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py
new file mode 100644
index 0000000000..4994207487
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/utils.py
@@ -0,0 +1,287 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Util functions to create pytorch pose configuration files."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.utils import auxiliaryfunctions
+
+
+def replace_default_values(
+ config: dict | list,
+ num_bodyparts: int | None = None,
+ num_individuals: int | None = None,
+ backbone_output_channels: int | None = None,
+ **kwargs,
+) -> dict:
+ """Replaces placeholder values in a model configuration with their actual values.
+
+ This method allows to create template PyTorch configurations for models with values
+ such as "num_bodyparts", which are replaced with the number of bodyparts for a
+ project when making its Pytorch configuration.
+
+ This code can also do some basic arithmetic. You can write "num_bodyparts x 2" (or
+ any factor other than 2) for location refinement channels, and the number of
+ channels will be twice the number of bodyparts. You can write
+ "backbone_output_channels // 2" for the number of channels in a layer, and it will
+ be half the number of channels output by the backbone. You can write
+ "num_bodyparts + 1" (such as for DEKR heatmaps, where a "center" bodypart is added).
+
+ The three base placeholder values that can be computed are "num_bodyparts",
+ "num_individuals" and "backbone_output_channels". You can add more through the
+ keyword arguments (such as "paf_graph": list[tuple[int, int]] or
+ "paf_edges_to_keep": list[int] for DLCRNet models).
+
+ Args:
+ config: the configuration in which to replace default values
+ num_bodyparts: the number of bodyparts
+ num_individuals: the number of individuals
+ backbone_output_channels: the number of backbone output channels
+ kwargs: other placeholder values to fill in
+
+ Returns:
+ the configuration with placeholder values replaced
+
+ Raises:
+ ValueError: if there is a placeholder value who's "updated" value was not
+ given to the method
+ """
+
+ def get_updated_value(variable: str) -> int | list[int]:
+ var_parts = variable.strip().split(" ")
+ var_name = var_parts[0]
+ if updated_values[var_name] is None:
+ raise ValueError(
+ f"Found {variable} in the configuration file, but there is no default value for this variable."
+ )
+
+ if len(var_parts) == 1:
+ return updated_values[var_name]
+ elif len(var_parts) == 3:
+ operator, factor = var_parts[1], var_parts[2]
+ if not factor.isdigit():
+ raise ValueError(f"F must be an integer in variable: {variable}")
+
+ factor = int(factor)
+ if operator == "+":
+ return updated_values[var_name] + factor
+ elif operator == "x":
+ return updated_values[var_name] * factor
+ elif operator == "//":
+ return updated_values[var_name] // factor
+ else:
+ raise ValueError(f"Unknown operator for variable: {variable}")
+
+ raise ValueError(f"Found {variable} in the configuration file, but cannot parse it.")
+
+ updated_values = {
+ "num_bodyparts": num_bodyparts,
+ "num_individuals": num_individuals,
+ "backbone_output_channels": backbone_output_channels,
+ **kwargs,
+ }
+
+ config = copy.deepcopy(config)
+ if isinstance(config, dict):
+ keys_to_update = list(config.keys())
+ elif isinstance(config, list):
+ keys_to_update = range(len(config))
+ else:
+ raise ValueError(f"Config to update must be dict or list, found {type(config)}")
+
+ for k in keys_to_update:
+ if isinstance(config[k], (list, dict)):
+ config[k] = replace_default_values(
+ config[k],
+ num_bodyparts,
+ num_individuals,
+ backbone_output_channels,
+ **kwargs,
+ )
+ elif isinstance(config[k], str) and config[k].strip().split(" ")[0] in updated_values.keys():
+ config[k] = get_updated_value(config[k])
+
+ return config
+
+
+def update_config(config: dict, updates: dict, copy_original: bool = True) -> dict:
+ """Updates items in the configuration file.
+
+ The configuration dict should only be composed of primitive Python types
+ (dict, list and values). This is the case when reading the file using
+ `read_config_as_dict`.
+
+ Args:
+ config: the configuration dict to update
+ updates: the updates to make to the configuration dict
+ copy_original: whether to copy the original dict before updating it
+
+ Returns:
+ the updated dictionary
+ """
+ if copy_original:
+ config = copy.deepcopy(config)
+
+ for k, v in updates.items():
+ if k in config and isinstance(config[k], dict) and isinstance(v, dict):
+ if k in ("optimizer", "scheduler") and config["type"] != v["type"]:
+ # if changing the optimizer or scheduler type, update all values
+ config[k] = v
+ else:
+ config[k] = update_config(config[k], v, copy_original=False)
+ else:
+ config[k] = copy.deepcopy(v)
+ return config
+
+
+def update_config_by_dotpath(config: dict, updates: dict, copy_original: bool = True) -> dict:
+ """Updates items in the configuration file using dot notation for nested keys.
+
+ The configuration dict should only be composed of primitive Python types
+ (dict, list and values). This is the case when reading the file using
+ `read_config_as_dict`.
+
+ Args:
+ config: the configuration dict to update
+ updates: single-level dict with dot notation keys indicating nested paths
+ e.g. {"device": "cuda", "runner.gpus": [0,1]}
+ copy_original: whether to copy the original dict before updating it
+
+ Returns:
+ the updated dictionary
+ """
+ if copy_original:
+ config = copy.deepcopy(config)
+
+ for key, value in updates.items():
+ # Split key into parts by dots
+ parts = key.split(".")
+
+ # Handle non-nested case
+ if len(parts) == 1:
+ config[key] = copy.deepcopy(value)
+ continue
+
+ # Navigate to nested location
+ current = config
+ for part in parts[:-1]:
+ if part not in current:
+ current[part] = {}
+ current = current[part]
+
+ # Set the value at final location
+ current[parts[-1]] = copy.deepcopy(value)
+
+ return config
+
+
+def get_config_folder_path() -> Path:
+ """Returns: the Path to the folder containing the "configs" for DeepLabCut 3.0"""
+ dlc_parent_path = Path(auxiliaryfunctions.get_deeplabcut_path())
+ return dlc_parent_path / "pose_estimation_pytorch" / "config"
+
+
+def load_base_config(config_folder_path: Path) -> dict:
+ """Returns: the base configuration for all PyTorch DeepLabCut models"""
+ base_dir = config_folder_path / "base"
+ base_config = read_config_as_dict(base_dir / "base.yaml")
+ return base_config
+
+
+def load_backbones(configs_dir: Path) -> list[str]:
+ """
+ Args:
+ configs_dir: the Path to the folder containing the "configs" for PyTorch
+ DeepLabCut
+
+ Returns:
+ all backbones with default configurations that can be used
+ """
+ backbone_dir = configs_dir / "backbones"
+ backbones = [p.stem for p in backbone_dir.iterdir() if p.suffix == ".yaml"]
+ return backbones
+
+
+def load_detectors(configs_dir: Path) -> list[str]:
+ """
+ Args:
+ configs_dir: the Path to the folder containing the "configs" for PyTorch
+ DeepLabCut
+
+ Returns:
+ all detectors that are available
+ """
+ detector_dir = configs_dir / "detectors"
+ detectors = [p.stem for p in detector_dir.iterdir() if p.suffix == ".yaml"]
+ return detectors
+
+
+def available_models() -> list[str]:
+ """Returns: the possible variants of models that can be used"""
+ configs_folder_path = get_config_folder_path()
+ backbones = load_backbones(configs_folder_path)
+ models = set()
+ for backbone in backbones:
+ models.add(backbone)
+ models.add("top_down_" + backbone)
+
+ other_architectures = [
+ p for p in configs_folder_path.iterdir() if p.is_dir() and p.name not in ("backbones", "base", "detectors")
+ ]
+ for folder in other_architectures:
+ variants = [p.stem for p in folder.iterdir() if p.suffix == ".yaml"]
+ for variant in variants:
+ models.add(variant)
+
+ return list(sorted(models))
+
+
+def is_model_top_down(net_type: str) -> bool:
+ """Checks whenever a given net_type is top-down or not."""
+ if net_type not in available_models():
+ raise ValueError(f"Model {net_type} is not part of available models, which are {str(available_models())}")
+
+ configs_dir = get_config_folder_path()
+ backbones = load_backbones(configs_dir)
+
+ if net_type.startswith("top_down_"):
+ return True
+ elif net_type in backbones:
+ return False
+
+ configs_dir = get_config_folder_path()
+
+ architecture = net_type.split("_")[0]
+
+ cfg_path = configs_dir / architecture / f"{net_type}.yaml"
+ model_cfg = read_config_as_dict(cfg_path)
+
+ return model_cfg.get("method", "BU").upper() == "TD"
+
+
+def is_model_cond_top_down(net_type: str) -> bool:
+ """Checks whether a given net_type is conditional top-down or not."""
+ if net_type not in available_models():
+ raise ValueError(f"Model {net_type} is not part of available models, which are {str(available_models())}")
+
+ if net_type.startswith("ctd_"):
+ return True
+ else:
+ return False
+
+
+def available_detectors() -> list[str]:
+ """Returns: all the possible detectors that can be used"""
+ return load_detectors(get_config_folder_path())
diff --git a/deeplabcut/pose_estimation_pytorch/data/__init__.py b/deeplabcut/pose_estimation_pytorch/data/__init__.py
new file mode 100644
index 0000000000..6d0c4e4556
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/__init__.py
@@ -0,0 +1,36 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.data.base import Loader
+from deeplabcut.pose_estimation_pytorch.data.cocoloader import COCOLoader
+from deeplabcut.pose_estimation_pytorch.data.collate import COLLATE_FUNCTIONS
+from deeplabcut.pose_estimation_pytorch.data.dataset import (
+ PoseDataset,
+ PoseDatasetParameters,
+)
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+from deeplabcut.pose_estimation_pytorch.data.generative_sampling import (
+ GenerativeSampler,
+ GenSamplingConfig,
+)
+from deeplabcut.pose_estimation_pytorch.data.image import top_down_crop
+from deeplabcut.pose_estimation_pytorch.data.postprocessor import (
+ Postprocessor,
+ build_bottom_up_postprocessor,
+ build_detector_postprocessor,
+ build_top_down_postprocessor,
+)
+from deeplabcut.pose_estimation_pytorch.data.preprocessor import (
+ Preprocessor,
+ build_bottom_up_preprocessor,
+ build_top_down_preprocessor,
+)
+from deeplabcut.pose_estimation_pytorch.data.snapshots import Snapshot, list_snapshots
+from deeplabcut.pose_estimation_pytorch.data.transforms import build_transforms
diff --git a/deeplabcut/pose_estimation_pytorch/data/base.py b/deeplabcut/pose_estimation_pytorch/data/base.py
new file mode 100644
index 0000000000..4795826ab4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/base.py
@@ -0,0 +1,366 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+import albumentations as A
+import numpy as np
+
+import deeplabcut.core.config as config_utils
+import deeplabcut.pose_estimation_pytorch.config as config
+from deeplabcut.pose_estimation_pytorch.data.dataset import (
+ PoseDataset,
+ PoseDatasetParameters,
+)
+from deeplabcut.pose_estimation_pytorch.data.generative_sampling import (
+ GenSamplingConfig,
+)
+from deeplabcut.pose_estimation_pytorch.data.snapshots import Snapshot, list_snapshots
+from deeplabcut.pose_estimation_pytorch.data.utils import (
+ _compute_crop_bounds,
+ bbox_from_keypoints,
+ map_id_to_annotations,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+class Loader(ABC):
+ """Abstract class that represents a blueprint for loading and processing dataset
+ information.
+
+ Methods:
+ load_data(mode: str = 'train') -> dict:
+ Abstract method to convert the project configuration to a standard COCO format.
+ create_dataset(images: dict = None, annotations: dict = None, transform: object = None,
+ mode: str = "train", task: Task = Task.BOTTOM_UP) -> PoseDataset:
+ Creates and returns a PoseDataset given a set of images, annotations, and other parameters.
+ _compute_bboxes(images, annotations, method: str = 'gt') -> dict:
+ Retrieves all bounding boxes based on the specified method.
+ get_dataset_parameters(*args, **kwargs) -> dict:
+ Returns a dictionary containing dataset parameters derived from the configuration.
+ """
+
+ def __init__(
+ self,
+ project_root: str | Path,
+ image_root: str | Path,
+ model_config_path: str | Path,
+ ) -> None:
+ self.project_root = Path(project_root)
+ self.image_root = Path(image_root)
+ self.model_config_path = Path(model_config_path)
+ self.model_cfg = config_utils.read_config_as_dict(str(model_config_path))
+ self.pose_task = Task(self.model_cfg["method"])
+ self._loaded_data: dict[str, dict[str, list[dict]]] = {}
+
+ @property
+ def model_folder(self) -> Path:
+ """Returns: The path of the folder containing the model data"""
+ return self.model_config_path.parent
+
+ def snapshots(
+ self,
+ detector: bool = False,
+ best_in_last: bool = True,
+ ) -> list[Snapshot]:
+ """Lists snapshots saved for the model.
+
+ Args:
+ detector: If the Loader is for a Top-Down model, passing detector=True
+ will return the snapshots for the detector. Otherwise, the snapshots
+ for the pose model are returned.
+ best_in_last: Whether to place the snapshot with the best performance in the
+ last position in the list, even if it wasn't the last epoch.
+
+ Returns:
+ The snapshots stored in a folder, sorted by the number of epochs they were
+ trained for. If best_in_last=True and a best snapshot exists, it will be the
+ last one in the list.
+ """
+ prefix = self.pose_task.snapshot_prefix
+ if detector:
+ prefix = Task.DETECT.snapshot_prefix
+ return list_snapshots(self.model_folder, prefix, best_in_last=best_in_last)
+
+ def update_model_cfg(self, updates: dict) -> None:
+ """Updates the model configuration.
+
+ Args:
+ updates: the items to update in the model configuration
+ """
+ self.model_cfg = config.update_config_by_dotpath(self.model_cfg, updates)
+ config_utils.write_config(self.model_config_path, self.model_cfg)
+
+ @abstractmethod
+ def load_data(self, mode: str = "train") -> dict[str, list[dict]]:
+ """Abstract method to convert the project configuration to a standard coco
+ format.
+
+ Raises:
+ NotImplementedError: This method must be implemented in the derived classes.
+ """
+ raise NotImplementedError
+
+ def image_filenames(self, mode: str = "train") -> list[str]:
+ """
+ Args:
+ mode: {"train", "test"} whether to load train or test data
+
+ Returns:
+ the image paths for this mode
+ """
+ if mode not in self._loaded_data:
+ self._loaded_data[mode] = self.load_data(mode)
+
+ data = self._loaded_data[mode]
+ return [image["file_name"] for image in data["images"]]
+
+ def ground_truth_keypoints(self, mode: str = "train", unique_bodypart: bool = False) -> dict[str, np.ndarray]:
+ """Creates a dictionary containing the ground truth data.
+
+ TODO: make more efficient
+
+ Args:
+ mode: {"train", "test"} whether to load train or test data
+ unique_bodypart: returns the ground truth for unique bodyparts
+
+ Raises:
+ ValueError if unique_bodypart=True but there are no unique bodyparts
+
+ Returns:
+ A dict mapping image paths to the ground truth annotations for the mode in
+ the format:
+ {'image': keypoints with shape (num_individuals, num_keypoints, 2)}
+ """
+ parameters = self.get_dataset_parameters()
+ if unique_bodypart:
+ if not parameters.num_unique_bpts > 0:
+ raise ValueError("There are no unique bodyparts in this dataset!")
+ individuals = ["single"]
+ num_bodyparts = parameters.num_unique_bpts
+ else:
+ individuals = parameters.individuals
+ num_bodyparts = parameters.num_joints
+
+ if "weight_init" in self.model_cfg["train_settings"]:
+ weight_init_cfg = self.model_cfg["train_settings"]["weight_init"]
+ if weight_init_cfg["memory_replay"]:
+ conversion_array = weight_init_cfg["conversion_array"]
+ num_bodyparts = len(conversion_array)
+
+ if mode not in self._loaded_data:
+ self._loaded_data[mode] = self.load_data(mode)
+ data = self._loaded_data[mode]
+
+ annotations = self.filter_annotations(data["annotations"], task=Task.BOTTOM_UP)
+ img_to_ann_map = map_id_to_annotations(annotations)
+
+ ground_truth_dict = {}
+ for image in data["images"]:
+ image_path = image["file_name"]
+ individual_keypoints = {
+ annotations[i]["individual"]: annotations[i]["keypoints"] for i in img_to_ann_map[image["id"]]
+ }
+ gt_array = np.zeros((len(individuals), num_bodyparts, 3))
+ # Keep the shape of the ground truth
+ for idv_idx, idv in enumerate(individuals):
+ if idv in individual_keypoints:
+ keypoints = individual_keypoints[idv].reshape(num_bodyparts, -1)
+ gt_array[idv_idx, :, :] = keypoints[:, :3]
+
+ ground_truth_dict[image_path] = gt_array
+
+ return ground_truth_dict
+
+ def ground_truth_bboxes(self, mode: str = "train") -> dict[str, dict]:
+ """Creates a dictionary containing the ground truth bounding boxes.
+
+ Args:
+ mode: {"train", "test"} whether to load train or test data
+
+ Returns:
+ A dict mapping image paths to the ground truth annotations for the mode in
+ the format:
+ {
+ 'path/to/image000.png': {
+ "width": (int) the width of the image, in pixels
+ "height": (int) the height of the image, in pixels
+ "bboxes": (np.ndarray) bboxes with shape (num_individuals, xywh)
+ },
+ 'path/to/image000.png': {...},
+ }
+ """
+ if mode not in self._loaded_data:
+ self._loaded_data[mode] = self.load_data(mode)
+ data = self._loaded_data[mode]
+
+ annotations = self.filter_annotations(data["annotations"], task=Task.DETECT)
+ img_to_ann_map = map_id_to_annotations(annotations)
+
+ ground_truth_dict = {}
+ for image in data["images"]:
+ image_path = image["file_name"]
+ img_shape = image["height"], image["width"], 3
+ bboxes = [annotations[i]["bbox"] for i in img_to_ann_map[image["id"]]]
+ if len(bboxes) == 0:
+ bboxes = np.zeros((0, 4))
+ else:
+ bboxes = _compute_crop_bounds(np.stack(bboxes, axis=0), img_shape)
+
+ ground_truth_dict[image_path] = dict(
+ width=image["width"],
+ height=image["height"],
+ bboxes=bboxes,
+ )
+
+ return ground_truth_dict
+
+ def create_dataset(
+ self,
+ transform: A.BaseCompose | None = None,
+ mode: str = "train",
+ task: Task = Task.BOTTOM_UP,
+ ) -> PoseDataset:
+ """Creates a PoseDataset based on provided arguments.
+
+ Args:
+ transform: Transformation to be applied on dataset. Defaults to None.
+ mode: Mode in which dataset is to be used (e.g., 'train', 'test'). Defaults to 'train'.
+ task: Task for which the dataset is being used. Defaults to 'BU'.
+
+ Returns:
+ PoseDataset: An instance of the PoseDataset class.
+
+ Raises:
+ Any exception raised by `get_dataset_parameters` or `load_data` methods.
+ """
+ parameters = self.get_dataset_parameters()
+ data = self.load_data(mode)
+ data["annotations"] = self.filter_annotations(data["annotations"], task)
+ ctd_config = None
+ if self.pose_task == Task.COND_TOP_DOWN:
+ ctd_config = GenSamplingConfig(
+ bbox_margin=self.model_cfg["data"].get("bbox_margin", 20),
+ **self.model_cfg["data"].get("gen_sampling", {}),
+ )
+
+ dataset = PoseDataset(
+ images=data["images"],
+ annotations=data["annotations"],
+ transform=transform,
+ mode=mode,
+ task=task,
+ parameters=parameters,
+ ctd_config=ctd_config,
+ )
+ return dataset
+
+ @abstractmethod
+ def get_dataset_parameters(self) -> PoseDatasetParameters:
+ """Retrieves dataset parameters based on the instance's configuration.
+
+ Returns:
+ An instance of the PoseDatasetParameters with the parameters set.
+ """
+ raise NotImplementedError
+
+ @staticmethod
+ def filter_annotations(annotations: list[dict], task: Task) -> list[dict]:
+ """Filters annotations based on the task, removing empty annotations.
+
+ For pose estimation tasks, annotations with empty keypoints are removed. For
+ detection task, annotations with no bounding boxes are removed
+
+ Args:
+ annotations: the annotations to filter
+ task: the task for which to filter
+
+ Returns:
+ list: the filtered annotations
+ """
+ filtered_annotations = []
+ for annotation in annotations:
+ keypoints = annotation["keypoints"].reshape(-1, 3)
+ if task in (Task.DETECT, Task.TOP_DOWN) and (annotation["bbox"][2] <= 0 or annotation["bbox"][3] <= 0):
+ continue
+ elif task != Task.DETECT and np.all(keypoints[:, :2] <= 0):
+ continue
+
+ filtered_annotations.append(annotation)
+
+ return filtered_annotations
+
+ @staticmethod
+ def _compute_bboxes(
+ images: list[dict],
+ annotations: list[dict],
+ method: str = "gt",
+ bbox_margin: int = 20,
+ ):
+ """TODO: Nastya method of bbox computation (detection bbox, seg. mask, ...)
+ Retrieves all bounding boxes based on the given method.
+
+ Args:
+ images: A list of images.
+ annotations: A list of annotations corresponding to images.
+ method (str, optional): Method to use for retrieving bounding boxes. Defaults to 'gt'.
+ - 'gt': Ground truth bounding boxes.
+ - 'detection bbox': Bounding boxes from detection.
+ - 'keypoints': Bounding boxes from keypoints.
+ - 'segmentation mask': Bounding boxes from segmentation masks.
+ bbox_margin: Margin to add around keypoints when generating bounding boxes.
+
+ Returns:
+ list: Updated annotations based on the given method.
+
+ Raises:
+ ValueError: If 'bbox' is not found in annotation when method is 'gt'.
+ ValueError: If method is not one of 'gt', 'detection bbox', 'keypoints', or 'segmentation mask'.
+ """
+
+ if not method:
+ return annotations
+
+ elif method == "gt":
+ for _i, annotation in enumerate(annotations):
+ if "bbox" not in annotation:
+ # or do something else?
+ raise ValueError(
+ f"Bounding box not found in annotation {annotation}, please "
+ "chose another bbox computation method"
+ )
+ return annotations
+
+ elif method == "detection bbox":
+ raise NotImplementedError
+
+ elif method == "keypoints":
+ min_area = 1 # TODO: should not be hardcoded
+ img_id_to_annotations = map_id_to_annotations(annotations)
+ for img in images:
+ anns = [annotations[idx] for idx in img_id_to_annotations[img["id"]]]
+ for a in anns:
+ a["bbox"] = bbox_from_keypoints(
+ keypoints=a["keypoints"],
+ image_h=img["height"],
+ image_w=img["width"],
+ margin=bbox_margin,
+ )
+ a["area"] = max(min_area, (a["bbox"][2] * a["bbox"][3]).item())
+ return annotations
+
+ elif method == "segmentation mask":
+ raise NotImplementedError
+
+ else:
+ raise ValueError(f"Unknown method: {method}")
diff --git a/deeplabcut/pose_estimation_pytorch/data/cocoloader.py b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py
new file mode 100644
index 0000000000..7fdea47f48
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py
@@ -0,0 +1,350 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import json
+import os
+import warnings
+from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.pose_estimation_pytorch.data.base import Loader
+from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters
+from deeplabcut.pose_estimation_pytorch.data.utils import (
+ map_id_to_annotations,
+ map_image_path_to_id,
+)
+
+
+class COCOLoader(Loader):
+ """
+ Attributes:
+ project_root: root directory path of the COCO project.
+ model_config_path: path to the pytorch_config.yaml file
+ train_json_filename: the name of the json file containing the train annotations
+ test_json_filename: the name of the json file containing the train annotations.
+ None if there is no test set.
+
+ Examples:
+ loader = COCOLoader(
+ project_root='/path/to/project/',
+ model_config_path='/path/to/project/experiments/train/pytorch_config.yaml'
+ train_json_filename="train.json",
+ test_json_filename="test.json",
+ )
+ """
+
+ def __init__(
+ self,
+ project_root: str | Path,
+ model_config_path: str | Path,
+ train_json_filename: str = "train.json",
+ test_json_filename: str = "test.json",
+ ):
+ image_root = Path(project_root) / "images"
+ super().__init__(project_root, image_root, Path(model_config_path))
+ self.train_json_filename = train_json_filename
+ self.test_json_filename = test_json_filename
+ self._dataset_parameters = None
+
+ self.train_json = self.load_json(self.project_root, self.train_json_filename)
+ self.test_json = None
+ if self.test_json_filename:
+ self.test_json = self.load_json(self.project_root, self.test_json_filename)
+
+ def get_dataset_parameters(self) -> PoseDatasetParameters:
+ """Retrieves dataset parameters based on the instance's configuration.
+
+ Returns:
+ An instance of the PoseDatasetParameters with the parameters set.
+ """
+ if self._dataset_parameters is None:
+ num_individuals, bodyparts = self.get_project_parameters(self.train_json)
+
+ crop_cfg = self.model_cfg["data"]["train"].get("top_down_crop", {})
+ crop_w, crop_h = crop_cfg.get("width", 256), crop_cfg.get("height", 256)
+ crop_margin = crop_cfg.get("margin", 0)
+ crop_with_context = crop_cfg.get("crop_with_context", True)
+
+ self._dataset_parameters = PoseDatasetParameters(
+ bodyparts=bodyparts,
+ unique_bpts=[],
+ individuals=[f"individual{i}" for i in range(num_individuals)],
+ with_center_keypoints=self.model_cfg.get("with_center_keypoints", False),
+ color_mode=self.model_cfg.get("color_mode", "RGB"),
+ top_down_crop_size=(crop_w, crop_h),
+ top_down_crop_margin=crop_margin,
+ top_down_crop_with_context=crop_with_context,
+ )
+
+ return self._dataset_parameters
+
+ @staticmethod
+ def load_json(project_root: str | Path, filename: str) -> dict:
+ """Load a JSON file from the annotations directory.
+
+ Args:
+ project_root: path to the root directory for the project
+ filename: filename of JSON file to load
+
+ Returns:
+ json_obj: JSON object loaded from the file
+
+ Raises:
+ FileNotFoundError if the file does not exist
+ ValueError if the object stored in the file is not a dict
+
+ Examples:
+ Check https://docs.trainingdata.io/v1.0/Export%20Format/COCO/ to see
+ examples of how a json file looks like.
+ """
+ json_path = os.path.join(project_root, "annotations", filename)
+ if not os.path.exists(json_path):
+ raise FileNotFoundError(f"File {json_path} does not exist.")
+
+ with open(json_path) as f:
+ json_obj = json.load(f)
+
+ if not isinstance(json_obj, dict):
+ raise ValueError("COCO datasets need to be saved in JSON Objects")
+
+ return json_obj
+
+ @staticmethod
+ def validate_categories(coco_json: dict) -> dict:
+ """Checks that the categories for the COCO project are valid.
+
+ Checks that there is no category with ID 0 in the dataset, as this causes issues
+ with torchvision object detectors (label 0 is reserved for background
+ detections). If that's the case, all category IDs are shifted by 1 such that
+ there is no longer a category 0.
+
+ Currently, detectors can only be trained with a single category. This also
+ ensures that all annotations have `category_id` set to 1.
+
+ Args:
+ coco_json: the COCO dictionary containing the annotations
+
+ Returns:
+ the validated COCO object
+ """
+ cat_0 = False
+ for cat in coco_json["categories"]:
+ if cat["id"] == 0:
+ cat_0 = cat
+ warnings.warn(
+ f"Found a category with ID 0 ({cat}) in the COCO dataset. This is not"
+ f" allowed, as category ID 0 is reserved as the background ID for"
+ f" torchvision detectors. All category IDs have been shifted by 1.",
+ stacklevel=2,
+ )
+
+ if len(coco_json["categories"]) > 1:
+ warnings.warn(
+ "Found more than 1 category in the project. This is currently not"
+ " supported in DeepLabCut. All annotations will be given category 1",
+ stacklevel=2,
+ )
+
+ if cat_0:
+ for cat in coco_json["categories"]:
+ cat["id"] = 1
+
+ if cat_0 or len(coco_json["categories"]) > 1:
+ for ann in coco_json["annotations"]:
+ ann["category_id"] = 1
+
+ return coco_json
+
+ def validate_images(self, coco_json: dict) -> dict:
+ """Goes over images and annotations to look for potential errors.
+
+ This code tries to ensure that training a model on this project does not crash
+ down the line
+
+ Completes relative image filepaths to '/project_root/images/file_name'. Absolute
+ filepaths are not updated (which allows storing images to be stored in a folder
+ other than the project root) Then checks that all images files exist in the file
+ system.
+
+ Args:
+ project_root: the root path of the COCO project
+ coco_json: the COCO dictionary containing the annotations
+
+ Returns:
+ the validated COCO object
+ """
+ image_ids = set()
+ missing_images = {}
+ validated_images = []
+ for image in coco_json["images"]:
+ image_filename = Path(image["file_name"])
+ if image_filename.is_absolute():
+ image_path = image_filename
+ else:
+ image_path = self.image_root / image["file_name"]
+ image["file_name"] = str(image_path)
+
+ if not image_path.exists():
+ missing_images[image["id"]] = image["file_name"]
+ else:
+ validated_images.append(image)
+ image_ids.add(image["id"])
+
+ if len(missing_images) > 0:
+ warnings.warn(f"There are {len(missing_images)} images that cannot be found (here are some):", stacklevel=2)
+ for img_id, file_name in missing_images.items():
+ print(f" * {img_id}: {file_name}")
+
+ coco_json["images"] = validated_images
+
+ if len(missing_images) > 0:
+ validated_annotations = []
+ for ann in coco_json["annotations"]:
+ if ann["image_id"] not in missing_images:
+ validated_annotations.append(ann)
+
+ coco_json["annotations"] = validated_annotations
+
+ validated_annotations = []
+ for ann in coco_json["annotations"]:
+ if ann["image_id"] in image_ids:
+ validated_annotations.append(ann)
+
+ if len(coco_json["annotations"]) < len(validated_annotations):
+ warnings.warn(
+ "Found some annotations for which the image ID was not in the images. Removing them from the dataset.",
+ stacklevel=2,
+ )
+ print(f" All annotations: {len(coco_json['annotations'])}")
+ print(f" Annotations with correct image IDs: {len(validated_annotations)}")
+ coco_json["annotations"] = validated_annotations
+
+ return coco_json
+
+ def load_data(self, mode: str = "train") -> dict:
+ """Convert data from JSON object to dictionary.
+ Args:
+ mode: indicates which JSON object to convert. Defaults to "train".
+
+ Returns:
+ the train or test data
+ """
+ if mode == "train":
+ data = self.train_json
+ elif mode == "test":
+ data = self.test_json
+ else:
+ raise AttributeError(f"Unknown mode: {mode}")
+
+ data = COCOLoader.validate_categories(data)
+ data = self.validate_images(data)
+
+ annotations_per_image = {}
+ for annotation in data["annotations"]:
+ annotation["keypoints"] = np.array(annotation["keypoints"], dtype=float)
+ annotation["bbox"] = np.array(annotation["bbox"], dtype=float)
+
+ # set individual index
+ image_id = annotation["image_id"]
+ individual_idx = annotations_per_image.get(image_id, 0)
+ annotation["individual"] = f"individual{individual_idx}"
+ annotations_per_image[image_id] = individual_idx + 1
+
+ filter_annotations = []
+ for annotation in data["annotations"]:
+ keypoints = annotation["keypoints"]
+ bbox = annotation["bbox"]
+ if np.all(keypoints <= 0) or len(bbox) == 0:
+ continue
+ filter_annotations.append(annotation)
+
+ data["annotations"] = filter_annotations
+
+ # FIXME: why estimating bbox when there are already bbox?
+ annotations_with_bbox = self._compute_bboxes(
+ data["images"],
+ data["annotations"],
+ method="gt",
+ )
+ data["annotations"] = annotations_with_bbox
+ return data
+
+ @staticmethod
+ def get_project_parameters(train_json: dict) -> tuple[int, list[str]]:
+ """
+ Loads the parameters for the project from the train json file
+ TODO: Should this compute the number also using the test json?
+
+ Args:
+ train_json: the json dictionary containing the data for training
+
+ Returns:
+ int: the maximum number of individuals in a single image
+ list[str]: the name of keypoints annotated in this project
+ """
+ # TODO: Check that there's a single category
+ bodyparts = train_json["categories"][0]["keypoints"]
+
+ img_to_annotations = map_id_to_annotations(train_json["annotations"])
+ if len(img_to_annotations) == 0:
+ raise ValueError(f"No images found in the dataset: {train_json}!")
+ elif len(img_to_annotations) == 1:
+ num_individuals = len(list(img_to_annotations.values())[0])
+ else:
+ num_individuals = max(*[len(a_ids) for a_ids in img_to_annotations.values()])
+
+ return num_individuals, bodyparts
+
+ def predictions_to_coco(
+ self,
+ predictions: dict[str, dict[str, np.ndarray]],
+ mode: str = "train",
+ ) -> list[dict]:
+ """Converts detections to COCO format.
+
+ Args:
+ predictions: a dictionary mapping image name to the predictions made for it
+ mode: {"train", "test"} the mode that the predictions were made with
+
+ Returns:
+ The COCO-format predictions
+ """
+ data = self.load_data(mode)
+ image_path_to_id = map_image_path_to_id(data["images"])
+
+ # TODO: no unique bodyparts for COCO
+ coco_predictions = []
+ for image_path, pred in predictions.items():
+ image_id = image_path_to_id[image_path]
+
+ # Shape (num_individuals, num_keypoints, 3)
+ individuals = pred["bodyparts"]
+ for idx, keypoints in enumerate(individuals):
+ if not np.all(keypoints == -1):
+ score = np.mean(keypoints[:, 2]).item()
+ keypoints = keypoints.copy()
+ keypoints[:, 2] = 2 # set visibility instead of score
+ coco_pred = {
+ "image_id": int(image_id),
+ "category_id": 1, # TODO: get category ID from prediction?
+ "keypoints": keypoints.reshape(-1).tolist(),
+ "score": float(score),
+ }
+ if "bboxes" in pred:
+ coco_pred["bbox"] = pred["bboxes"][idx].reshape(-1).tolist()
+ if "bbox_scores" in pred:
+ coco_pred["bbox_scores"] = pred["bbox_scores"][idx].reshape(-1).tolist()
+
+ coco_predictions.append(coco_pred)
+
+ return coco_predictions
diff --git a/deeplabcut/pose_estimation_pytorch/data/collate.py b/deeplabcut/pose_estimation_pytorch/data/collate.py
new file mode 100644
index 0000000000..fee4057253
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/collate.py
@@ -0,0 +1,187 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Custom collate functions."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import numpy as np
+from torch.utils.data import default_collate
+
+from deeplabcut.pose_estimation_pytorch.data.image import resize_and_random_crop
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+COLLATE_FUNCTIONS = Registry("collate_functions", build_func=build_from_cfg)
+
+
+class CollateFunction(ABC):
+ """A class that can be called as a collate function."""
+
+ @abstractmethod
+ def __call__(self, batch) -> dict | list:
+ """Returns: the collated batch"""
+ raise NotImplementedError()
+
+
+class ResizeCollate(CollateFunction, ABC):
+ """A collate function which resizes all images in a batch to the same size.
+
+ Args:
+ max_shift: The maximum shift, in pixels, to add to the random crop (this means
+ there can be a slight border around the image)
+ max_size: The maximum size of the long edge of the image when resized. If the
+ longest side will be greater than this value, resizes such that the longest
+ side is this size, and the shortest side is smaller than the desired size.
+ This is useful to keep some information from images with extreme aspect
+ ratios.
+ seed: The random seed to use to sample scales/sizes.
+ """
+
+ def __init__(
+ self,
+ max_shift: int = 10,
+ max_size: int = 2048,
+ seed: int = 0,
+ ) -> None:
+ self.generator = np.random.default_rng(seed=seed)
+ self.max_size = max_size
+ self.max_shift = max_shift
+ self._current_batch = []
+
+ @abstractmethod
+ def _sample_scale(self) -> int | tuple[int, int]:
+ """Returns: the target shape for images in the batch"""
+ raise NotImplementedError()
+
+ def __call__(self, batch) -> dict | list:
+ """Returns: the collated batch"""
+ self._current_batch = batch
+ new_size = self._sample_scale()
+ updated_batch = []
+ for item in batch:
+ image, new_targets = resize_and_random_crop(
+ image=item["image"],
+ targets=item,
+ size=new_size,
+ max_size=self.max_size,
+ max_shift=self.max_shift,
+ )
+ new_targets["image"] = image
+ updated_batch.append(new_targets)
+
+ return default_collate(updated_batch)
+
+
+@COLLATE_FUNCTIONS.register_module
+class ResizeFromDataSizeCollate(ResizeCollate):
+ """A collate function which resizes all images in a batch to the same size.
+
+ The target size is obtained by taking the size of the first image in the batch, and
+ multiplying it by a scale taken uniformly at random from (min_scale, max_scale).
+
+ The aspect ratio of all images in the batch is preserved, with cropping/padding used
+ to generate images of the correct shapes.
+
+ If to_square:
+ The images will be resized to squares, where the side is the short side of the
+ original image.
+ else:
+ The images will be resized to a scaled version of the shape of the first image.
+
+ Args:
+ min_scale: The minimum scale factor to apply to the image size
+ max_scale: The maximum scale factor to apply to the image size
+ min_short_side: The smallest size for the target short side.
+ max_short_side: The largest size for the target short side.
+ max_ratio: The largest aspect ratio allowed for a target (longSide / shortSide).
+ If the aspect ratio is larger, it will be clamped to max_ratio. Must be >=1.
+ multiple_of: If defined, the height and width of all target sizes will be a
+ multiple of this value.
+ to_square: Whether images should be resized to squares.
+ """
+
+ def __init__(
+ self,
+ min_scale: float,
+ max_scale: float,
+ min_short_side: int = 128,
+ max_short_side: int = 1152,
+ max_ratio: float = 2.0,
+ multiple_of: int | None = None,
+ to_square: bool = False,
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.min_scale = min_scale
+ self.max_scale = max_scale
+ self.min_short_side = min_short_side
+ self.max_short_side = max_short_side
+ self.max_ratio = max_ratio
+ self.multiple_of = multiple_of
+ self.to_square = to_square
+
+ def _sample_scale(self) -> int | tuple[int, int]:
+ if len(self._current_batch) == 0:
+ raise ValueError("Cannot sample frame shape: no items in current batch")
+
+ h, w = self._current_batch[0]["image"].shape[1:]
+ scale = self.generator.uniform(self.min_scale, self.max_scale)
+ if self.to_square:
+ short_side = min(h, w)
+ size = int(round(min(self.max_short_side, max(self.min_short_side, scale * short_side))))
+ if self.multiple_of is not None:
+ size = _to_multiple(size, self.multiple_of)
+ return size
+
+ short, long = min(h, w), max(h, w)
+ ratio = long / short
+ if ratio > self.max_ratio:
+ ratio = self.max_ratio
+
+ short_size = int(round(min(self.max_short_side, max(self.min_short_side, scale * short))))
+ if h < w:
+ h = short_size
+ w = int(ratio * short_size)
+ else:
+ h = int(ratio * short_size)
+ w = short_size
+
+ if self.multiple_of is not None:
+ w = _to_multiple(w, self.multiple_of)
+ h = _to_multiple(h, self.multiple_of)
+
+ return h, w
+
+
+@COLLATE_FUNCTIONS.register_module
+class ResizeFromListCollate(ResizeCollate):
+ """A collate function which resizes all images in a batch to the same size.
+
+ The target size image size is sampled from a list. If it's a list of integers,
+ all images will be resized into squares. If it's a list of tuples, that will be the
+ target (h, w) for images.
+
+ Args:
+ scales: The target sizes to resize the images to.
+ """
+
+ def __init__(self, scales: list[int] | list[tuple[int, int]], **kwargs) -> None:
+ super().__init__(**kwargs)
+ self.scales = scales
+
+ def _sample_scale(self) -> int | tuple[int, int]:
+ return self.generator.choice(self.scales)
+
+
+def _to_multiple(value: int, of: int) -> int:
+ """Returns: the smallest integer >= ``value`` which is a multiple of ``of``"""
+ return of * ((value + of - 1) // of)
diff --git a/deeplabcut/pose_estimation_pytorch/data/ctd.py b/deeplabcut/pose_estimation_pytorch/data/ctd.py
new file mode 100644
index 0000000000..1b054f9e84
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/ctd.py
@@ -0,0 +1,507 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import json
+import pickle
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+from deeplabcut.pose_estimation_pytorch.data.snapshots import Snapshot
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+class CondProvider(ABC):
+ """A class providing conditions for a CTD model."""
+
+ @classmethod
+ @abstractmethod
+ def get_loader_and_snapshot(
+ cls,
+ config: str | Path,
+ shuffle: int,
+ trainset_index: int = 0,
+ modelprefix: str = "",
+ snapshot: str | None = None,
+ snapshot_index: int | None = None,
+ ) -> tuple[DLCLoader, Snapshot]:
+ """Creates a DLCLoader for the BU shuffle and the path to conditions snapshot.
+
+ One of `snapshot` or `snapshot_index` must be provided.
+
+ Args:
+ config: Path to the DeepLabCut project config, or the project config itself
+ trainset_index: The index of the TrainingsetFraction for which to load data
+ shuffle: The index of the shuffle for which to load data.
+ modelprefix: The modelprefix for the shuffle.
+ snapshot: The name of the snapshot to use.
+ snapshot_index: The index of the snapshot to use. If `snapshot` is
+ provided, the `snapshot_index` is not used.
+
+ Returns:
+ loader: The DLCLoader for the BU shuffle.
+ snapshot: The BU Snapshot to use for conditions.
+
+ Raises:
+ ValueError: If the given shuffle is not for a BU model.
+ """
+ loader = DLCLoader(
+ config,
+ trainset_index=trainset_index,
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+ if loader.pose_task != Task.BOTTOM_UP:
+ raise ValueError(
+ "Conditions can only be loaded from shuffles for bottom-up models, but "
+ f"shuffle {shuffle} has task {loader.pose_task} (config={config}, "
+ f"trainset_index={trainset_index}, modelprefix={modelprefix})."
+ )
+
+ if snapshot is not None:
+ snapshot_path = loader.model_folder / snapshot
+ if not snapshot_path.exists():
+ raise ValueError(f"Snapshot file {snapshot_path} does not exist.")
+ bu_snapshot = Snapshot.from_path(snapshot_path)
+
+ else:
+ if snapshot_index is None:
+ snapshot_index = -1
+
+ snapshots = loader.snapshots()
+ if len(snapshots) == 0:
+ raise ValueError(f"No snapshots found for shuffle={shuffle} in {loader.model_folder}")
+
+ if snapshot_index > len(snapshots):
+ snapshot_str = "\n".join([f" {i}: {s.path.name}" for i, s in enumerate(snapshots)])
+ raise ValueError(f"Snapshot index {snapshot_index} is out of range. Existing snapshots: {snapshot_str}")
+
+ bu_snapshot = snapshots[snapshot_index]
+
+ return loader, bu_snapshot
+
+
+class CondFromFile(CondProvider):
+ """A class providing conditions for a CTD model from a file.
+
+ Args:
+ filepath: The path to the file containing the conditions for the CTD model.
+ These conditions must be pose predictions made by a BU model on the data
+ images: Only load the conditions for the given image keys.
+ kwargs: A `CondFromFile` instance can also be created from a DeepLabCut
+ shuffle by passing kwargs and setting `filepath=None`. See examples for more
+ information.
+ """
+
+ def __init__(
+ self,
+ filepath: str | Path | None = None,
+ **kwargs,
+ ) -> None:
+ if filepath is None:
+ # Load the conditions filepath from the Shuffle
+ bu_loader, bu_snapshot = self.get_loader_and_snapshot(**kwargs)
+ bu_scorer = bu_loader.scorer(bu_snapshot)
+ filepath = bu_loader.evaluation_folder / f"{bu_scorer}.h5"
+ if not filepath.exists():
+ raise ValueError(
+ f"Conditions file {filepath} does not exist. Please make sure "
+ f"snapshot {bu_snapshot.path.name} for {kwargs['shuffle']} "
+ f"was evaluated (which is when the predictions file is created)."
+ )
+ else:
+ filepath = Path(filepath)
+
+ if not filepath.exists():
+ raise ValueError(f"Conditions file {filepath} does not exist. Please check the given path.")
+
+ self.filepath = filepath
+
+ @classmethod
+ def get_loader_and_snapshot(
+ cls,
+ config: str | Path,
+ shuffle: int,
+ trainset_index: int = 0,
+ modelprefix: str = "",
+ snapshot: str | None = None,
+ snapshot_index: int | None = None,
+ ) -> tuple[DLCLoader, Snapshot]:
+ return super().get_loader_and_snapshot(
+ config=config,
+ shuffle=shuffle,
+ trainset_index=trainset_index,
+ modelprefix=modelprefix,
+ snapshot=snapshot,
+ snapshot_index=snapshot_index,
+ )
+
+ def load_conditions(
+ self,
+ images: list[str] | None = None,
+ path_prefix: str | None = None,
+ ) -> dict[str, np.ndarray] | list[np.ndarray]:
+ """Loads conditions for a model from a file.
+
+ When loading conditions for individual images, the `images` must be provided
+ (indicating which images to load conditions for). A dict is returned containing
+ the conditions for each requested image.
+
+ When loading conditions for a video, the `images` parameter must be set to None.
+ A list is returned containing the conditions for each frame.
+
+ Args:
+ images: A list of image paths to load conditions for.
+ path_prefix: Optional prefix to prepend to image paths when looking up
+ conditions. This is useful when the paths in the conditions file are
+ relative but the provided image paths are absolute, or vice versa.
+
+ Returns:
+ If "images" is given: a dictionary mapping image paths to condition arrays.
+ Each array has shape (num_conditions, num_bodyparts, 3).
+ If "images" is None: a list containing the conditions for each frame.
+ """
+ suffix = Path(self.filepath).suffix.lower()
+ if suffix == ".h5":
+ return self.load_conditions_h5(self.filepath, images, path_prefix)
+ elif suffix == ".json":
+ return self.load_conditions_json(self.filepath, images, path_prefix)
+ elif suffix == ".pickle":
+ return self.load_conditions_pickle(self.filepath)
+
+ raise ValueError(
+ f"Unknown file suffix {suffix}. Can only read conditions from HDF5 or JSON files. Received {self.filepath}."
+ )
+
+ @staticmethod
+ def load_conditions_h5(
+ filepath: str | Path,
+ images: list[str] | None = None,
+ path_prefix: str | Path | None = None,
+ ) -> dict[str, np.ndarray] | list[np.ndarray]:
+ """Loads conditions for a model from a pandas DataFrame stored in an HDF file.
+
+ When loading conditions for individual images, the `images` must be provided
+ (indicating which images to load conditions for). A dict is returned containing
+ the conditions for each requested image.
+
+ When loading conditions for a video, the `images` parameter must be set to None.
+ A list is returned containing the conditions for each frame.
+
+ The DataFrame must be in the same format as DeepLabCut Predictions. For
+ predictions on images (e.g. on a training/test set), the DataFrame should be in
+ the format:
+
+ ```
+ scorer model-name ...
+ individuals idv0 ... idvM
+ bodyparts bpt0 ... bptN
+ coords x y likelihood ... x y likelihood
+ ----------------------------------------------------------------------------
+ (labeled-data, v0, 0.png) 87.0 62.0 0.73 ... 83.2 99.1 0.8326
+ ```
+
+ While for conditions for videos, the DataFrame should be in the format:
+
+ ```
+ scorer model-name ...
+ individuals idv0 ... idvM
+ bodyparts bpt0 ... bptN
+ coords x y likelihood ... x y likelihood
+ ----------------------------------------------------------------------------
+ frame0000.png 87.0 62.0 0.73 ... 83.2 99.1 0.8326
+ ```
+
+ Args:
+ images: A list of image paths to load conditions for
+ filepath: Path to the JSON file containing conditions.
+ path_prefix: Optional prefix to prepend to image paths when looking up
+ conditions. This is useful when the paths in the conditions file are
+ relative but the provided image paths are absolute, or vice versa.
+
+ Returns:
+ If "images" is given: a dictionary mapping image paths to condition arrays.
+ Each array has shape (num_conditions, num_bodyparts, 3).
+ If "images" is None: a list containing the conditions for each frame.
+ """
+
+ def _parse_row(df_row) -> np.ndarray:
+ # Row to numpy and reshape
+ pose = df_row.to_numpy().reshape((num_conditions, num_bodyparts, 3))
+
+ # Remove missing data
+ missing_keypoints = np.any(np.isnan(pose) | (pose < 0), axis=2)
+ pose[missing_keypoints] = 0
+
+ # Only keep conditions with at least one visible keypoint
+ visible_conditions = np.any(~missing_keypoints, axis=1)
+ if np.sum(visible_conditions) > 0:
+ pose = pose[visible_conditions]
+ else:
+ pose = np.zeros((0, num_bodyparts, 3))
+
+ return pose
+
+ if path_prefix is not None:
+ path_prefix = Path(path_prefix)
+
+ df = pd.read_hdf(filepath)
+ if not isinstance(df, pd.DataFrame):
+ raise ValueError(f"{filepath} is not a dataframe.")
+
+ num_bodyparts = len(df.columns.get_level_values("bodyparts").unique())
+ num_conditions = 1
+ if "individuals" in df.columns.names:
+ num_conditions = len(df.columns.get_level_values("individuals").unique())
+
+ # Parse as list and return
+ if images is None:
+ parsed = []
+ for _, cond in df.iterrows():
+ parsed.append(_parse_row(cond))
+
+ return parsed
+
+ image_set = set(images)
+ conditions = {}
+ for filename, row in df.iterrows():
+ if isinstance(filename, tuple):
+ filename = str(Path(*filename))
+
+ if path_prefix is not None and filename not in image_set:
+ filename = str(path_prefix / filename)
+
+ if filename in image_set:
+ conditions[filename] = _parse_row(row)
+
+ missing = image_set.difference(set(conditions.keys()))
+ if len(missing) > 0:
+ print(
+ f"Warning: did not find conditions for {len(missing)} of the {len(images)} images. Missing conditions:"
+ )
+ for img_path in missing:
+ print(f" - {img_path}")
+
+ return conditions
+
+ @staticmethod
+ def load_conditions_json(
+ filepath: str | Path,
+ images: list[str] | None = None,
+ path_prefix: str | Path | None = None,
+ ) -> dict[str, np.ndarray] | list[np.ndarray]:
+ """Loads conditions for a model from a JSON file.
+
+ When loading conditions for individual images, the `images` must be provided
+ (indicating which images to load conditions for). A dict is returned containing
+ the conditions for each requested image. The JSON data structure should be:
+
+ ```
+ {
+ "img000.png": [ # conditions for image 0
+ [ # condition 0 pose
+ [x, y, score], # keypoint 0
+ [x, y, score], # keypoint 1
+ ...
+ [x, y, score], # keypoint N
+ ],
+ [ ... ], # condition 1
+ ...
+ [ ... ] # condition M
+ ],
+ "img001.png": [...] # conditions for image 1
+ }
+ ```
+
+ When loading conditions for a video, the `images` parameter must be set to None.
+ A list is returned containing the conditions for each frame. The JSON data
+ structure should be:
+
+ ```
+ [
+ [ # conditions for frame 0
+ [ # condition 0 pose
+ [x, y, score], # keypoint 0
+ [x, y, score], # keypoint 1
+ ...
+ [x, y, score], # keypoint N
+ ],
+ [ ... ], # condition 1
+ ...
+ [ ... ] # condition M
+ ],
+ [ ... ], # conditions for frame 1
+ ...
+ [ ... ] # conditions for frame N
+ ]
+ ```
+
+ Args:
+ images: A list of image paths to load conditions for.
+ filepath: Path to the JSON file containing conditions.
+ path_prefix: Optional prefix to prepend to image paths when looking up
+ conditions. This is useful when the paths in the conditions file are
+ relative but the provided image paths are absolute, or vice versa.
+
+ Returns:
+ A dictionary mapping image paths to condition arrays. Each array has shape
+ (num_conditions, num_bodyparts, 3).
+ """
+ with open(filepath) as f:
+ conditions = json.load(f)
+
+ # Parse list and return
+ if images is None:
+ if not isinstance(conditions, list):
+ raise ValueError(
+ f"Conditions are expected to be of type list when `images=None`, got {type(conditions)}."
+ )
+
+ parsed = []
+ for cond in conditions:
+ if len(cond) == 0:
+ parsed.append(np.zeros((0, 0, 3)))
+ else:
+ parsed.append(np.asarray(cond))
+ return parsed
+
+ if not isinstance(conditions, dict):
+ raise ValueError(
+ f"Conditions are expected to be of type dict, got {type(conditions)}. "
+ "They should be in the format 'labeled-data/video-0/img0000.png' -> "
+ "list[list[list[float]]], where the list represents an array of shape "
+ "(num_conditions, num_bodyparts, 3)."
+ )
+
+ path_with_prefix_to_key = {}
+ if path_prefix is not None:
+ path_with_prefix_to_key = {str(Path(path_prefix) / k): k for k in conditions.keys()}
+
+ parsed = {}
+ missing = []
+ for img_path in images:
+ if img_path in conditions:
+ pose = np.asarray(conditions[img_path])
+ elif img_path in path_with_prefix_to_key:
+ pose = np.asarray(conditions[path_with_prefix_to_key[img_path]])
+ else:
+ pose = np.zeros((0, 0, 3))
+ missing.append(img_path)
+
+ if len(pose) == 0:
+ pose = np.zeros((0, 0, 3))
+
+ parsed[img_path] = pose
+
+ if len(missing) > 0:
+ print(
+ f"Warning: did not find conditions for {len(missing)} of the {len(images)} images. Missing conditions:"
+ )
+ for img_path in missing:
+ print(f" - {img_path}")
+
+ return parsed
+
+ @staticmethod
+ def load_conditions_pickle(filepath: str | Path) -> list[np.ndarray]:
+ """Loads conditions from a `*_assemblies.pickle` file containing predictions.
+
+ Args:
+ filepath: Path to the Pickle file containing conditions.
+ """
+ with open(filepath, "rb") as f:
+ data = pickle.load(f)
+
+ frames = [f for f in data.keys() if isinstance(f, int)]
+ n_frames = max(*frames) + 1
+
+ parsed = []
+ for i in range(n_frames):
+ assemblies = data.get(i)
+ if assemblies is None or len(assemblies) == 0:
+ pose = np.zeros((0, 0, 3))
+ else:
+ pose = np.stack(assemblies, axis=0)[:, :, :3]
+
+ mask = np.any(np.all(pose > 0, axis=-1), axis=-1)
+ if np.sum(mask) == 0:
+ pose = np.zeros((0, 0, 3))
+ else:
+ pose = pose[mask]
+
+ parsed.append(pose)
+ return parsed
+
+
+class CondFromModel(CondProvider):
+ """A class providing conditions for a CTD model from a BU model.
+
+ Attributes:
+ config_path: (Path)
+ The path to the `pytorch_config.yaml` for the BU model to use as conditions.
+ snapshot_path: (Path)
+ The path to the BU snapshot to use to generate conditions for the CTD model.
+ scorer: str
+ The scorer name for the BU model. This can be used to look for files
+ containing conditions instead of recomputing them.
+
+ Args:
+ config_path: (Path)
+ The path to the `pytorch_config.yaml` for the BU model to use as conditions.
+ snapshot_path: (Path)
+ The path to the BU snapshot to use to generate conditions for the CTD model.
+ **kwargs: A `CondFromModel` instance can also be created from a DeepLabCut
+ shuffle. See examples for more information.
+ """
+
+ def __init__(
+ self,
+ config_path: str | Path | None = None,
+ snapshot_path: str | Path | None = None,
+ scorer: str | None = None,
+ **kwargs,
+ ) -> None:
+ if config_path is not None and snapshot_path is not None:
+ config_path = Path(config_path)
+ snapshot_path = Path(config_path)
+ elif "config" in kwargs and "shuffle" in kwargs:
+ bu_loader, snapshot = self.get_loader_and_snapshot(**kwargs)
+ config_path = bu_loader.model_config_path
+ snapshot_path = snapshot.path
+ if scorer is None:
+ scorer = bu_loader.scorer(snapshot)
+
+ self.config_path = config_path
+ self.snapshot_path = snapshot_path
+ self.scorer = scorer
+
+ @classmethod
+ def get_loader_and_snapshot(
+ cls,
+ config: str | Path,
+ shuffle: int,
+ trainset_index: int = 0,
+ modelprefix: str = "",
+ snapshot: str | None = None,
+ snapshot_index: int | None = None,
+ ) -> tuple[DLCLoader, Snapshot]:
+ return super().get_loader_and_snapshot(
+ config=config,
+ shuffle=shuffle,
+ trainset_index=trainset_index,
+ modelprefix=modelprefix,
+ snapshot=snapshot,
+ snapshot_index=snapshot_index,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py
new file mode 100644
index 0000000000..9ca893a6cc
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py
@@ -0,0 +1,507 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import albumentations as A
+import numpy as np
+from torch.utils.data import Dataset
+
+from deeplabcut.pose_estimation_pytorch.data.generative_sampling import (
+ GenerativeSampler,
+ GenSamplingConfig,
+)
+from deeplabcut.pose_estimation_pytorch.data.image import load_image, top_down_crop
+from deeplabcut.pose_estimation_pytorch.data.utils import (
+ _crop_image_keypoints,
+ _extract_keypoints_and_bboxes,
+ apply_transform,
+ bbox_from_keypoints,
+ calc_bbox_overlap,
+ map_id_to_annotations,
+ map_image_path_to_id,
+ out_of_bounds_keypoints,
+ pad_to_length,
+ safe_stack,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+@dataclass(frozen=True)
+class PoseDatasetParameters:
+ """Parameters for a pose dataset.
+
+ Attributes:
+ bodyparts: the names of bodyparts in the dataset
+ unique_bpts: the names of unique bodyparts, or an empty list
+ individuals: the names of individuals
+ with_center_keypoints: whether to compute center keypoints for individuals
+ color_mode: {"RGB", "BGR"} the mode to load images in
+ ctd_config: for CTD models, the configuration for bbox calculation and error sampling
+ top_down_crop_size: for top-down models, the (width, height) to crop bboxes to
+ top_down_crop_margin: for top-down models, the margin to add around bboxes
+ """
+
+ bodyparts: list[str]
+ unique_bpts: list[str]
+ individuals: list[str]
+ with_center_keypoints: bool = False
+ color_mode: str = "RGB"
+ ctd_config: GenSamplingConfig | None = None
+ top_down_crop_size: tuple[int, int] | None = None
+ top_down_crop_margin: int | None = None
+ top_down_crop_with_context: bool = True
+
+ @property
+ def num_joints(self) -> int:
+ return len(self.bodyparts)
+
+ @property
+ def num_unique_bpts(self) -> int:
+ return len(self.unique_bpts)
+
+ @property
+ def max_num_animals(self) -> int:
+ return len(self.individuals)
+
+
+@dataclass
+class PoseDataset(Dataset):
+ """A pose dataset."""
+
+ images: list[dict]
+ annotations: list[dict]
+ parameters: PoseDatasetParameters
+ transform: A.BaseCompose | None = None
+ mode: str = "train"
+ task: Task = Task.BOTTOM_UP
+ ctd_config: GenSamplingConfig | None = None
+
+ def __post_init__(self):
+ self.image_path_id_map = map_image_path_to_id(self.images)
+ self.annotation_idx_map = map_id_to_annotations(self.annotations)
+ self.img_id_to_index = {img["id"]: index for index, img in enumerate(self.images)}
+ if self.task == Task.TOP_DOWN and (
+ self.parameters.top_down_crop_size is None or self.parameters.top_down_crop_margin is None
+ ):
+ raise ValueError(
+ "You must specify a ``top_down_crop_size`` and ``top_down_crop_margin``"
+ "in your PoseDatasetParameters when the task is TOP_DOWN."
+ )
+
+ self.td_crop_size = self.parameters.top_down_crop_size
+ self.td_crop_margin = self.parameters.top_down_crop_margin
+
+ if self.task == Task.COND_TOP_DOWN:
+ if self.ctd_config is None:
+ raise ValueError("Must specify a ``ctd_config`` in your PoseDatasetParameters for CTD models.")
+
+ self.generative_sampler = GenerativeSampler(
+ self.parameters.num_joints,
+ **self.ctd_config.to_dict(),
+ )
+
+ def __len__(self):
+ # TODO: TD/CTD should only return the number of annotations that aren't unique_bodyparts
+ if self.task in (Task.BOTTOM_UP, Task.DETECT):
+ return len(self.images)
+
+ return len(self.annotations)
+
+ def _get_raw_item(self, index: int) -> tuple[str, list[dict], int]:
+ """Retrieve the image path and annotations for the specified index.
+
+ Args:
+ index (int): The index of the item to retrieve.
+
+ Returns:
+ tuple[str, list]: A tuple containing the image path and annotations.
+
+ Note:
+ This method is used by the __getitem__ method to fetch raw data from the dataset storage.
+ If `self.crop` is True, it returns the image path and a list with a single annotation.
+ Otherwise, it returns the image path and a list of annotations for all instances in the image.
+ """
+ img = self.images[index]
+ anns = [self.annotations[idx] for idx in self.annotation_idx_map[img["id"]]]
+ return img["file_name"], anns, img["id"]
+
+ def _get_raw_item_crop(self, index: int) -> tuple[str, list[dict], int]:
+ ann = self.annotations[index]
+ img = self.images[self.img_id_to_index[ann["image_id"]]]
+ return img["file_name"], [ann], img["id"]
+
+ def _get_raw_item_crop_context(self, index: int) -> tuple[str, list[dict], int]:
+ """Includes keypoints from other individuals in the image ("context")."""
+ ann = self.annotations[index]
+ img = self.images[self.img_id_to_index[ann["image_id"]]]
+ near_anns = []
+ for idx in self.annotation_idx_map[img["id"]]:
+ # we consider near annotations to be those whose bounding boxes overlap with
+ # the current item
+ # HACK: add same annotation as near keypoints so that we don't have empty list
+ if calc_bbox_overlap(ann["bbox"], self.annotations[idx]["bbox"]) > 0:
+ near_anns.append(self.annotations[idx])
+ return img["file_name"], [ann] + near_anns, img["id"]
+
+ def __getitem__(self, index: int) -> dict:
+ """Gets the item at the specified index from the dataset.
+
+ Args:
+ index: ordered number of the items in the dataset
+
+ Returns:
+ dict: corresponding to the image annotations, with keys:
+ {
+ "image": image tensor of shape (c, h, w),
+ "image_id": the ID of the image,
+ "path": the filepath to the image,
+ "original_size": the original (h, w) size before transforms
+ "offsets": the (x, y) offsets to apply to the keypoints in TD mode
+ "scales": the (x, y) scales to apply to the keypoints in TD mode
+ "annotations": {
+ "keypoints": array of keypoints, invisible keypoints appear as (-1,-1)
+ "keypoints_unique": the unique keypoints, if there are any
+ "area": array of animals area in this image
+ "boxes": the bounding boxes in this image
+ "is_crowd": is_crowd annotations
+ "labels": category_id annotations for boxes
+ },
+ }
+ """
+ image_path, anns, image_id = self._get_data_based_on_task(index)
+ image = load_image(image_path, color_mode=self.parameters.color_mode)
+ original_size = image.shape
+ (
+ keypoints,
+ keypoints_unique,
+ bboxes,
+ annotations_merged,
+ ) = self.extract_keypoints_and_bboxes(anns, image.shape)
+
+ # this is applying data augmentations before the cropping
+ # though normalization should be applied after the cropping
+ transformed = self.apply_transform_all_keypoints(image, keypoints, keypoints_unique, bboxes)
+ image = transformed["image"]
+ keypoints = transformed["keypoints"]
+ keypoints_unique = transformed["keypoints_unique"]
+ bboxes = transformed["bboxes"]
+ offsets = (0, 0)
+ scales = (1.0, 1.0)
+
+ if self.task in (Task.TOP_DOWN, Task.COND_TOP_DOWN):
+ if self.parameters.top_down_crop_size is None:
+ raise ValueError("You must specify a cropped image size for top-down models")
+ if len(bboxes) > 1 and self.task == Task.TOP_DOWN:
+ raise ValueError(
+ "There can only be one bbox per item in TD datasets, found "
+ f"{bboxes} for {index} (image {image_path})"
+ )
+ bboxes = bboxes.astype(int)
+
+ if self.task == Task.COND_TOP_DOWN:
+ near_keypoints = keypoints[1:]
+ keypoints = keypoints[:1]
+ synthesized_keypoints = self.generative_sampler(
+ keypoints=keypoints.reshape(-1, 3),
+ near_keypoints=near_keypoints.reshape(len(near_keypoints), -1, 3),
+ area=bboxes[0, 2] * bboxes[0, 3],
+ image_size=original_size,
+ )
+
+ # if conditional keypoints are empty, we take original bbox
+ if np.any(synthesized_keypoints[..., -1] > 0):
+ bboxes[0] = bbox_from_keypoints(
+ synthesized_keypoints,
+ original_size[0],
+ original_size[1],
+ self.ctd_config.bbox_margin,
+ )
+
+ if bboxes[0, 2] == 0 or bboxes[0, 3] == 0:
+ # bbox was augmented out of the image; blank image, no keypoints
+ keypoints[..., 2] = 0.0
+ if self.task == Task.COND_TOP_DOWN:
+ keypoints = safe_stack(
+ [keypoints, keypoints],
+ (2, 1, self.parameters.num_joints, 3),
+ )
+
+ image = np.zeros(
+ (self.td_crop_size[1], self.td_crop_size[0], image.shape[-1]),
+ dtype=image.dtype,
+ )
+ else:
+ image, offsets, scales = top_down_crop(
+ image,
+ bboxes[0],
+ self.parameters.top_down_crop_size,
+ self.parameters.top_down_crop_margin,
+ crop_with_context=self.parameters.top_down_crop_with_context,
+ )
+
+ keypoints[:, :, 0] = (keypoints[:, :, 0] - offsets[0]) / scales[0]
+ keypoints[:, :, 1] = (keypoints[:, :, 1] - offsets[1]) / scales[1]
+ if self.task == Task.COND_TOP_DOWN:
+ synthesized_keypoints[:, 0] = (synthesized_keypoints[:, 0] - offsets[0]) / scales[0]
+ synthesized_keypoints[:, 1] = (synthesized_keypoints[:, 1] - offsets[1]) / scales[1]
+ keypoints = safe_stack(
+ [keypoints, synthesized_keypoints[None, ...]],
+ (2, 1, self.parameters.num_joints, 3),
+ )
+
+ bboxes = bboxes[:1]
+ bboxes[..., 0] = (bboxes[..., 0] - offsets[0]) / scales[0]
+ bboxes[..., 1] = (bboxes[..., 1] - offsets[1]) / scales[1]
+ bboxes[..., 2] = bboxes[..., 2] / scales[0]
+ bboxes[..., 3] = bboxes[..., 3] / scales[1]
+ bboxes = np.clip(bboxes, 0, self.parameters.top_down_crop_size[0] - 1) # TODO: clip based on [x,y,x,y]?
+
+ # RandomBBoxTransform may move keypoints outside the cropped image
+ oob_mask = out_of_bounds_keypoints(keypoints, self.td_crop_size)
+ if np.sum(oob_mask) > 0:
+ keypoints[oob_mask, 2] = 0.0
+
+ if self.parameters.with_center_keypoints:
+ keypoints = self.add_center_keypoints(keypoints)
+
+ return self._prepare_final_data_dict(
+ image,
+ keypoints,
+ keypoints_unique,
+ original_size,
+ image_path,
+ bboxes,
+ image_id,
+ annotations_merged,
+ offsets,
+ scales,
+ )
+
+ def _prepare_final_data_dict(
+ self,
+ image: np.ndarray,
+ keypoints: np.ndarray,
+ keypoints_unique: np.ndarray,
+ original_size: tuple[int, int],
+ image_path: str,
+ bboxes: np.array,
+ image_id: int,
+ annotations_merged: dict,
+ offsets: tuple[int, int],
+ scales: tuple[float, float],
+ ) -> dict[str, np.ndarray | dict[str, np.ndarray]]:
+ context = dict()
+ if self.task == Task.COND_TOP_DOWN:
+ context["cond_keypoints"] = keypoints[1, :, :, :].astype(np.single)
+
+ return {
+ "image": image.transpose((2, 0, 1)),
+ "image_id": image_id,
+ "path": image_path,
+ "original_size": np.array(original_size),
+ "offsets": np.array(offsets, dtype=int),
+ "scales": np.array(scales, dtype=float),
+ "annotations": self._prepare_final_annotation_dict(keypoints, keypoints_unique, bboxes, annotations_merged),
+ "context": context,
+ }
+
+ def _prepare_final_annotation_dict(
+ self,
+ keypoints: np.ndarray,
+ keypoints_unique: np.ndarray,
+ bboxes: np.array,
+ anns: dict,
+ ) -> dict[str, np.ndarray]:
+ num_animals = self.parameters.max_num_animals
+ if self.task in (Task.TOP_DOWN, Task.COND_TOP_DOWN):
+ num_animals = 1
+
+ bbox_widths = np.maximum(1, bboxes[..., 2])
+ bbox_heights = np.maximum(1, bboxes[..., 3])
+ area = bbox_widths * bbox_heights
+ if "individual_id" not in anns:
+ anns["individual_id"] = -np.ones(len(anns["category_id"]), dtype=int)
+
+ individual_ids = anns["individual_id"]
+ is_crowd = anns["iscrowd"]
+ labels = anns["category_id"]
+ if self.task == Task.COND_TOP_DOWN:
+ keypoints = keypoints[0]
+ area = area[:1]
+ bboxes = bboxes[:1]
+ individual_ids = individual_ids[:1]
+ is_crowd = is_crowd[:1]
+ labels = labels[:1]
+
+ # we use ..., :3 to pass the visibility flag along
+ return {
+ "keypoints": pad_to_length(keypoints[..., :3], num_animals, 0).astype(np.single),
+ "keypoints_unique": keypoints_unique[..., :3].astype(np.single),
+ "with_center_keypoints": self.parameters.with_center_keypoints,
+ "area": pad_to_length(area, num_animals, 0).astype(np.single),
+ "boxes": pad_to_length(bboxes, num_animals, 0).astype(np.single),
+ "is_crowd": pad_to_length(is_crowd, num_animals, 0).astype(int),
+ "labels": pad_to_length(labels, num_animals, -1).astype(int),
+ "individual_ids": pad_to_length(individual_ids, num_animals, -1).astype(int),
+ }
+
+ def _get_data_based_on_task(self, index: int) -> tuple[str, list[dict], int]:
+ """Retrieve data based on the specified task.
+
+ For the 'TD' (top-down pose estimation) task:
+ - Provides a cropped image and its annotations.
+ - The shape of annotations['keypoints'] is (1, num_joints, 2).
+
+ For 'BU' and 'DT' tasks:
+ - Provides the full, non-cropped image and its annotations.
+ - The shape of annotations['keypoints'] is (max_num_animals, num_joints, 2).
+
+ Args:
+ index: Index of the item in the dataset.
+
+ Returns:
+ tuple: Tuple containing the image path, annotations, and image ID.
+ """
+ if self.task == Task.TOP_DOWN:
+ return self._get_raw_item_crop(index)
+ elif self.task == Task.COND_TOP_DOWN:
+ return self._get_raw_item_crop_context(index)
+ elif self.task in (Task.BOTTOM_UP, Task.DETECT):
+ return self._get_raw_item(index)
+
+ raise ValueError(f"Unknown task: {self.task}")
+
+ def apply_transform_all_keypoints(
+ self,
+ image: np.ndarray,
+ keypoints: np.ndarray,
+ keypoints_unique: np.ndarray,
+ bboxes: np.ndarray,
+ ) -> dict[str, np.ndarray]:
+ """Transforms the image using this class's transform.
+
+ Args:
+ image: the image to transform
+ keypoints: an array of shape (num_individuals, num_joints, 3) containing
+ the keypoints in the image
+ keypoints_unique: an array of shape (num_unique_bodyparts, 3) containing
+ the unique keypoints in the image
+ bboxes: the bounding boxes in the image
+
+ Returns:
+ the augmented image, keypoints and bboxes, in format
+ {
+ "image": (h, w, c),
+ "keypoints": (num_individuals, num_joints, 3),
+ "keypoints_unique": (num_unique_bodyparts, 3),
+ "bboxes": (4,),
+ }
+ """
+ class_labels = [f"individual{i}_{bpt}" for i in range(len(keypoints)) for bpt in self.parameters.bodyparts] + [
+ f"unique_{bpt}" for bpt in self.parameters.unique_bpts
+ ]
+
+ all_keypoints = keypoints.reshape(-1, 3)
+ if self.parameters.num_unique_bpts > 0:
+ all_keypoints = np.concatenate([all_keypoints, keypoints_unique], axis=0)
+
+ transformed = apply_transform(self.transform, image, all_keypoints, bboxes, class_labels=class_labels)
+ if self.parameters.num_unique_bpts > 0:
+ keypoints = transformed["keypoints"][: -self.parameters.num_unique_bpts].reshape(*keypoints.shape)
+ keypoints_unique = transformed["keypoints"][-self.parameters.num_unique_bpts :]
+ keypoints_unique = keypoints_unique.reshape(self.parameters.num_unique_bpts, 3)
+ else:
+ keypoints = transformed["keypoints"].reshape(*keypoints.shape)
+ keypoints_unique = np.zeros((0,))
+
+ transformed["keypoints"] = keypoints
+ transformed["keypoints_unique"] = keypoints_unique
+ transformed["bboxes"] = np.array(transformed["bboxes"])
+ if len(transformed["bboxes"]) == 0:
+ transformed["bboxes"] = np.zeros((0, 4))
+
+ return transformed
+
+ @staticmethod
+ def crop(
+ image: np.ndarray,
+ keypoints,
+ coords: tuple[tuple[int, int], tuple[int, int]],
+ output_size: tuple[int, int],
+ ) -> tuple[np.ndarray, np.ndarray, tuple[int, int], tuple[int, int]]:
+ """Crop the image based on a given bounding box and resize it to the desired
+ output size.
+
+ Args:
+ image: the image to transform
+ keypoints: an array of shape (num_individuals, num_joints, 3) containing
+ the keypoints in the image
+ coords: A bounding box defined as ((x_center, y_center), (width, height)).
+ output_size: Desired size for the output cropped, padded and resized image.
+
+ Returns:
+ Cropped (and possibly padded) and resized image.
+ Offsets used for cropping.
+ Padding sizes.
+ Scale factor used to resize the image.
+ """
+ return _crop_image_keypoints(image, keypoints, coords, output_size)
+
+ def extract_keypoints_and_bboxes(
+ self, anns: list[dict], image_shape: tuple[int, int, int]
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, np.ndarray]]:
+ """
+ Args:
+ anns: COCO-style annotations
+ image_shape: the (h, w, c) shape of the image for which to get annotations
+
+ Returns:
+ keypoints with shape (n_annotation, num_joints, 3)
+ unique_keypoints with shape (num_unique_bpts, 3)
+ bboxes in xywh format with shape (n_annotation, 4)
+ annotations_merged, where each key contains n_annotation values
+ """
+ return _extract_keypoints_and_bboxes(
+ anns,
+ image_shape,
+ self.parameters.num_joints,
+ self.parameters.num_unique_bpts,
+ )
+
+ @staticmethod
+ def add_center_keypoints(keypoints: np.ndarray) -> np.ndarray:
+ """Adds a keypoint in the mean of each individual.
+
+ Args:
+ keypoints: shape (num_idv, num_kpts, 3)
+
+ Returns:
+ keypoints with centers, of shape (num_idv, num_kpts + 1, 3)
+ """
+ num_idv = keypoints.shape[0]
+ centers = np.full((num_idv, 1, 3), np.nan)
+
+ keypoints_xy = keypoints.copy()[..., :2]
+ keypoints_xy[keypoints[..., 2] <= 0] = np.nan
+
+ # only set centers for individuals where at least 1 bodypart is visible
+ vis_mask = (~np.isnan(keypoints_xy) > 0).all(axis=2).any(axis=1)
+ if np.any(vis_mask):
+ centers[vis_mask, 0, :2] = np.nanmean(keypoints_xy[vis_mask], axis=1)
+
+ masked_centers = np.any(np.isnan(centers[:, 0, :2]), axis=1)
+ centers[masked_centers, 0, 2] = 0
+ centers[~masked_centers, 0, 2] = 2
+ np.nan_to_num(centers, copy=False, nan=0)
+
+ return np.concatenate((keypoints, centers), axis=1)
diff --git a/deeplabcut/pose_estimation_pytorch/data/dlcloader.py b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py
new file mode 100644
index 0000000000..c034f0e743
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py
@@ -0,0 +1,761 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Class implementing the Loader for DeepLabCut projects."""
+
+from __future__ import annotations
+
+import logging
+import pickle
+import re
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import scipy.io as sio
+
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns
+from deeplabcut.pose_estimation_pytorch.data.base import Loader
+from deeplabcut.pose_estimation_pytorch.data.dataset import PoseDatasetParameters
+from deeplabcut.pose_estimation_pytorch.data.snapshots import Snapshot
+from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints, read_image_shape_fast
+
+
+class DLCLoader(Loader):
+ """A Loader for DeepLabCut projects."""
+
+ def __init__(
+ self,
+ config: str | Path | dict,
+ trainset_index: int = 0,
+ shuffle: int = 0,
+ modelprefix: str = "",
+ ):
+ """
+ Args:
+ config: Path to the DeepLabCut project config, or the project config itself
+ trainset_index: the index of the TrainingsetFraction for which to load data
+ shuffle: the index of the shuffle for which to load data
+ modelprefix: the modelprefix for the shuffle
+ """
+ if isinstance(config, (str, Path)):
+ self._project_root = Path(config).parent
+ self._project_config = af.read_config(str(config))
+ else:
+ self._project_root = Path(config["project_path"])
+ self._project_config = config
+
+ self._shuffle = shuffle
+ self._trainset_index = trainset_index
+ self._train_frac = self._project_config["TrainingFraction"][trainset_index]
+ self._model_folder = af.get_model_folder(
+ self._train_frac,
+ shuffle,
+ self._project_config,
+ engine=Engine.PYTORCH,
+ modelprefix=modelprefix,
+ )
+ self._evaluation_folder = af.get_evaluation_folder(
+ trainFraction=self._train_frac,
+ shuffle=shuffle,
+ cfg=self._project_config,
+ engine=Engine.PYTORCH,
+ modelprefix=modelprefix,
+ )
+ model_config_path = self._project_root / self._model_folder / "train" / Engine.PYTORCH.pose_cfg_name
+ super().__init__(self._project_root, self._project_root, model_config_path)
+
+ # lazy-load split and DataFrames
+ self._split: dict[str, list[int]] | None = None
+ self._loaded_df: dict[str, pd.DataFrame] | None = None
+ self._resolutions = set()
+
+ @property
+ def project_cfg(self) -> dict:
+ """Returns: the configuration for the DeepLabCut project"""
+ return self._project_config
+
+ @property
+ def df(self) -> pd.DataFrame:
+ """Returns: The ground truth dataframe. Should not be modified."""
+ return self._dfs["full"]
+
+ @property
+ def df_test(self) -> pd.DataFrame:
+ """Returns: A copy of the DataFrame containing the test data."""
+ return self._dfs["test"].copy()
+
+ @property
+ def df_train(self) -> pd.DataFrame:
+ """Returns: A copy of the DataFrame containing the training data."""
+ return self._dfs["train"].copy()
+
+ def image_resolutions(self) -> set[tuple[int, int]]:
+ """Returns: The collection of image resolutions present in the dataset"""
+ return self._resolutions
+
+ @property
+ def evaluation_folder(self) -> Path:
+ """Returns: The path to the evaluation folder"""
+ return self._project_root / self._evaluation_folder
+
+ @property
+ def project_path(self) -> Path:
+ """Returns: The path to the DeepLabCut project"""
+ return self._project_root
+
+ @property
+ def shuffle(self) -> int:
+ """Returns: the shuffle being loaded"""
+ return self._shuffle
+
+ @property
+ def train_fraction(self) -> float:
+ """Returns: the fraction of the dataset used for training"""
+ return self._train_frac
+
+ @property
+ def split(self) -> dict[str, list[int]]:
+ if self._split is None:
+ self._split = self.load_split(self._project_config, self._trainset_index, self.shuffle)
+
+ return self._split
+
+ def scorer(
+ self,
+ snapshot: Snapshot | str | Path,
+ detector_snapshot: Snapshot | str | Path | None = None,
+ ) -> str:
+ """Returns the scorer for this DLCLoader and the given snapshot."""
+ task, date = self.project_cfg["Task"], self.project_cfg["date"]
+ name = "".join([p.capitalize() for p in self.model_cfg["net_type"].split("_")])
+
+ if not isinstance(snapshot, Snapshot):
+ snapshot = Snapshot.from_path(Path(snapshot))
+
+ snapshot_id = f"snapshot_{snapshot.uid()}"
+ if detector_snapshot is not None:
+ if not isinstance(detector_snapshot, Snapshot):
+ detector_snapshot = Snapshot.from_path(Path(detector_snapshot))
+
+ detect_id = detector_snapshot.uid()
+ snapshot_id = f"detector_{detect_id}_{snapshot_id}"
+
+ return f"DLC_{name}_{task}{date}shuffle{self.shuffle}_{snapshot_id}"
+
+ def get_dataset_parameters(self) -> PoseDatasetParameters:
+ """Retrieves dataset parameters based on the instance's configuration.
+
+ Returns:
+ An instance of the PoseDatasetParameters with the parameters set.
+ """
+ crop_cfg = self.model_cfg["data"]["train"].get("top_down_crop", {})
+ crop_w, crop_h = crop_cfg.get("width", 256), crop_cfg.get("height", 256)
+ crop_margin = crop_cfg.get("margin", 0)
+ crop_with_context = crop_cfg.get("crop_with_context", True)
+
+ return PoseDatasetParameters(
+ bodyparts=self.model_cfg["metadata"]["bodyparts"],
+ unique_bpts=self.model_cfg["metadata"]["unique_bodyparts"],
+ individuals=self.model_cfg["metadata"]["individuals"],
+ with_center_keypoints=self.model_cfg.get("with_center_keypoints", False),
+ color_mode=self.model_cfg.get("color_mode", "RGB"),
+ top_down_crop_size=(crop_w, crop_h),
+ top_down_crop_margin=crop_margin,
+ top_down_crop_with_context=crop_with_context,
+ )
+
+ def load_data(self, mode: str = "train") -> dict:
+ """Loads DeepLabCut data into COCO-style annotations.
+
+ This function reads data from h5 file, split the data and returns it in
+ COCO-like format
+
+ Args:
+ mode: mode indicating whether to use 'train' or 'test' data.
+
+ Raises:
+ AttributeError: if the specified mode (train or test) does not exist.
+
+ Returns:
+ the coco-style annotations
+ """
+ if mode not in ["train", "test"]:
+ raise AttributeError(f"Unknown mode: {mode}")
+ if mode not in self._dfs:
+ raise ValueError(f"No split for: {mode} (found {self._dfs.keys()})")
+ if self._dfs[mode] is None:
+ raise ValueError(f"No data in {mode} split for this shuffle!")
+
+ params = self.get_dataset_parameters()
+ data = self.to_coco(str(self._project_root), self._dfs[mode], params)
+ with_bbox = self._compute_bboxes(
+ data["images"],
+ data["annotations"],
+ method="keypoints",
+ bbox_margin=self.model_cfg["data"].get("bbox_margin", 20),
+ )
+ data["annotations"] = with_bbox
+ return data
+
+ def load_ground_truth(
+ self,
+ config: dict,
+ trainset_index: int,
+ shuffle: int,
+ ) -> tuple[dict[str, pd.DataFrame], set[tuple[int, int]]]:
+ """Loads the ground truth dataset for a DeepLabCut project.
+
+ Args:
+ config: the DeepLabCut project configuration file
+ trainset_index: the TrainingsetFraction for which to load data
+ shuffle: the index of the shuffle for which to load data
+
+ Returns: ground_truth_dataframes, image_resolutions
+ ground_truth_dataframes: a dictionary containing the different DataFrames
+ for the annotated DeepLabCut data for the current iteration
+ image_resolutions: all possible image resolutions in the dataset
+
+ Raises:
+ ValueError: if the data contained in the ground truth HDF does not contain
+ a dataframe.
+ """
+ trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config)
+ dataset_path = f"CollectedData_{config['scorer']}.h5"
+ train_frac = int(100 * config["TrainingFraction"][trainset_index])
+ project_id = f"{config['Task']}_{config['scorer']}"
+ dataset_file = trainset_dir / f"{project_id}{train_frac}shuffle{shuffle}"
+ params = self.get_dataset_parameters()
+
+ # as in TF DeepLabCut, load the training data from the .mat/.pickle file
+ if config.get("multianimalproject", False):
+ image_sizes, df_train = _load_pickle_dataset(
+ dataset_file.with_suffix(".pickle"),
+ config["scorer"],
+ params=params,
+ )
+ else:
+ image_sizes, df_train = _load_mat_dataset(
+ dataset_file.with_suffix(".mat"),
+ config["scorer"],
+ params=params,
+ )
+
+ # load the full dataset file
+ df = pd.read_hdf(trainset_dir / dataset_path)
+ if not isinstance(df, pd.DataFrame):
+ raise ValueError(f"The ground truth data in {trainset_dir} must contain a DataFrame! Found {df}")
+
+ # load the data splits, check that there's nothing suspect
+ dfs = self.split_data(df, self.split)
+ dfs["full"] = df
+ # let's not validate for now
+ # dfs = _validate_dataframes(dfs, df_train)
+ return dfs, image_sizes
+
+ @staticmethod
+ def load_split(
+ config: dict,
+ trainset_index: int = 0,
+ shuffle: int = 0,
+ ) -> dict[str, list[int]]:
+ """Loads the train/test split for a DeepLabCut shuffle.
+
+ Args:
+ config: the DeepLabCut project config
+ trainset_index: the TrainingsetFraction for which to load data
+ shuffle: the index of the shuffle for which to load data
+
+ Return:
+ the {"train": [train_ids], "test": [test_ids]} data split
+ """
+ trainset_dir = Path(config["project_path"]) / af.get_training_set_folder(config)
+ train_frac = int(100 * config["TrainingFraction"][trainset_index])
+ shuffle_id = f"{config['Task']}_{train_frac}shuffle{shuffle}.pickle"
+ doc_path = trainset_dir / f"Documentation_data-{shuffle_id}"
+
+ with open(doc_path, "rb") as f:
+ meta = pickle.load(f)
+
+ train_ids = [int(i) for i in meta[1]]
+ test_ids = [int(i) for i in meta[2]]
+ return {"train": train_ids, "test": test_ids}
+
+ @staticmethod
+ def load_predictions(
+ bu_snapshot: Path,
+ bu_predictions: Path,
+ parameters: PoseDatasetParameters,
+ ) -> pd.DataFrame:
+ if bu_predictions is None:
+ pred_path = Path(str(bu_snapshot).replace("dlc-models", "evaluation-results")).parent.parent
+ cfg = af.read_config(pred_path.parent.parent.parent / "config.yaml")
+ scorer = af.get_scorer_name(
+ cfg=cfg,
+ shuffle=int(re.search(r"shuffle(\d+)", str(bu_snapshot)).group(1)),
+ trainFraction=int(re.search(r"trainset(\d+)", str(bu_snapshot)).group(1)) / 100,
+ engine=Engine.PYTORCH,
+ trainingsiterations=re.search(r"snapshot-(.+)\.pth", str(bu_snapshot)).group(1),
+ modelprefix="",
+ )
+
+ pred_file = pred_path / f"{scorer[0]}.h5"
+ dlc_preds = pd.read_hdf(pred_file, key="df_with_missing")
+
+ # FIXME: Implement the case where snapshot is loaded
+ raise NotImplementedError("Need to implement the case with loaded snapshot")
+
+ else:
+ pred_path = bu_predictions.parent.parent
+ dlc_preds = pd.read_hdf(bu_predictions, key="df_with_missing")
+
+ predictions = {}
+ for idx in dlc_preds.index.unique():
+ if isinstance(idx, tuple):
+ img_path = pred_path.parent.parent / Path(*idx)
+ else:
+ img_path = pred_path.parent.parent / Path(idx)
+
+ keypoints = dlc_preds.loc[idx].values.reshape(-1, len(parameters.bodyparts), 3)[..., :2]
+ keypoints = keypoints[~np.isnan(keypoints).all(axis=-1).all(axis=-1)]
+ cond_keypoints = np.zeros((*keypoints.shape[:-1], 3))
+ cond_keypoints[..., :2] = keypoints
+ cond_keypoints[..., 2] = 2
+ predictions[str(img_path)] = cond_keypoints
+
+ return predictions
+
+ @staticmethod
+ def split_data(
+ dlc_df: pd.DataFrame,
+ split: dict[str, list[int]],
+ ) -> dict[str, pd.DataFrame | None]:
+ """Splits a DeepLabCut DataFrame into train/test dataframes.
+
+ Args:
+ dlc_df: the dataframe containing the labeled data
+ split: the train/test indices
+
+ Returns:
+ a dictionary containing the same keys as the split dictionary, where the
+ values are the rows of dlc_df with index in the split, or None if there are
+ no indices in that split
+ """
+ split_dfs = {}
+ for k, indices in split.items():
+ if len(indices) == 0:
+ split_dfs[k] = None
+ else:
+ split_dfs[k] = dlc_df.iloc[indices]
+ return split_dfs
+
+ @staticmethod
+ def to_coco(
+ project_root: str | Path,
+ df: pd.DataFrame,
+ parameters: PoseDatasetParameters,
+ ) -> dict:
+ """Formerly Shaokai's function.
+
+ Args:
+ project_root: the path to the project root
+ df: the DLC-format annotation dataframe to convert to a COCO-format dict
+ parameters: the parameters for pose estimation
+
+ Returns:
+ the coco format data
+ """
+ df = drop_likelihood_columns(df)
+
+ with_individuals = "individuals" in df.columns.names
+ if not with_individuals and (len(parameters.individuals) > 1 or len(parameters.unique_bpts) > 0):
+ raise ValueError(
+ "The DataFrame contains single-animal annotations (for a single, "
+ "individual), but the parameters suggest this is a multi-animal project"
+ f": {parameters} (with multiple individuals or unique bodyparts)"
+ )
+
+ categories = [
+ {
+ "id": 1,
+ "name": "animals",
+ "supercategory": "animal",
+ "keypoints": parameters.bodyparts,
+ },
+ ]
+ individuals = [idv for idv in parameters.individuals]
+ if len(parameters.unique_bpts) > 0:
+ individuals += ["single"]
+ categories.append(
+ {
+ "id": 2,
+ "name": "unique_bodypart",
+ "supercategory": "animal",
+ "keypoints": parameters.unique_bpts,
+ }
+ )
+
+ anns, images = [], []
+ base_path = Path(project_root)
+ for idx, row in df.iterrows():
+ image_id = len(images) + 1
+ rel_path = Path(*idx) if isinstance(idx, tuple) else Path(str(idx))
+ path = str(base_path / rel_path)
+ _, height, width = read_image_shape_fast(path)
+ images.append(
+ {
+ "id": image_id,
+ "file_name": path,
+ "width": width,
+ "height": height,
+ }
+ )
+
+ for idv_idx, idv in enumerate(individuals):
+ category_id = 1
+ individual_id = idv_idx
+ if with_individuals:
+ if idv == "single":
+ category_id = 2
+ individual_id = -1
+ data = row.xs(idv, level="individuals")
+ else:
+ data = row
+
+ raw_keypoints = data.to_numpy().reshape((-1, 2))
+ keypoints = np.zeros((len(raw_keypoints), 3))
+ keypoints[:, :2] = raw_keypoints
+ is_visible = np.logical_and(
+ ~pd.isnull(raw_keypoints).all(axis=1),
+ np.logical_and(
+ np.logical_and(
+ 0 < keypoints[..., 0],
+ keypoints[..., 0] < width,
+ ),
+ np.logical_and(
+ 0 < keypoints[..., 1],
+ keypoints[..., 1] < height,
+ ),
+ ),
+ )
+ keypoints[:, 2] = np.where(is_visible, 2, 0)
+ num_keypoints = is_visible.sum()
+ if num_keypoints > 0:
+ anns.append(
+ {
+ "id": len(anns) + 1,
+ "image_id": image_id,
+ "category_id": category_id,
+ "individual": idv,
+ "individual_id": individual_id,
+ "num_keypoints": num_keypoints,
+ "keypoints": keypoints,
+ "iscrowd": 0,
+ }
+ )
+
+ coco_dict = {"annotations": anns, "categories": categories, "images": images}
+ coco_dict = DLCLoader._add_bbox_annotations(coco_dict)
+ coco_dict = DLCLoader._remove_nans(coco_dict)
+ return coco_dict
+
+ @staticmethod
+ def _add_bbox_annotations(coco_dict: dict) -> dict:
+ for annotation in coco_dict.get("annotations", []):
+ if "bbox" not in annotation:
+ image = [img for img in coco_dict.get("images") if img.get("id") == annotation.get("image_id")][0]
+ bbox = bbox_from_keypoints(
+ keypoints=np.array(annotation["keypoints"]), # (..., num_keypoints, xy)
+ image_h=image.get("height"),
+ image_w=image.get("width"),
+ margin=20,
+ )
+ annotation["bbox"] = list(bbox)
+ return coco_dict
+
+ @staticmethod
+ def _remove_nans(coco_dict: dict) -> dict:
+ # Iterate through annotations and fix keypoints
+ for annotation in coco_dict.get("annotations", []):
+ if "keypoints" in annotation:
+ for keypoint in annotation["keypoints"]:
+ if any(isinstance(v, float) and np.isnan(v) for v in keypoint[:2]):
+ keypoint[0] = 0.0 # Replace x with 0
+ keypoint[1] = 0.0 # Replace y with 0
+ keypoint[2] = 0.0 # Ensure visibility is also 0
+ return coco_dict
+
+ @property
+ def _dfs(self) -> dict[str, pd.DataFrame]:
+ """Lazy-loading of the training dataset dataframes."""
+ if self._loaded_df is None:
+ self._loaded_df, image_sizes = self.load_ground_truth(
+ self._project_config,
+ trainset_index=self._trainset_index,
+ shuffle=self.shuffle,
+ )
+ self._resolutions = self._resolutions.union(image_sizes)
+
+ return self._loaded_df
+
+
+def _load_mat_dataset(
+ file: Path,
+ scorer: str,
+ params: PoseDatasetParameters,
+) -> tuple[set[tuple[int, int]], pd.DataFrame]:
+ """Loads the training dataset stored as a .mat file.
+
+ Returns: images_sizes, dlc_dataset
+ images_sizes: all possible images sizes in the dataset
+ dlc_dataset: the dataset in a DLC-format DataFrame
+ """
+ if not params.max_num_animals == 1:
+ raise RuntimeError(f"Cannot load a multi-animal pose dataset from a `.mat` file ({file})")
+
+ raw_data = sio.loadmat(str(file))
+ dataset = raw_data["dataset"]
+ num_images = dataset.shape[1]
+
+ image_sizes = set()
+ index, data = [], []
+ for i in range(num_images):
+ item = dataset[0, i]
+
+ # add the image size
+ c, h, w = item[1][0]
+ image_sizes.add((h, w))
+
+ # parse image path
+ raw_path = item[0][0]
+ if isinstance(raw_path, str):
+ image_path = Path(raw_path).parts[-3:]
+ else:
+ image_path = tuple([p.strip() for p in raw_path])
+ index.append(image_path)
+
+ # parse data
+ keypoints = np.zeros((1, params.num_joints, 2))
+ keypoints.fill(np.nan)
+ if len(item) >= 3:
+ joints = item[2][0][0]
+ for joint_id, x, y in joints:
+ keypoints[0, joint_id, 0] = x
+ keypoints[0, joint_id, 1] = y
+
+ joint_id = joints[:, 0]
+ if joint_id.size != 0: # make sure joint ids are 0-indexed
+ assert (joint_id < params.num_joints).any()
+ joints[:, 0] = joint_id
+
+ data.append(keypoints)
+
+ dataframe = pd.DataFrame(
+ data=np.stack(data, axis=0).reshape((num_images, -1)),
+ index=pd.MultiIndex.from_tuples(index),
+ columns=build_dlc_dataframe_columns(scorer, params, False),
+ )
+ dataframe = dataframe.sort_index(axis=0)
+ return image_sizes, dataframe
+
+
+def _load_pickle_dataset(
+ file: Path,
+ scorer: str,
+ params: PoseDatasetParameters,
+) -> tuple[set[tuple[int, int]], pd.DataFrame]:
+ """Loads the training dataset stored as a .mat file.
+
+ Returns: images_sizes, dlc_dataset
+ images_sizes: all possible images sizes in the dataset
+ dlc_dataset: the dataset in a DLC-format DataFrame
+ """
+ with open(file, "rb") as f:
+ raw_data = pickle.load(f)
+
+ num_images = len(raw_data)
+ image_sizes = set()
+ index, data = [], []
+ data_unique = None
+ if params.num_unique_bpts > 0:
+ data_unique = []
+
+ for image_data in raw_data:
+ # add image path
+ index.append(image_data["image"])
+
+ # add image size
+ c, h, w = image_data["size"]
+ image_sizes.add((h, w))
+
+ # add keypoints
+ keypoints = np.zeros((params.max_num_animals, params.num_joints, 2))
+ keypoints.fill(np.nan)
+ keypoints_unique = None
+ for idv_idx, idv_bodyparts in image_data.get("joints", {}).items():
+ if idv_idx < params.max_num_animals:
+ for joint_id, x, y in idv_bodyparts:
+ bodypart = int(joint_id)
+ keypoints[idv_idx, bodypart, 0] = x
+ keypoints[idv_idx, bodypart, 1] = y
+
+ elif idv_idx == params.max_num_animals and data_unique is not None and keypoints_unique is None:
+ keypoints_unique = np.zeros((params.num_unique_bpts, 2))
+ keypoints_unique.fill(np.nan)
+ for joint_id, x, y in idv_bodyparts:
+ unique_bpt_id = int(joint_id) - params.num_joints
+ keypoints_unique[unique_bpt_id, 0] = x
+ keypoints_unique[unique_bpt_id, 1] = y
+
+ else:
+ raise ValueError(f"Malformed dataset: {params}, {image_data}")
+
+ data.append(keypoints)
+ if data_unique is not None:
+ if keypoints_unique is None:
+ keypoints_unique = np.zeros((params.num_unique_bpts, 2))
+ keypoints_unique.fill(np.nan)
+ data_unique.append(keypoints_unique)
+
+ data = np.stack(data, axis=0).reshape((num_images, -1))
+ if data_unique is not None:
+ data_unique = np.stack(data_unique, axis=0).reshape((num_images, -1))
+ data = np.concatenate([data, data_unique], axis=1)
+
+ dataframe = pd.DataFrame(
+ data=data,
+ index=pd.MultiIndex.from_tuples(index),
+ columns=build_dlc_dataframe_columns(scorer, params, False),
+ )
+ dataframe = dataframe.sort_index(axis=0)
+ return image_sizes, dataframe
+
+
+def _validate_dataframes(
+ dfs: dict[str, pd.DataFrame],
+ df_train: pd.DataFrame,
+ strict: bool = False,
+) -> dict[str, pd.DataFrame]:
+ """Validates the training/test DataFrames.
+
+ Performs the following validation steps:
+ 1. Checks that the training data loaded from CollectedData.h5 matches the
+ training data stored in the ".mat" or ".pickle" file.
+ 2. Checks that there are no duplicate entries in the DataFrames (if there are
+ any, removes them)
+ 3. Checks that there is no data leak between the training and test set (if there
+ is, prints a warning)
+
+ Args:
+ dfs: the "full" and split DataFrames loaded from the H5 file
+ df_train: the training data loaded from the ".mat" or ".pickle" file
+ strict: Whether to fail if the data does not pass validation (instead of
+ attempting a fix).
+
+ Returns:
+ The validated and sanitized DataFrames
+
+ Raises:
+ ValueError: if strict and there is a small fixable error, or if there are images
+ that are present in both the training and test set.
+ """
+ error = False
+
+ # checks that all images in the .pickle/.mat file are in the HDF
+ pickle_train_images = set(df_train.index)
+ hdf_train_images = set(dfs["train"].index)
+ missing_images = pickle_train_images - hdf_train_images
+ extra_images = hdf_train_images - pickle_train_images
+ if len(missing_images) > 0:
+ error = True
+ logging.debug(f"Found images in the dataset file which were not in H5: {missing_images}")
+ if len(extra_images) > 0:
+ error = True
+ logging.debug(f"Found images in the H5 file which were not in the dataset: {extra_images}")
+
+ # checks that the data is close for the similar images
+ train_index = list(hdf_train_images.intersection(pickle_train_images))
+ data_h5 = np.nan_to_num(dfs["full"].loc[train_index], nan=-1)
+ data_pickle_mat = np.nan_to_num(df_train, nan=-1)
+ if not np.isclose(data_h5, data_pickle_mat, atol=0.1).all():
+ error = True
+ logging.debug(
+ "Found differences between the training-dataset HDF (.h5) data and the "
+ "training data found. This might be the case if you refined your data "
+ "after creating the dataset, and then created a new shuffle."
+ )
+
+ # checks that there are no duplicate entries
+ dfs_clean = {}
+ for split, df in dfs.items():
+ dup = df.index.duplicated(keep="first")
+ num_dup = dup.sum()
+ if dup.sum() > 0:
+ error = True
+ logging.debug(f"Found {num_dup} duplicates in {split}: {df[dup].index}")
+ dfs_clean[split] = df[~dup]
+ else:
+ dfs_clean[split] = df[~dup]
+
+ # check for leaks
+ if dfs["test"] is not None:
+ train_images = set(dfs["train"].index)
+ test_images = set(dfs["test"].index)
+ leak = train_images.intersection(test_images)
+ if len(leak) > 0:
+ logging.warning(
+ f"Found images both in the training and test set: {leak}! To resolve "
+ "this issue please try the following:\n"
+ f" 1. Check that each video is listed exactly once in your project's"
+ f"`config.yaml`\n"
+ f" 2. Make sure all of your videos have different names."
+ f" 3. You can use `dropduplicatesinannotatinfiles` and "
+ f"`comparevideolistsanddatafolders` to ensure that there are no more "
+ f"duplicates"
+ f" 3. Switch to a new iteration and create a fresh training dataset"
+ )
+
+ if error and strict:
+ raise ValueError("Found errors when validating the dataset")
+
+ return dfs
+
+
+def build_dlc_dataframe_columns(
+ scorer: str,
+ parameters: PoseDatasetParameters,
+ with_likelihood: bool,
+) -> pd.MultiIndex:
+ """Builds the columns for a DeepLabCut DataFrame.
+
+ Args:
+ scorer: the scorer name
+ parameters: the parameters for the project
+ with_likelihood: whether the DataFrame contains pose likelihood
+
+ Returns:
+ the multi-index columns for the DataFrame
+ """
+ levels = ["scorer", "individuals", "bodyparts", "coords"]
+ kpt_entries = ["x", "y"]
+ if with_likelihood:
+ kpt_entries.append("likelihood")
+
+ columns = []
+ for i in parameters.individuals:
+ for b in parameters.bodyparts:
+ columns += [(scorer, i, b, entry) for entry in kpt_entries]
+
+ for unique_bpt in parameters.unique_bpts:
+ columns += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries]
+
+ return pd.MultiIndex.from_tuples(columns, names=levels)
diff --git a/deeplabcut/pose_estimation_pytorch/data/generative_sampling.py b/deeplabcut/pose_estimation_pytorch/data/generative_sampling.py
new file mode 100644
index 0000000000..68289f737b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/generative_sampling.py
@@ -0,0 +1,421 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""A file containing code to perform generative sampling of keypoints for CTD.
+
+This code comes from PoseFix (see https://arxiv.org/pdf/1812.03595.pdf), and was then
+adapted for BUCTD (github.com/amathislab/BUCTD/blob/main/lib/dataset/pose_synthesis.py,
+see `synthesize_pose_fish(...)`).
+They say:
+> ... synthesized poses need to be diverse and realistic. To satisfy these properties,
+> we generate synthesized poses randomly based on the error distributions of real poses
+> as described in [24]. The distributions include the frequency of each pose error
+> (i.e., jitter, inversion, swap, and miss) according to the joint type, number of
+> visible keypoints, and overlap in the input image.
+> ...
+> Types of Keypoints:
+> Good. Good status is defined as a very small displacement from the GT keypoint.
+> Jitter. Jitter error is defined as a small displacement from the GT keypoint.
+> Inversion. Inversion error occurs when a pose estimation model is confused between
+> semantically similar parts that belong to the same instance.
+> Swap. Swap error represents a confusion between the same or similar parts which belong
+> to different persons.
+> Miss. Miss error represents a large displacement from the GT keypoint position.
+
+In BUCTD and their adaptation to the maDLC fish dataset, they set:
+ if cfg.DATASET.DATASET == 'coco':
+ kps_symmetry = [(1, 2), (3, 4), (5, 6), ...]
+ kps_sigmas = np.array([.26, .25, .25, ...]) / 10.0
+ elif cfg.DATASET.DATASET == 'crowdpose':
+ kps_sigmas = np.array([.79, .79, .72, ...])/10.0
+ kps_symmetry= [(0, 1), (2, 3), (4, 5), ...] # l/r shoulder, l/r elbow, wrist,
+ else:
+ kps_symmetry = []
+ kps_sigmas = np.array([1.] * num_kpts)/10.0
+"""
+
+from __future__ import annotations
+
+import math
+import random
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass(frozen=True)
+class GenSamplingConfig:
+ """Configuration for CTD models.
+
+ Args:
+ bbox_margin: The margin added around conditional keypoints
+ keypoint_sigmas: The sigma for each keypoint.
+ keypoints_symmetry: Indices of symmetric keypoints (e.g. left/right eye)
+ jitter_prob: The probability of applying jitter. Jitter error is defined as
+ a small displacement from the GT keypoint.
+ swap_prob: The probability of applying a swap error. Swap error represents
+ a confusion between the same or similar parts which belong to different
+ persons.
+ inv_prob: The probability of applying an inversion error. Inversion error
+ occurs when a pose estimation model is confused between semantically
+ similar parts that belong to the same instance.
+ miss_prob: The probability of applying a miss error. Miss error represents a
+ large displacement from the GT keypoint position.
+ """
+
+ bbox_margin: int
+ keypoint_sigmas: float | list[float] = 0.1
+ keypoints_symmetry: list[tuple[int, int]] | None = None
+ jitter_prob: float = 0.16
+ swap_prob: float = 0.08
+ inv_prob: float = 0.03
+ miss_prob: float = 0.10
+
+ def to_dict(self) -> dict:
+ return {
+ "keypoint_sigmas": self.keypoint_sigmas,
+ "keypoints_symmetry": self.keypoints_symmetry,
+ "jitter_prob": self.jitter_prob,
+ "swap_prob": self.swap_prob,
+ "inv_prob": self.inv_prob,
+ "miss_prob": self.miss_prob,
+ }
+
+
+class GenerativeSampler:
+ """Performs generative sampling of keypoints for CTD model training."""
+
+ def __init__(
+ self,
+ num_keypoints: int,
+ keypoint_sigmas: float | list[float] = 0.1,
+ keypoints_symmetry: list[tuple[int, int]] | None = None,
+ jitter_prob: float = 0.16,
+ swap_prob: float = 0.08,
+ inv_prob: float = 0.03,
+ miss_prob: float = 0.10,
+ ):
+ """
+ Args:
+ num_keypoints: the number of keypoints per individual
+ keypoint_sigmas: the sigma for each keypoint
+ keypoints_symmetry: indices of keypoints that are symmetric (e.g., left and
+ right eye)
+ jitter_prob: The probability of applying jitter. Jitter error is defined as
+ a small displacement from the GT keypoint.
+ swap_prob: The probability of applying a swap error. Swap error represents
+ a confusion between the same or similar parts which belong to different
+ persons.
+ inv_prob: The probability of applying an inversion error. Inversion error
+ occurs when a pose estimation model is confused between semantically
+ similar parts that belong to the same instance.
+ miss_prob: The probability of applying a miss error. Miss error represents a
+ large displacement from the GT keypoint position.
+ """
+ if isinstance(keypoint_sigmas, float):
+ keypoint_sigmas = num_keypoints * [keypoint_sigmas]
+
+ self.keypoint_sigmas = np.array(keypoint_sigmas)
+ self.keypoints_symmetry = keypoints_symmetry
+ self.num_keypoints = num_keypoints
+ self.jitter_prob = jitter_prob
+ self.swap_prob = swap_prob
+ self.inv_prob = inv_prob
+ self.miss_prob = miss_prob
+
+ def __call__(
+ self,
+ keypoints: np.ndarray,
+ near_keypoints: np.ndarray,
+ area: float,
+ image_size: tuple[int, int],
+ ) -> np.ndarray:
+ """Samples keypoints.
+
+ PoseFix uses conditional keypoints (estimated by a bottom-up model) when ground
+ truth keypoints are not available. For simplicity, we omit that. See
+ https://github.com/mks0601/PoseFix_RELEASE/blob/master/main/gen_batch.py#L76
+
+ Args:
+ keypoints: (num_keypoints, x-y-visibility) the ground truth keypoints
+ near_keypoints: (num_other_individuals, num_keypoints, x-y-visibility) joints
+ from other individuals near this one, for which keypoints might be swapped
+ area: the total area of the bounding box surrounding the keypoints
+
+ Returns:
+ the generative sampled keypoints, of shape (num_keypoints, x-y-visibility)
+ """
+ if not keypoints.shape[0] == self.num_keypoints:
+ raise ValueError(f"Expected {self.num_keypoints} kpts, had {keypoints}")
+
+ ks_10_dist = self.get_distance_wrt_keypoint_sim(0.10, area)
+ ks_50_dist = self.get_distance_wrt_keypoint_sim(0.50, area)
+ ks_85_dist = self.get_distance_wrt_keypoint_sim(0.85, area)
+
+ synth_joints = keypoints.copy()
+ # FIXME: In the original codebase, if some keypoints are not annotated then they
+ # use the predictions made by a pose model. This is complex to integrate into
+ # the current codebase (where is the prediction file saved? how do we load
+ # predictions? which model?) so we ignore it for now
+ # for j in range(self.num_keypoints):
+ # # in case of not annotated joints, use other models`s result and add noise
+ # if keypoints[j, 2] == 0:
+ # synth_joints[j] = estimated_joints[j]
+
+ # num_valid_joint = np.sum(keypoints[:, 2] > 0)
+
+ N = 500 # TODO: do not know how this is set
+ for j in range(self.num_keypoints):
+ # Skip unlabeled / invisible GT joints. Synthesizing a conditional
+ # keypoint for them creates a spurious cue and biases CTD training.
+ # Previously, this was prevented implicitly by NaN propagation; now
+ # we make the contract explicit. (Required since PR #2995)
+ if keypoints[j, 2] <= 0:
+ synth_joints[j] = 0 # (x, y, vis) = (0, 0, 0)
+ continue
+
+ # source keypoint position candidates to generate error on that (gt, swap, inv, swap+inv)
+ coord_list = []
+ # on top of gt
+ gt_coord = np.expand_dims(synth_joints[j, :2], 0)
+ coord_list.append(gt_coord)
+ # on top of swap gt
+ swap_coord = near_keypoints[near_keypoints[:, j, 2] > 0, j, :2]
+ coord_list.append(swap_coord)
+
+ # on top of inv gt, swap inv gt
+ if self.keypoints_symmetry is None or len(self.keypoints_symmetry) == 0:
+ # randomly sample keypoint pairs to swap
+ kps_symmetry = np.random.choice(
+ list(range(self.num_keypoints)),
+ size=(self.num_keypoints // 2, 2),
+ replace=False,
+ )
+ else:
+ kps_symmetry = self.keypoints_symmetry
+
+ pair_idx = None
+ for q, w in kps_symmetry:
+ if j == q or j == w:
+ if j == q:
+ pair_idx = w
+ else:
+ pair_idx = q
+
+ if pair_idx is not None and (keypoints[pair_idx, 2] > 0):
+ inv_coord = np.expand_dims(synth_joints[pair_idx, :2], 0)
+ coord_list.append(inv_coord)
+ else:
+ coord_list.append(np.empty([0, 2]))
+
+ if pair_idx is not None:
+ swap_inv_coord = near_keypoints[near_keypoints[:, pair_idx, 2] > 0, pair_idx, :2]
+ coord_list.append(swap_inv_coord)
+ else:
+ coord_list.append(np.empty([0, 2]))
+
+ # shape (s, 2)
+ tot_coord_list = np.concatenate(coord_list)
+
+ assert len(coord_list) == 4
+
+ # jitter error
+ synth_jitter = np.zeros(3)
+ jitter_prob = self.jitter_prob
+
+ angle = np.random.uniform(0, 2 * math.pi, [N])
+ r = np.random.uniform(ks_85_dist[j], ks_50_dist[j], [N])
+ jitter_idx = 0 # gt
+ x = tot_coord_list[jitter_idx][0] + r * np.cos(angle)
+ y = tot_coord_list[jitter_idx][1] + r * np.sin(angle)
+ dist_mask = True
+ for i in range(len(tot_coord_list)):
+ if i == jitter_idx:
+ continue
+ dist_mask = np.logical_and(
+ dist_mask,
+ np.sqrt((tot_coord_list[i][0] - x) ** 2 + (tot_coord_list[i][1] - y) ** 2) > r,
+ )
+
+ x = x[dist_mask].reshape(-1)
+ y = y[dist_mask].reshape(-1)
+ if len(x) > 0:
+ rand_idx = random.randrange(0, len(x))
+ synth_jitter[0] = x[rand_idx]
+ synth_jitter[1] = y[rand_idx]
+ synth_jitter[2] = 1
+
+ # miss error
+ synth_miss = np.zeros(3)
+ miss_prob = self.miss_prob
+
+ miss_pt_list = []
+ for miss_idx in range(len(tot_coord_list)):
+ angle = np.random.uniform(0, 2 * math.pi, [4 * N])
+ r = np.random.uniform(ks_50_dist[j], ks_10_dist[j], [4 * N])
+ x = tot_coord_list[miss_idx][0] + r * np.cos(angle)
+ y = tot_coord_list[miss_idx][1] + r * np.sin(angle)
+ dist_mask = True
+ for i in range(len(tot_coord_list)):
+ if i == miss_idx:
+ continue
+ dist_mask = np.logical_and(
+ dist_mask,
+ np.sqrt((tot_coord_list[i][0] - x) ** 2 + (tot_coord_list[i][1] - y) ** 2) > ks_50_dist[j],
+ )
+ x = x[dist_mask].reshape(-1)
+ y = y[dist_mask].reshape(-1)
+ if len(x) > 0:
+ if miss_idx == 0:
+ coord = np.transpose(np.vstack([x, y]), [1, 0])
+ miss_pt_list.append(coord)
+ else:
+ rand_idx = np.random.choice(range(len(x)), size=len(x) // 4)
+ x = np.take(x, rand_idx)
+ y = np.take(y, rand_idx)
+ coord = np.transpose(np.vstack([x, y]), [1, 0])
+ miss_pt_list.append(coord)
+ if len(miss_pt_list) > 0:
+ miss_pt_list = np.concatenate(miss_pt_list, axis=0).reshape(-1, 2)
+ rand_idx = random.randrange(0, len(miss_pt_list))
+ synth_miss[0] = miss_pt_list[rand_idx][0]
+ synth_miss[1] = miss_pt_list[rand_idx][1]
+ synth_miss[2] = 1
+
+ # inversion prob
+ synth_inv = np.zeros(3)
+ inv_prob = self.inv_prob
+ if pair_idx is not None and keypoints[pair_idx, 2] > 0:
+ angle = np.random.uniform(0, 2 * math.pi, [N])
+ r = np.random.uniform(0, ks_50_dist[j], [N])
+ inv_idx = len(coord_list[0]) + len(coord_list[1])
+ x = tot_coord_list[inv_idx][0] + r * np.cos(angle)
+ y = tot_coord_list[inv_idx][1] + r * np.sin(angle)
+ dist_mask = True
+ for i in range(len(tot_coord_list)):
+ if i == inv_idx:
+ continue
+ dist_mask = np.logical_and(
+ dist_mask,
+ np.sqrt((tot_coord_list[i][0] - x) ** 2 + (tot_coord_list[i][1] - y) ** 2) > r,
+ )
+ x = x[dist_mask].reshape(-1)
+ y = y[dist_mask].reshape(-1)
+ if len(x) > 0:
+ rand_idx = random.randrange(0, len(x))
+ synth_inv[0] = x[rand_idx]
+ synth_inv[1] = y[rand_idx]
+ synth_inv[2] = 1
+
+ # swap prob
+ synth_swap = np.zeros(3)
+ swap_exist = (len(coord_list[1]) > 0) or (len(coord_list[3]) > 0)
+ swap_prob = self.swap_prob
+
+ if swap_exist:
+ swap_pt_list = []
+ for swap_idx in range(len(tot_coord_list)):
+ if swap_idx == 0 or swap_idx == len(coord_list[0]) + len(coord_list[1]):
+ continue
+ angle = np.random.uniform(0, 2 * math.pi, [N])
+ r = np.random.uniform(0, ks_50_dist[j], [N])
+ x = tot_coord_list[swap_idx][0] + r * np.cos(angle)
+ y = tot_coord_list[swap_idx][1] + r * np.sin(angle)
+ dist_mask = True
+ for i in range(len(tot_coord_list)):
+ if i == 0 or i == len(coord_list[0]) + len(coord_list[1]):
+ dist_mask = np.logical_and(
+ dist_mask,
+ np.sqrt((tot_coord_list[i][0] - x) ** 2 + (tot_coord_list[i][1] - y) ** 2) > r,
+ )
+ x = x[dist_mask].reshape(-1)
+ y = y[dist_mask].reshape(-1)
+ if len(x) > 0:
+ coord = np.transpose(np.vstack([x, y]), [1, 0])
+ swap_pt_list.append(coord)
+
+ if len(swap_pt_list) > 0:
+ swap_pt_list = np.concatenate(swap_pt_list, axis=0).reshape(-1, 2)
+ rand_idx = random.randrange(0, len(swap_pt_list))
+ synth_swap[0] = swap_pt_list[rand_idx][0]
+ synth_swap[1] = swap_pt_list[rand_idx][1]
+ synth_swap[2] = 1
+
+ # good prob
+ synth_good = np.zeros(3)
+ good_prob = 1 - (jitter_prob + miss_prob + inv_prob + swap_prob)
+ assert good_prob >= 0
+ angle = np.random.uniform(0, 2 * math.pi, [N // 4])
+ r = np.random.uniform(0, ks_85_dist[j], [N // 4])
+ good_idx = 0 # gt
+ x = tot_coord_list[good_idx][0] + r * np.cos(angle)
+ y = tot_coord_list[good_idx][1] + r * np.sin(angle)
+ dist_mask = True
+ for i in range(len(tot_coord_list)):
+ if i == good_idx:
+ continue
+ dist_mask = np.logical_and(
+ dist_mask,
+ np.sqrt((tot_coord_list[i][0] - x) ** 2 + (tot_coord_list[i][1] - y) ** 2) > r,
+ )
+
+ x = x[dist_mask].reshape(-1)
+ y = y[dist_mask].reshape(-1)
+ if len(x) > 0:
+ rand_idx = random.randrange(0, len(x))
+ synth_good[0] = x[rand_idx]
+ synth_good[1] = y[rand_idx]
+ synth_good[2] = 1
+
+ if synth_jitter[2] == 0:
+ jitter_prob = 0
+ if synth_inv[2] == 0:
+ inv_prob = 0
+ if synth_swap[2] == 0:
+ swap_prob = 0
+ if synth_miss[2] == 0:
+ miss_prob = 0
+ if synth_good[2] == 0:
+ good_prob = 0
+
+ normalizer = jitter_prob + miss_prob + inv_prob + swap_prob + good_prob
+ if normalizer == 0:
+ synth_joints[j] = 0
+ continue
+
+ jitter_prob = jitter_prob / normalizer
+ miss_prob = miss_prob / normalizer
+ inv_prob = inv_prob / normalizer
+ swap_prob = swap_prob / normalizer
+ good_prob = good_prob / normalizer
+
+ prob_list = [jitter_prob, miss_prob, inv_prob, swap_prob, good_prob]
+ synth_list = [synth_jitter, synth_miss, synth_inv, synth_swap, synth_good]
+ sampled_idx = np.random.choice(5, 1, p=prob_list)[0]
+ synth_joints[j] = synth_list[sampled_idx]
+ synth_joints[j, 2] = 2
+
+ nan_mask = np.isnan(synth_joints).any(axis=1)
+ synth_joints[nan_mask, 2] = 0
+ np.clip(synth_joints[:, 0], 0, image_size[1], out=synth_joints[:, 0])
+ np.clip(synth_joints[:, 1], 0, image_size[0], out=synth_joints[:, 1])
+ return synth_joints
+
+ def get_distance_wrt_keypoint_sim(self, ks: float, area: float) -> np.ndarray:
+ """
+ Args:
+ ks: the desired keypoint similarity
+ area: the area of the bounding box for the individual
+
+ Returns:
+ For each bodypart, the L2 distance for which the keypoint similarity is
+ equal to ks
+ """
+ return np.sqrt(-2 * area * ((self.keypoint_sigmas * 2) ** 2) * np.log(ks))
diff --git a/deeplabcut/pose_estimation_pytorch/data/helper.py b/deeplabcut/pose_estimation_pytorch/data/helper.py
new file mode 100644
index 0000000000..ce73a141ad
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/helper.py
@@ -0,0 +1,95 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABCMeta
+
+
+def cfg_getter(key, default=None):
+ def _getter(cfg):
+ return cfg.get(key, default)
+
+ return _getter
+
+
+def class_property(func, arg_func):
+ """Decorator to create a class property.
+
+ Parameters:
+ - func: Callable that represents the logic of the property.
+ - arg_func: Callable that provides the arguments for `func`.
+
+ Returns:
+ - A property with the logic encapsulated in `func` and arguments derived from `arg_func`.
+ """
+
+ def decorator_wrapper(method):
+ def wrapper(self):
+ return func(arg_func(self))
+
+ return property(wrapper)
+
+ return decorator_wrapper
+
+
+class PropertyMeta(type):
+ """Metaclass for creating class properties in a more organized and systematic
+ manner.
+
+ This metaclass allows a class to define its properties using a simple dictionary
+ structure (`properties`). The dictionary keys represent the property names,
+ while the values are tuples containing two callables:
+ 1. The function that represents the logic of the property.
+ 2. The function that provides the arguments for the logic function.
+
+ Usage:
+ class MyClass(metaclass=PropertyMeta):
+ properties = {
+ 'property_name': (logic_function, arguments_function),
+ # ... more properties ...
+ }
+
+ For each property specified in the `properties` dictionary, the metaclass will
+ generate a real property that uses the logic from `logic_function` and
+ arguments from `arguments_function`.
+
+ Attributes:
+ - properties (dict): Dictionary containing property names as keys and tuples
+ of (logic_function, arguments_function) as values.
+ """
+
+ def __new__(cls, name, bases, attrs):
+ if "properties" not in attrs:
+ raise AttributeError(f"{name} must define a 'properties' dictionary.")
+ properties = attrs.get("properties", {})
+ for prop_name, (func, arg_func) in properties.items():
+ attrs[prop_name] = class_property(func, arg_func)(lambda self: None)
+ return super().__new__(cls, name, bases, attrs)
+
+
+class CombinedPropertyMeta(ABCMeta, PropertyMeta):
+ """Combined metaclass that integrates the functionalities of both `ABCMeta` and
+ `BasePropertyMeta`.
+
+ This metaclass is useful in scenarios where a class needs to use both abstract methods (from `ABCMeta`)
+ and the property definition utilities provided by `BasePropertyMeta`.
+
+ By using this metaclass, a class can be both an abstract class (with abstract methods and/or properties)
+ and can also define properties in the structured manner facilitated by `PropertyMeta`.
+
+ Inherits:
+ - ABCMeta: Metaclass for base classes that include abstract methods.
+ - PropertyMeta: Metaclass that facilitates structured property definitions.
+
+ Note:
+ When defining a class using `CombinedPropertyMeta`, ensure that the class also inherits
+ from `ABC` to make it compatible with the `ABCMeta` behavior.
+ """
diff --git a/deeplabcut/pose_estimation_pytorch/data/image.py b/deeplabcut/pose_estimation_pytorch/data/image.py
new file mode 100644
index 0000000000..09b20dbd7c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/image.py
@@ -0,0 +1,302 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Classes and functions to manipulate images."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+import cv2
+import numpy as np
+import torch
+import torchvision.transforms.functional as F
+
+from deeplabcut.pose_estimation_pytorch.data.utils import _compute_crop_bounds
+
+
+def load_image(filepath: str | Path, color_mode: str = "RGB") -> np.ndarray:
+ """Loads an image from a file using cv2.
+
+ Args:
+ filepath: the path of the file containing the image to load
+ color_mode: {'RGB', 'BGR'} the color mode to load the image with
+
+ Returns:
+ the image as a numpy array
+ """
+ image = cv2.imread(str(filepath))
+ if color_mode == "RGB":
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+ elif not color_mode == "BGR":
+ raise ValueError(f"Unsupported `color_mode`: {color_mode}")
+
+ return image
+
+
+def resize_and_random_crop(
+ image: np.ndarray,
+ targets: dict,
+ size: int | tuple[int, int],
+ max_size: int | None = None,
+ max_shift: int | None = None,
+) -> tuple[torch.tensor, dict]:
+ """Resizes images while preserving their aspect ratio.
+
+ If size is an integer: resizes to square images.
+ First, resizes the image so that it's short side is equal to `size`. If this
+ makes its long side greater than `max_size`, resizes the long side to `max_size`
+ and the short side to the corresponding value to preserve the aspect ratio.
+
+ Then, the image is cropped to a size-by-size square with a random crop.
+
+ If size is a tuple, resize images to (w=size[1], h=size[0])
+ First, rescales the image while preserving the aspect ratio such that both its
+ width and height are greater or equal to the target width/height for the image
+ (where either the width/height is the target width/height). If this makes its
+ long side greater than `max_size`, resizes the long side to `max_size`.
+
+ Then, the image is cropped to (w=size[1], h=size[0]) with a random crop.
+
+ Args:
+ image: an image of shape (C, H, W)
+ targets: the dictionary containing targets
+ size: the size of the output image (it will be square)
+ max_size: if defined, the maximum size of any side of the output image
+ max_shift: the maximum shift for the crop after resizing
+
+ Returns: image, targets
+ the resized image as a PyTorch tensor
+ the updated targets in the resized image
+ """
+
+ def get_resize_hw(
+ original_size: tuple[int, int], tgt_short_side: int, max_long_side: int | None
+ ) -> tuple[int, int]:
+ short_side, long_side = min(*original_size), max(*original_size)
+ tgt_long_side = int((tgt_short_side / short_side) * long_side)
+
+ # if the image's long side will be too big, make the image smaller
+ if max_long_side is not None and tgt_long_side > max_long_side:
+ tgt_long_side = max_long_side
+ tgt_short_side = int((tgt_long_side / long_side) * short_side)
+
+ # height is the short side
+ if original_size[0] < original_size[1]:
+ return tgt_short_side, tgt_long_side
+
+ # width is the short side
+ return tgt_long_side, tgt_short_side
+
+ def get_resize_preserve_ratio(
+ oh: int, ow: int, tgt_h: int, tgt_w: int, max_long_side: int | None
+ ) -> tuple[int, int]:
+ w_scale = ow / tgt_w
+ h_scale = oh / tgt_h
+ if h_scale <= w_scale:
+ h = tgt_h
+ w = int(ow * (tgt_h / oh))
+ else:
+ h = int(oh * (tgt_w / ow))
+ w = tgt_w
+
+ # if the image's long side will be too big, make the image smaller
+ long_side = max(h, w)
+ if max_long_side is not None and long_side > max_long_side:
+ if h <= w:
+ w = max_long_side
+ h = int(oh * (max_long_side / ow))
+ else:
+ w = int(ow * (max_long_side / oh))
+ h = max_long_side
+
+ return h, w
+
+ def scale_kpts(
+ keypoints: np.ndarray, kpt_scale: np.ndarray, kpt_offset: np.ndarray, tgt_h: int, tgt_w: int
+ ) -> np.ndarray:
+ scaled_kpts = keypoints.copy()
+ scaled_kpts[..., :2] = (scaled_kpts[..., :2] / kpt_scale) - kpt_offset
+ scaled_kpts[(scaled_kpts[..., 0] >= tgt_w)] = -1
+ scaled_kpts[(scaled_kpts[..., 1] >= tgt_h)] = -1
+ scaled_kpts[(scaled_kpts[..., :2] < 0).any(axis=-1)] = -1
+ return scaled_kpts
+
+ oh, ow = image.shape[1:]
+ if isinstance(size, int):
+ h, w = get_resize_hw((oh, ow), tgt_short_side=size, max_long_side=max_size)
+ tgt_h, tgt_w = size, size
+ else:
+ h, w = get_resize_preserve_ratio(oh, ow, size[0], size[1], max_long_side=max_size)
+ tgt_h, tgt_w = size
+
+ scale_x, scale_y = ow / w, oh / h
+ scaled_image = F.resize(torch.tensor(image), [h, w])
+
+ # shift the image
+ if max_shift is None:
+ max_shift = 0
+ extra_x, extra_y = max(0, w - tgt_w), max(0, h - tgt_h)
+ offset_x = np.random.randint(
+ max(-tgt_w // 2, -max(0, tgt_w - w) - max_shift),
+ min(max_shift + extra_x, extra_x + (min(w, tgt_w) // 2)),
+ )
+ offset_y = np.random.randint(
+ max(-tgt_h // 2, -max(0, tgt_h - h) - max_shift),
+ min(max_shift + extra_y, extra_y + (min(h, tgt_h) // 2)),
+ )
+
+ # 0-pads, then crops if image size is smaller than output size along any edge
+ scaled_cropped_image = F.crop(scaled_image, offset_y, offset_x, tgt_h, tgt_w)
+
+ # update targets
+ targets = copy.deepcopy(targets)
+
+ # update scales and offsets
+ sx, sy = targets["scales"]
+ ox, oy = targets["offsets"]
+ targets["offsets"] = ox + (offset_x * sx), oy + (offset_y * sy)
+ targets["scales"] = sx * scale_x, sy * scale_y
+
+ # update annotations and context
+ anns = targets.get("annotations", {})
+ context = targets.get("context", {})
+
+ kpt_scale = np.array([scale_x, scale_y])
+ kpt_offset = np.array([offset_x, offset_y])
+ for kpt_key in ["keypoints", "keypoints_unique"]:
+ keypoints = anns.get(kpt_key)
+ if keypoints is not None and len(keypoints) > 0:
+ anns[kpt_key] = scale_kpts(keypoints, kpt_scale, kpt_offset, tgt_h, tgt_w)
+ cond_keypoints = context.get("cond_keypoints")
+ if cond_keypoints is not None and len(cond_keypoints) > 0:
+ context["cond_keypoints"] = scale_kpts(cond_keypoints, kpt_scale, kpt_offset, tgt_h, tgt_w)
+
+ bbox_scale = np.array([scale_x, scale_y, scale_x, scale_y])
+ bbox_offset = np.array([offset_x, offset_y, 0, 0])
+ for bbox_key in ["boxes"]:
+ boxes = anns.get(bbox_key)
+ if boxes is not None and len(boxes) > 0:
+ scaled_boxes = (boxes / bbox_scale) - bbox_offset
+ scaled_boxes = _compute_crop_bounds(
+ scaled_boxes,
+ (tgt_h, tgt_w, 3),
+ remove_empty=False,
+ )
+ anns[bbox_key] = scaled_boxes
+
+ area = anns.get("area")
+ if area is not None:
+ if "boxes" in anns: # recompute areas from the new bounding boxes
+ widths = np.maximum(anns["boxes"][..., 2], 1)
+ heights = np.maximum(anns["boxes"][..., 3], 1)
+ anns["area"] = widths * heights
+ else: # just rescale
+ scaled_area = area * (scale_x * scale_y)
+ anns["area"] = scaled_area
+
+ return scaled_cropped_image, targets
+
+
+def top_down_crop(
+ image: np.ndarray,
+ bbox: np.ndarray,
+ output_size: tuple[int, int],
+ margin: int = 0,
+ center_padding: bool = False,
+ crop_with_context: bool = True,
+) -> tuple[np.array, tuple[int, int], tuple[float, float]]:
+ """Crops images around bounding boxes for top-down pose estimation. Computes offsets
+ so that coordinates in the original image can be mapped to the cropped one;
+
+ x_cropped = (x - offset_x) / scale_x
+ x_cropped = (y - offset_y) / scale_y
+
+ Bounding boxes are expected to be in COCO-format (xywh).
+
+ Args:
+ image: (h, w, c) the image to crop
+ bbox: (4,) the bounding box to crop around
+ output_size: the (width, height) of the output cropped image
+ margin: a margin to add around the bounding box before cropping
+ center_padding: whether to center the image in the padding if any is needed
+ crop_with_context: Whether to keep context around the bounding box when cropping
+
+ Returns:
+ cropped_image, (offset_x, offset_y), (scale_x, scale_y)
+ """
+ image_h, image_w, c = image.shape
+ out_w, out_h = output_size
+ x, y, w, h = bbox
+
+ cx = x + w / 2
+ cy = y + h / 2
+ w += 2 * margin
+ h += 2 * margin
+
+ if crop_with_context:
+ input_ratio = w / h
+ output_ratio = out_w / out_h
+ if input_ratio > output_ratio: # h/w < h0/w0 => h' = w * h0/w0
+ h = w / output_ratio
+ elif input_ratio < output_ratio: # w/h < w0/h0 => w' = h * w0/h0
+ w = h * output_ratio
+
+ # cx,cy,w,h will now give the right ratio -> check if padding is needed
+ x1, y1 = int(round(cx - (w / 2))), int(round(cy - (h / 2)))
+ x2, y2 = int(round(cx + (w / 2))), int(round(cy + (h / 2)))
+
+ # pad symmetrically - compute total padding across axis
+ pad_left, pad_right, pad_top, pad_bottom = 0, 0, 0, 0
+ if x1 < 0:
+ pad_left = -x1
+ x1 = 0
+ if x2 > image_w:
+ pad_right = x2 - image_w
+ x2 = image_w
+ if y1 < 0:
+ pad_top = -y1
+ y1 = 0
+ if y2 > image_h:
+ pad_bottom = y2 - image_h
+ y2 = image_h
+
+ w, h = x2 - x1, y2 - y1
+ if not crop_with_context:
+ input_ratio = w / h
+ output_ratio = out_w / out_h
+ if input_ratio > output_ratio: # h/w < h0/w0 => h' = w * h0/w0
+ w_pad = int(w - h * output_ratio) // 2
+ pad_top += w_pad
+ pad_bottom += w_pad
+
+ elif input_ratio < output_ratio: # w/h < w0/h0 => w' = h * w0/h0
+ h_pad = int(h - (w / output_ratio)) // 2
+ pad_left += h_pad
+ pad_right += h_pad
+
+ pad_x = pad_left + pad_right
+ pad_y = pad_top + pad_bottom
+ if center_padding:
+ pad_left = pad_x // 2
+ pad_top = pad_y // 2
+
+ # crop the pixels we care about
+ image_crop = np.zeros((h + pad_y, w + pad_x, c), dtype=image.dtype)
+ image_crop[pad_top : pad_top + h, pad_left : pad_left + w] = image[y1:y2, x1:x2]
+
+ # resize the cropped image
+ image = cv2.resize(image_crop, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
+
+ # compute scale and offset
+ offset = x1 - pad_left, y1 - pad_top
+ scale = (w + pad_x) / out_w, (h + pad_y) / out_h
+ return image, offset, scale
diff --git a/deeplabcut/pose_estimation_pytorch/data/postprocessor.py b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py
new file mode 100644
index 0000000000..e408b9b498
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py
@@ -0,0 +1,588 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Post-process predictions made by models."""
+
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Any
+
+import numpy as np
+
+from deeplabcut.pose_estimation_pytorch.data.preprocessor import Context
+from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity
+
+
+class Postprocessor(ABC):
+ """A post-processor can be called on the output of a model
+ TODO: Documentation
+ """
+
+ @abstractmethod
+ def __call__(self, predictions: Any, context: Context) -> Any:
+ """Post-processes the outputs of a model into a single prediction.
+
+ Args:
+ predictions: the predictions made by the model on a single image
+ context: the context returned by the pre-processor with the image
+
+ Returns:
+ a single post-processed prediction
+ """
+ pass
+
+
+def build_bottom_up_postprocessor(
+ max_individuals: int,
+ num_bodyparts: int,
+ num_unique_bodyparts: int,
+ with_identity: bool = False,
+ with_backbone_features: bool = False,
+) -> ComposePostprocessor:
+ """Creates a postprocessor for bottom-up pose estimation (or object detection)
+
+ Args:
+ max_individuals: the maximum number of individuals in a single image
+ num_bodyparts: the number of bodyparts output by the model
+ num_unique_bodyparts: the number of unique_bodyparts output by the model
+ with_identity: whether the model has an identity head
+ with_backbone_features: When True, the backbone features are extracted from
+ the output and saved in a `features` key. The `PoseModel` must have its
+ `output_features` attribute set to True, or this will raise an Exception.
+
+ Returns:
+ A default bottom-up Postprocessor
+ """
+ keys_to_concatenate = {"bodyparts": ("bodypart", "poses")}
+ empty_shapes = {"bodyparts": (num_bodyparts, 3)}
+ keys_to_rescale = ["bodyparts"]
+
+ if num_unique_bodyparts > 0:
+ keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses")
+ empty_shapes["unique_bodyparts"] = (num_bodyparts, 3)
+ keys_to_rescale.append("unique_bodyparts")
+
+ if with_identity:
+ keys_to_concatenate["identity_heatmap"] = ("identity", "heatmap")
+ empty_shapes["identity_heatmap"] = (1, 1, max_individuals)
+
+ if with_backbone_features:
+ keys_to_concatenate["features"] = ("backbone", "features")
+ empty_shapes["features"] = (num_bodyparts, 0, 1)
+
+ components = [
+ ConcatenateOutputs(
+ keys_to_concatenate=keys_to_concatenate,
+ empty_shapes=empty_shapes,
+ create_empty_outputs=True,
+ ),
+ ]
+
+ if with_identity:
+ components.append(
+ PredictKeypointIdentities(
+ identity_key="identity_scores",
+ identity_map_key="identity_heatmap",
+ pose_key="bodyparts",
+ keep_id_maps=False,
+ )
+ )
+
+ components += [
+ RescaleAndOffset(
+ keys_to_rescale=keys_to_rescale,
+ mode=RescaleAndOffset.Mode.KEYPOINT,
+ ),
+ PadOutputs(
+ max_individuals={
+ "bodyparts": max_individuals,
+ "identity_scores": max_individuals,
+ },
+ pad_value=-1,
+ expected_shapes={
+ "bodyparts": (num_bodyparts, 3),
+ "identity_scores": (num_bodyparts, max_individuals),
+ },
+ ),
+ ]
+
+ if with_identity:
+ components.append(
+ AssignIndividualIdentities(
+ identity_key="identity_scores",
+ pose_key="bodyparts",
+ )
+ )
+
+ return ComposePostprocessor(components=components)
+
+
+def build_top_down_postprocessor(
+ max_individuals: int,
+ num_bodyparts: int,
+ num_unique_bodyparts: int,
+ with_backbone_features: bool = False,
+) -> Postprocessor:
+ """Creates a postprocessor for top-down pose estimation.
+
+ Args:
+ max_individuals: the maximum number of individuals in a single image
+ num_bodyparts: the number of bodyparts output by the model
+ num_unique_bodyparts: the number of unique_bodyparts output by the model
+ with_backbone_features: When True, the backbone features are extracted from
+ the output and saved in a `features` key. The `PoseModel` must have its
+ `output_features` attribute set to True, or this will raise an Exception.
+
+ Returns:
+ A default top-down Postprocessor
+ """
+ keys_to_concatenate = {"bodyparts": ("bodypart", "poses")}
+ empty_shapes = {"bodyparts": (num_bodyparts, 3)}
+ keys_to_rescale = ["bodyparts"]
+ if num_unique_bodyparts > 0:
+ keys_to_concatenate["unique_bodyparts"] = ("unique_bodypart", "poses")
+ empty_shapes["unique_bodyparts"] = (num_unique_bodyparts, 3)
+ keys_to_rescale.append("unique_bodyparts")
+
+ if with_backbone_features:
+ keys_to_concatenate["features"] = ("backbone", "features")
+ empty_shapes["features"] = (num_bodyparts, 0, 1)
+
+ return ComposePostprocessor(
+ components=[
+ ConcatenateOutputs(
+ keys_to_concatenate=keys_to_concatenate,
+ empty_shapes=empty_shapes,
+ create_empty_outputs=True,
+ ),
+ RescaleAndOffset(
+ keys_to_rescale=keys_to_rescale,
+ mode=RescaleAndOffset.Mode.KEYPOINT_TD,
+ ),
+ AddContextToOutput(keys=["bboxes", "bbox_scores"]),
+ PadOutputs(
+ max_individuals={
+ "bodyparts": max_individuals,
+ "bboxes": max_individuals,
+ "bbox_scores": max_individuals,
+ },
+ pad_value=-1,
+ expected_shapes={
+ "bodyparts": (num_bodyparts, 3),
+ "bboxes": (4,),
+ "bbox_scores": (), # scalar
+ },
+ ),
+ ]
+ )
+
+
+def build_detector_postprocessor(
+ max_individuals: int,
+ min_bbox_score: float | None = None,
+) -> Postprocessor:
+ """Creates a postprocessor for top-down pose estimation.
+
+ Args:
+ max_individuals: the maximum number of detections to keep in a single image
+ min_bbox_score: the threshold for filtering bounding boxes. Only bboxes
+ with a value higher than this threshold are kept, the rest is removed.
+
+ Returns:
+ A default top-down Postprocessor
+ """
+ components = [
+ ConcatenateOutputs(
+ keys_to_concatenate={
+ "bboxes": ("detection", "bboxes"),
+ "bbox_scores": ("detection", "scores"),
+ }
+ ),
+ TrimOutputs(
+ max_individuals={
+ "bboxes": max_individuals,
+ "bbox_scores": max_individuals,
+ },
+ ),
+ BboxToCoco(bounding_box_keys=["bboxes"]),
+ RescaleAndOffset(
+ keys_to_rescale=["bboxes"],
+ mode=RescaleAndOffset.Mode.BBOX_XYWH,
+ ),
+ ]
+ if min_bbox_score is not None:
+ components.append(RemoveLowConfidenceBoxes(min_bbox_score))
+ return ComposePostprocessor(components=components)
+
+
+class ComposePostprocessor(Postprocessor):
+ """Class to preprocess an image and turn it into a batch of inputs before running
+ inference."""
+
+ def __init__(self, components: list[Postprocessor]) -> None:
+ self.components = components
+
+ def __call__(self, predictions: Any, context: Context) -> tuple[Any, Context]:
+ for postprocessor in self.components:
+ predictions, context = postprocessor(predictions, context)
+ return predictions, context
+
+
+class ConcatenateOutputs(Postprocessor):
+ """Checks that there is a single prediction for the image and returns it."""
+
+ def __init__(
+ self,
+ keys_to_concatenate: dict[str, tuple[str, str]],
+ empty_shapes: dict[str, tuple[int, ...]] | None = None,
+ create_empty_outputs: bool = False,
+ ):
+ self.keys_to_concatenate = keys_to_concatenate
+ self.empty_shapes = empty_shapes
+ self.create_empty_outputs = create_empty_outputs
+
+ if self.create_empty_outputs:
+ if not all([k in self.empty_shapes for k in self.keys_to_concatenate]):
+ raise ValueError(
+ "You must provide the expected shape for all keys to concatenate"
+ f" when create_empty_outputs is true, found {self.empty_shapes}"
+ )
+
+ def __call__(self, predictions: Any, context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ if len(predictions) == 0:
+ outputs = {name: np.zeros((0, *self.empty_shapes[name])) for name in self.keys_to_concatenate.keys()}
+ return outputs, context
+
+ outputs = {}
+ for output_name, head_key in self.keys_to_concatenate.items():
+ head_name, val_name = head_key
+ outputs[output_name] = np.concatenate([p[head_name][val_name] for p in predictions])
+
+ return outputs, context
+
+
+class PadOutputs(Postprocessor):
+ """Pads the outputs to have the maximum number of individuals."""
+
+ def __init__(
+ self,
+ max_individuals: dict[str, int],
+ pad_value: int,
+ expected_shapes: dict[str, tuple[int, ...]],
+ ):
+ self.max_individuals = max_individuals
+ self.pad_value = pad_value
+ self.expected_shapes = expected_shapes
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ for name in predictions:
+ output = predictions[name]
+ output = np.array(output) # Normalize all inputs to np.ndarray
+
+ expected_shape = self.expected_shapes.get(name, ())
+ expected_ndim = 1 + len(expected_shape) # individuals_dimension + expected shape for single individual
+
+ # Special handling for empty arrays
+ if len(output) == 0:
+ output = np.empty((0, *expected_shape), dtype=float)
+ elif output.ndim < expected_ndim:
+ output = np.reshape(output, (len(output), *expected_shape))
+
+ if name in self.max_individuals and len(output) < self.max_individuals[name]:
+ pad_size = self.max_individuals[name] - len(output)
+ tail_shape = output.shape[1:]
+ padding = self.pad_value * np.ones((pad_size, *tail_shape), dtype=output.dtype)
+ output = np.concatenate([output, padding], axis=0)
+
+ predictions[name] = output
+
+ return predictions, context
+
+
+class TrimOutputs(Postprocessor):
+ """Ensures all outputs have at most `max_individuals` detections.
+
+ Assumes that the outputs are sorted by decreasing score, such that the first
+ `max_individuals` predictions are the ones to keep.
+ """
+
+ def __init__(self, max_individuals: dict[str, int]):
+ self.max_individuals = max_individuals
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ for name in predictions:
+ output = predictions[name]
+ if len(output) > self.max_individuals[name]:
+ predictions[name] = output[: self.max_individuals[name]]
+
+ return predictions, context
+
+
+class RescaleAndOffset(Postprocessor):
+ """Rescales and offsets predictions back to their position in the original image.
+
+ This can be done in 3 ways:
+ BBOX_XYWH: the data has shape (num_individuals, 4), in xywh format, and there
+ is a single scale and offset for all bounding boxes (e.g., because the image
+ was resized before being passed to a detector)
+ KEYPOINT: the data has shape (num_individuals, num_keypoints, 2/3), and there
+ is a single scale and offset for all individuals (e.g., because the image
+ was resized before being passed to a BU pose model)
+ KEYPOINT_TD: the data has shape (num_individuals, num_keypoints, 2/3), and there
+ are num_individuals scales and offsets (one for each individual, as TD crops
+ one image per individual)
+
+ If no scale and no offsets are given, then this postprocessor simply forwards the
+ predictions and context.
+ """
+
+ class Mode(Enum):
+ BBOX_XYWH = "bbox_xywh"
+ KEYPOINT = "keypoint"
+ KEYPOINT_TD = "keypoint_td"
+
+ def __init__(
+ self,
+ keys_to_rescale: list[str],
+ mode: RescaleAndOffset.Mode,
+ ) -> None:
+ super().__init__()
+ self.keys_to_rescale = keys_to_rescale
+ self.mode = mode
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ if "scales" not in context and "offsets" not in context:
+ # no rescaling needed
+ return predictions, context
+
+ updated_predictions = {}
+ scales, offsets = context["scales"], context["offsets"]
+ for name, outputs in predictions.items():
+ if name in self.keys_to_rescale:
+ if self.mode == self.Mode.BBOX_XYWH:
+ rescaled = outputs.copy()
+ rescaled[:, 0] = outputs[:, 0] * scales[0] + offsets[0]
+ rescaled[:, 1] = outputs[:, 1] * scales[1] + offsets[1]
+ rescaled[:, 2] = outputs[:, 2] * scales[0]
+ rescaled[:, 3] = outputs[:, 3] * scales[1]
+ elif self.mode == self.Mode.KEYPOINT:
+ rescaled = outputs.copy()
+ rescaled[..., :2] = outputs[..., :2] * scales + offsets
+ else: # Mode.KEYPOINT_TD
+ if not len(outputs) == len(scales) == len(offsets):
+ raise ValueError(
+ "There must be as many 'scales' and 'offsets' as outputs, found "
+ f"{len(outputs)}, {len(scales)}, {len(offsets)}"
+ )
+
+ if len(outputs) == 0:
+ rescaled = outputs
+ else:
+ rescaled_individuals = []
+ for output, scale, offset in zip(outputs, scales, offsets, strict=False):
+ output_rescaled = output.copy()
+ output_rescaled[:, :2] = output[:, :2] * scale + offset
+ rescaled_individuals.append(output_rescaled)
+ rescaled = np.stack(rescaled_individuals)
+
+ # rescoring: https://github.com/amathislab/BUCTD/blob/main/lib/dataset/crowdpose.py#L182-L206
+ if "cond_kpts" in context:
+ kpt_scores = rescaled[:, :, 2].copy()
+ valid_kpt_scores = kpt_scores >= 0.2
+
+ num_valid_kpts = np.sum(valid_kpt_scores, axis=1)
+ num_valid_kpts[num_valid_kpts == 0] = 1
+ kpt_scores[~valid_kpt_scores] = 0
+ kpt_score_sums = np.sum(kpt_scores, axis=1)
+ idv_scores = kpt_score_sums / num_valid_kpts
+
+ cond_kpt_scores = np.mean(context["cond_kpts"][:, :, 2], axis=1)
+
+ rescaled[:, :, 2] = (cond_kpt_scores * idv_scores).reshape(-1, 1)
+
+ updated_predictions[name] = rescaled
+ else:
+ updated_predictions[name] = outputs.copy()
+
+ return updated_predictions, context
+
+
+class RemoveLowConfidenceBoxes(Postprocessor):
+ """Removes low confidence bounding boxes from detector output before they reach the
+ pose estimator."""
+
+ def __init__(self, bbox_score_thresh: float):
+ super().__init__()
+ logging.info("utilizing low confidence bbox filtering")
+ self.bbox_score_thresh = bbox_score_thresh
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ above_threshold = predictions["bbox_scores"] >= self.bbox_score_thresh
+ keepers = np.where(above_threshold)
+ if any(~above_threshold):
+ predictions["bboxes"] = predictions["bboxes"][keepers]
+ predictions["bbox_scores"] = predictions["bbox_scores"][keepers]
+ return predictions, context
+
+
+class BboxToCoco(Postprocessor):
+ """Transforms bounding boxes from xyxy to COCO format (xywh)"""
+
+ def __init__(self, bounding_box_keys: list[str]) -> None:
+ super().__init__()
+ self.bounding_box_keys = bounding_box_keys
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ for bbox_key in self.bounding_box_keys:
+ predictions[bbox_key][:, 2] -= predictions[bbox_key][:, 0]
+ predictions[bbox_key][:, 3] -= predictions[bbox_key][:, 1]
+
+ return predictions, context
+
+
+class AddContextToOutput(Postprocessor):
+ """Adds items from the context to the output, such as the bounding boxes contained
+ during top-down inference."""
+
+ def __init__(self, keys: list[str]) -> None:
+ super().__init__()
+ self.keys = keys
+
+ def __call__(
+ self,
+ predictions: dict[str, np.ndarray],
+ context: Context,
+ ) -> tuple[dict[str, np.ndarray], Context]:
+ for k in self.keys:
+ if k in context:
+ predictions[k] = context[k].copy()
+ return predictions, context
+
+
+class PredictKeypointIdentities(Postprocessor):
+ """Assigns predicted identities to keypoints.
+
+ The identity maps have shape (h, w, num_ids).
+
+ Attributes:
+ identity_key: Key with which to add predicted identities in the predictions dict
+ identity_map_key: Key for the identity maps in the predictions dict
+ pose_key: Key for the bodyparts in the predictions dict
+ keep_id_maps: Whether to keep identity heatmaps in the output dictionary.
+ Setting this value to True can be useful for debugging, but can lead to
+ memory issues when running video analysis on long videos.
+ """
+
+ def __init__(
+ self,
+ identity_key: str,
+ identity_map_key: str,
+ pose_key: str,
+ keep_id_maps: bool = False,
+ ) -> None:
+ self.identity_key = identity_key
+ self.identity_map_key = identity_map_key
+ self.pose_key = pose_key
+ self.keep_id_maps = keep_id_maps
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ pose = predictions[self.pose_key]
+ num_preds, num_keypoints, _ = pose.shape
+
+ identity_heatmap = predictions[self.identity_map_key] # (h, w, num_ids)
+ h, w, num_ids = identity_heatmap.shape
+
+ id_score_matrix = np.zeros((num_preds, num_keypoints, num_ids))
+ for pred_idx, individual_keypoints in enumerate(pose):
+ heatmap_indices = np.rint(individual_keypoints).astype(int)
+ xs = np.clip(heatmap_indices[:, 0], 0, w - 1)
+ ys = np.clip(heatmap_indices[:, 1], 0, h - 1)
+
+ # get the score from each identity heatmap at each predicted keypoint
+ for kpt_idx, (x, y) in enumerate(zip(xs, ys, strict=False)):
+ id_score_matrix[pred_idx, kpt_idx] = identity_heatmap[y, x, :]
+
+ predictions[self.identity_key] = id_score_matrix
+ if not self.keep_id_maps:
+ # delete the heatmaps as this saves memory
+ id_heatmaps = predictions.pop(self.identity_map_key)
+ del id_heatmaps
+
+ return predictions, context
+
+
+class AssignIndividualIdentities(Postprocessor):
+ """Assigns predicted identities to individuals.
+
+ Attributes:
+ identity_key: Key with which to add predicted identities in the predictions dict
+ pose_key: Key for the bodyparts in the predictions dict
+ """
+
+ def __init__(self, identity_key: str, pose_key: str) -> None:
+ self.identity_key = identity_key
+ self.pose_key = pose_key
+
+ def __call__(self, predictions: dict[str, np.ndarray], context: Context) -> tuple[dict[str, np.ndarray], Context]:
+ map_ = assign_identity(predictions["bodyparts"], predictions["identity_scores"])
+ predictions["bodyparts"] = predictions["bodyparts"][map_]
+ predictions["identity_scores"] = predictions["identity_scores"][map_]
+ return predictions, context
+
+
+class PrepareBackboneFeatures(Postprocessor):
+ """Adds backbone features for each individual and keypoint to the outputs.
+
+ Attributes:
+ top_down: Whether the model is a top-down model.
+ """
+
+ def __init__(self, top_down: bool) -> None:
+ self.top_down = top_down
+
+ def __call__(self, predictions: Any, context: Context) -> tuple[Any, Context]:
+ if self.top_down:
+ input_w, input_h = context["top_down_crop_size"]
+ else:
+ input_w, input_h = context["image_size"]
+
+ for pred in predictions:
+ features: np.ndarray = pred["backbone"]["features"]
+ pose: np.ndarray = pred["bodypart"]["poses"]
+
+ # only extract features from valid pose
+ mask = ~np.all((pose < 0) | np.isnan(pose), axis=(1, 2))
+ pose = pose[mask]
+ pred["bodypart"]["poses"] = pose.copy()
+
+ pose = np.nan_to_num(pose, nan=0)
+
+ num_features, h, w = features.shape
+ backbone_stride = input_w / w, input_h / h
+
+ num_preds, num_keypoints, _ = pose.shape
+
+ bodypart_features = np.zeros((num_preds, num_keypoints, num_features))
+ indices = np.rint(pose[..., :2] / backbone_stride).astype(int)
+ indices[..., 0] = np.clip(indices[..., 0], 0, w - 1)
+ indices[..., 1] = np.clip(indices[..., 1], 0, h - 1)
+
+ for idv, idv_indices in enumerate(indices):
+ for kpt, (x, y) in enumerate(idv_indices):
+ # only assign features if the pose was defined
+ if np.sum(x + y) > 0:
+ bodypart_features[idv, kpt] = features[:, y, x]
+
+ pred["backbone"]["bodypart_features"] = bodypart_features
+
+ return predictions, context
diff --git a/deeplabcut/pose_estimation_pytorch/data/preprocessor.py b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py
new file mode 100644
index 0000000000..d3c6cd35b4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py
@@ -0,0 +1,499 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Helpers to run preprocess data before running inference."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any, TypeVar
+
+import albumentations as A
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.data.image import load_image, top_down_crop
+from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints
+
+Image = TypeVar("Image", torch.Tensor, np.ndarray, str, Path)
+Context = TypeVar("Context", dict[str, Any], None)
+
+
+class Preprocessor(ABC):
+ """Class to preprocess an image and turn it into a batch of inputs before running
+ inference.
+
+ As an example, a pre-processor can load an image, use a "bboxes" key from context to
+ crop bounding boxes for individuals (going from a (h, w, 3) array to a
+ (num_individuals, h, w, 3) array), and convert it into a tensor ready for inference.
+ """
+
+ @abstractmethod
+ def __call__(self, image: Image, context: Context) -> tuple[Image, Context]:
+ """Pre-processes an image.
+
+ Args:
+ image: an image (containing height, width and channel dimensions) or a
+ batch of images linked to a single input (containing an extra batch
+ dimension)
+ context: the context for this image or batch of images (such as bounding
+ boxes, conditional pose, ...)
+
+ Returns:
+ the pre-processed image (or batch of images) and their context
+ """
+ pass
+
+
+def build_bottom_up_preprocessor(color_mode: str, transform: A.BaseCompose) -> Preprocessor:
+ """Creates a preprocessor for bottom-up pose estimation (or object detection)
+
+ Creates a preprocessor that loads an image, runs some transform on it (such as
+ normalization), creates a tensor from the numpy array (going from (h, w, 3) to
+ (3, h, w)) and adds a batch dimension (so the final tensor shape is (1, 3, h, w))
+
+ Args:
+ color_mode: whether to load the image as an RGB or BGR
+ transform: the transform to apply to the image
+
+ Returns:
+ A default bottom-up Preprocessor
+ """
+ return ComposePreprocessor(
+ components=[
+ LoadImage(color_mode),
+ AugmentImage(transform),
+ ToTensor(),
+ ToBatch(),
+ ]
+ )
+
+
+def build_top_down_preprocessor(
+ color_mode: str,
+ transform: A.BaseCompose,
+ top_down_crop_size: tuple[int, int],
+ top_down_crop_margin: int = 0,
+ top_down_crop_with_context: bool = True,
+) -> Preprocessor:
+ """Creates a preprocessor for top-down pose estimation.
+
+ Creates a preprocessor that loads an image, crops all bounding boxes given as a
+ context (through a "bboxes" key), runs some transforms on each cropped image (such
+ as normalization), creates a tensor from the numpy array (going from
+ (num_ind, h, w, 3) to (num_ind, 3, h, w)).
+
+ Args:
+ color_mode: whether to load the image as an RGB or BGR
+ transform: the transform to apply to the image
+ top_down_crop_size: the (width, height) to resize cropped bboxes to
+ top_down_crop_margin: the margin to add around detected bboxes for the crop
+ top_down_crop_with_context: whether to keep context when applying the top-down crop
+
+ Returns:
+ A default top-down Preprocessor
+ """
+ return ComposePreprocessor(
+ components=[
+ LoadImage(color_mode),
+ TopDownCrop(
+ output_size=top_down_crop_size,
+ margin=top_down_crop_margin,
+ with_context=top_down_crop_with_context,
+ ),
+ AugmentImage(transform),
+ ToTensor(),
+ ]
+ )
+
+
+def build_conditional_top_down_preprocessor(
+ color_mode: str,
+ transform: A.BaseCompose,
+ bbox_margin: int,
+ top_down_crop_size: tuple[int, int],
+ top_down_crop_margin: int = 0,
+ top_down_crop_with_context: bool = False,
+) -> Preprocessor:
+ """Creates a preprocessor for conditional top-down pose estimation.
+
+ Creates a preprocessor that loads an image, computes bounding boxes from conditional
+ keypoints (given as a context (through a "cond_kpts" key), crops all bounding boxes,
+ runs some transforms on each cropped image (such as normalization), creates a tensor
+ from the numpy array (going from (num_ind, h, w, 3) to (num_ind, 3, h, w)).
+
+ Args:
+ color_mode: whether to load the image as an RGB or BGR
+ transform: the transform to apply to the image
+ bbox_margin: The margin to add around keypoints when generating bounding boxes
+ from conditional keypoints.
+ top_down_crop_size: the (width, height) to resize cropped bboxes to
+ top_down_crop_margin: the margin to add around detected bboxes for the crop
+ top_down_crop_with_context: whether to keep context when applying the top-down crop
+
+ Returns:
+ A default conditional top-down Preprocessor
+ """
+ return ComposePreprocessor(
+ components=[
+ LoadImage(color_mode),
+ FilterLowConfidencePoses(),
+ ComputeBoundingBoxesFromCondKeypoints(bbox_margin=bbox_margin),
+ FilterInvalidBoundingBoxes(),
+ TopDownCrop(
+ output_size=top_down_crop_size,
+ margin=top_down_crop_margin,
+ with_context=top_down_crop_with_context,
+ ),
+ AugmentImage(transform),
+ ConditionalKeypointsToModelInputs(),
+ ToTensor(),
+ ]
+ )
+
+
+class ComposePreprocessor(Preprocessor):
+ """Class to preprocess an image and turn it into a batch of inputs before running
+ inference."""
+
+ def __init__(self, components: list[Preprocessor]) -> None:
+ self.components = components
+
+ def __call__(self, image: Image, context: Context) -> tuple[Image, Context]:
+ for preprocessor in self.components:
+ image, context = preprocessor(image, context)
+ return image, context
+
+
+class LoadImage(Preprocessor):
+ """Loads an image from a file, if not yet loaded."""
+
+ def __init__(self, color_mode: str = "RGB") -> None:
+ self.color_mode = color_mode
+
+ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]:
+ if isinstance(image, (str, Path)):
+ image = load_image(image, color_mode=self.color_mode)
+
+ h, w = image.shape[:2]
+ context["image_size"] = w, h
+ return image, context
+
+
+class AugmentImage(Preprocessor):
+ """
+
+ Adds an offset and scale key to the context:
+ offset: (x, y) position of the pixel in the top left corner of the augmented
+ image in the original image
+ scale: size of the original image divided by the size of the new image
+
+ This allows to map the position of predictions in the transformed image back to the
+ original image space.
+ p_original = p_transformed * scale + offset
+ p_transformed = (p_original - offset) / scale
+ """
+
+ def __init__(self, transform: A.BaseCompose) -> None:
+ self.transform = transform
+
+ @staticmethod
+ def get_offsets_and_scales(
+ h: int,
+ w: int,
+ output_bboxes: list[tuple[float, float, float, float]],
+ ) -> tuple[list[tuple[float, float]], list[tuple[float, float]]]:
+ offsets, scales = [], []
+ for bbox in output_bboxes:
+ x_origin, y_origin, w_out, h_out = bbox
+ x_scale, y_scale = w / w_out, h / h_out
+ x_offset = -x_origin * x_scale
+ y_offset = -y_origin * y_scale
+ offsets.append((x_offset, y_offset))
+ scales.append((x_scale, y_scale))
+
+ return offsets, scales
+
+ @staticmethod
+ def update_offset(
+ offset: tuple[float, float],
+ scale: tuple[float, float],
+ new_offset: tuple[float, float],
+ ) -> tuple[float, float]:
+ return (
+ scale[0] * new_offset[0] + offset[0],
+ scale[1] * new_offset[1] + offset[1],
+ )
+
+ @staticmethod
+ def update_scale(scale: tuple[float, float], new_scale: tuple[float, float]) -> tuple[float, float]:
+ return scale[0] * new_scale[0], scale[1] * new_scale[1]
+
+ @staticmethod
+ def update_offsets_and_scales(context, new_offsets, new_scales) -> tuple:
+ """X = x' * scale' + offset'.
+
+ x' = x'' * scale'' + offset''
+ -> x = x'' * (scale' * scale'') + (scale' * offset'' + offset')
+ """
+ # scales and offsets are either both lists or both tuples
+ offsets = context.get("offsets", (0, 0))
+ scales = context.get("scales", (1, 1))
+ if isinstance(offsets, tuple):
+ if isinstance(new_offsets, list):
+ updated_offsets = [
+ AugmentImage.update_offset(offsets, scales, new_offset) for new_offset in new_offsets
+ ]
+ updated_scales = [AugmentImage.update_scale(scales, new_scale) for new_scale in new_scales]
+ else:
+ if not len(offsets) == len(new_offsets):
+ raise ValueError("Cannot rescale lists when not same length")
+
+ updated_offsets = AugmentImage.update_offset(offsets, scales, new_offsets)
+ updated_scales = AugmentImage.update_scale(scales, new_scales)
+ else:
+ if isinstance(new_offsets, list):
+ if not len(offsets) == len(new_offsets):
+ raise ValueError("Cannot rescale lists when not same length")
+
+ updated_offsets = [
+ AugmentImage.update_offset(offset, scale, new_offset)
+ for offset, scale, new_offset in zip(offsets, scales, new_offsets, strict=False)
+ ]
+ updated_scales = [
+ AugmentImage.update_scale(scale, new_scale)
+ for scale, new_scale in zip(scales, new_scales, strict=False)
+ ]
+ else:
+ updated_offsets = [
+ AugmentImage.update_offset(offset, scale, new_offsets)
+ for offset, scale in zip(offsets, scales, strict=False)
+ ]
+ updated_scales = [AugmentImage.update_scale(scale, new_scales) for scale in scales]
+ return updated_offsets, updated_scales
+
+ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]:
+ # If the image is a batch, process each entry
+ if len(image.shape) == 4:
+ batch_size, h, w, _ = image.shape
+ if batch_size == 0:
+ # no images in top-down when no detections
+ offsets, scales = (0, 0), (1, 1)
+ else:
+ transformed = [
+ self.transform(
+ image=img,
+ keypoints=[],
+ class_labels=[],
+ bboxes=[[0, 0, w, h]],
+ bbox_labels=["image"],
+ )
+ for img in image
+ ]
+ image = np.stack([t["image"] for t in transformed])
+ output_bboxes = [t["bboxes"][0] for t in transformed]
+ offsets, scales = self.get_offsets_and_scales(h, w, output_bboxes)
+ else:
+ h, w, _ = image.shape
+ transformed = self.transform(
+ image=image,
+ keypoints=[],
+ class_labels=[],
+ bboxes=[[0, 0, w, h]],
+ bbox_labels=["image"],
+ )
+ image = transformed["image"]
+ output_bboxes = [transformed["bboxes"][0]]
+ offsets, scales = self.get_offsets_and_scales(h, w, output_bboxes)
+ offsets = offsets[0]
+ scales = scales[0]
+
+ offsets, scales = self.update_offsets_and_scales(context, offsets, scales)
+ context["offsets"] = offsets
+ context["scales"] = scales
+ return image, context
+
+
+class ToTensor(Preprocessor):
+ """Transforms lists and numpy arrays into tensors."""
+
+ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]:
+ image = torch.tensor(image, dtype=torch.float)
+ if len(image.shape) == 4:
+ image = image.permute(0, 3, 1, 2)
+ else:
+ image = image.permute(2, 0, 1)
+ return image, context
+
+
+class ToBatch(Preprocessor):
+ """Adds a batch dimension to the image tensor.
+
+ This preprocessor is used to convert a single image tensor into a batched format by
+ unsqueezing along the 0th dimension. This is typically required before passing the
+ image to models that expect batched input (i.e., shape `[B, C, H, W]`).
+ """
+
+ def __call__(self, image: Image, context: Context) -> tuple[np.ndarray, Context]:
+ return image.unsqueeze(0), context
+
+
+class FilterLowConfidencePoses(Preprocessor):
+ """Filters out poses with low confidence scores.
+
+ By default, the confidence associated to the pose is the max confidence value.
+ """
+
+ def __init__(
+ self,
+ confidence_threshold: float = 0.05,
+ aggregate_func: Callable[[np.ndarray], float] = lambda arr: np.nanmax(arr, axis=1),
+ ) -> None:
+ self.confidence_threshold = confidence_threshold
+ self.aggregate_func = aggregate_func
+
+ def __call__(self, image: np.ndarray, context: Context) -> tuple[np.ndarray, Context]:
+ if "cond_kpts" not in context:
+ raise ValueError(f"Must include cond_kpts, found {context}")
+
+ keypoints = context["cond_kpts"]
+
+ if 0 in keypoints.shape:
+ # No poses to filter; return early
+ return image, context
+
+ confidences = keypoints[:, :, 2]
+ aggregated_confidence = self.aggregate_func(confidences)
+ mask = aggregated_confidence >= self.confidence_threshold
+ context["cond_kpts"] = keypoints[mask]
+
+ return image, context
+
+
+class FilterInvalidBoundingBoxes(Preprocessor):
+ """Filters out poses and bounding boxes that are invalid (e.g., area too small)."""
+
+ def __init__(self, min_area: int = 1) -> None:
+ self.min_area = min_area
+
+ def __call__(self, image: np.ndarray, context: Context) -> tuple[np.ndarray, Context]:
+ bboxes = context.get("bboxes", [])
+ keypoints = context.get("cond_kpts", [])
+
+ valid_bboxes = []
+ valid_indices = []
+
+ for i, bbox in enumerate(bboxes):
+ _, _, w, h = bbox
+ if w * h >= self.min_area:
+ valid_bboxes.append(bbox)
+ valid_indices.append(i)
+
+ context["bboxes"] = valid_bboxes
+ context["cond_kpts"] = keypoints[valid_indices]
+
+ return image, context
+
+
+class TopDownCrop(Preprocessor):
+ """Crops bounding boxes out of images for top-down pose estimation.
+
+ Args:
+ output_size: The (width, height) of crops to output
+ margin: The margin to add around detected bounding boxes before cropping
+ with_context: Whether to keep context in the top-down crop
+ """
+
+ def __init__(
+ self,
+ output_size: int | tuple[int, int],
+ margin: int = 0,
+ with_context: bool = True,
+ ) -> None:
+ if isinstance(output_size, int):
+ output_size = (output_size, output_size)
+
+ self.output_size = output_size
+ self.margin = margin
+ self.with_context = with_context
+
+ def __call__(self, image: np.ndarray, context: Context) -> tuple[np.ndarray, Context]:
+ """TODO: numpy implementation"""
+ if "bboxes" not in context:
+ raise ValueError(f"Must include bboxes to CropDetections, found {context}")
+
+ images, offsets, scales = [], [], []
+ for bbox in context["bboxes"]:
+ crop, offset, scale = top_down_crop(
+ image,
+ bbox,
+ self.output_size,
+ margin=self.margin,
+ crop_with_context=self.with_context,
+ )
+ images.append(crop)
+ offsets.append(offset)
+ scales.append(scale)
+
+ context["offsets"] = np.array(offsets)
+ context["scales"] = np.array(scales)
+
+ # can have no bounding boxes if detector made no detections
+ if len(images) == 0:
+ h, w = self.output_size[1], self.output_size[0] # output_size = (w, h)
+ c = image.shape[2] if image.ndim == 3 else 1
+ images = np.zeros((0, h, w, c), dtype=image.dtype)
+ else:
+ images = np.stack(images, axis=0)
+
+ context["top_down_crop_size"] = self.output_size
+ return images, context
+
+
+class ComputeBoundingBoxesFromCondKeypoints(Preprocessor):
+ """Generates bounding boxes from predicted keypoints.
+
+ Args:
+ cond_kpt_key: The key under which cond. keypoints are stored in the context.
+ bbox_margin: The margin to add around keypoints when generating bounding boxes.
+ """
+
+ def __init__(self, cond_kpt_key: str = "cond_kpts", bbox_margin: int = 0) -> None:
+ self.cond_kpt_key = cond_kpt_key
+ self.bbox_margin = bbox_margin
+
+ def __call__(self, image: np.ndarray, context: Context) -> tuple[np.ndarray, Context]:
+ """TODO: numpy implementation"""
+ if "cond_kpts" not in context:
+ raise ValueError(f"Must include cond kpts to ComputeBBoxes, found {context}")
+
+ h, w = image.shape[:2]
+ context["bboxes"] = [
+ bbox_from_keypoints(cond_kpts, h, w, self.bbox_margin) for cond_kpts in context[self.cond_kpt_key]
+ ]
+ return image, context
+
+
+class ConditionalKeypointsToModelInputs(Preprocessor):
+ def __init__(self, cond_kpt_key: str = "cond_kpts") -> None:
+ self.cond_kpt_key = cond_kpt_key
+
+ def __call__(self, image: np.ndarray, context: Context) -> tuple[np.ndarray, Context]:
+ cond_keypoints = context[self.cond_kpt_key]
+
+ rescaled = cond_keypoints.copy()
+ if rescaled.size > 0: # only rescale if non-empty
+ rescaled[..., :2] = (rescaled[..., :2] - np.array(context["offsets"])[:, None]) / np.array(
+ context["scales"]
+ )[:, None]
+ context["model_kwargs"] = {"cond_kpts": np.expand_dims(rescaled, axis=1)}
+ return image, context
diff --git a/deeplabcut/pose_estimation_pytorch/data/snapshots.py b/deeplabcut/pose_estimation_pytorch/data/snapshots.py
new file mode 100644
index 0000000000..d2f2ed6a47
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/snapshots.py
@@ -0,0 +1,79 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Code to handle storing models."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class Snapshot:
+ """A snapshot for a model."""
+
+ best: bool
+ epochs: int | None
+ path: Path
+
+ def uid(self) -> str:
+ if self.best:
+ return f"best-{self.epochs}"
+ else:
+ return str(self.epochs)
+
+ @staticmethod
+ def from_path(path: Path) -> Snapshot:
+ best = "-best" in path.stem
+ # Use regex to extract epoch number more robustly
+ match = re.search(r"-(\d+)\.pt$", path.name)
+ if match:
+ epochs = int(match.group(1))
+ else:
+ # Fallback to original method if regex fails
+ epochs = int(path.stem.split("-")[-1])
+ return Snapshot(best=best, epochs=epochs, path=path)
+
+
+def list_snapshots(
+ model_folder: Path,
+ snapshot_prefix: str,
+ best_in_last: bool = True,
+) -> list[Snapshot]:
+ """Lists snapshots in a model folder.
+
+ Args:
+ model_folder: The model in which the snapshots are found.
+ snapshot_prefix: The prefix for the snapshot names.
+ best_in_last: Whether to place the snapshot with the best performance in the
+ last position in the list, even if it wasn't the last epoch.
+
+ Returns:
+ The snapshots stored in a folder, sorted by the number of epochs they were
+ trained for. If ``best_in_last=True`` and a best snapshot exists, it will be
+ the last one in the list.
+ """
+
+ def _sort_key(snapshot: Snapshot) -> int:
+ return snapshot.epochs
+
+ def _sort_key_best_as_last(snapshot: Snapshot) -> tuple[int, int]:
+ return 1 if snapshot.best else 0, snapshot.epochs
+
+ pattern = r"^(" + snapshot_prefix + r"(-best)?-\d+\.pt)$"
+ snapshots = [Snapshot.from_path(f) for f in model_folder.iterdir() if re.match(pattern, f.name)]
+
+ sort_key = _sort_key
+ if best_in_last:
+ sort_key = _sort_key_best_as_last
+ snapshots.sort(key=sort_key)
+ return snapshots
diff --git a/deeplabcut/pose_estimation_pytorch/data/transforms.py b/deeplabcut/pose_estimation_pytorch/data/transforms.py
new file mode 100644
index 0000000000..ad030cd768
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/transforms.py
@@ -0,0 +1,669 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import warnings
+from collections.abc import Iterable, Sequence
+from typing import Any
+
+import albumentations as A
+import cv2
+import numpy as np
+from albumentations.augmentations.geometric import functional as F
+from numpy.typing import NDArray
+from scipy.spatial.distance import pdist, squareform
+from scipy.stats import truncnorm
+
+
+def build_transforms(augmentations: dict) -> A.BaseCompose:
+ transforms = []
+
+ if resize_aug := augmentations.get("resize", False):
+ transforms += build_resize_transforms(resize_aug)
+
+ if (lms_cfg := augmentations.get("longest_max_size")) is not None:
+ transforms.append(A.LongestMaxSize(lms_cfg))
+
+ if hflip_cfg := augmentations.get("hflip"):
+ hflip_proba = 0.5
+ symmetries = None
+ if isinstance(hflip_cfg, float):
+ hflip_proba = hflip_cfg
+ elif isinstance(hflip_cfg, dict):
+ if "p" in hflip_cfg:
+ hflip_proba = float(hflip_cfg["p"])
+
+ if "symmetries" in hflip_cfg:
+ symmetries = []
+ for kpt_a, kpt_b in hflip_cfg["symmetries"]:
+ symmetries.append((int(kpt_a), int(kpt_b)))
+
+ if symmetries is not None:
+ transforms.append(HFlip(symmetries=symmetries, p=hflip_proba))
+ else:
+ warnings.warn(
+ "Be careful! Do not train pose models with horizontal flips if you have symmetric keypoints!",
+ stacklevel=2,
+ )
+ transforms.append(A.HorizontalFlip(p=hflip_proba))
+
+ if (affine := augmentations.get("affine")) is not None:
+ scaling = affine.get("scaling")
+ rotation = affine.get("rotation")
+ translation = affine.get("translation")
+ if rotation is not None:
+ rotation = (-rotation, rotation)
+ if translation is not None:
+ translation = (-translation, translation)
+
+ transforms.append(
+ A.Affine(
+ scale=scaling,
+ rotate=rotation,
+ translate_px=translation,
+ p=affine.get("p", 0.9),
+ keep_ratio=True,
+ )
+ )
+
+ if bbox_tfm := augmentations.get("random_bbox_transform", False):
+ transforms.append(
+ RandomBBoxTransform(
+ shift_factor=bbox_tfm.get("shift_factor", 0.1),
+ shift_prob=bbox_tfm.get("shift_prob", 0.25),
+ scale_factor=bbox_tfm.get("scale_factor", (0.75, 1.25)),
+ scale_prob=bbox_tfm.get("scale_prob", 1.0),
+ p=bbox_tfm.get("p", 1.0),
+ )
+ )
+
+ if crop_sampling := augmentations.get("crop_sampling"):
+ transforms.append(
+ A.PadIfNeeded(
+ min_height=crop_sampling["height"],
+ min_width=crop_sampling["width"],
+ border_mode=cv2.BORDER_CONSTANT,
+ always_apply=True,
+ )
+ )
+ transforms.append(
+ KeypointAwareCrop(
+ crop_sampling["width"],
+ crop_sampling["height"],
+ crop_sampling["max_shift"],
+ crop_sampling["method"],
+ )
+ )
+
+ if augmentations.get("hist_eq", False):
+ transforms.append(A.Equalize(p=0.5))
+ if augmentations.get("motion_blur", False):
+ transforms.append(A.MotionBlur(p=0.5))
+ if augmentations.get("covering", False):
+ transforms.append(
+ CoarseDropout(
+ max_holes=10,
+ max_height=0.05,
+ min_height=0.01,
+ max_width=0.05,
+ min_width=0.01,
+ p=0.5,
+ )
+ )
+ if augmentations.get("elastic_transform", False):
+ transforms.append(ElasticTransform(sigma=5, p=0.5))
+ if augmentations.get("grayscale", False):
+ transforms.append(Grayscale(alpha=(0.5, 1.0)))
+ if noise := augmentations.get("gaussian_noise", False):
+ # TODO inherit custom gaussian transform to support per_channel = 0.5
+ if not isinstance(noise, (int, float)):
+ noise = 0.05 * 255
+ transforms.append(
+ A.GaussNoise(
+ var_limit=(0, noise**2),
+ mean=0,
+ per_channel=True,
+ # Albumentations doesn't support per_channel = 0.5
+ p=0.5,
+ )
+ )
+
+ if augmentations.get("auto_padding"):
+ transforms.append(build_auto_padding(**augmentations["auto_padding"]))
+
+ if augmentations.get("normalize_images"):
+ transforms.append(A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]))
+
+ if augmentations.get("scale_to_unit_range"):
+ transforms.append(ScaleToUnitRange())
+
+ return A.Compose(
+ transforms,
+ keypoint_params=A.KeypointParams("xy", remove_invisible=False, label_fields=["class_labels"]),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+
+
+def build_auto_padding(
+ min_height: int | None = None,
+ min_width: int | None = None,
+ pad_height_divisor: int | None = 1,
+ pad_width_divisor: int | None = 1,
+ position: str = "random", # TODO: Which default to set?
+ border_mode: str = "reflect_101", # TODO: Which default to set?
+ border_value: float | None = None,
+ border_mask_value: float | None = None,
+) -> A.PadIfNeeded:
+ """Create an albumentations PadIfNeeded transform from a config.
+
+ Args:
+ min_height: the minimum height of the image
+ min_width: the minimum width of the image
+ pad_height_divisor: if not None, ensures height is dividable by value of this argument
+ pad_width_divisor: if not None, ensures width is dividable by value of this argument
+ position: position of the image, one of the possible PadIfNeeded
+ border_mode: 'constant' or 'reflect_101' (see cv2.BORDER modes)
+ border_value: padding value if border_mode is 'constant'
+ border_mask_value: padding value for mask if border_mode is 'constant'
+
+ Raises:
+ ValueError:
+ Only one of 'min_height' and 'pad_height_divisor' parameters must be set
+ Only one of 'min_width' and 'pad_width_divisor' parameters must be set
+
+ Returns:
+ the auto-padding transform
+ """
+ border_modes = {
+ "constant": cv2.BORDER_CONSTANT,
+ "reflect_101": cv2.BORDER_REFLECT_101,
+ }
+ if border_mode not in border_modes:
+ raise ValueError(
+ f"Unknown border mode for auto_padding: {border_mode} (valid values are: {border_modes.keys()})"
+ )
+
+ return A.PadIfNeeded(
+ min_height=min_height,
+ min_width=min_width,
+ pad_height_divisor=pad_height_divisor,
+ pad_width_divisor=pad_width_divisor,
+ position=position,
+ border_mode=border_modes[border_mode],
+ value=border_value,
+ mask_value=border_mask_value,
+ )
+
+
+def build_resize_transforms(resize_cfg: dict) -> list[A.BasicTransform]:
+ height, width = resize_cfg["height"], resize_cfg["width"]
+
+ transforms = []
+ if resize_cfg.get("keep_ratio", True):
+ transforms.append(KeepAspectRatioResize(width=width, height=height, mode="pad"))
+ transforms.append(
+ A.PadIfNeeded(
+ min_height=height,
+ min_width=width,
+ border_mode=cv2.BORDER_CONSTANT,
+ position=A.PadIfNeeded.PositionType.TOP_LEFT,
+ )
+ )
+ else:
+ transforms.append(A.Resize(height, width))
+ return transforms
+
+
+class HFlip(A.HorizontalFlip):
+ """Horizontal Flip which swaps symmetric keypoints."""
+
+ def __init__(self, symmetries: list[tuple[int, int]], *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._symmetries = {}
+ for i, j in symmetries:
+ self._symmetries[i] = j
+ self._symmetries[j] = i
+
+ def apply_to_keypoints(self, keypoints, **params):
+ swapped_keypoints = [keypoints[self._symmetries.get(kpt_idx, kpt_idx)] for kpt_idx in range(len(keypoints))]
+ return super().apply_to_keypoints(swapped_keypoints, **params)
+
+
+class KeypointAwareCrop(A.RandomCrop):
+ """Random crop for an image around keypoints.
+
+ Args:
+ width: Crop images down to this maximum width.
+ height: Crop images down to this maximum height.
+ max_shift: Maximum allowed shift of the cropping center position
+ as a fraction of the crop size.
+ crop_sampling: Crop centers sampling method. Must be either:
+ "uniform" (randomly over the image),
+ "keypoints" (randomly over the annotated keypoints),
+ "density" (weighing preferentially dense regions of keypoints),
+ "hybrid" (alternating randomly between "uniform" and "density").
+ """
+
+ def __init__(
+ self,
+ width: int,
+ height: int,
+ max_shift: float = 0.4,
+ crop_sampling: str = "hybrid",
+ ):
+ super().__init__(height, width, always_apply=True)
+ # Clamp to 40% of crop size to ensure that at least
+ # the center keypoint remains visible after the offset is applied.
+ self.max_shift = max(0.0, min(max_shift, 0.4))
+ if crop_sampling not in ("uniform", "keypoints", "density", "hybrid"):
+ raise ValueError(
+ f"Invalid sampling {crop_sampling}. Must be either 'uniform', 'keypoints', 'density', or 'hybrid."
+ )
+ self.crop_sampling = crop_sampling
+
+ @staticmethod
+ def calc_n_neighbors(xy: NDArray, radius: float) -> NDArray:
+ d = pdist(xy, "sqeuclidean")
+ mat = squareform(d <= radius * radius, checks=False)
+ return np.sum(mat, axis=0)
+
+ @property
+ def targets_as_params(self) -> list[str]:
+ return ["image", "keypoints"]
+
+ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, Any]:
+ img = params["image"]
+ kpts = params["keypoints"]
+ shift_factors = np.random.random(2)
+ shift = self.max_shift * shift_factors * np.array([self.width, self.height])
+ sampling = self.crop_sampling
+ if self.crop_sampling == "hybrid":
+ sampling = np.random.choice(["uniform", "density"])
+ if len(kpts) == 0:
+ sampling = "uniform"
+ if sampling == "uniform":
+ center = np.random.random(2)
+ else:
+ h, w = img.shape[:2]
+ kpts = np.array([[k[0], k[1]] for k in kpts])
+ kpts = kpts[~np.isnan(kpts).all(axis=1)]
+ n_kpts = kpts.shape[0]
+ inds = np.arange(n_kpts)
+ if sampling == "density":
+ # Points located close to one another are sampled preferentially
+ # in order to augment crowded regions.
+ radius = 0.1 * min(h, w)
+ n_neighbors = self.calc_n_neighbors(kpts, radius)
+ # Include keypoints in the count to avoid null probabilities
+ n_neighbors += 1
+ p = n_neighbors / n_neighbors.sum()
+ else:
+ p = np.ones_like(inds) / n_kpts
+ center = kpts[np.random.choice(inds, p=p)]
+ # Shift the crop center in both dimensions by random amounts
+ # and normalize to the original image dimensions.
+ center = (center + shift) / [w, h]
+ center = np.clip(center, 0, np.nextafter(1, 0)) # Clip to 1 exclusive
+ return {"h_start": center[1], "w_start": center[0]}
+
+ def apply_to_keypoints(
+ self,
+ keypoints,
+ **params,
+ ) -> list[tuple[float]]:
+ keypoints = super().apply_to_keypoints(keypoints, **params)
+ new_keypoints = []
+ for kp in keypoints:
+ x, y = kp[:2]
+ if not (0 <= x < self.width and 0 <= y < self.height):
+ kp = list(kp)
+ kp[:2] = np.nan, np.nan
+ kp = tuple(kp)
+ new_keypoints.append(kp)
+ return new_keypoints
+
+ def get_transform_init_args_names(self) -> tuple[str, ...]:
+ return "width", "height", "max_shift", "crop_sampling"
+
+
+class KeepAspectRatioResize(A.DualTransform):
+ """Resizes images while preserving their aspect ratio.
+
+ In 'pad' mode, the image will be rescaled to the largest possible size such that it
+ can be padded to the correct size (with PadIfNeeded). So we'll have: output_width <=
+ width, output_height <= height
+
+ In 'crop' mode, the image will be rescaled to the smallest possible size such that
+ it can be cropped to the correct size (with any random crop you want), so:
+ output_width >= width, output_height >= height
+ """
+
+ def __init__(
+ self,
+ width: int,
+ height: int,
+ mode: str = "pad",
+ interpolation: Any = cv2.INTER_LINEAR,
+ p: float = 1.0,
+ always_apply: bool = True,
+ ) -> None:
+ super().__init__(always_apply=always_apply, p=p)
+ self.height = height
+ self.width = width
+ self.mode = mode
+ self.interpolation = interpolation
+
+ def apply(self, img, scale=0, interpolation=cv2.INTER_LINEAR, **params):
+ return A.scale(img, scale, interpolation)
+
+ def apply_to_bbox(self, bbox, **params):
+ # Bounding box coordinates are scale invariant
+ return bbox
+
+ def apply_to_keypoint(self, keypoint, scale=0, **params):
+ keypoint = A.keypoint_scale(keypoint, scale, scale)
+ return keypoint
+
+ @property
+ def targets_as_params(self) -> list[str]:
+ return ["image"]
+
+ def get_params_dependent_on_targets(self, params: dict[str, Any]) -> dict[str, Any]:
+ h, w, _ = params["image"].shape
+ if self.mode == "pad":
+ scale = min(self.height / h, self.width / w)
+ else:
+ scale = max(self.height / h, self.width / w)
+
+ return {"scale": scale}
+
+ def get_transform_init_args_names(self):
+ return "height", "width", "mode", "interpolation"
+
+
+class Grayscale(A.ToGray):
+ def __init__(
+ self,
+ alpha: float | int | tuple[float, float] = 1.0,
+ always_apply: bool = False,
+ p: float = 0.5,
+ ):
+ """
+ Args:
+ alpha: int, float or tuple of floats, optional
+ The alpha value of the new colorspace when overlaid over the
+ old one. A value close to 1.0 means that mostly the new
+ colorspace is visible. A value close to 0.0 means that mostly the
+ old image is visible.
+
+ * If a float, exactly that value will be used.
+ * If a tuple ``(a, b)``, a random value from the range
+ ``a <= x <= b`` will be sampled per image.
+ """
+ super().__init__(always_apply, p)
+ if isinstance(alpha, (float, int)):
+ self._alpha = self._validate_alpha(alpha)
+ elif isinstance(alpha, tuple):
+ if len(alpha) != 2:
+ raise ValueError("`alpha` must be a tuple of two numbers.")
+ self._alpha = tuple([self._validate_alpha(val) for val in alpha])
+ else:
+ raise ValueError("")
+
+ @staticmethod
+ def _validate_alpha(val: float) -> float:
+ if not 0.0 <= val <= 1.0:
+ warnings.warn("`alpha` will be clipped to the interval [0.0, 1.0].", stacklevel=2)
+ return min(1.0, max(0.0, val))
+
+ @property
+ def alpha(self) -> float:
+ if isinstance(self._alpha, float):
+ return self._alpha
+ return np.random.uniform(*self._alpha)
+
+ def apply(self, img: NDArray, **params) -> NDArray:
+ img_gray = super().apply(img, **params)
+ alpha = self.alpha
+ img_blend = img * (1 - alpha) + img_gray * alpha
+ return img_blend.astype(img.dtype)
+
+
+class ElasticTransform(A.ElasticTransform):
+ def __init__(
+ self,
+ alpha: float = 20.0,
+ sigma: float = 5.0, # As in DLC TF
+ alpha_affine: float = 0.0, # Deactivate affine prior to elastic deformation
+ interpolation: int = cv2.INTER_CUBIC, # As in imgaug
+ border_mode: int = cv2.BORDER_CONSTANT, # As in imgaug
+ value: float | None = None,
+ mask_value: float | None = None,
+ always_apply: bool = False,
+ approximate: bool = True, # Faster by a factor of 2
+ same_dxdy: bool = True, # Here too
+ p: float = 0.5,
+ ):
+ super().__init__(
+ alpha,
+ sigma,
+ alpha_affine,
+ interpolation,
+ border_mode,
+ value,
+ mask_value,
+ always_apply,
+ approximate,
+ same_dxdy,
+ p,
+ )
+ self._neighbor_dist = 3
+ self._neighbor_dist_square = self._neighbor_dist**2
+
+ def apply_to_keypoints(self, keypoints: Sequence[float], random_state: int | None = None, **params) -> list[float]:
+ heatmaps = np.zeros((params["rows"], params["cols"], len(keypoints)), dtype=np.float32)
+ grid = np.mgrid[: params["rows"], : params["cols"]].transpose((1, 2, 0))
+ kpts = np.array([(k[1], k[0]) for k in keypoints])
+ valid_kpts = np.all(kpts > 0.0, axis=1)
+ dist = ((grid - kpts[:, None, None]) ** 2).sum(axis=3)
+ mask = (dist <= self._neighbor_dist_square) & valid_kpts[:, None, None]
+ heatmaps[mask.transpose(1, 2, 0)] = 1
+
+ heatmaps_aug = F.elastic_transform(
+ heatmaps,
+ self.alpha,
+ self.sigma,
+ self.alpha_affine,
+ cv2.INTER_NEAREST,
+ self.border_mode,
+ self.mask_value,
+ np.random.RandomState(random_state),
+ self.approximate,
+ self.same_dxdy,
+ )
+
+ inds = np.indices(heatmaps_aug.shape[:2])[::-1]
+ mask = np.transpose(heatmaps_aug == 1, (2, 0, 1))
+ # Let's compute the average, rather than the median, coordinates
+ div = np.sum(mask, axis=(1, 2))
+ sum_indices = np.sum(inds[:, None] * mask[None], axis=(2, 3)).T
+ xy = sum_indices / div[:, None]
+ new_keypoints = []
+ for kp, new_coords in zip(keypoints, xy, strict=False):
+ kp = list(kp)
+ kp[:2] = new_coords
+ new_keypoints.append(tuple(kp))
+ return new_keypoints
+
+
+class CoarseDropout(A.CoarseDropout):
+ def __init__(
+ self,
+ max_holes: int = 8,
+ max_height: int | float = 8,
+ max_width: int | float = 8,
+ min_holes: int | None = None,
+ min_height: int | float | None = None,
+ min_width: int | float | None = None,
+ fill_value: int = 0,
+ mask_fill_value: int | None = None,
+ always_apply: bool = False,
+ p: float = 0.5,
+ ):
+ super().__init__(
+ max_holes,
+ max_height,
+ max_width,
+ min_holes,
+ min_height,
+ min_width,
+ fill_value,
+ mask_fill_value,
+ always_apply,
+ p,
+ )
+
+ def apply_to_bboxes(self, bboxes: Sequence[float], **params) -> list[float]:
+ return list(bboxes)
+
+ def apply_to_keypoints(
+ self,
+ keypoints: Sequence[float],
+ holes: Iterable[tuple[int, int, int, int]] = (),
+ **params,
+ ) -> list[float]:
+ new_keypoints = []
+ for kp in keypoints:
+ in_hole = False
+ for hole in holes:
+ if self._keypoint_in_hole(kp, hole):
+ in_hole = True
+ break
+ if in_hole:
+ kp = list(kp)
+ kp[:2] = np.nan, np.nan
+ kp = tuple(kp)
+ new_keypoints.append(kp)
+ return new_keypoints
+
+ def _keypoint_in_hole(self, keypoint, hole: tuple[int, int, int, int]) -> bool:
+ """Reimplemented from Albumentations as was removed in v1.4.0."""
+ x1, y1, x2, y2 = hole
+ x, y = keypoint[:2]
+ return x1 <= x < x2 and y1 <= y < y2
+
+
+class RandomBBoxTransform(A.DualTransform):
+ """Random jittering for bounding boxes for top-down pose estimation models.
+
+ Implementation based on the mmpose `RandomBBoxTransform`. For more information,
+ see .
+ """
+
+ def __init__(
+ self,
+ shift_factor: float = 0.1,
+ shift_prob: float = 0.25,
+ scale_factor: tuple[float, float] = (0.5, 1.5),
+ scale_prob: float = 1.0,
+ sampling: str = "truncnorm",
+ p: float = 1.0,
+ ):
+ super().__init__(p=p)
+ self.shift_factor = shift_factor
+ self.shift_prob = shift_prob
+ self.scale_factor = scale_factor
+ self.scale_prob = scale_prob
+ self.sampling = sampling
+
+ def apply(self, img: np.ndarray, **params) -> np.ndarray:
+ return img
+
+ def apply_to_keypoints(self, keypoints: np.ndarray, **params) -> np.ndarray:
+ return keypoints
+
+ def apply_to_bboxes(self, bboxes, **params):
+ if len(bboxes) == 0:
+ return bboxes
+
+ # Albumentations provides bounding boxes in normalized xyxy format internally
+ bboxes_xyxy = np.asarray(bboxes)
+ bboxes_extra = None
+ if bboxes_xyxy.shape[1] > 4:
+ # can't take from array - may have different dtype
+ bboxes_extra = [bbox[4:] for bbox in bboxes]
+ bboxes_xyxy = bboxes_xyxy[:, :4]
+
+ # sample parameters
+ bboxes_to_scale = np.random.random(len(bboxes)) < self.scale_prob
+ num_bboxes_to_scale = np.sum(bboxes_to_scale).item()
+ scale_factors = np.ones((len(bboxes), 2))
+ if num_bboxes_to_scale > 0:
+ scale_factors[bboxes_to_scale] = self._sample(
+ (num_bboxes_to_scale, 2),
+ low=self.scale_factor[0],
+ high=self.scale_factor[1],
+ )
+
+ bboxes_to_shift = np.random.random(len(bboxes)) < self.shift_prob
+ num_bboxes_to_shift = np.sum(bboxes_to_shift).item()
+ shift_factors = np.zeros((len(bboxes), 2))
+ if num_bboxes_to_shift > 0:
+ shift_factors[bboxes_to_shift] = self._sample(
+ (num_bboxes_to_shift, 2),
+ low=-self.shift_factor,
+ high=self.shift_factor,
+ )
+
+ bbox_wh = bboxes_xyxy[:, 2:] - bboxes_xyxy[:, :2]
+ bbox_cxcy = bboxes_xyxy[:, :2] + (0.5 * bbox_wh)
+
+ # scale + shift bounding boxes
+ bbox_cxcy = bbox_cxcy + (shift_factors * bbox_wh)
+ bbox_wh = bbox_wh * scale_factors
+
+ # convert to xyxy, clip so all bounding boxes are in the image
+ bbox_half_wh = 0.5 * bbox_wh
+ bbox_xyxy = np.empty((len(bboxes), 4))
+ bbox_xyxy[:, :2] = bbox_cxcy - bbox_half_wh
+ bbox_xyxy[:, 2:] = bbox_cxcy + bbox_half_wh
+ bbox_xyxy = np.clip(bbox_xyxy, 0, 1)
+
+ # add the extra information back; tuples for albumentations<=1.4.3
+ bboxes_out = [tuple(bbox) for bbox in bbox_xyxy]
+ if bboxes_extra is not None:
+ bboxes_out = [bbox + extra for bbox, extra in zip(bboxes_out, bboxes_extra, strict=False)]
+ return bboxes_out
+
+ def get_transform_init_args_names(self):
+ return "shift_factor", "shift_prob", "scale_factor", "scale_prob", "sampling"
+
+ def _sample(
+ self,
+ size: tuple[int, ...],
+ low: float = -1.0,
+ high: float = 1.0,
+ ) -> np.ndarray:
+ if self.sampling == "truncnorm":
+ return truncnorm.rvs(low, high, size=size).astype(np.float32)
+ elif self.sampling == "uniform":
+ delta = high - low
+ return low + (delta * np.random.random(size))
+
+ raise ValueError(f"Unknown sampling: {self.sampling}")
+
+
+class ScaleToUnitRange(A.ImageOnlyTransform):
+ def __init__(self, always_apply=True, p=1.0):
+ super().__init__(always_apply=always_apply, p=p)
+
+ def apply(self, img, **params):
+ return img.astype(np.float32) / 255.0
diff --git a/deeplabcut/pose_estimation_pytorch/data/utils.py b/deeplabcut/pose_estimation_pytorch/data/utils.py
new file mode 100644
index 0000000000..d4f6797a20
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/utils.py
@@ -0,0 +1,548 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import warnings
+from collections import defaultdict
+from functools import cache, reduce
+from pathlib import Path
+
+import albumentations as A
+import numpy as np
+from PIL import Image
+
+
+@cache
+def read_image_shape_fast(path: str | Path) -> tuple[int, int, int]:
+ """Blazing fast and does not load the image into memory."""
+ with Image.open(path) as img:
+ width, height = img.size
+ return len(img.getbands()), height, width
+
+
+def bbox_from_keypoints(
+ keypoints: np.ndarray,
+ image_h: int,
+ image_w: int,
+ margin: int,
+) -> np.ndarray:
+ """Computes bounding boxes from keypoints.
+
+ Args:
+ keypoints: (..., num_keypoints, xy) the keypoints from which to get bboxes
+ image_h: the height of the image
+ image_w: the width of the image
+ margin: the bounding box margin
+
+ Returns:
+ the bounding boxes for the keypoints, of shape (..., 4) in the xywh format
+ """
+ squeeze = False
+
+ # we do not estimate bbox on keypoints that have 0 or -1 flag
+ keypoints = np.copy(keypoints)
+ keypoints[keypoints[..., -1] <= 0] = np.nan
+
+ if len(keypoints.shape) == 2:
+ squeeze = True
+ keypoints = np.expand_dims(keypoints, axis=0)
+
+ bboxes = np.full((keypoints.shape[0], 4), np.nan)
+ with warnings.catch_warnings(): # silence warnings when all pose confidence levels are <= 0
+ warnings.simplefilter("ignore", category=RuntimeWarning)
+ bboxes[:, :2] = np.nanmin(keypoints[..., :2], axis=1) - margin # X1, Y1
+ bboxes[:, 2:4] = np.nanmax(keypoints[..., :2], axis=1) + margin # X2, Y2
+
+ # can have NaNs if some individuals have no visible keypoints
+ bboxes = np.nan_to_num(bboxes, nan=0)
+
+ bboxes = np.clip(
+ bboxes,
+ a_min=[0, 0, 0, 0],
+ a_max=[image_w, image_h, image_w, image_h],
+ )
+ bboxes[..., 2] = bboxes[..., 2] - bboxes[..., 0] # to width
+ bboxes[..., 3] = bboxes[..., 3] - bboxes[..., 1] # to height
+ if squeeze:
+ return bboxes[0]
+
+ return bboxes
+
+
+def merge_list_of_dicts(list_of_dicts: list[dict], keys_to_include: list[str]) -> dict[str, list]:
+ """Flattens a list of dictionaries into a dictionary with the lists concatenated.
+
+ Args:
+ list_of_dicts: the dictionaries to merge
+ keys_to_include: the keys to include in the new dictionary
+
+ Returns:
+ the merged dictionary
+
+ Examples:
+ input:
+ list_of_dicts: [{"id": 0, "num": 1}, {"id": 1, "num": 10}]
+ keys_to_include: ["id", "num"]
+ output:
+ {"id": [0, 1], "num": [1, 10]}
+ """
+ return reduce(
+ lambda acc, d: {key: acc.get(key, []) + [value] for key, value in d.items() if key in keys_to_include},
+ list_of_dicts,
+ defaultdict(list),
+ )
+
+
+def map_image_path_to_id(images: list[dict]) -> dict[str, int]:
+ """Binds the image paths to their respective IDs.
+
+ Args:
+ images: List of dictionaries containing image data in COCO-like format.
+ Each dictionary should have 'file_name' and 'id' keys.
+
+ Returns:
+ A dictionary mapping image paths to their respective IDs.
+
+ Examples:
+ images = [{"file_name": "path/to/image1.jpg", "id": 1}, ...]
+ """
+
+ return {image["file_name"]: image["id"] for image in images}
+
+
+def map_id_to_annotations(annotations: list[dict]) -> dict[int, list[int]]:
+ """Maps image IDs to their corresponding annotation indices.
+
+ Args:
+ annotations: List of dictionaries containing annotation data. Each dictionary
+ should have 'image_id' key.
+
+ Returns:
+ A dictionary mapping image IDs to lists of corresponding annotation indices.
+
+ Examples:
+ annotations = [{"image_id": 1, ...}, ...]
+ """
+
+ annotation_idx_map = defaultdict(list)
+ for idx, annotation in enumerate(annotations):
+ annotation_idx_map[annotation["image_id"]].append(idx)
+
+ return annotation_idx_map
+
+
+def _crop_and_pad_image(
+ image: np.ndarray,
+ coords: tuple[tuple[int, int], tuple[int, int]],
+ output_size: tuple[int, int],
+) -> tuple[np.ndarray, tuple[int, int]]:
+ """Crop the image using the given coordinates and pad the larger dimension to change
+ the aspect ratio.
+
+ Args:
+ image: Image to crop, of shape (height, width, channels).
+ coords: Coordinates for cropping as [(xmin, xmax), (ymin, ymax)].
+ output_size: The (output_h, output_w) that this cropped image will be resized
+ to. Used to compute padding to keep aspect ratios.
+
+ Returns:
+ Cropped (and possibly padded) image
+ Padding (pad_h, pad_w)
+ """
+ cropped_image = image[coords[1][0] : coords[1][1], coords[0][0] : coords[0][1], :]
+
+ crop_h, crop_w, c = cropped_image.shape
+ pad_h, pad_w = 0, 0
+ target_ratio_h = output_size[0] / crop_h
+ target_ratio_w = output_size[1] / crop_w
+
+ if target_ratio_h != target_ratio_w:
+ if crop_h < crop_w:
+ # Pad the height
+ new_h = int(crop_w * output_size[0] / output_size[1])
+ pad_h = new_h - crop_h
+ pad_image = np.zeros((new_h, crop_w, c))
+ y_offset = pad_h // 2
+ pad_image[y_offset : y_offset + crop_h, :] = cropped_image
+ else:
+ # Pad the width
+ new_w = int(crop_h * output_size[1] / output_size[0])
+ pad_w = new_w - crop_w
+ pad_image = np.zeros((crop_h, new_w, c))
+ x_offset = pad_w // 2
+ pad_image[:, x_offset : x_offset + crop_w] = cropped_image
+ else:
+ pad_image = cropped_image
+
+ return pad_image, (pad_h, pad_w)
+
+
+def _crop_and_pad_keypoints(keypoints: np.ndarray, coords: tuple[int, int], pad_size: tuple[int, int]):
+ """Adjust the keypoints after cropping and padding.
+
+ Parameters:
+ keypoints: The original keypoints, typically a 2D array of shape (..., 2).
+ coords: The (xmin, ymin) crop coordinates used for cropping the image.
+ pad_size: The padding sizes added to the cropped image, in the format (pad_h, pad_w).
+
+ Returns:
+ Adjusted keypoints.
+ """
+ keypoints[..., 0] -= coords[0]
+ keypoints[..., 1] -= coords[1]
+ keypoints[..., 0] += pad_size[1] // 2
+ keypoints[..., 1] += pad_size[0] // 2
+ return keypoints
+
+
+def _crop_image_keypoints(
+ image, keypoints, coords, output_size
+) -> tuple[np.ndarray, np.ndarray, tuple[int, int], tuple[int, int]]:
+ """TODO: Requires fixing
+ Crop the image based on a given bounding box and resize it to the desired output
+ size. Returns offsets and scales to map keypoints in the resized image to
+ coordinates in the original image:
+
+ x_original = (x_cropped * x_scale) + x_offset
+ y_original = (y_cropped * y_scale) + y_offset
+
+ Args:
+ image: Image to crop, of shape (height, width, channels).
+ coords: Coordinates for cropping as ((xmin, xmax), (ymin, ymax)).
+ output_size: The (h, w) that the cropped image should be resized to.
+
+ Returns:
+ Cropped, possibly padded, and resized image.
+ The position of the keypoints in the cropped, resized image
+ Offsets used for cropping.
+ The offsets to map predicted keypoints back to the original image
+ The scale to map predicted keypoints back to the original image
+ """
+
+ cropped_image, pad_size = _crop_and_pad_image(image, coords, output_size)
+ cropped_keypoints = _crop_and_pad_keypoints(keypoints, (coords[0][0], coords[1][0]), pad_size)
+
+ offsets = (coords[0][0], coords[1][0])
+ scales = [
+ output_size[0] / cropped_image.shape[0],
+ output_size[1] / cropped_image.shape[1],
+ ]
+
+ # TODO: Fix resizing, use OpenCV
+ cropped_resized_image = np.resize(cropped_image, (*output_size, cropped_image.shape[2]))
+
+ cropped_resized_keypoints = np.array(cropped_keypoints) * np.array(scales + [1])
+
+ return cropped_resized_image, cropped_resized_keypoints, offsets, scales
+
+
+def _compute_crop_bounds(
+ bboxes: np.ndarray,
+ image_shape: tuple[int, int, int],
+ remove_empty: bool = True,
+) -> np.ndarray:
+ """Compute the boundaries for cropping an image based on a COCO-format bounding box
+ and image shape by clipping values so the bounding boxes are entirely in the image.
+
+ Args:
+ bboxes: COCO-format bounding box of shape (b, xywh)
+ image_shape: Shape of the image defined as (height, width, channels).
+
+ Returns:
+ The bounding boxes, clipped to be entirely inside the image
+ """
+ h, w = image_shape[:2]
+ # to xyxy
+ bboxes[:, 2] = bboxes[:, 0] + bboxes[:, 2]
+ bboxes[:, 3] = bboxes[:, 1] + bboxes[:, 3]
+ # clip
+ bboxes = np.clip(bboxes, 0, np.array([w, h, w, h]))
+ # to xywh
+ bboxes[:, 2] = bboxes[:, 2] - bboxes[:, 0]
+ bboxes[:, 3] = bboxes[:, 3] - bboxes[:, 1]
+ # filter
+ if remove_empty:
+ squashed_bbox_mask = np.logical_or(bboxes[:, 2] <= 0, bboxes[:, 3] <= 0)
+ bboxes = bboxes[~squashed_bbox_mask]
+ return bboxes
+
+
+def _extract_keypoints_and_bboxes(
+ anns: list[dict],
+ image_shape: tuple[int, int, int],
+ num_joints: int,
+ num_unique_bodyparts: int,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, np.ndarray]]:
+ """
+ Args:
+ anns: COCO-style annotations
+ image_shape: the (h, w, c) shape of the image for which to get annotations
+ num_joints: the number of joints in the annotations
+
+ Returns:
+ keypoints, unique_keypoints, bboxes in xywh format, annotations_merged
+ """
+ keypoints = []
+ original_bboxes = []
+ anns_to_merge = []
+ unique_keypoints = None
+ h, w = image_shape[:2]
+ for _i, annotation in enumerate(anns):
+ keypoints_individual = _annotation_to_keypoints(annotation, h, w)
+ if annotation["individual"] != "single":
+ bbox_individual = annotation["bbox"]
+ original_bboxes.append(bbox_individual)
+ keypoints.append(keypoints_individual)
+ anns_to_merge.append(annotation)
+ else:
+ unique_keypoints = keypoints_individual
+
+ if unique_keypoints is None:
+ unique_keypoints = -1 * np.ones((num_unique_bodyparts, 3), dtype=float)
+
+ keypoints = safe_stack(keypoints, (0, num_joints, 3))
+ original_bboxes = safe_stack(original_bboxes, (0, 4))
+ bboxes = _compute_crop_bounds(original_bboxes, image_shape, remove_empty=False)
+
+ # at least 1 visible joint to keep individuals
+ vis_mask = (keypoints[..., 2] > 0).any(axis=1)
+ keypoints = keypoints[vis_mask]
+ bboxes = bboxes[vis_mask]
+
+ keys_to_merge = ["area", "category_id", "iscrowd", "individual_id"]
+ anns_merged = {k: [] for k in keys_to_merge}
+ if len(anns_to_merge) > 0:
+ anns_merged = merge_list_of_dicts(anns_to_merge, keys_to_include=keys_to_merge)
+ anns_merged = {k: np.array(v)[vis_mask] for k, v in anns_merged.items()}
+
+ if len(anns_merged["area"]) != len(keypoints):
+ raise ValueError(f"Missing area values! {anns_merged}, {keypoints.shape}")
+
+ return keypoints, unique_keypoints, bboxes, anns_merged
+
+
+def calc_area_from_keypoints(keypoints: np.ndarray) -> np.ndarray:
+ """Calculate the area from keypoints.
+
+ TODO: in the pups benchmark, there are 5 keypoints perfectly aligned so
+ the area is 0.
+ How do we deal with that?
+ Makes more sense to compute the area from the bboxes (they are padded)
+ Below is a temporary fix, which sets a min height and width to 5
+ Suggestion: compute min height/width using labeled data
+
+ Args:
+ keypoints (np.ndarray): array of keypoints
+
+ Returns:
+ np.ndarray: array containing the computed areas based on the keypoints
+ """
+ w = np.maximum(keypoints[:, :, 0].max(axis=1) - keypoints[:, :, 0].min(axis=1), 1)
+ h = np.maximum(keypoints[:, :, 1].max(axis=1) - keypoints[:, :, 1].min(axis=1), 1)
+ return w * h
+
+
+def calc_bbox_overlap(bbox1: np.ndarray, bbox2: np.ndarray) -> np.ndarray:
+ """Calculate the overlap between two bounding boxes.
+
+ Args:
+ bbox1: the first bounding box in the format (x, y, w, h)
+ bbox2: the second bounding box in the format (x, y, w, h)
+
+ Returns:
+ The overlap between
+ """
+ x1, y1, w1, h1 = bbox1
+ x2, y2, w2, h2 = bbox2
+
+ x1_max = x1 + w1
+ y1_max = y1 + h1
+ x2_max = x2 + w2
+ y2_max = y2 + h2
+
+ x_overlap = max(0, min(x1_max, x2_max) - max(x1, x2))
+ y_overlap = max(0, min(y1_max, y2_max) - max(y1, y2))
+
+ intersection = x_overlap * y_overlap
+ union = w1 * h1 + w2 * h2 - intersection
+
+ return intersection / union
+
+
+def _annotation_to_keypoints(annotation: dict, h: int, w: int) -> np.array:
+ """Convert the coco annotations into array of keypoints returns the array of the
+ keypoints' visibility. If keypoint is not visible, the value for (x,y) coordinates
+ is set to 0. If the keypoints are outside of the image, they are also set to 0.
+
+ Args:
+ annotation: dictionary containing coco-like annotations with essential
+ `keypoints` field
+ h: the image height
+ w: the image width
+
+ Returns:
+ keypoints: np.array where the first two columns are x and y coordinates of the
+ """
+ # we don't mess up visibility flags here
+ return annotation["keypoints"].reshape(-1, 3)
+
+
+def apply_transform(
+ transform: A.BaseCompose,
+ image: np.ndarray,
+ keypoints: np.ndarray,
+ bboxes: np.ndarray,
+ class_labels: list[str],
+) -> dict[str, np.ndarray]:
+ """Applies a transformation to the provided image and keypoints.
+
+ Args:
+ transform: The transformation to apply.
+ image: The input image to which the transformation will be applied.
+ keypoints: List of keypoints to be transformed along with the image. Each keypoint
+ is expected to be a tuple or list with at least three values,
+ where the third value indicates the class label index.
+ bboxes: List of bounding boxes to be transformed along with the image.
+ class_labels: List of class labels corresponding to the keypoints.
+
+ Returns:
+ transformed: A dictionary containing the transformed image and keypoints.
+ """
+
+ if transform:
+ oob_mask = out_of_bounds_keypoints(keypoints, image.shape)
+ transformed = _apply_transform(transform, image, keypoints, bboxes, class_labels)
+
+ transformed["keypoints"] = np.array(transformed["keypoints"])
+
+ # out-of-bound keypoints have visibility flag 0. But we don't touch coordinates
+ if np.sum(oob_mask) > 0:
+ transformed["keypoints"][oob_mask, 2] = 0.0
+
+ out_shape = transformed["image"].shape
+ if len(transformed["keypoints"]) > 0:
+ oob_mask = out_of_bounds_keypoints(transformed["keypoints"], out_shape)
+ # out-of-bound keypoints have visibility flag 0. Don't touch coordinates
+ if np.sum(oob_mask) > 0:
+ transformed["keypoints"][oob_mask, 2] = 0.0
+
+ # TODO: Check that the transformed bboxes are still within the image
+ if len(transformed["bboxes"]) > 0:
+ transformed["bboxes"] = np.array(transformed["bboxes"])
+ else:
+ transformed["bboxes"] = np.zeros(shape=(0, 4))
+
+ else:
+ transformed = {"keypoints": keypoints, "image": image}
+
+ # do we ever need to do this if we had check_keypoints_within_bounds above?
+ # np.nan_to_num(transformed["keypoints"], copy=False, nan=-1)
+ return transformed
+
+
+def _apply_transform(
+ transform: A.BaseCompose,
+ image: np.ndarray,
+ keypoints: np.ndarray,
+ bboxes: np.ndarray,
+ class_labels: list[str],
+) -> dict[str, np.ndarray]:
+ """Applies a transformation to the provided image and keypoints.
+
+ Args:
+ image : np.array or similar image data format
+ The input image to which the transformation will be applied.
+
+ keypoints : list or similar data format
+ List of keypoints to be transformed along with the image. Each keypoint
+ is expected to be a tuple or list with at least three values,
+ where the third value indicates the class label index.
+
+ Returns:
+ dict
+ A dictionary containing the transformed image and keypoints.
+ """
+ transformed = transform(
+ image=image,
+ keypoints=keypoints,
+ class_labels=class_labels,
+ bboxes=bboxes,
+ bbox_labels=np.arange(len(bboxes)),
+ )
+
+ bboxes_out = np.zeros(bboxes.shape)
+ for bbox, bbox_id in zip(transformed["bboxes"], transformed["bbox_labels"], strict=False):
+ bboxes_out[bbox_id] = bbox
+
+ transformed["bboxes"] = bboxes_out
+ return transformed
+
+
+def out_of_bounds_keypoints(keypoints: np.ndarray, shape: tuple) -> np.ndarray:
+ """Computes which visible keypoints are outside an image.
+
+ Args:
+ keypoints: A (N, 3) shaped array where N is the number of keypoints and each
+ keypoint is represented as (x, y, visibility).
+ shape: A tuple representing the shape or bounds as (height, width).
+
+ Returns:
+ A boolean array of shape (N,) where each element corresponds to whether
+ the respective keypoint is visible (visibility > 0) and outside the image
+ bounds. This mask can be used to set the visibility bit to 0 for keypoints that
+ were kicked off an image due to augmentation.
+ """
+ return (keypoints[..., 2] > 0) & (
+ np.isnan(keypoints[..., 0])
+ | np.isnan(keypoints[..., 1])
+ | (keypoints[..., 0] < 0)
+ | (keypoints[..., 0] > shape[1])
+ | (keypoints[..., 1] < 0)
+ | (keypoints[..., 1] > shape[0])
+ )
+
+
+def pad_to_length(data: np.array, length: int, value: float) -> np.array:
+ """Pads the first dimension of an array with a given value.
+
+ Args:
+ data: the array to pad, of shape (l, ...), where l <= length
+ length: the desired length of the tensor
+ value: the value to pad with
+
+ Returns:
+ the padded array of shape (length, ...)
+ """
+ pad_length = length - len(data)
+ if pad_length == 0:
+ return data
+ elif pad_length > 0:
+ padding = value * np.ones((pad_length, *data.shape[1:]), dtype=data.dtype)
+ return np.concatenate([data, padding])
+
+ raise ValueError(f"Cannot pad! data.shape={data.shape} > length={length}")
+
+
+def safe_stack(data: list[np.ndarray], default_shape: tuple[int, ...]) -> np.ndarray:
+ """Stacks a list of arrays if there are any, otherwise returns an array of zeros of
+ a desired shape.
+
+ Args:
+ data: the list of arrays to stack
+ default_shape: the shape of the array to return if the list is empty
+
+ Returns:
+ the stacked data or empty array
+ """
+ if len(data) == 0:
+ return np.zeros(default_shape, dtype=float)
+
+ return np.stack(data, axis=0)
diff --git a/deeplabcut/pose_estimation_pytorch/metrics/__init__.py b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py
new file mode 100644
index 0000000000..117d127147
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/metrics/__init__.py
@@ -0,0 +1,10 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_pytorch/metrics/scoring.py b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py
new file mode 100644
index 0000000000..c830974ef3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.py
@@ -0,0 +1,72 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import pickle
+
+import numpy as np
+from sklearn.metrics import accuracy_score
+
+from deeplabcut.core.crossvalutils import find_closest_neighbors
+from deeplabcut.utils.auxiliaryfunctions import read_config
+
+
+def _match_identity_preds_to_gt(config_path: str, full_pickle_path: str) -> tuple[np.ndarray, list]:
+ with open(full_pickle_path, "rb") as f:
+ data = pickle.load(f)
+ metadata = data.pop("metadata")
+ cfg = read_config(config_path)
+ all_ids = cfg["individuals"].copy()
+ all_bpts = cfg["multianimalbodyparts"] * len(all_ids)
+ n_multibodyparts = len(all_bpts)
+ if cfg["uniquebodyparts"]:
+ all_ids += ["single"]
+ all_bpts += cfg["uniquebodyparts"]
+ all_bpts = np.asarray(all_bpts)
+ joints = metadata["all_joints_names"]
+ ids = np.full((len(data), len(all_bpts), 2), np.nan)
+ for i, dict_ in enumerate(data.values()):
+ id_gt, _, df_gt = dict_["groundtruth"]
+ for j, id_ in enumerate(id_gt):
+ if id_.size:
+ ids[i, j, 0] = all_ids.index(id_)
+
+ df = df_gt.unstack("coords").reindex(joints, level="bodyparts")
+ xy_pred = dict_["prediction"]["coordinates"][0]
+ for bpt, xy_gt in df.groupby(level="bodyparts"):
+ inds_gt = np.flatnonzero(np.all(~np.isnan(xy_gt), axis=1))
+ n_joint = joints.index(bpt)
+ xy = xy_pred[n_joint]
+ if inds_gt.size and xy.size:
+ # Pick the predictions closest to ground truth,
+ # rather than the ones the model has most confident in
+ xy_gt_values = xy_gt.iloc[inds_gt].values
+ neighbors = find_closest_neighbors(xy_gt_values, xy, k=3)
+ found = neighbors != -1
+ inds = np.flatnonzero(all_bpts == bpt)
+ id_ = dict_["prediction"]["identity"][n_joint]
+ ids[i, inds[inds_gt[found]], 1] = np.argmax(id_[neighbors[found]], axis=1)
+ ids = ids[:, :n_multibodyparts].reshape((len(data), len(cfg["individuals"]), -1, 2))
+ return ids, list(data)
+
+
+def compute_id_accuracy(ids: np.ndarray, mask_test: np.ndarray) -> np.ndarray:
+ nbpts = ids.shape[2] # ids shape is (n_images, n_individuals, n_bodyparts, 2)
+ accu = np.empty((nbpts, 2))
+ for i in range(nbpts):
+ temp = ids[:, :, i].reshape((-1, 2))
+ valid = np.isfinite(temp).all(axis=1)
+ y_true, y_pred = temp[valid].T
+ mask = np.repeat(mask_test, ids.shape[1])[valid]
+ ac_train = accuracy_score(y_true[~mask], y_pred[~mask])
+ ac_test = accuracy_score(y_true[mask], y_pred[mask])
+ accu[i] = ac_train, ac_test
+ return accu
diff --git a/deeplabcut/pose_estimation_pytorch/models/__init__.py b/deeplabcut/pose_estimation_pytorch/models/__init__.py
new file mode 100644
index 0000000000..6e28f8722c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/__init__.py
@@ -0,0 +1,23 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ CRITERIONS,
+ LOSS_AGGREGATORS,
+)
+from deeplabcut.pose_estimation_pytorch.models.detectors import DETECTORS
+from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS
+from deeplabcut.pose_estimation_pytorch.models.model import PoseModel
+from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS
+from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS
+from deeplabcut.pose_estimation_pytorch.models.target_generators import (
+ TARGET_GENERATORS,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py
new file mode 100644
index 0000000000..5e88ef236d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/__init__.py
@@ -0,0 +1,19 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import (
+ BACKBONES,
+ BaseBackbone,
+)
+from deeplabcut.pose_estimation_pytorch.models.backbones.cond_prenet import CondPreNet
+from deeplabcut.pose_estimation_pytorch.models.backbones.cspnext import CSPNeXt
+from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet import HRNet
+from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet_coam import HRNetCoAM
+from deeplabcut.pose_estimation_pytorch.models.backbones.resnet import DLCRNet, ResNet
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/base.py b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py
new file mode 100644
index 0000000000..dedac0f709
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/base.py
@@ -0,0 +1,137 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import logging
+import shutil
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+from huggingface_hub import hf_hub_download
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+BACKBONES = Registry("backbones", build_func=build_from_cfg)
+
+
+class BaseBackbone(ABC, nn.Module):
+ """Base Backbone class for pose estimation.
+
+ Attributes:
+ stride: the stride for the backbone
+ freeze_bn_weights: freeze weights of batch norm layers during training
+ freeze_bn_stats: freeze stats of batch norm layers during training
+ """
+
+ def __init__(
+ self,
+ stride: int | float,
+ freeze_bn_weights: bool = True,
+ freeze_bn_stats: bool = True,
+ ):
+ super().__init__()
+ self.stride = stride
+ self.freeze_bn_weights = freeze_bn_weights
+ self.freeze_bn_stats = freeze_bn_stats
+
+ @abstractmethod
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Abstract method for the forward pass through the backbone.
+
+ Args:
+ x: Input tensor of shape (batch_size, channels, height, width).
+
+ Returns:
+ a feature map for the input, of shape (batch_size, c', h', w')
+ """
+ pass
+
+ def freeze_batch_norm_layers(self) -> None:
+ """Freezes batch norm layers.
+
+ Running mean + var are always given to F.batch_norm, except when the layer is
+ in `train` mode and track_running_stats is False, see
+ https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html
+ So to 'freeze' the running stats, the only way is to set the layer to "eval"
+ mode.
+ """
+ for module in self.modules():
+ if isinstance(module, nn.BatchNorm2d):
+ if self.freeze_bn_weights:
+ module.weight.requires_grad = False
+ module.bias.requires_grad = False
+ if self.freeze_bn_stats:
+ module.eval()
+
+ def train(self, mode: bool = True) -> None:
+ """Sets the module in training or evaluation mode.
+
+ Args:
+ mode: whether to set training mode (True) or evaluation mode (False)
+ """
+ super().train(mode)
+ if self.freeze_bn_weights or self.freeze_bn_stats:
+ self.freeze_batch_norm_layers()
+
+
+class HuggingFaceWeightsMixin:
+ """Mixin for backbones where the pretrained weights are stored on HuggingFace."""
+
+ def __init__(
+ self,
+ backbone_weight_folder: str | Path | None = None,
+ repo_id: str = "DeepLabCut/DeepLabCut-Backbones",
+ *args,
+ **kwargs,
+ ) -> None:
+ super().__init__(*args, **kwargs)
+ if backbone_weight_folder is None:
+ backbone_weight_folder = Path(__file__).parent / "pretrained_weights"
+ else:
+ backbone_weight_folder = Path(backbone_weight_folder).resolve()
+
+ self.backbone_weight_folder = backbone_weight_folder
+ self.repo_id = repo_id
+
+ def download_weights(self, filename: str, force: bool = False) -> Path:
+ """Downloads the backbone weights from the HuggingFace repo.
+
+ Args:
+ filename: The name of the model file to download in the repo.
+ force: Whether to re-download the file if it already exists locally.
+
+ Returns:
+ The path to the model snapshot.
+ """
+ model_path = self.backbone_weight_folder / filename
+ if model_path.exists():
+ if not force:
+ return model_path
+ model_path.unlink()
+
+ logging.info(f"Downloading the pre-trained backbone to {model_path}")
+ self.backbone_weight_folder.mkdir(exist_ok=True, parents=False)
+ output_path = Path(hf_hub_download(self.repo_id, filename, cache_dir=self.backbone_weight_folder))
+
+ # resolve gets the actual path if the output path is a symlink
+ output_path = output_path.resolve()
+ # move to the target path
+ output_path.rename(model_path)
+
+ # delete downloaded artifacts
+ uid, rid = self.repo_id.split("/")
+ artifact_dir = self.backbone_weight_folder / f"models--{uid}--{rid}"
+ if artifact_dir.exists():
+ shutil.rmtree(artifact_dir)
+
+ return model_path
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/cond_prenet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/cond_prenet.py
new file mode 100644
index 0000000000..15f8f5307f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/cond_prenet.py
@@ -0,0 +1,114 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import numpy as np
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import (
+ BACKBONES,
+ BaseBackbone,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules import ( # ColoredKeypointEncoder,; StackedKeypointEncoder,
+ KEYPOINT_ENCODERS,
+ BaseKeypointEncoder,
+)
+
+
+@BACKBONES.register_module
+class CondPreNet(BaseBackbone):
+ """Wrapper module that adds a conditional preNet before any backbone.
+
+ This allows to process image and condition features and prepare them for the main
+ backbone.
+ """
+
+ def __init__(
+ self,
+ kpt_encoder: dict | BaseKeypointEncoder,
+ backbone: dict | BaseBackbone,
+ img_size: tuple[int, int] = (256, 256),
+ **kwargs,
+ ):
+ """Initialize the PreNetWrapper.
+
+ Args:
+ backbone: The backbone model to wrap.
+ img_size: The (height, width) of the input images.
+ """
+ pretrained = kwargs.pop("pretrained", False)
+ if not isinstance(backbone, BaseBackbone):
+ backbone["pretrained"] = pretrained
+ backbone = BACKBONES.build(backbone)
+
+ super().__init__(stride=backbone.stride, **kwargs)
+
+ if not isinstance(kpt_encoder, BaseKeypointEncoder):
+ if "img_size" not in kpt_encoder:
+ kpt_encoder["img_size"] = img_size
+ kpt_encoder = KEYPOINT_ENCODERS.build(kpt_encoder)
+ self.cond_enc = kpt_encoder
+
+ self.backbone = backbone
+ self.rgb_preNet = self._make_preNet(num_inputs=3, num_outputs=3, input_image=True)
+ self.cond_preNet = self._make_preNet(num_inputs=self.cond_enc.num_channels, num_outputs=3, input_image=False)
+
+ self.init_weights()
+
+ def _make_preNet(self, num_inputs, num_outputs, input_image=False):
+ if not input_image: # cond
+ preNet = nn.Sequential(
+ nn.Conv2d(num_inputs, num_outputs, kernel_size=7, stride=1, padding="same"),
+ nn.BatchNorm2d(num_outputs),
+ )
+ else:
+ preNet = nn.Sequential(
+ nn.Conv2d(num_inputs, 64, kernel_size=3, stride=1, padding="same"),
+ nn.BatchNorm2d(64),
+ nn.Conv2d(64, num_outputs, kernel_size=7, stride=1, padding="same"),
+ nn.BatchNorm2d(num_outputs),
+ )
+ return preNet
+
+ def forward(self, x: torch.Tensor, cond_kpts: np.ndarray | torch.Tensor) -> torch.Tensor:
+ """Forward pass through the conditional preNet + backbone.
+
+ Args:
+ x: Input tensor of shape (batch_size, channels, height, width).
+ cond_kpts: Conditional keypoints of shape (batch_size, num_joints, 2).
+
+ Returns:
+ the feature map
+ """
+ # create conditional heatmap
+ if isinstance(cond_kpts, torch.Tensor):
+ cond_kpts = cond_kpts.detach().numpy()
+
+ cond_hm = self.cond_enc(cond_kpts.squeeze(1), x.size()[2:])
+ cond_hm = torch.from_numpy(cond_hm).float().to(x.device)
+ cond_hm = cond_hm.permute(0, 3, 1, 2) # (B, C, H, W)
+
+ x0 = self.rgb_preNet(x)
+ x1 = self.cond_preNet(cond_hm)
+ x = x0 + x1
+
+ return self.backbone(x)
+
+ def init_weights(self):
+ """Initialize PreNet weights from a Normal distribution."""
+ for prenet in [self.rgb_preNet, self.cond_preNet]:
+ for m in prenet.modules():
+ if isinstance(m, nn.Conv2d):
+ nn.init.normal_(m.weight, std=0.001)
+ if m.bias is not None:
+ nn.init.constant_(m.bias, 0)
+ elif isinstance(m, nn.BatchNorm2d):
+ nn.init.constant_(m.weight, 1)
+ nn.init.constant_(m.bias, 0)
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/cspnext.py b/deeplabcut/pose_estimation_pytorch/models/backbones/cspnext.py
new file mode 100644
index 0000000000..44af1960af
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/cspnext.py
@@ -0,0 +1,206 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Implementation of the CSPNeXt Backbone.
+
+Based on the ``mmdetection`` CSPNeXt implementation. For more information, see:
+
+
+For more details about this architecture, see `RTMDet: An Empirical Study of Designing
+Real-Time Object Detectors`: https://arxiv.org/abs/1711.05101.
+"""
+
+from dataclasses import dataclass
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import (
+ BACKBONES,
+ BaseBackbone,
+ HuggingFaceWeightsMixin,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules.csp import (
+ CSPConvModule,
+ CSPLayer,
+ SPPBottleneck,
+)
+
+
+@dataclass(frozen=True)
+class CSPNeXtLayerConfig:
+ """Configuration for a CSPNeXt layer."""
+
+ in_channels: int
+ out_channels: int
+ num_blocks: int
+ add_identity: bool
+ use_spp: bool
+
+
+@BACKBONES.register_module
+class CSPNeXt(HuggingFaceWeightsMixin, BaseBackbone):
+ """CSPNeXt Backbone.
+
+ Args:
+ model_name: The model variant to build. If ``pretrained==True``, must be one of
+ the variants for which weights are available on HuggingFace (in the
+ `DeepLabCut/DeepLabCut-Backbones` hub, e.g. `cspnext_m`).
+ pretrained: Whether to load pretrained weights for the model.
+ arch: The model architecture to build. Must be one of the keys of the
+ ``CSPNeXt.ARCH`` attribute (e.g. `P5`, `P6`, ...).
+ expand_ratio: Ratio used to adjust the number of channels of the hidden layer.
+ deepen_factor: Number of blocks in each CSP layer is multiplied by this value.
+ widen_factor: Number of channels in each layer is multiplied by this value.
+ out_indices: The branch indices to output. If a tuple of integers, the outputs
+ are returned as a list of tensors. If a single integer, a tensor is returned
+ containing the configured index.
+ channel_attention: Add channel attention to all stages
+ norm_layer: The type of normalization layer to use.
+ activation_fn: The type of activation function to use.
+ **kwargs: BaseBackbone kwargs.
+ """
+
+ ARCH: dict[str, list[CSPNeXtLayerConfig]] = {
+ "P5": [
+ CSPNeXtLayerConfig(64, 128, 3, True, False),
+ CSPNeXtLayerConfig(128, 256, 6, True, False),
+ CSPNeXtLayerConfig(256, 512, 6, True, False),
+ CSPNeXtLayerConfig(512, 1024, 3, False, True),
+ ],
+ "P6": [
+ CSPNeXtLayerConfig(64, 128, 3, True, False),
+ CSPNeXtLayerConfig(128, 256, 6, True, False),
+ CSPNeXtLayerConfig(256, 512, 6, True, False),
+ CSPNeXtLayerConfig(512, 768, 3, True, False),
+ CSPNeXtLayerConfig(768, 1024, 3, False, True),
+ ],
+ }
+
+ def __init__(
+ self,
+ model_name: str = "cspnext_m",
+ pretrained: bool = False,
+ arch: str = "P5",
+ expand_ratio: float = 0.5,
+ deepen_factor: float = 0.67,
+ widen_factor: float = 0.75,
+ out_indices: int | tuple[int, ...] = -1,
+ channel_attention: bool = True,
+ norm_layer: str = "SyncBN",
+ activation_fn: str = "SiLU",
+ **kwargs,
+ ) -> None:
+ super().__init__(stride=32, **kwargs)
+ if arch not in self.ARCH:
+ raise ValueError(f"Unknown `CSPNeXT` architecture: {arch}. Must be one of {self.ARCH.keys()}")
+
+ self.model_name = model_name
+ self.layer_configs = self.ARCH[arch]
+ self.stem_out_channels = self.layer_configs[0].in_channels
+ self.spp_kernel_sizes = (5, 9, 13)
+
+ # stem has stride 2
+ self.stem = nn.Sequential(
+ CSPConvModule(
+ in_channels=3,
+ out_channels=int(self.stem_out_channels * widen_factor // 2),
+ kernel_size=3,
+ padding=1,
+ stride=2,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ ),
+ CSPConvModule(
+ in_channels=int(self.stem_out_channels * widen_factor // 2),
+ out_channels=int(self.stem_out_channels * widen_factor // 2),
+ kernel_size=3,
+ padding=1,
+ stride=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ ),
+ CSPConvModule(
+ in_channels=int(self.stem_out_channels * widen_factor // 2),
+ out_channels=int(self.stem_out_channels * widen_factor),
+ kernel_size=3,
+ padding=1,
+ stride=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ ),
+ )
+ self.layers = ["stem"]
+
+ for i, layer_cfg in enumerate(self.layer_configs):
+ layer_cfg: CSPNeXtLayerConfig
+ in_channels = int(layer_cfg.in_channels * widen_factor)
+ out_channels = int(layer_cfg.out_channels * widen_factor)
+ num_blocks = max(round(layer_cfg.num_blocks * deepen_factor), 1)
+ stage = []
+ conv_layer = CSPConvModule(
+ in_channels,
+ out_channels,
+ 3,
+ stride=2,
+ padding=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ stage.append(conv_layer)
+ if layer_cfg.use_spp:
+ spp = SPPBottleneck(
+ out_channels,
+ out_channels,
+ kernel_sizes=self.spp_kernel_sizes,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ stage.append(spp)
+
+ csp_layer = CSPLayer(
+ out_channels,
+ out_channels,
+ num_blocks=num_blocks,
+ add_identity=layer_cfg.add_identity,
+ expand_ratio=expand_ratio,
+ channel_attention=channel_attention,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ stage.append(csp_layer)
+ self.add_module(f"stage{i + 1}", nn.Sequential(*stage))
+ self.layers.append(f"stage{i + 1}")
+
+ self.single_output = isinstance(out_indices, int)
+ if self.single_output:
+ if out_indices == -1:
+ out_indices = len(self.layers) - 1
+ out_indices = (out_indices,)
+ self.out_indices = out_indices
+
+ if pretrained:
+ weights_filename = f"{model_name}.pt"
+ weights_path = self.download_weights(weights_filename, force=False)
+ snapshot = torch.load(weights_path, map_location="cpu", weights_only=True)
+ self.load_state_dict(snapshot["state_dict"])
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor | tuple[torch.Tensor]:
+ outs = []
+ for i, layer_name in enumerate(self.layers):
+ layer = getattr(self, layer_name)
+ x = layer(x)
+ if i in self.out_indices:
+ outs.append(x)
+
+ if self.single_output:
+ return outs[-1]
+
+ return tuple(outs)
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py
new file mode 100644
index 0000000000..6af4905723
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py
@@ -0,0 +1,126 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import timm
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import (
+ BACKBONES,
+ BaseBackbone,
+)
+
+
+@BACKBONES.register_module
+class HRNet(BaseBackbone):
+ """HRNet backbone.
+
+ This version returns high-resolution feature maps of size 1/4 * original_image_size.
+ This is obtained using bilinear interpolation and concatenation of all the outputs
+ of the HRNet stages.
+
+ The model outputs 4 branches, with strides 4, 8, 16 and 32.
+
+ Args:
+ stride: The stride of the HRNet. Should always be 4, except for custom models.
+ model_name: Any HRNet variant available through timm (e.g., 'hrnet_w32',
+ 'hrnet_w48'). See timm for more options.
+ pretrained: If True, loads the backbone with ImageNet pretrained weights from
+ timm.
+ interpolate_branches: Needed for DEKR. Instead of returning features from the
+ high-resolution branch, interpolates all other branches to the same shape
+ and concatenates them.
+ increased_channel_count: As described by timm, it "allows grabbing increased
+ channel count features using part of the classification head" (otherwise,
+ the default features are returned).
+ kwargs: BaseBackbone kwargs
+
+ Attributes:
+ model: the HRNet model
+ """
+
+ def __init__(
+ self,
+ stride: int = 4,
+ model_name: str = "hrnet_w32",
+ pretrained: bool = False,
+ interpolate_branches: bool = False,
+ increased_channel_count: bool = False,
+ **kwargs,
+ ) -> None:
+ super().__init__(stride=stride, **kwargs)
+ self.model = _load_hrnet(model_name, pretrained, increased_channel_count)
+ self.interpolate_branches = interpolate_branches
+
+ def prepare_output(self, y_list: list) -> torch.Tensor:
+ if not self.interpolate_branches:
+ return y_list[0]
+
+ x0_h, x0_w = y_list[0].size(2), y_list[0].size(3)
+ x = torch.cat(
+ [
+ y_list[0],
+ F.interpolate(y_list[1], size=(x0_h, x0_w), mode="bilinear"),
+ F.interpolate(y_list[2], size=(x0_h, x0_w), mode="bilinear"),
+ F.interpolate(y_list[3], size=(x0_h, x0_w), mode="bilinear"),
+ ],
+ 1,
+ )
+ return x
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass through the HRNet backbone.
+
+ Args:
+ x: Input tensor of shape (batch_size, channels, height, width).
+
+ Returns:
+ the feature map
+
+ Example:
+ >>> import torch
+ >>> from deeplabcut.pose_estimation_pytorch.models.backbones import HRNet
+ >>> backbone = HRNet(model_name='hrnet_w32', pretrained=False)
+ >>> x = torch.randn(1, 3, 256, 256)
+ >>> y = backbone(x)
+ """
+ y_list = self.model(x)
+
+ return self.prepare_output(y_list)
+
+
+def _load_hrnet(
+ model_name: str,
+ pretrained: bool,
+ increased_channel_count: bool,
+) -> nn.Module:
+ """Loads a TIMM HRNet model.
+
+ Args:
+ model_name: Any HRNet variant available through timm (e.g., 'hrnet_w32',
+ 'hrnet_w48'). See timm for more options.
+ pretrained: If True, loads the backbone with ImageNet pretrained weights from
+ timm.
+ increased_channel_count: As described by timm, it "allows grabbing increased
+ channel count features using part of the classification head" (otherwise,
+ the default features are returned).
+
+ Returns:
+ the HRNet model
+ """
+ # First stem conv is used for stride 2 features, so only return branches 1-4
+ return timm.create_model(
+ model_name,
+ pretrained=pretrained,
+ features_only=True,
+ feature_location="incre" if increased_channel_count else "",
+ out_indices=(1, 2, 3, 4),
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet_coam.py b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet_coam.py
new file mode 100644
index 0000000000..8c9d274d7f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet_coam.py
@@ -0,0 +1,191 @@
+# ------------------------------------------------------------------------------
+# Copyright (c) Microsoft
+# Licensed under the MIT License.
+# Written by Bin Xiao (Bin.Xiao@microsoft.com)
+# Modified to Conditional Top Down by Mu Zhou, Lucas Stoffl et al. (ICCV 2023)
+# ------------------------------------------------------------------------------
+
+from __future__ import annotations
+
+import numpy as np
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import BACKBONES
+from deeplabcut.pose_estimation_pytorch.models.backbones.hrnet import HRNet
+from deeplabcut.pose_estimation_pytorch.models.modules import ( # ColoredKeypointEncoder,; StackedKeypointEncoder,
+ KEYPOINT_ENCODERS,
+ BaseKeypointEncoder,
+ CoAMBlock,
+ SelfAttentionModule_CoAM,
+)
+
+
+@BACKBONES.register_module
+class HRNetCoAM(HRNet):
+ """HRNet backbone with Conditional Attention Module (CoAM).
+
+ This version returns high-resolution feature maps of size 1/4 * original_image_size.
+
+ Attributes:
+ model: the HRNet model
+ coam_stages: CoAM blocks for each stage
+ """
+
+ def __init__(
+ self,
+ kpt_encoder: dict | BaseKeypointEncoder,
+ base_model_name: str = "hrnet_w32",
+ pretrained: bool = True,
+ coam_modules: tuple[int, ...] = (2,),
+ selfatt_coam_modules: tuple[int, ...] | None = None,
+ channel_att_only: bool = False,
+ att_heads: int = 1,
+ img_size: tuple[int, int] = (256, 256),
+ **kwargs,
+ ) -> None:
+ """Constructs an ImageNet pretrained HRNet from timm and creates CoAM blocks.
+
+ Args:
+ base_model_name: Type of HRNet (e.g., 'hrnet_w32', 'hrnet_w48').
+ pretrained: If True, loads the model with ImageNet pretrained weights.
+ coam_modules: List of stages to apply CoAM.
+ selfatt_coam_modules: List of stages to apply Self-Attention-CoAM.
+ channel_att_only: Whether to use only channel attention block in CoAM.
+ att_heads: Number of attention heads.
+ cond_enc: Type of conditional encoding ('stacked', 'colored', or greyscale).
+ img_size: The (height, width) size of the input images.
+ num_joints: Number of joints in the dataset.
+ """
+
+ super().__init__(model_name=base_model_name, pretrained=pretrained, **kwargs)
+
+ self.coam_modules = coam_modules
+ self.selfatt_coam_modules = selfatt_coam_modules
+ self.channel_att_only = channel_att_only
+ if not isinstance(kpt_encoder, BaseKeypointEncoder):
+ if "img_size" not in kpt_encoder:
+ kpt_encoder["img_size"] = img_size
+ kpt_encoder = KEYPOINT_ENCODERS.build(kpt_encoder)
+
+ self.cond_enc = kpt_encoder
+
+ self.coam_stages = nn.ModuleList([None, None, None, None])
+ self.selfatt_coam_stages = nn.ModuleList([None, None, None, None])
+
+ spat_dims = [
+ (int(img_size[0] / 4), int(img_size[1] / 4)),
+ (int(img_size[0] / 8), int(img_size[1] / 8)),
+ (int(img_size[0] / 16), int(img_size[1] / 16)),
+ (int(img_size[0] / 32), int(img_size[1] / 32)),
+ ]
+
+ assert not (set(coam_modules) & set(selfatt_coam_modules) if selfatt_coam_modules else set()), (
+ "CoAM and Self-Attention-CoAM cannot be used at the same time"
+ )
+
+ all_output_channels = [
+ self.model.stage2_cfg["num_channels"],
+ self.model.stage3_cfg["num_channels"],
+ self.model.stage4_cfg["num_channels"],
+ ]
+
+ for coam_pos in self.coam_modules:
+ if coam_pos == 4:
+ spat_dims_ = [spat_dims[0]]
+ channels = [all_output_channels[-1][0]]
+ else:
+ spat_dims_ = spat_dims[: coam_pos + 1]
+ channels = all_output_channels[coam_pos - 1]
+
+ self.coam_stages[coam_pos - 1] = CoAMBlock(
+ spat_dims=spat_dims_,
+ channel_list=channels,
+ cond_enc=self.cond_enc,
+ n_heads=att_heads,
+ channel_only=self.channel_att_only,
+ )
+
+ if self.selfatt_coam_modules:
+ for selfatt_coam_pos in self.selfatt_coam_modules:
+ if selfatt_coam_pos == 4:
+ spat_dims_ = [spat_dims[0]]
+ channels = [all_output_channels[-1][0]]
+ else:
+ spat_dims_ = spat_dims[: selfatt_coam_pos + 1]
+ channels = all_output_channels[coam_pos - 1]
+ self.selfatt_coam_stages[selfatt_coam_pos - 1] = SelfAttentionModule_CoAM(
+ spat_dims=spat_dims_, channel_list=channels
+ )
+
+ def stages(self, x, cond_hm) -> list[torch.Tensor]:
+ x = self.model.layer1(x)
+
+ xl = [t(x) for i, t in enumerate(self.model.transition1)]
+
+ if self.coam_stages[0]:
+ xl = self.coam_stages[0](xl, cond_hm)
+ elif self.selfatt_coam_stages[0]:
+ xl = self.selfatt_coam_stages[0](xl)
+
+ yl = self.model.stage2(xl)
+
+ xl = [t(yl[-1]) if not isinstance(t, nn.Identity) else yl[i] for i, t in enumerate(self.model.transition2)]
+
+ if self.coam_stages[1]:
+ xl = self.coam_stages[1](xl, cond_hm)
+ elif self.selfatt_coam_stages[1]:
+ xl = self.selfatt_coam_stages[1](xl)
+
+ yl = self.model.stage3(xl)
+
+ xl = [t(yl[-1]) if not isinstance(t, nn.Identity) else yl[i] for i, t in enumerate(self.model.transition3)]
+
+ if self.coam_stages[2]:
+ xl = self.coam_stages[2](xl, cond_hm)
+ elif self.selfatt_coam_stages[2]:
+ xl = self.selfatt_coam_stages[2](xl)
+
+ yl = self.model.stage4(xl)
+
+ if self.coam_stages[3]:
+ yl = self.coam_stages[3](yl, cond_hm)
+ elif self.selfatt_coam_stages[3]:
+ yl = self.selfatt_coam_stages[3](yl)
+
+ return yl
+
+ def forward(self, x: torch.Tensor, cond_kpts: np.ndarray):
+ """Forward pass through the HRNetCoAM backbone.
+
+ Args:
+ x: Input tensor of shape (batch_size, channels, height, width).
+ cond_kpts: Conditional keypoints of shape (batch_size, num_joints, 2).
+
+ Returns:
+ the feature map
+ """
+
+ # create conditional heatmap
+ if isinstance(cond_kpts, torch.Tensor):
+ cond_kpts = cond_kpts.detach().numpy()
+ cond_hm = self.cond_enc(cond_kpts.squeeze(1), x.size()[2:])
+ cond_hm = torch.from_numpy(cond_hm).float().to(x.device)
+ cond_hm = cond_hm.permute(0, 3, 1, 2) # (B, C, H, W)
+
+ # Stem
+ x = self.model.conv1(x)
+ x = self.model.bn1(x)
+ x = self.model.act1(x)
+ x = self.model.conv2(x)
+ x = self.model.bn2(x)
+ x = self.model.act2(x)
+
+ # Stages
+ y = self.stages(x, cond_hm)
+
+ if self.model.incre_modules is not None:
+ raise NotImplementedError("Incremental HRNet modules not supported for HRNetCoAM")
+ x = [incre(f) for f, incre in zip(x, self.model.incre_modules, strict=False)]
+
+ return self.prepare_output(y)
diff --git a/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py
new file mode 100644
index 0000000000..d8ba3904b6
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py
@@ -0,0 +1,139 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import timm
+import torch
+import torch.nn as nn
+from torchvision.transforms.functional import resize
+
+from deeplabcut.pose_estimation_pytorch.models.backbones.base import (
+ BACKBONES,
+ BaseBackbone,
+)
+
+
+@BACKBONES.register_module
+class ResNet(BaseBackbone):
+ """ResNet backbone.
+
+ This class represents a typical ResNet backbone for pose estimation.
+
+ Attributes:
+ model: the ResNet model
+ """
+
+ def __init__(
+ self,
+ model_name: str = "resnet50",
+ output_stride: int = 32,
+ pretrained: bool = False,
+ drop_path_rate: float = 0.0,
+ drop_block_rate: float = 0.0,
+ **kwargs,
+ ) -> None:
+ """Initialize the ResNet backbone.
+
+ Args:
+ model_name: Name of the ResNet model to use, e.g., 'resnet50', 'resnet101'
+ output_stride: Output stride of the network, 32, 16, or 8.
+ pretrained: If True, initializes with ImageNet pretrained weights.
+ drop_path_rate: Stochastic depth drop-path rate
+ drop_block_rate: Drop block rate
+ kwargs: BaseBackbone kwargs
+ """
+ super().__init__(stride=output_stride, **kwargs)
+ self.model = timm.create_model(
+ model_name,
+ output_stride=output_stride,
+ pretrained=pretrained,
+ drop_path_rate=drop_path_rate,
+ drop_block_rate=drop_block_rate,
+ )
+ self.model.fc = nn.Identity() # remove the FC layer
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass through the ResNet backbone.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ torch.Tensor: Output tensor.
+ Example:
+ >>> import torch
+ >>> from deeplabcut.pose_estimation_pytorch.models.backbones import ResNet
+ >>> backbone = ResNet(model_name='resnet50', pretrained=False)
+ >>> x = torch.randn(1, 3, 256, 256)
+ >>> y = backbone(x)
+
+ Expected Output Shape:
+ If input size is (batch_size, 3, shape_x, shape_y), the output shape
+ will be (batch_size, 3, shape_x//16, shape_y//16)
+ """
+ return self.model.forward_features(x)
+
+
+@BACKBONES.register_module
+class DLCRNet(ResNet):
+ def __init__(
+ self,
+ model_name: str = "resnet50",
+ output_stride: int = 32,
+ pretrained: bool = True,
+ **kwargs,
+ ) -> None:
+ super().__init__(model_name, output_stride, pretrained, **kwargs)
+ self.interm_features = {}
+ self.model.layer1[2].register_forward_hook(self._get_features("bank1"))
+ self.model.layer2[2].register_forward_hook(self._get_features("bank2"))
+ self.conv_block1 = self._make_conv_block(in_channels=512, out_channels=512, kernel_size=3, stride=2)
+ self.conv_block2 = self._make_conv_block(in_channels=512, out_channels=128, kernel_size=1, stride=1)
+ self.conv_block3 = self._make_conv_block(in_channels=256, out_channels=256, kernel_size=3, stride=2)
+ self.conv_block4 = self._make_conv_block(in_channels=256, out_channels=256, kernel_size=3, stride=2)
+ self.conv_block5 = self._make_conv_block(in_channels=256, out_channels=128, kernel_size=1, stride=1)
+
+ def _make_conv_block(
+ self,
+ in_channels: int,
+ out_channels: int,
+ kernel_size: int,
+ stride: int,
+ momentum: float = 0.001, # (1 - decay)
+ ) -> torch.nn.Sequential:
+ return nn.Sequential(
+ nn.Conv2d(in_channels, out_channels, kernel_size=kernel_size, stride=stride),
+ nn.BatchNorm2d(out_channels, momentum=momentum),
+ nn.ReLU(),
+ )
+
+ def _get_features(self, name):
+ def hook(model, input, output):
+ self.interm_features[name] = output.detach()
+
+ return hook
+
+ def forward(self, x):
+ out = super().forward(x)
+
+ # Fuse intermediate features
+ bank_2_s8 = self.interm_features["bank2"]
+ bank_1_s4 = self.interm_features["bank1"]
+ bank_2_s16 = self.conv_block1(bank_2_s8)
+ bank_2_s16 = self.conv_block2(bank_2_s16)
+ bank_1_s8 = self.conv_block3(bank_1_s4)
+ bank_1_s16 = self.conv_block4(bank_1_s8)
+ bank_1_s16 = self.conv_block5(bank_1_s16)
+ # Resizing here is required to guarantee all shapes match, as
+ # Conv2D(..., padding='same') is invalid for strided convolutions.
+ h, w = out.shape[-2:]
+ bank_1_s16 = resize(bank_1_s16, [h, w], antialias=True)
+ bank_2_s16 = resize(bank_2_s16, [h, w], antialias=True)
+
+ return torch.cat((bank_1_s16, bank_2_s16, out), dim=1)
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py
new file mode 100644
index 0000000000..c1b07634ae
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/__init__.py
@@ -0,0 +1,31 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.criterions.aggregators import (
+ WeightedLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.criterions.base import (
+ CRITERIONS,
+ LOSS_AGGREGATORS,
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.criterions.dekr import (
+ DEKRHeatmapLoss,
+ DEKROffsetLoss,
+)
+from deeplabcut.pose_estimation_pytorch.models.criterions.kl_discrete import (
+ KLDiscreteLoss,
+)
+from deeplabcut.pose_estimation_pytorch.models.criterions.weighted import (
+ WeightedBCECriterion,
+ WeightedHuberCriterion,
+ WeightedMSECriterion,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py
new file mode 100644
index 0000000000..98d538a753
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/aggregators.py
@@ -0,0 +1,29 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.criterions.base import (
+ LOSS_AGGREGATORS,
+ BaseLossAggregator,
+)
+
+
+@LOSS_AGGREGATORS.register_module
+class WeightedLossAggregator(BaseLossAggregator):
+ def __init__(self, weights: dict[str, float]) -> None:
+ super().__init__()
+ self.weights = weights
+
+ def forward(self, losses: dict[str, torch.Tensor]) -> torch.Tensor:
+ weighted_losses = [weight * losses[loss_name] for loss_name, weight in self.weights.items()]
+ return torch.mean(torch.stack(weighted_losses))
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/base.py b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py
new file mode 100644
index 0000000000..25e6003904
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/base.py
@@ -0,0 +1,52 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+LOSS_AGGREGATORS = Registry("loss_aggregators", build_func=build_from_cfg)
+CRITERIONS = Registry("criterions", build_func=build_from_cfg)
+
+
+class BaseCriterion(ABC, nn.Module):
+ def __init__(self) -> None:
+ super().__init__()
+
+ @abstractmethod
+ def forward(self, output: torch.Tensor, target: torch.Tensor, **kwargs) -> torch.Tensor:
+ """
+ Args:
+ output: the output from which to compute the loss
+ target: the target for the loss
+
+ Returns:
+ the different losses for the module, including one "total_loss" key which
+ is the loss from which to start backpropagation
+ """
+ raise NotImplementedError
+
+
+class BaseLossAggregator(ABC, nn.Module):
+ @abstractmethod
+ def forward(self, losses: dict[str, torch.Tensor]) -> torch.Tensor:
+ """
+ Args:
+ losses: the losses to aggregate
+
+ Returns:
+ the aggregate loss
+ """
+ raise NotImplementedError
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py b/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py
new file mode 100644
index 0000000000..e05e19f6af
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/dekr.py
@@ -0,0 +1,86 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Loss criterions for DEKR models."""
+
+from __future__ import annotations
+
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.criterions.base import (
+ CRITERIONS,
+ BaseCriterion,
+)
+
+
+@CRITERIONS.register_module
+class DEKRHeatmapLoss(BaseCriterion):
+ """DEKR Heatmap loss."""
+
+ def forward(
+ self,
+ output: torch.Tensor,
+ target: torch.Tensor,
+ weights: torch.Tensor | float = 1.0,
+ **kwargs,
+ ) -> torch.Tensor:
+ """
+ Args:
+ output: the output from which to compute the loss
+ target: the target for the loss
+ weights: the weights for the loss
+
+ Returns:
+ the DEKR offset loss
+ """
+ assert output.size() == target.size()
+ loss = ((output - target) ** 2) * weights
+ return loss.mean(dim=3).mean(dim=2).mean(dim=1).mean(dim=0)
+
+
+@CRITERIONS.register_module
+class DEKROffsetLoss(BaseCriterion):
+ """DEKR Offset loss."""
+
+ def __init__(self, beta: float = 1 / 9):
+ super().__init__()
+ self.beta = beta
+
+ def smooth_l1_loss(self, pred, gt):
+ l1_loss = torch.abs(pred - gt)
+ return torch.where(
+ l1_loss < self.beta,
+ 0.5 * l1_loss**2 / self.beta,
+ l1_loss - 0.5 * self.beta,
+ )
+
+ def forward(
+ self,
+ output: torch.Tensor,
+ target: torch.Tensor,
+ weights: torch.Tensor | float = 1.0,
+ **kwargs,
+ ) -> torch.Tensor:
+ """
+ Args:
+ output: the output from which to compute the loss
+ target: the target for the loss
+ weights: the weights for the loss
+
+ Returns:
+ the DEKR offset loss
+ """
+ assert output.size() == target.size()
+ num_pos = torch.nonzero(weights > 0).size()[0]
+ loss = self.smooth_l1_loss(output, target) * weights
+ if num_pos == 0:
+ num_pos = 1.0
+ loss = loss.sum() / num_pos
+ return loss
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/kl_discrete.py b/deeplabcut/pose_estimation_pytorch/models/criterions/kl_discrete.py
new file mode 100644
index 0000000000..677c4263d5
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/kl_discrete.py
@@ -0,0 +1,87 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""SimCC Discrete KL Divergence loss with Gaussian Label Smoothing.
+
+Can be used for SimCC-type heads. Modified from the `mmpose` implementation. For more
+details, see .
+"""
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.criterions.base import (
+ CRITERIONS,
+ BaseCriterion,
+)
+
+
+@CRITERIONS.register_module
+class KLDiscreteLoss(BaseCriterion):
+ """KLDiscrete loss.
+
+ Args:
+ beta: Temperature for the softmax.
+ label_softmax: Use softmax on the labels.
+ label_beta: Temperature for the softmax on the labels.
+ use_target_weight: Allows the use a weighted loss for different joints.
+ mask: Indices of masked keypoints.
+ mask_weight: Weight for masked keypoints.
+ """
+
+ def __init__(
+ self,
+ beta: float = 1.0,
+ label_softmax: bool = False,
+ label_beta: float = 10.0,
+ use_target_weight: bool = True,
+ mask: list[int] | None = None,
+ mask_weight: float = 1.0,
+ ):
+ super().__init__()
+ self.beta = beta
+ self.label_softmax = label_softmax
+ self.label_beta = label_beta
+ self.use_target_weight = use_target_weight
+ self.mask = mask
+ self.mask_weight = mask_weight
+
+ self.log_softmax = nn.LogSoftmax(dim=1)
+ self.kl_loss = nn.KLDivLoss(reduction="none")
+
+ def forward(
+ self,
+ output: torch.Tensor,
+ target: torch.Tensor,
+ weights: torch.Tensor | float = 1.0,
+ **kwargs,
+ ) -> torch.Tensor:
+ n, k, _ = output.shape
+ if self.use_target_weight and isinstance(weights, torch.Tensor):
+ weight = weights.reshape(-1)
+ else:
+ weight = 1.0
+
+ pred = output.reshape(-1, output.size(-1))
+ target = target.reshape(-1, target.size(-1))
+ loss = self.criterion(pred, target).mul(weight)
+ if self.mask is not None:
+ loss = loss.reshape(n, k)
+ loss[:, self.mask] = loss[:, self.mask] * self.mask_weight
+
+ return loss.sum() / k
+
+ def criterion(self, dec_outs, labels):
+ log_pt = self.log_softmax(dec_outs * self.beta)
+ if self.label_softmax:
+ labels = F.softmax(labels * self.label_beta, dim=1)
+ loss = torch.mean(self.kl_loss(log_pt, labels), dim=1)
+ return loss
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py
new file mode 100644
index 0000000000..4fc455c585
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/utils.py
@@ -0,0 +1,46 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+
+
+def count_nonzero_elems(losses: torch.Tensor, weights: float | torch.Tensor, per_batch: bool = False):
+ """
+ Compute the number of elements in the loss function induced by `weights`.
+ This is a torch implementation of https://github.com/tensorflow/tensorflow/blob/4dacf3f368eb7965e9b5c3bbdd5193986081c3b2/tensorflow/python/ops/losses/losses_impl.py#L89
+
+ Args:
+ losses (Tensor): Tensor of shape [batch_size, d1, ... dN].
+ weights (Tensor): Tensor of shape [], [batch_size] or [batch_size, d1, ... dK], where K < N.
+ per_batch (bool): Whether to return the number of elements per batch or as a sum total.
+
+ Returns:
+ Tensor: The number of present (non-zero) elements in the losses tensor.
+ """
+ if isinstance(weights, float):
+ if weights != 0.0:
+ return losses.numel()
+ else:
+ return torch.tensor(0)
+
+ weights = torch.as_tensor(weights, dtype=torch.float32)
+
+ # Check for non-zero weights and broadcast to match losses
+ present = torch.where(weights == 0.0, torch.zeros_like(weights), torch.ones_like(weights))
+ present = present.expand_as(losses)
+
+ # Reduce sum across the desired dimensions
+ if per_batch:
+ reduction_dims = tuple(range(1, present.dim()))
+ return torch.sum(present, dim=reduction_dims, keepdim=True)
+ else:
+ return torch.sum(present)
diff --git a/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py
new file mode 100644
index 0000000000..d2fcab46b6
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/criterions/weighted.py
@@ -0,0 +1,120 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import utils
+from deeplabcut.pose_estimation_pytorch.models.criterions.base import (
+ CRITERIONS,
+ BaseCriterion,
+)
+
+
+class WeightedCriterion(BaseCriterion):
+ """Base class for weighted criterions."""
+
+ def __init__(self, criterion: nn.Module):
+ super().__init__()
+ self.criterion = criterion
+
+ def forward(
+ self,
+ output: torch.Tensor,
+ target: torch.Tensor,
+ weights: torch.Tensor | float = 1.0,
+ **kwargs,
+ ) -> torch.Tensor:
+ """
+ Args:
+ output: predicted tensor
+ target: target tensor
+ weights: weights for each element in the loss calculation. If a float,
+ weights all elements by that value. Defaults to 1.
+
+ Returns:
+ the weighted loss
+ """
+ # shape of loss: (batch_size, n_kpts, heatmap_size, heatmap_size)
+ loss = self.criterion(output, target)
+ n_elems = utils.count_nonzero_elems(loss, weights)
+ if n_elems == 0:
+ n_elems = 1
+
+ return torch.sum(loss * weights) / n_elems
+
+
+@CRITERIONS.register_module
+class WeightedMSECriterion(WeightedCriterion):
+ """Weighted Mean Squared Error (MSE) Loss.
+
+ This loss computes the Mean Squared Error between the prediction and target tensors,
+ but it also incorporates weights to adjust the contribution of each element in the
+ loss calculation. The loss is computed element-wise, and elements with a weight of 0
+ (masked items) are excluded from the loss calculation.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(nn.MSELoss(reduction="none"))
+
+ def forward(
+ self,
+ output: torch.Tensor,
+ target: torch.Tensor,
+ weights: torch.Tensor | float = 1.0,
+ **kwargs,
+ ) -> torch.Tensor:
+ """
+ Args:
+ output: predicted tensor
+ target: target tensor
+ weights: weights for each element in the loss calculation. If a float,
+ weights all elements by that value. Defaults to 1.
+
+ Returns:
+ the weighted loss
+ """
+ # shape of loss: (batch_size, n_kpts, h, w)
+ loss = self.criterion(output, target)
+ n_elems = utils.count_nonzero_elems(loss, weights)
+ if n_elems == 0:
+ n_elems = 1
+
+ return torch.sum(loss * weights) / n_elems
+
+
+@CRITERIONS.register_module
+class WeightedHuberCriterion(WeightedCriterion):
+ """Weighted Huber Loss.
+
+ This loss computes the Huber loss between the prediction and target tensors, but it
+ also incorporates weights to adjust the contribution of each element in the loss
+ calculation. The loss is computed element-wise, and elements with a weight of 0 are
+ excluded from the loss calculation.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(nn.HuberLoss(reduction="none"))
+
+
+@CRITERIONS.register_module
+class WeightedBCECriterion(WeightedCriterion):
+ """Weighted Binary Cross Entropy (BCE) Loss.
+
+ This loss computes the Binary Cross Entropy loss between the prediction and target
+ tensors, but it also incorporates weights to adjust the contribution of each element
+ in the loss calculation. The loss is computed element-wise, and elements with a
+ weight of 0 are excluded from the loss calculation.
+ """
+
+ def __init__(self) -> None:
+ super().__init__(nn.BCEWithLogitsLoss(reduction="none"))
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py
new file mode 100644
index 0000000000..27f50f345a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/__init__.py
@@ -0,0 +1,16 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.detectors.base import (
+ DETECTORS,
+ BaseDetector,
+)
+from deeplabcut.pose_estimation_pytorch.models.detectors.fasterRCNN import FasterRCNN
+from deeplabcut.pose_estimation_pytorch.models.detectors.ssd import SSDLite
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/base.py b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py
new file mode 100644
index 0000000000..9660b81881
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.py
@@ -0,0 +1,124 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import logging
+from abc import ABC, abstractmethod
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+
+def _build_detector(
+ cfg: dict,
+ weight_init: WeightInitialization | None = None,
+ pretrained: bool = False,
+ **kwargs,
+) -> BaseDetector:
+ """Builds a detector using its configuration file.
+
+ Args:
+ cfg: The detector configuration.
+ weight_init: The weight initialization to use.
+ pretrained: Whether COCO pretrained weights should be loaded for the detector
+ **kwargs: Other parameters given by the Registry.
+
+ Returns:
+ the built detector
+ """
+ cfg["pretrained"] = pretrained
+ detector: BaseDetector = build_from_cfg(cfg, **kwargs)
+
+ if weight_init is not None and weight_init.detector_snapshot_path is not None:
+ logging.info(f"Loading detector checkpoint from {weight_init.detector_snapshot_path}")
+ snapshot = torch.load(weight_init.detector_snapshot_path, map_location="cpu")
+ detector.load_state_dict(snapshot["model"])
+
+ return detector
+
+
+DETECTORS = Registry("detectors", build_func=_build_detector)
+
+
+class BaseDetector(ABC, nn.Module):
+ """Definition of the class BaseDetector object.
+
+ This is an abstract class defining the common structure and inference for detectors.
+ """
+
+ def __init__(
+ self,
+ freeze_bn_stats: bool = False,
+ freeze_bn_weights: bool = False,
+ pretrained: bool = False,
+ ) -> None:
+ super().__init__()
+ self.freeze_bn_stats = freeze_bn_stats
+ self.freeze_bn_weights = freeze_bn_weights
+ self._pretrained = pretrained
+
+ @abstractmethod
+ def forward(
+ self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None
+ ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]:
+ """Forward pass of the detector.
+
+ Args:
+ x: images to be processed
+ targets: ground-truth boxes present in each images
+
+ Returns:
+ losses: {'loss_name': loss_value}
+ detections: for each of the b images, {"boxes": bounding_boxes}
+ """
+ pass
+
+ @abstractmethod
+ def get_target(self, labels: dict) -> list[dict]:
+ """Get the target for training the detector.
+
+ Args:
+ labels: annotations containing keypoints, bounding boxes, etc.
+
+ Returns:
+ list of dictionaries, each representing target information for a single annotation.
+ """
+ pass
+
+ def freeze_batch_norm_layers(self) -> None:
+ """Freezes batch norm layers.
+
+ Running mean + var are always given to F.batch_norm, except when the layer is
+ in `train` mode and track_running_stats is False, see
+ https://pytorch.org/docs/stable/_modules/torch/nn/modules/batchnorm.html
+ So to 'freeze' the running stats, the only way is to set the layer to "eval"
+ mode.
+ """
+ for module in self.modules():
+ if isinstance(module, nn.modules.batchnorm._BatchNorm):
+ if self.freeze_bn_weights:
+ module.weight.requires_grad = False
+ module.bias.requires_grad = False
+ if self.freeze_bn_stats:
+ module.eval()
+
+ def train(self, mode: bool = True) -> None:
+ """Sets the module in training or evaluation mode.
+
+ Args:
+ mode: whether to set training mode (True) or evaluation mode (False)
+ """
+ super().train(mode)
+ if self.freeze_bn_weights or self.freeze_bn_stats:
+ self.freeze_batch_norm_layers()
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py
new file mode 100644
index 0000000000..a36d9e8fa9
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/fasterRCNN.py
@@ -0,0 +1,72 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torchvision.models.detection as detection
+
+from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS
+from deeplabcut.pose_estimation_pytorch.models.detectors.torchvision import (
+ TorchvisionDetectorAdaptor,
+)
+
+
+@DETECTORS.register_module
+class FasterRCNN(TorchvisionDetectorAdaptor):
+ """A FasterRCNN detector.
+
+ Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks
+ Ren, Shaoqing, Kaiming He, Ross Girshick, and Jian Sun. "Faster r-cnn: Towards
+ real-time object detection with region proposal networks." Advances in neural
+ information processing systems 28 (2015).
+
+ This class is a wrapper of the torchvision implementation of a FasterRCNN (source:
+ https://github.com/pytorch/vision/blob/main/torchvision/models/detection/faster_rcnn.py).
+
+ Some of the available FasterRCNN variants (from fastest to most powerful):
+ - fasterrcnn_mobilenet_v3_large_fpn
+ - fasterrcnn_resnet50_fpn
+ - fasterrcnn_resnet50_fpn_v2
+
+ Args:
+ variant: The FasterRCNN variant to use (see all options at
+ https://pytorch.org/vision/stable/models.html#object-detection).
+ pretrained: Whether to load model weights pretrained on COCO
+ box_score_thresh: during inference, only return proposals with a classification
+ score greater than box_score_thresh
+ """
+
+ def __init__(
+ self,
+ freeze_bn_stats: bool = False,
+ freeze_bn_weights: bool = False,
+ variant: str = "fasterrcnn_mobilenet_v3_large_fpn",
+ pretrained: bool = False,
+ box_score_thresh: float = 0.01,
+ ) -> None:
+ if not variant.lower().startswith("fasterrcnn"):
+ raise ValueError(
+ "The version must start with `fasterrcnn`. See available models at "
+ "https://pytorch.org/vision/stable/models.html#object-detection"
+ )
+
+ super().__init__(
+ model=variant,
+ weights=("COCO_V1" if pretrained else None),
+ num_classes=None,
+ freeze_bn_stats=freeze_bn_stats,
+ freeze_bn_weights=freeze_bn_weights,
+ box_score_thresh=box_score_thresh,
+ )
+
+ # Modify the base predictor to output the correct number of classes
+ num_classes = 2
+ in_features = self.model.roi_heads.box_predictor.cls_score.in_features
+ self.model.roi_heads.box_predictor = detection.faster_rcnn.FastRCNNPredictor(in_features, num_classes)
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/filtered_detector.py b/deeplabcut/pose_estimation_pytorch/models/detectors/filtered_detector.py
new file mode 100644
index 0000000000..6d74c496d9
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/filtered_detector.py
@@ -0,0 +1,39 @@
+import torch
+from torch import nn
+
+
+class FilteredDetector(nn.Module):
+ def __init__(self, base_model: nn.Module, class_id: int):
+ """Wrap a torchvision detector to return predictions only for a single class.
+
+ Args:
+ base_model: A torchvision-style object detector.
+ class_id: The integer class ID to keep (e.g., 1 for 'person' in COCO).
+ """
+ super().__init__()
+ self.base_model = base_model
+ self.class_id = class_id
+
+ def forward(self, images: list[torch.Tensor]) -> list[dict[str, torch.Tensor]]:
+ """
+ Arguments:
+ images: list of input images as Tensors
+
+ Returns:
+ List of dicts, each containing boxes/scores/labels filtered to the specified class.
+ """
+ with torch.no_grad():
+ outputs = self.base_model(images)
+
+ filtered_outputs = []
+ for output in outputs:
+ mask = output["labels"] == self.class_id
+ filtered_output = {
+ "boxes": output["boxes"][mask],
+ "scores": output["scores"][mask],
+ "labels": output["labels"][mask],
+ }
+ filtered_outputs.append(filtered_output)
+
+ losses = {}
+ return losses, filtered_outputs
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py b/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py
new file mode 100644
index 0000000000..b4149eb423
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/ssd.py
@@ -0,0 +1,70 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torchvision.models.detection as detection
+
+from deeplabcut.pose_estimation_pytorch.models.detectors.base import DETECTORS
+from deeplabcut.pose_estimation_pytorch.models.detectors.torchvision import (
+ TorchvisionDetectorAdaptor,
+)
+
+
+@DETECTORS.register_module
+class SSDLite(TorchvisionDetectorAdaptor):
+ """An SSD object detection model."""
+
+ def __init__(
+ self,
+ freeze_bn_stats: bool = False,
+ freeze_bn_weights: bool = False,
+ pretrained: bool = False,
+ pretrained_from_imagenet: bool = False,
+ box_score_thresh: float = 0.01,
+ ) -> None:
+ model_kwargs = dict(weights_backbone=None)
+ if pretrained_from_imagenet:
+ model_kwargs["weights_backbone"] = "IMAGENET1K_V2"
+
+ super().__init__(
+ model="ssdlite320_mobilenet_v3_large",
+ weights=None,
+ num_classes=2,
+ freeze_bn_stats=freeze_bn_stats,
+ freeze_bn_weights=freeze_bn_weights,
+ box_score_thresh=box_score_thresh,
+ model_kwargs=model_kwargs,
+ )
+
+ if pretrained and not pretrained_from_imagenet:
+ weights = detection.SSDLite320_MobileNet_V3_Large_Weights.verify("COCO_V1")
+ state_dict = weights.get_state_dict(progress=False, check_hash=True)
+ for k, v in state_dict.items():
+ key_parts = k.split(".")
+ if (
+ len(key_parts) == 6
+ and key_parts[0] == "head"
+ and key_parts[1] == "classification_head"
+ and key_parts[2] == "module_list"
+ and key_parts[4] == "1"
+ and key_parts[5] in ("weight", "bias")
+ ):
+ # number of COCO classes: 90 + background (91)
+ # number of DLC classes: 1 + background (2)
+ # -> only keep weights for the background + first class
+
+ # future improvement: find best-suited class for the project
+ # and use those weights, instead of naively taking the first
+ all_classes_size = v.shape[0]
+ two_classes_size = 2 * (all_classes_size // 91)
+ state_dict[k] = v[:two_classes_size]
+
+ self.model.load_state_dict(state_dict)
diff --git a/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py b/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py
new file mode 100644
index 0000000000..5f7cdb12a8
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/torchvision.py
@@ -0,0 +1,161 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Module to adapt torchvision detectors for DeepLabCut."""
+
+from __future__ import annotations
+
+import torch
+import torchvision.models.detection as detection
+
+from deeplabcut.pose_estimation_pytorch.models.detectors.base import (
+ BaseDetector,
+)
+
+
+class TorchvisionDetectorAdaptor(BaseDetector):
+ """An adaptor for torchvision detectors.
+
+ This class is an adaptor for torchvision detectors to DeepLabCut detectors. Some of
+ the models (from fastest to most powerful) available are:
+ - ssdlite320_mobilenet_v3_large
+ - fasterrcnn_mobilenet_v3_large_fpn
+ - fasterrcnn_resnet50_fpn_v2
+
+ This class should not be used out-of-the-box. Subclasses (such as FasterRCNN or
+ SSDLite) should be used instead.
+
+ The torchvision implementation does not allow to get both predictions and losses
+ with a single forward pass. Therefore, during evaluation only bounding box metrics
+ (mAP, mAR) are available for the test set. See validation loss issue:
+ - https://discuss.pytorch.org/t/compute-validation-loss-for-faster-rcnn/62333/12
+ - https://stackoverflow.com/a/65347721
+
+ Args:
+ model: The torchvision model to use (see all options at
+ https://pytorch.org/vision/stable/models.html#object-detection).
+ weights: The weights to load for the model. If None, no pre-trained weights are
+ loaded.
+ num_classes: Number of classes that the model should output. If None, the number
+ of classes the model is pre-trained on is used.
+ freeze_bn_stats: Whether to freeze stats for BatchNorm layers.
+ freeze_bn_weights: Whether to freeze weights for BatchNorm layers.
+ box_score_thresh: during inference, only return proposals with a classification
+ score greater than box_score_thresh
+ """
+
+ def __init__(
+ self,
+ model: str,
+ weights: str | None = None,
+ num_classes: int | None = 2,
+ freeze_bn_stats: bool = False,
+ freeze_bn_weights: bool = False,
+ box_score_thresh: float = 0.01,
+ model_kwargs: dict | None = None,
+ ) -> None:
+ super().__init__(
+ freeze_bn_stats=freeze_bn_stats,
+ freeze_bn_weights=freeze_bn_weights,
+ pretrained=weights is not None,
+ )
+
+ # Load the model
+ model_fn = getattr(detection, model)
+ if model_kwargs is None:
+ model_kwargs = {}
+
+ self.model = model_fn(
+ weights=weights,
+ box_score_thresh=box_score_thresh,
+ num_classes=num_classes,
+ **model_kwargs,
+ )
+
+ # See source: https://stackoverflow.com/a/65347721
+ self.model.eager_outputs = lambda losses, detections: (losses, detections)
+
+ def forward(
+ self, x: torch.Tensor, targets: list[dict[str, torch.Tensor]] | None = None
+ ) -> tuple[dict[str, torch.Tensor], list[dict[str, torch.Tensor]]]:
+ """Forward pass of the torchvision detector.
+
+ Args:
+ x: images to be processed, of shape (b, c, h, w)
+ targets: ground-truth boxes present in the images
+
+ Returns:
+ losses: {'loss_name': loss_value}
+ detections: for each of the b images, {"boxes": bounding_boxes}
+ """
+ return self.model(x, targets)
+
+ def get_target(self, labels: dict) -> list[dict[str, torch.Tensor]]:
+ """Returns target in a format a torchvision detector can handle.
+
+ Args:
+ labels: dict of annotations, must contain the keys:
+ area: tensor containing area information for each annotation
+ labels: tensor containing class labels for each annotation
+ is_crowd: tensor indicating if each annotation is a crowd (1) or not (0)
+ image_id: tensor containing image ids for each annotation
+ boxes: tensor containing bounding box information for each annotation
+
+ Returns:
+ res: list of dictionaries, each representing target information for a single
+ annotation. Each dictionary contains the following keys:
+ 'area'
+ 'labels'
+ 'is_crowd'
+ 'boxes'
+
+ Examples:
+ input:
+ annotations = {
+ "area": torch.Tensor([100, 200]),
+ "labels": torch.Tensor([1, 2]),
+ "is_crowd": torch.Tensor([0, 1]),
+ "boxes": torch.Tensor([[10, 20, 30, 40], [50, 60, 70, 80]])
+ }
+ output:
+ res = [
+ {
+ 'area': tensor([100.]),
+ 'labels': tensor([1]),
+ 'image_id': tensor([1]),
+ 'is_crowd': tensor([0]),
+ 'boxes': tensor([[10., 20., 40., 60.]])
+ },
+ {
+ 'area': tensor([200.]),
+ 'labels': tensor([2]),
+ 'image_id': tensor([1]),
+ 'is_crowd': tensor([1]),
+ 'boxes': tensor([[50., 60., 70., 80.]])
+ }
+ ]
+ """
+ res = []
+ for i, box_ann in enumerate(labels["boxes"]):
+ mask = (box_ann[:, 2] > 0.0) & (box_ann[:, 3] > 0.0)
+ box_ann = box_ann[mask]
+ # bbox format conversion (x, y, w, h) -> (x1, y1, x2, y2)
+ box_ann[:, 2] += box_ann[:, 0]
+ box_ann[:, 3] += box_ann[:, 1]
+ res.append(
+ {
+ "area": labels["area"][i][mask],
+ "labels": labels["labels"][i][mask].long(),
+ "is_crowd": labels["is_crowd"][i][mask].long(),
+ "boxes": box_ann,
+ }
+ )
+
+ return res
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py
new file mode 100644
index 0000000000..4a65c8f84d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/__init__.py
@@ -0,0 +1,16 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead
+from deeplabcut.pose_estimation_pytorch.models.heads.dekr import DEKRHead
+from deeplabcut.pose_estimation_pytorch.models.heads.dlcrnet import DLCRNetHead
+from deeplabcut.pose_estimation_pytorch.models.heads.rtmcc_head import RTMCCHead
+from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import HeatmapHead
+from deeplabcut.pose_estimation_pytorch.models.heads.transformer import TransformerHead
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/base.py b/deeplabcut/pose_estimation_pytorch/models/heads/base.py
new file mode 100644
index 0000000000..c494eab916
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.py
@@ -0,0 +1,174 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+from deeplabcut.pose_estimation_pytorch.models.weight_init import (
+ WEIGHT_INIT,
+ BaseWeightInitializer,
+)
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+HEADS = Registry("heads", build_func=build_from_cfg)
+
+
+class BaseHead(ABC, nn.Module):
+ """A head for pose estimation models.
+
+ Attributes:
+ stride: The stride for the head (or neck + head pair), where positive values
+ indicate an increase in resolution while negative values a decrease.
+ Assuming that H and W are divisible by `stride`, this is the value such
+ that if a backbone outputs an encoding of shape (C, H, W), the head will
+ output heatmaps of shape:
+ (C, H * stride, W * stride) if stride > 0
+ (C, -H/stride, -W/stride) if stride < 0
+ predictor: an object to generate predictions from the head outputs
+ target_generator: a target generator which must output a target for each
+ output key of this module (i.e. if forward returns a "heatmap" tensor and
+ an "offset" tensor, then targets must be generated for both)
+ criterion: either a single criterion (e.g. if this head only outputs heatmaps)
+ or a dictionary mapping the outputs of this head to the criterion to use
+ (e.g. a "heatmap" criterion and an "offset" criterion for DEKR).
+ aggregator: if the criterion is a dictionary, cannot be none. used to combine
+ the individual losses from this head into one "total_loss"
+ """
+
+ def __init__(
+ self,
+ stride: int | float,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion] | BaseCriterion,
+ aggregator: BaseLossAggregator | None = None,
+ weight_init: str | dict | BaseWeightInitializer | None = None,
+ ) -> None:
+ super().__init__()
+ if stride == 0:
+ raise ValueError(f"Stride must not be 0. Found {stride}.")
+
+ self.stride = stride
+ self.predictor = predictor
+ self.target_generator = target_generator
+ self.criterion = criterion
+ self.aggregator = aggregator
+
+ self.weight_init: BaseWeightInitializer | None = None
+ if isinstance(weight_init, BaseWeightInitializer):
+ self.weight_init = weight_init
+ elif isinstance(weight_init, (str, dict)):
+ self.weight_init = WEIGHT_INIT.build(weight_init)
+ elif weight_init is not None:
+ raise ValueError(f"Could not parse ``weight_init`` parameter: {weight_init}.")
+
+ if isinstance(criterion, dict):
+ if aggregator is None:
+ raise ValueError("When multiple criterions are defined, a loss aggregator must also be given")
+ else:
+ if aggregator is not None:
+ raise ValueError("Cannot use a loss aggregator with a single criterion")
+
+ @abstractmethod
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ """Given the feature maps for an image ()
+
+ Args:
+ x: the feature maps, of shape (b, c, h, w)
+
+ Returns:
+ the head outputs (e.g. "heatmap", "locref")
+ """
+ pass
+
+ def get_loss(
+ self,
+ outputs: dict[str, torch.Tensor],
+ targets: dict[str, dict[str, torch.Tensor]],
+ ) -> dict[str, torch.Tensor]:
+ """Computes the loss for this head.
+
+ Args:
+ outputs: the outputs of this head
+ targets: the targets for this head
+
+ Returns:
+ A dictionary containing minimally "total_loss" key mapping to the total
+ loss for this head (from which backwards() should be called). Can contain
+ other keys containing losses that can be logged for informational purposes.
+ """
+ if self.aggregator is None:
+ assert len(outputs) == len(targets) == 1
+ key = [k for k in outputs.keys()][0]
+ return {"total_loss": self.criterion(outputs[key], **targets[key])}
+
+ losses = {name: criterion(outputs[name], **targets[name]) for name, criterion in self.criterion.items()}
+ losses["total_loss"] = self.aggregator(losses)
+ return losses
+
+ def _init_weights(self) -> None:
+ """Should be called once all modules for the class are created."""
+ if self.weight_init is not None:
+ self.weight_init.init_weights(self)
+
+
+class WeightConversionMixin(ABC):
+ """A mixin for heads that can re-order and/or filter the output channels.
+
+ This mixin is useful to convert SuperAnimal model weights such that they can be used
+ in downstream projects (either existing or new), where only a subset of keypoints
+ are available (and where they might be re-ordered).
+ """
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+
+ @staticmethod
+ @abstractmethod
+ def convert_weights(
+ state_dict: dict[str, torch.Tensor],
+ module_prefix: str,
+ conversion: torch.Tensor,
+ ) -> dict[str, torch.Tensor]:
+ """Converts pre-trained weights to be fine-tuned on another dataset.
+
+ Args:
+ state_dict: the state dict for the pre-trained model
+ module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart.')
+ conversion: the mapping of old indices to new indices
+
+ Examples:
+ A SuperAnimal model was trained on the keypoints ["ear_left", "ear_right",
+ "eye_left", "eye_right", "nose"]. A down-stream project has the bodyparts
+ labeled ["nose", "eye_left", "eye_right"]. The SuperAnimal weights can be
+ converted (to be used with the downstream project) with the following code:
+
+ ``
+ state_dict = torch.load(
+ snapshot_path, map_location=torch.device('cpu')
+ )["model"]
+ state_dict = HeadClass.convert_weights(
+ state_dict,
+ "heads.bodypart",
+ [4, 2, 3]
+ )
+ ``
+ """
+ pass
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py
new file mode 100644
index 0000000000..64f4b137bc
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py
@@ -0,0 +1,403 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS, BaseHead
+from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import (
+ AdaptBlock,
+ BaseBlock,
+ BasicBlock,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer
+
+
+@HEADS.register_module
+class DEKRHead(BaseHead):
+ """
+ DEKR head based on:
+ Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression
+ Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021
+ Code based on:
+ https://github.com/HRNet/DEKR
+ """
+
+ def __init__(
+ self,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion],
+ aggregator: BaseLossAggregator,
+ heatmap_config: dict,
+ offset_config: dict,
+ weight_init: str | dict | BaseWeightInitializer | None = "dekr",
+ stride: int | float = 1, # head stride - should always be 1 for DEKR
+ ) -> None:
+ super().__init__(stride, predictor, target_generator, criterion, aggregator, weight_init)
+ self.heatmap_head = DEKRHeatmap(**heatmap_config)
+ self.offset_head = DEKROffset(**offset_config)
+ self._init_weights()
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ return {"heatmap": self.heatmap_head(x), "offset": self.offset_head(x)}
+
+
+class DEKRHeatmap(nn.Module):
+ """
+ DEKR head to compute the heatmaps corresponding to keypoints based on:
+ Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression
+ Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021
+ Code based on:
+ https://github.com/HRNet/DEKR
+ """
+
+ def __init__(
+ self,
+ channels: tuple[int],
+ num_blocks: int,
+ dilation_rate: int,
+ final_conv_kernel: int,
+ block: type(BaseBlock) = BasicBlock,
+ ) -> None:
+ """Summary:
+ Constructor of the HeatmapDEKRHead.
+ Loads the data.
+
+ Args:
+ channels: tuple containing the number of channels for the head.
+ num_blocks: number of blocks in the head
+ dilation_rate: dilation rate for the head
+ final_conv_kernel: kernel size for the final convolution
+ block: type of block to use in the head. Defaults to BasicBlock.
+
+ Returns:
+ None
+
+ Examples:
+ channels = (64,128,17)
+ num_blocks = 3
+ dilation_rate = 2
+ final_conv_kernel = 3
+ block = BasicBlock
+ """
+ super().__init__()
+ self.bn_momentum = 0.1
+ self.inp_channels = channels[0]
+ self.num_joints_with_center = channels[2] # Should account for the center being a joint
+ self.final_conv_kernel = final_conv_kernel
+
+ self.transition_heatmap = self._make_transition_for_head(self.inp_channels, channels[1])
+ self.head_heatmap = self._make_heatmap_head(block, num_blocks, channels[1], dilation_rate)
+
+ def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential:
+ """Summary:
+ Construct the transition layer for the head.
+
+ Args:
+ in_channels: number of input channels
+ out_channels: number of output channels
+
+ Returns:
+ Transition layer consisting of Conv2d, BatchNorm2d, and ReLU
+ """
+ transition_layer = [
+ nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False),
+ nn.BatchNorm2d(out_channels),
+ nn.ReLU(True),
+ ]
+ return nn.Sequential(*transition_layer)
+
+ def _make_heatmap_head(
+ self,
+ block: type(BaseBlock),
+ num_blocks: int,
+ num_channels: int,
+ dilation_rate: int,
+ ) -> nn.ModuleList:
+ """Summary:
+ Construct the heatmap head
+
+ Args:
+ block: type of block to use in the head.
+ num_blocks: number of blocks in the head.
+ num_channels: number of input channels for the head.
+ dilation_rate: dilation rate for the head.
+
+ Returns:
+ List of modules representing the heatmap head layers.
+ """
+ heatmap_head_layers = []
+
+ feature_conv = self._make_layer(block, num_channels, num_channels, num_blocks, dilation=dilation_rate)
+ heatmap_head_layers.append(feature_conv)
+
+ heatmap_conv = nn.Conv2d(
+ in_channels=num_channels,
+ out_channels=self.num_joints_with_center,
+ kernel_size=self.final_conv_kernel,
+ stride=1,
+ padding=1 if self.final_conv_kernel == 3 else 0,
+ )
+ heatmap_head_layers.append(heatmap_conv)
+
+ return nn.ModuleList(heatmap_head_layers)
+
+ def _make_layer(
+ self,
+ block: type(BaseBlock),
+ in_channels: int,
+ out_channels: int,
+ num_blocks: int,
+ stride: int = 1,
+ dilation: int = 1,
+ ) -> nn.Sequential:
+ """Summary:
+ Construct a layer in the head.
+
+ Args:
+ block: type of block to use in the head.
+ in_channels: number of input channels for the layer.
+ out_channels: number of output channels for the layer.
+ num_blocks: number of blocks in the layer.
+ stride: stride for the convolutional layer. Defaults to 1.
+ dilation: dilation rate for the convolutional layer. Defaults to 1.
+
+ Returns:
+ Sequential layer containing the specified num_blocks.
+ """
+ downsample = None
+ if stride != 1 or in_channels != out_channels * block.expansion:
+ downsample = nn.Sequential(
+ nn.Conv2d(
+ in_channels,
+ out_channels * block.expansion,
+ kernel_size=1,
+ stride=stride,
+ bias=False,
+ ),
+ nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum),
+ )
+
+ layers = [block(in_channels, out_channels, stride, downsample, dilation=dilation)]
+ in_channels = out_channels * block.expansion
+ for _ in range(1, num_blocks):
+ layers.append(block(in_channels, out_channels, dilation=dilation))
+
+ return nn.Sequential(*layers)
+
+ def forward(self, x):
+ heatmap = self.head_heatmap[1](self.head_heatmap[0](self.transition_heatmap(x)))
+
+ return heatmap
+
+
+class DEKROffset(nn.Module):
+ """
+ DEKR module to compute the offset from the center corresponding to each keypoints:
+ Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression
+ Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021
+ Code based on:
+ https://github.com/HRNet/DEKR
+ """
+
+ def __init__(
+ self,
+ channels: tuple[int, ...],
+ num_offset_per_kpt: int,
+ num_blocks: int,
+ dilation_rate: int,
+ final_conv_kernel: int,
+ block: type(BaseBlock) = AdaptBlock,
+ ) -> None:
+ """Args:
+ channels: tuple containing the number of input, offset, and output channels.
+ num_offset_per_kpt: number of offset values per keypoint.
+ num_blocks: number of blocks in the head.
+ dilation_rate: dilation rate for convolutional layers.
+ final_conv_kernel: kernel size for the final convolution.
+ block: type of block to use in the head. Defaults to AdaptBlock.
+ """
+ super().__init__()
+ self.inp_channels = channels[0]
+ self.num_joints = channels[2]
+ self.num_joints_with_center = self.num_joints + 1
+
+ self.bn_momentum = 0.1
+ self.offset_perkpt = num_offset_per_kpt
+ self.num_joints_without_center = self.num_joints
+ self.offset_channels = self.offset_perkpt * self.num_joints_without_center
+ assert self.offset_channels == channels[1]
+
+ self.num_blocks = num_blocks
+ self.dilation_rate = dilation_rate
+ self.final_conv_kernel = final_conv_kernel
+
+ self.transition_offset = self._make_transition_for_head(self.inp_channels, self.offset_channels)
+ (
+ self.offset_feature_layers,
+ self.offset_final_layer,
+ ) = self._make_separete_regression_head(
+ block,
+ num_blocks=num_blocks,
+ num_channels_per_kpt=self.offset_perkpt,
+ dilation_rate=self.dilation_rate,
+ )
+
+ def _make_layer(
+ self,
+ block: type(BaseBlock),
+ in_channels: int,
+ out_channels: int,
+ num_blocks: int,
+ stride: int = 1,
+ dilation: int = 1,
+ ) -> nn.Sequential:
+ """Summary:
+ Create a sequential layer with the specified block and number of num_blocks.
+
+ Args:
+ block: block type to use in the layer.
+ in_channels: number of input channels.
+ out_channels: number of output channels.
+ num_blocks: number of blocks to be stacked in the layer.
+ stride: stride for the first block. Defaults to 1.
+ dilation: dilation rate for the blocks. Defaults to 1.
+
+ Returns:
+ A sequential layer containing stacked num_blocks.
+
+ Examples:
+ input:
+ block=BasicBlock
+ in_channels=64
+ out_channels=128
+ num_blocks=3
+ stride=1
+ dilation=1
+ """
+ downsample = None
+ if stride != 1 or in_channels != out_channels * block.expansion:
+ downsample = nn.Sequential(
+ nn.Conv2d(
+ in_channels,
+ out_channels * block.expansion,
+ kernel_size=1,
+ stride=stride,
+ bias=False,
+ ),
+ nn.BatchNorm2d(out_channels * block.expansion, momentum=self.bn_momentum),
+ )
+
+ layers = []
+ layers.append(block(in_channels, out_channels, stride, downsample, dilation=dilation))
+ in_channels = out_channels * block.expansion
+ for _ in range(1, num_blocks):
+ layers.append(block(in_channels, out_channels, dilation=dilation))
+
+ return nn.Sequential(*layers)
+
+ def _make_transition_for_head(self, in_channels: int, out_channels: int) -> nn.Sequential:
+ """Summary:
+ Create a transition layer for the head.
+
+ Args:
+ in_channels: number of input channels
+ out_channels: number of output channels
+
+ Returns:
+ Sequential layer containing the transition operations.
+ """
+ transition_layer = [
+ nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False),
+ nn.BatchNorm2d(out_channels),
+ nn.ReLU(True),
+ ]
+ return nn.Sequential(*transition_layer)
+
+ def _make_separete_regression_head(
+ self,
+ block: type(BaseBlock),
+ num_blocks: int,
+ num_channels_per_kpt: int,
+ dilation_rate: int,
+ ) -> tuple:
+ """Summary:
+
+ Args:
+ block: type of block to use in the head
+ num_blocks: number of blocks in the regression head
+ num_channels_per_kpt: number of channels per keypoint
+ dilation_rate: dilation rate for the regression head
+
+ Returns:
+ A tuple containing two ModuleList objects.
+ The first ModuleList contains the feature convolution layers for each keypoint,
+ and the second ModuleList contains the final offset convolution layers.
+ """
+ offset_feature_layers = []
+ offset_final_layer = []
+
+ for _ in range(self.num_joints):
+ feature_conv = self._make_layer(
+ block,
+ num_channels_per_kpt,
+ num_channels_per_kpt,
+ num_blocks,
+ dilation=dilation_rate,
+ )
+ offset_feature_layers.append(feature_conv)
+
+ offset_conv = nn.Conv2d(
+ in_channels=num_channels_per_kpt,
+ out_channels=2,
+ kernel_size=self.final_conv_kernel,
+ stride=1,
+ padding=1 if self.final_conv_kernel == 3 else 0,
+ )
+ offset_final_layer.append(offset_conv)
+
+ return nn.ModuleList(offset_feature_layers), nn.ModuleList(offset_final_layer)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Summary:
+ Perform forward pass through the OffsetDEKRHead.
+
+ Args:
+ x: input tensor to the head.
+
+ Returns:
+ offset: Computed offsets from the center corresponding to each keypoint.
+ The tensor will have the shape (N, num_joints * 2, H, W), where N is the batch size,
+ num_joints is the number of keypoints, and H and W are the height and width of the output tensor.
+ """
+ final_offset = []
+ offset_feature = self.transition_offset(x)
+
+ for j in range(self.num_joints):
+ final_offset.append(
+ self.offset_final_layer[j](
+ self.offset_feature_layers[j](
+ offset_feature[:, j * self.offset_perkpt : (j + 1) * self.offset_perkpt]
+ )
+ )
+ )
+
+ offset = torch.cat(final_offset, dim=1)
+
+ return offset
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py
new file mode 100644
index 0000000000..4fc236c58d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/dlcrnet.py
@@ -0,0 +1,138 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads.base import HEADS
+from deeplabcut.pose_estimation_pytorch.models.heads.simple_head import (
+ DeconvModule,
+ HeatmapHead,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer
+
+
+@HEADS.register_module
+class DLCRNetHead(HeatmapHead):
+ """A head for DLCRNet models using Part-Affinity Fields to predict individuals."""
+
+ def __init__(
+ self,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion],
+ aggregator: BaseLossAggregator,
+ heatmap_config: dict,
+ locref_config: dict,
+ paf_config: dict,
+ num_stages: int = 5,
+ features_dim: int = 128,
+ weight_init: str | dict | BaseWeightInitializer | None = None,
+ ) -> None:
+ self.num_stages = num_stages
+ # FIXME Cleaner __init__ to avoid initializing unused layers
+ in_channels = heatmap_config["channels"][0]
+ num_keypoints = heatmap_config["channels"][-1]
+ num_limbs = paf_config["channels"][-1] # Already has the 2x multiplier
+ in_refined_channels = features_dim + num_keypoints + num_limbs
+ if num_stages > 0:
+ heatmap_config["channels"][0] = paf_config["channels"][0] = in_refined_channels
+ locref_config["channels"][0] = locref_config["channels"][-1]
+
+ super().__init__(
+ predictor,
+ target_generator,
+ criterion,
+ aggregator,
+ heatmap_config,
+ locref_config,
+ weight_init,
+ )
+ if num_stages > 0:
+ self.stride *= 2 # extra deconv layer where it's multi-stage
+
+ self.paf_head = DeconvModule(**paf_config)
+
+ self.convt1 = self._make_layer_same_padding(in_channels=in_channels, out_channels=num_keypoints)
+ self.convt2 = self._make_layer_same_padding(in_channels=in_channels, out_channels=locref_config["channels"][-1])
+ self.convt3 = self._make_layer_same_padding(in_channels=in_channels, out_channels=num_limbs)
+ self.convt4 = self._make_layer_same_padding(in_channels=in_channels, out_channels=features_dim)
+ self.hm_ref_layers = nn.ModuleList()
+ self.paf_ref_layers = nn.ModuleList()
+ for _ in range(num_stages):
+ self.hm_ref_layers.append(
+ self._make_refinement_layer(in_channels=in_refined_channels, out_channels=num_keypoints)
+ )
+ self.paf_ref_layers.append(
+ self._make_refinement_layer(in_channels=in_refined_channels, out_channels=num_limbs)
+ )
+ self._init_weights()
+
+ def _make_layer_same_padding(self, in_channels: int, out_channels: int) -> nn.ConvTranspose2d:
+ # FIXME There is no consensual solution to emulate TF behavior in pytorch
+ # see https://github.com/pytorch/pytorch/issues/3867
+ return nn.ConvTranspose2d(
+ in_channels=in_channels,
+ out_channels=out_channels,
+ kernel_size=3,
+ stride=2,
+ padding=1,
+ output_padding=1,
+ )
+
+ def _make_refinement_layer(self, in_channels: int, out_channels: int) -> nn.Conv2d:
+ """Summary:
+ Helper function to create a refinement layer.
+
+ Args:
+ in_channels: number of input channels
+ out_channels: number of output channels
+
+ Returns:
+ refinement_layer: the refinement layer.
+ """
+ return nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding="same")
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ if self.num_stages > 0:
+ stage1_hm_out = self.convt1(x)
+ stage1_paf_out = self.convt3(x)
+ features = self.convt4(x)
+ stage2_in = torch.cat((stage1_hm_out, stage1_paf_out, features), dim=1)
+ stage_in = stage2_in
+ stage_paf_out = stage1_paf_out
+ stage_hm_out = stage1_hm_out
+ for i, (hm_ref_layer, paf_ref_layer) in enumerate(
+ zip(self.hm_ref_layers, self.paf_ref_layers, strict=True)
+ ):
+ pre_stage_hm_out = stage_hm_out
+ stage_hm_out = hm_ref_layer(stage_in)
+ stage_paf_out = paf_ref_layer(stage_in)
+ if i > 0:
+ stage_hm_out += pre_stage_hm_out
+ stage_in = torch.cat((stage_hm_out, stage_paf_out, features), dim=1)
+ return {
+ "heatmap": self.heatmap_head(stage_in),
+ "locref": self.locref_head(self.convt2(x)),
+ "paf": self.paf_head(stage_in),
+ }
+ return {
+ "heatmap": self.heatmap_head(x),
+ "locref": self.locref_head(x),
+ "paf": self.paf_head(x),
+ }
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/rtmcc_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/rtmcc_head.py
new file mode 100644
index 0000000000..d887e96d84
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/rtmcc_head.py
@@ -0,0 +1,222 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Modified SimCC head for the RTMPose model.
+
+Based on the official ``mmpose`` RTMCC head implementation. For more information, see
+.
+"""
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads.base import (
+ HEADS,
+ BaseHead,
+ WeightConversionMixin,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules import (
+ GatedAttentionUnit,
+ ScaleNorm,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer
+
+
+@HEADS.register_module
+class RTMCCHead(WeightConversionMixin, BaseHead):
+ """RTMPose Coordinate Classification head.
+
+ The RTMCC head is itself adapted from the SimCC head. For more information, see
+ "SimCC: a Simple Coordinate Classification Perspective for Human Pose Estimation"
+ () and "RTMPose: Real-Time Multi-Person Pose
+ Estimation based on MMPose" ().
+
+ Args:
+ input_size: The size of images given to the pose estimation model.
+ in_channels: The number of input channels for the head.
+ out_channels: Number of channels output by the head (number of bodyparts).
+ in_featuremap_size: The size of the input feature map for the head. This is
+ equal to the input_size divided by the backbone stride.
+ simcc_split_ratio: The split ratio of pixels, as described in SimCC.
+ final_layer_kernel_size: Kernel size of the final convolutional layer.
+ gau_cfg: Configuration for the GatedAttentionUnit.
+ predictor: The predictor for the head. Should usually be a `SimCCPredictor`.
+ target_generator: The target generator for the head. Should usually be a
+ `SimCCGenerator`.
+ criterion: The loss criterions for the RTMCC outputs. There should be a
+ criterion for "x" and a criterion for "y".
+ aggregator: The loss aggregator to combine the losses.
+ weight_init: The weight initializer to use for the head.
+ """
+
+ def __init__(
+ self,
+ input_size: tuple[int, int],
+ in_channels: int,
+ out_channels: int,
+ in_featuremap_size: tuple[int, int],
+ simcc_split_ratio: float,
+ final_layer_kernel_size: int,
+ gau_cfg: dict,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion],
+ aggregator: BaseLossAggregator,
+ weight_init: str | dict | BaseWeightInitializer | None = None,
+ ) -> None:
+ super().__init__(
+ 1,
+ predictor,
+ target_generator,
+ criterion,
+ aggregator,
+ weight_init,
+ )
+
+ self.input_size = input_size
+ self.in_channels = in_channels
+ self.out_channels = out_channels
+
+ self.in_featuremap_size = in_featuremap_size
+ self.simcc_split_ratio = simcc_split_ratio
+
+ flatten_dims = self.in_featuremap_size[0] * self.in_featuremap_size[1]
+ out_w = int(self.input_size[0] * self.simcc_split_ratio)
+ out_h = int(self.input_size[1] * self.simcc_split_ratio)
+
+ self.gau = GatedAttentionUnit(
+ num_token=self.out_channels,
+ in_token_dims=gau_cfg["hidden_dims"],
+ out_token_dims=gau_cfg["hidden_dims"],
+ expansion_factor=gau_cfg["expansion_factor"],
+ s=gau_cfg["s"],
+ eps=1e-5,
+ dropout_rate=gau_cfg["dropout_rate"],
+ drop_path=gau_cfg["drop_path"],
+ attn_type="self-attn",
+ act_fn=gau_cfg["act_fn"],
+ use_rel_bias=gau_cfg["use_rel_bias"],
+ pos_enc=gau_cfg["pos_enc"],
+ )
+
+ self.final_layer = nn.Conv2d(
+ in_channels,
+ out_channels,
+ kernel_size=final_layer_kernel_size,
+ stride=1,
+ padding=final_layer_kernel_size // 2,
+ )
+ self.mlp = nn.Sequential(
+ ScaleNorm(flatten_dims),
+ nn.Linear(flatten_dims, gau_cfg["hidden_dims"], bias=False),
+ )
+
+ self.cls_x = nn.Linear(gau_cfg["hidden_dims"], out_w, bias=False)
+ self.cls_y = nn.Linear(gau_cfg["hidden_dims"], out_h, bias=False)
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ feats = self.final_layer(x) # -> B, K, H, W
+ feats = torch.flatten(feats, start_dim=2) # -> B, K, hidden=HxW
+ feats = self.mlp(feats) # -> B, K, hidden
+ feats = self.gau(feats)
+ x, y = self.cls_x(feats), self.cls_y(feats)
+ return dict(x=x, y=y)
+
+ @staticmethod
+ def convert_weights(
+ state_dict: dict[str, torch.Tensor],
+ module_prefix: str,
+ conversion: torch.Tensor,
+ *,
+ omit_gau_w: bool = False,
+ ) -> dict[str, torch.Tensor]:
+ """Re-order / subset bodypart (token) channels for transfer from SuperAnimal.
+
+ Args:
+ state_dict: State dict for this head.
+ module_prefix: Prefix for state-dict keys.
+ conversion: Mapping from new bodyparts to source bodyparts.
+ omit_gau_w: If True, remove ``gau.w`` from the returned dict instead of
+ constructing a remapped replacement. This requires loading with
+ ``strict=False`` to avoid missing-key errors.
+ Prefer omitting when source/target keypoint ordering semantics differ.
+ """
+ conv = conversion.long()
+ k_new = int(conv.shape[0])
+
+ # Remap final layer weights and biases if they exist.
+ fl_w = f"{module_prefix}final_layer.weight"
+ fl_b = f"{module_prefix}final_layer.bias"
+ if fl_w in state_dict:
+ state_dict[fl_w] = state_dict[fl_w][conv]
+ if fl_b in state_dict:
+ state_dict[fl_b] = state_dict[fl_b][conv]
+
+ # Remap or re-init gau.w if it exists (only if omit_gau_w is False)
+ w_key = f"{module_prefix}gau.w"
+ if w_key in state_dict:
+ if omit_gau_w:
+ state_dict.pop(w_key, None)
+ return state_dict
+
+ w_old = state_dict[w_key]
+ k_old = (w_old.shape[0] + 1) // 2
+ old_center = k_old - 1
+ new_center = k_new - 1
+
+ # Deterministic default for unmapped offsets (mean of original weights).
+ default_val = w_old.mean()
+ w_new = torch.empty(2 * k_new - 1, dtype=w_old.dtype, device=w_old.device)
+ for idx_new, d in enumerate(range(-new_center, new_center + 1)):
+ old_vals = []
+ for i in range(k_new):
+ j = i - d
+ if not (0 <= j < k_new):
+ continue
+ old_idx = int(conv[i] - conv[j]) + old_center
+ if 0 <= old_idx < w_old.shape[0]:
+ old_vals.append(w_old[old_idx])
+ w_new[idx_new] = torch.stack(old_vals).mean() if old_vals else default_val
+ state_dict[w_key] = w_new
+ return state_dict
+
+ @staticmethod
+ def update_input_size(model_cfg: dict, input_size: tuple[int, int]) -> None:
+ """Updates an RTMPose model configuration file for a new image input size.
+
+ Args:
+ model_cfg: The model configuration to update in-place.
+ input_size: The updated input (width, height).
+ """
+ _sigmas = {192: 4.9, 256: 5.66, 288: 6, 384: 6.93}
+
+ def _sigma(size: int) -> float:
+ sigma = _sigmas.get(size)
+ if sigma is None:
+ return 2.87 + 0.01 * size
+
+ return sigma
+
+ w, h = input_size
+ model_cfg["data"]["inference"]["top_down_crop"] = dict(width=w, height=h)
+ model_cfg["data"]["train"]["top_down_crop"] = dict(width=w, height=h)
+ head_cfg = model_cfg["model"]["heads"]["bodypart"]
+ head_cfg["input_size"] = input_size
+ head_cfg["in_featuremap_size"] = h // 32, w // 32
+ head_cfg["target_generator"]["input_size"] = input_size
+ head_cfg["target_generator"]["sigma"] = (_sigma(w), _sigma(h))
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py
new file mode 100644
index 0000000000..0859ae6205
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/simple_head.py
@@ -0,0 +1,250 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ BaseCriterion,
+ BaseLossAggregator,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads.base import (
+ HEADS,
+ BaseHead,
+ WeightConversionMixin,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+from deeplabcut.pose_estimation_pytorch.models.weight_init import BaseWeightInitializer
+
+
+@HEADS.register_module
+class HeatmapHead(WeightConversionMixin, BaseHead):
+ """Deconvolutional head to predict maps from the extracted features.
+
+ This class implements a simple deconvolutional head to predict maps from the
+ extracted features.
+
+ Args:
+ predictor: The predictor used to transform heatmaps into keypoints.
+ target_generator: The module to generate target heatmaps from keypoints.
+ criterion: The loss criterion(s) for the head.
+ aggregator: The loss aggregator to use, if multiple criterions are used.
+ heatmap_config: The configuration for the heatmap outputs of the head.
+ locref_config: The configuration for the location refinement outputs (None if
+ no location refinement should be used).
+ weight_init: The way to initialize weights for the head. If None, default
+ PyTorch initialization is used. Otherwise, a BaseWeightInitializer can be
+ given (or a configuration for a BaseWeightInitializer). To initialize
+ the weights with a normal distribution, you could pass
+ ``weight_init="normal"`` (which initializes weights using a Normal
+ distribution 0.001 and biases with 0), or you could pass ``weight_init={
+ type="normal", std=0.01}`` to change the standard deviation used. All
+ BaseWeightInitializers are defined in deeplabcut/pose_estimation_pytorch/
+ models/weight_init.py.
+ """
+
+ def __init__(
+ self,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict[str, BaseCriterion] | BaseCriterion,
+ aggregator: BaseLossAggregator | None,
+ heatmap_config: dict,
+ locref_config: dict | None = None,
+ weight_init: str | dict | BaseWeightInitializer | None = None,
+ ) -> None:
+ heatmap_head = DeconvModule(**heatmap_config)
+ locref_head = None
+ if locref_config is not None:
+ locref_head = DeconvModule(**locref_config)
+
+ # check that the heatmap and locref modules have the same stride
+ if heatmap_head.stride != locref_head.stride:
+ raise ValueError(
+ f"Invalid model config: Your heatmap and locref need to have the "
+ f"same stride (found {heatmap_head.stride}, "
+ f"{locref_head.stride}). Please check your config (found "
+ f"heatmap_config={heatmap_config}, locref_config={locref_config}"
+ )
+
+ super().__init__(
+ heatmap_head.stride,
+ predictor,
+ target_generator,
+ criterion,
+ aggregator,
+ weight_init,
+ )
+ self.heatmap_head = heatmap_head
+ self.locref_head = locref_head
+ self._init_weights()
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ outputs = {"heatmap": self.heatmap_head(x)}
+ if self.locref_head is not None:
+ outputs["locref"] = self.locref_head(x)
+ return outputs
+
+ @staticmethod
+ def convert_weights(
+ state_dict: dict[str, torch.Tensor],
+ module_prefix: str,
+ conversion: torch.Tensor,
+ ) -> dict[str, torch.Tensor]:
+ """Converts pre-trained weights to be fine-tuned on another dataset.
+
+ Args:
+ state_dict: the state dict for the pre-trained model
+ module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart.')
+ conversion: the mapping of old indices to new indices
+ """
+ state_dict = DeconvModule.convert_weights(
+ state_dict,
+ f"{module_prefix}heatmap_head.",
+ conversion,
+ )
+
+ locref_conversion = torch.stack(
+ [2 * conversion, 2 * conversion + 1],
+ dim=1,
+ ).reshape(-1)
+ state_dict = DeconvModule.convert_weights(
+ state_dict,
+ f"{module_prefix}locref_head.",
+ locref_conversion,
+ )
+ return state_dict
+
+
+class DeconvModule(nn.Module):
+ """Deconvolutional module to predict maps from the extracted features."""
+
+ def __init__(
+ self,
+ channels: list[int],
+ kernel_size: list[int],
+ strides: list[int],
+ final_conv: dict | None = None,
+ ) -> None:
+ """
+ Args:
+ channels: List containing the number of input and output channels for each
+ deconvolutional layer.
+ kernel_size: List containing the kernel size for each deconvolutional layer.
+ strides: List containing the stride for each deconvolutional layer.
+ final_conv: Configuration for a conv layer after the deconvolutional layers,
+ if one should be added. Must have keys "out_channels" and "kernel_size".
+ """
+ super().__init__()
+ if not (len(channels) == len(kernel_size) + 1 == len(strides) + 1):
+ raise ValueError(
+ "Incorrect DeconvModule configuration: there should be one more number"
+ f" of channels than kernel_sizes and strides, found {len(channels)} "
+ f"channels, {len(kernel_size)} kernels and {len(strides)} strides."
+ )
+
+ in_channels = channels[0]
+ head_stride = 1
+ self.deconv_layers = nn.Identity()
+ if len(kernel_size) > 0:
+ self.deconv_layers = nn.Sequential(*self._make_layers(in_channels, channels[1:], kernel_size, strides))
+ for s in strides:
+ head_stride *= s
+
+ self.stride = head_stride
+ self.final_conv = nn.Identity()
+ if final_conv:
+ self.final_conv = nn.Conv2d(
+ in_channels=channels[-1],
+ out_channels=final_conv["out_channels"],
+ kernel_size=final_conv["kernel_size"],
+ stride=1,
+ )
+
+ @staticmethod
+ def _make_layers(
+ in_channels: int,
+ out_channels: list[int],
+ kernel_sizes: list[int],
+ strides: list[int],
+ ) -> list[nn.Module]:
+ """Helper function to create the deconvolutional layers.
+
+ Args:
+ in_channels: number of input channels to the module
+ out_channels: number of output channels of each layer
+ kernel_sizes: size of the deconvolutional kernel
+ strides: stride for the convolution operation
+
+ Returns:
+ the deconvolutional layers
+ """
+ layers = []
+ for out_c, k, s in zip(out_channels, kernel_sizes, strides, strict=False):
+ layers.append(nn.ConvTranspose2d(in_channels, out_c, kernel_size=k, stride=s))
+ layers.append(nn.ReLU())
+ in_channels = out_c
+ return layers[:-1]
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass of the HeatmapHead.
+
+ Args:
+ x: input tensor
+
+ Returns:
+ out: output tensor
+ """
+ x = self.deconv_layers(x)
+ x = self.final_conv(x)
+ return x
+
+ @staticmethod
+ def convert_weights(
+ state_dict: dict[str, torch.Tensor],
+ module_prefix: str,
+ conversion: torch.Tensor,
+ ) -> dict[str, torch.Tensor]:
+ """Converts pre-trained weights to be fine-tuned on another dataset.
+
+ Args:
+ state_dict: the state dict for the pre-trained model
+ module_prefix: the prefix for weights in this head (e.g., 'heads.bodypart')
+ conversion: the mapping of old indices to new indices
+ """
+ if f"{module_prefix}final_conv.weight" in state_dict:
+ # has final convolution
+ weight_key = f"{module_prefix}final_conv.weight"
+ bias_key = f"{module_prefix}final_conv.bias"
+ state_dict[weight_key] = state_dict[weight_key][conversion]
+ state_dict[bias_key] = state_dict[bias_key][conversion]
+ return state_dict
+
+ # get the last deconv layer of the net
+ next_index = 0
+ while f"{module_prefix}deconv_layers.{next_index}.weight" in state_dict:
+ next_index += 1
+ last_index = next_index - 1
+
+ # if there are deconv layers for this module prefix (there might not be,
+ # e.g., when there are no location refinement layers in a heatmap head)
+ if last_index >= 0:
+ weight_key = f"{module_prefix}deconv_layers.{last_index}.weight"
+ bias_key = f"{module_prefix}deconv_layers.{last_index}.bias"
+
+ # for ConvTranspose2d, the weight shape is (in_channels, out_channels, ...)
+ # while it's (out_channels, in_channels, ...) for Conv2d
+ state_dict[weight_key] = state_dict[weight_key][:, conversion]
+ state_dict[bias_key] = state_dict[bias_key][conversion]
+
+ return state_dict
diff --git a/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py
new file mode 100644
index 0000000000..2ff37f23f8
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py
@@ -0,0 +1,99 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+from einops import rearrange
+from timm.layers import trunc_normal_
+from torch import nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.criterions import BaseCriterion
+from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead
+from deeplabcut.pose_estimation_pytorch.models.predictors import BasePredictor
+from deeplabcut.pose_estimation_pytorch.models.target_generators import BaseGenerator
+
+
+@HEADS.register_module
+class TransformerHead(BaseHead):
+ """Transformer Head module to predict heatmaps using a transformer-based
+ approach."""
+
+ def __init__(
+ self,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: BaseCriterion,
+ dim: int,
+ hidden_heatmap_dim: int,
+ heatmap_dim: int,
+ apply_multi: bool,
+ heatmap_size: tuple[int, int],
+ apply_init: bool,
+ head_stride: int,
+ ):
+ """
+ Args:
+ dim: Dimension of the input features.
+ hidden_heatmap_dim: Dimension of the hidden features in the MLP head.
+ heatmap_dim: Dimension of the output heatmaps.
+ apply_multi: If True, apply a multi-layer perceptron (MLP) with LayerNorm
+ to generate heatmaps. If False, directly apply a single linear
+ layer for heatmap prediction.
+ heatmap_size: Tuple (height, width) representing the size of the output
+ heatmaps.
+ apply_init: If True, apply weight initialization to the module's layers.
+ head_stride: The stride for the head (or neck + head pair), where positive
+ values indicate an increase in resolution while negative values a
+ decrease. Assuming that H and W are divisible by head_stride, this is
+ the value such that if a backbone outputs an encoding of shape
+ (C, H, W), the head will output heatmaps of shape:
+ (C, H * head_stride, W * head_stride) if head_stride > 0
+ (C, -H/head_stride, -W/head_stride) if head_stride < 0
+ """
+ super().__init__(head_stride, predictor, target_generator, criterion)
+ self.mlp_head = (
+ nn.Sequential(
+ nn.LayerNorm(dim * 3),
+ nn.Linear(dim * 3, hidden_heatmap_dim),
+ nn.LayerNorm(hidden_heatmap_dim),
+ nn.Linear(hidden_heatmap_dim, heatmap_dim),
+ )
+ if (dim * 3 <= hidden_heatmap_dim * 0.5 and apply_multi)
+ else nn.Sequential(nn.LayerNorm(dim * 3), nn.Linear(dim * 3, heatmap_dim))
+ )
+ self.heatmap_size = heatmap_size
+ # trunc_normal_(self.keypoint_token, std=.02)
+ if apply_init:
+ self.apply(self._init_weights)
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ x = self.mlp_head(x)
+ x = rearrange(
+ x,
+ "b c (p1 p2) -> b c p1 p2",
+ p1=self.heatmap_size[0],
+ p2=self.heatmap_size[1],
+ )
+ return {"heatmap": x}
+
+ def _init_weights(self, m: nn.Module) -> None:
+ """Custom weight initialization for linear and layer normalization layers.
+
+ Args:
+ m: module to initialize
+ """
+ if isinstance(m, nn.Linear):
+ trunc_normal_(m.weight, std=0.02)
+ if isinstance(m, nn.Linear) and m.bias is not None:
+ nn.init.constant_(m.bias, 0)
+ elif isinstance(m, nn.LayerNorm):
+ nn.init.constant_(m.bias, 0)
+ nn.init.constant_(m.weight, 1.0)
diff --git a/deeplabcut/pose_estimation_pytorch/models/model.py b/deeplabcut/pose_estimation_pytorch/models/model.py
new file mode 100644
index 0000000000..a0b8dee13d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/model.py
@@ -0,0 +1,262 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import copy
+import logging
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone
+from deeplabcut.pose_estimation_pytorch.models.criterions import (
+ CRITERIONS,
+ LOSS_AGGREGATORS,
+)
+from deeplabcut.pose_estimation_pytorch.models.heads import HEADS, BaseHead
+from deeplabcut.pose_estimation_pytorch.models.necks import NECKS, BaseNeck
+from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS
+from deeplabcut.pose_estimation_pytorch.models.target_generators import (
+ TARGET_GENERATORS,
+)
+
+
+class PoseModel(nn.Module):
+ """A pose estimation model.
+
+ A pose estimation model is composed of a backbone, optionally a neck, and an
+ arbitrary number of heads. Outputs are computed as follows:
+ """
+
+ def __init__(
+ self,
+ cfg: dict,
+ backbone: BaseBackbone,
+ heads: dict[str, BaseHead],
+ neck: BaseNeck | None = None,
+ ) -> None:
+ """
+ Args:
+ cfg: configuration dictionary for the model.
+ backbone: backbone network architecture.
+ heads: the heads for the model
+ neck: neck network architecture (default is None). Defaults to None.
+ """
+ super().__init__()
+ self.cfg = cfg
+ self.backbone = backbone
+ self.heads = nn.ModuleDict(heads)
+ self.neck = neck
+ self.output_features = False
+
+ self._strides = {name: _model_stride(self.backbone.stride, head.stride) for name, head in heads.items()}
+
+ def forward(self, x: torch.Tensor, **backbone_kwargs) -> dict[str, dict[str, torch.Tensor]]:
+ """Forward pass of the PoseModel.
+
+ Args:
+ x: input images
+
+ Returns:
+ Outputs of head groups
+ """
+ if x.dim() == 3:
+ x = x[None, :]
+ features = self.backbone(x, **backbone_kwargs)
+ if self.neck:
+ features = self.neck(features)
+
+ outputs = {}
+ if self.output_features:
+ outputs["backbone"] = dict(features=features)
+
+ for head_name, head in self.heads.items():
+ outputs[head_name] = head(features)
+ return outputs
+
+ def get_loss(
+ self,
+ outputs: dict[str, dict[str, torch.Tensor]],
+ targets: dict[str, dict[str, torch.Tensor]],
+ ) -> dict[str, torch.Tensor]:
+ total_losses = []
+ losses: dict[str, torch.Tensor] = {}
+ for name, head in self.heads.items():
+ head_losses = head.get_loss(outputs[name], targets[name])
+ total_losses.append(head_losses["total_loss"])
+ for k, v in head_losses.items():
+ losses[f"{name}_{k}"] = v
+
+ # TODO: Different aggregation for multi-head loss?
+ losses["total_loss"] = torch.mean(torch.stack(total_losses))
+ return losses
+
+ def get_target(
+ self,
+ outputs: dict[str, dict[str, torch.Tensor]],
+ labels: dict,
+ ) -> dict[str, dict]:
+ """Summary:
+ Get targets for model training.
+
+ Args:
+ outputs: output of each head group
+ labels: dictionary of labels
+
+ Returns:
+ targets: dict of the targets for each model head group
+ """
+ return {
+ name: head.target_generator(self._strides[name], outputs[name], labels) for name, head in self.heads.items()
+ }
+
+ def get_predictions(self, outputs: dict[str, dict[str, torch.Tensor]]) -> dict:
+ """Abstract method for the forward pass of the Predictor.
+
+ Args:
+ outputs: outputs of the model heads
+
+ Returns:
+ A dictionary containing the predictions of each head group
+ """
+ predictions = {name: head.predictor(self._strides[name], outputs[name]) for name, head in self.heads.items()}
+ if self.output_features:
+ predictions["backbone"] = outputs["backbone"]
+
+ return predictions
+
+ def get_stride(self, head: str) -> int:
+ """
+ Args:
+ head: The head for which to get the total stride.
+
+ Returns:
+ The total stride for the outputs of the head.
+
+ Raises:
+ ValueError: If there is no such head.
+ """
+ return self._strides[head]
+
+ @staticmethod
+ def build(
+ cfg: dict,
+ weight_init: None | WeightInitialization = None,
+ pretrained_backbone: bool = False,
+ ) -> PoseModel:
+ """
+ Args:
+ cfg: The configuration of the model to build.
+ weight_init: How model weights should be initialized. If None, ImageNet
+ pre-trained backbone weights are loaded from Timm.
+ pretrained_backbone: Whether to load an ImageNet-pretrained weights for
+ the backbone. This should only be set to True when building a model
+ which will be trained on a transfer learning task.
+
+ Returns:
+ the built pose model
+ """
+ cfg["backbone"]["pretrained"] = pretrained_backbone
+ backbone = BACKBONES.build(dict(cfg["backbone"]))
+
+ neck = None
+ if cfg.get("neck"):
+ neck = NECKS.build(dict(cfg["neck"]))
+
+ heads = {}
+ for name, head_cfg in cfg["heads"].items():
+ head_cfg = copy.deepcopy(head_cfg)
+ if "type" in head_cfg["criterion"]:
+ head_cfg["criterion"] = CRITERIONS.build(head_cfg["criterion"])
+ else:
+ weights = {}
+ criterions = {}
+ for loss_name, criterion_cfg in head_cfg["criterion"].items():
+ weights[loss_name] = criterion_cfg.get("weight", 1.0)
+ criterion_cfg = {k: v for k, v in criterion_cfg.items() if k != "weight"}
+ criterions[loss_name] = CRITERIONS.build(criterion_cfg)
+
+ aggregator_cfg = {"type": "WeightedLossAggregator", "weights": weights}
+ head_cfg["aggregator"] = LOSS_AGGREGATORS.build(aggregator_cfg)
+ head_cfg["criterion"] = criterions
+
+ head_cfg["target_generator"] = TARGET_GENERATORS.build(head_cfg["target_generator"])
+ head_cfg["predictor"] = PREDICTORS.build(head_cfg["predictor"])
+ heads[name] = HEADS.build(head_cfg)
+
+ model = PoseModel(cfg=cfg, backbone=backbone, neck=neck, heads=heads)
+
+ if weight_init is not None:
+ logging.info(f"Loading pretrained model weights: {weight_init}")
+ logging.info(f"The pose model is loading from {weight_init.snapshot_path}")
+ snapshot = torch.load(weight_init.snapshot_path, map_location="cpu")
+ state_dict = snapshot["model"]
+
+ # load backbone state dict
+ model.backbone.load_state_dict(filter_state_dict(state_dict, "backbone"))
+
+ # if there's a neck, load state dict
+ if model.neck is not None:
+ model.neck.load_state_dict(filter_state_dict(state_dict, "neck"))
+
+ # load head state dicts
+ if weight_init.with_decoder:
+ all_head_state_dicts = filter_state_dict(state_dict, "heads")
+ conversion_tensor = torch.from_numpy(weight_init.conversion_array)
+ for name, head in model.heads.items():
+ head_state_dict = filter_state_dict(all_head_state_dicts, name)
+
+ # requires WeightConversionMixin
+ if not weight_init.memory_replay:
+ head_state_dict = head.convert_weights(
+ state_dict=head_state_dict,
+ module_prefix="",
+ conversion=conversion_tensor,
+ )
+
+ head.load_state_dict(head_state_dict)
+
+ return model
+
+
+def filter_state_dict(state_dict: dict, module: str) -> dict[str, torch.Tensor]:
+ """Filters keys in the state dict for a module to only keep a given prefix. Removes
+ the module from the keys (e.g. for module="backbone", "backbone.stage1.weight" will
+ be converted to "stage1.weight" so the state dict can be loaded into the backbone
+ directly).
+
+ Args:
+ state_dict: the state dict
+ module: the module to keep, e.g. "backbone"
+
+ Returns:
+ the filtered state dict, with the module removed from the keys
+
+ Examples:
+ state_dict = {"backbone.conv.weight": t1, "head.conv.weight": t2}
+ filtered = filter_state_dict(state_dict, "backbone")
+ # filtered = {"conv.weight": t1}
+ model.backbone.load_state_dict(filtered)
+ """
+ return {
+ ".".join(k.split(".")[1:]): v # remove 'backbone.' from the keys
+ for k, v in state_dict.items()
+ if k.startswith(module)
+ }
+
+
+def _model_stride(backbone_stride: int | float, head_stride: int | float) -> float:
+ """Computes the model stride from a backbone and a head."""
+ if head_stride > 0:
+ return backbone_stride / head_stride
+
+ return backbone_stride * -head_stride
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py
new file mode 100644
index 0000000000..4a83694122
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/__init__.py
@@ -0,0 +1,31 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.modules.coam_module import CoAMBlock, SelfAttentionModule_CoAM
+from deeplabcut.pose_estimation_pytorch.models.modules.conv_block import (
+ AdaptBlock,
+ BasicBlock,
+ Bottleneck,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules.conv_module import (
+ HighResolutionModule,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules.gated_attention_unit import (
+ GatedAttentionUnit,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules.kpt_encoders import (
+ KEYPOINT_ENCODERS,
+ BaseKeypointEncoder,
+ ColoredKeypointEncoder,
+ StackedKeypointEncoder,
+)
+from deeplabcut.pose_estimation_pytorch.models.modules.norm import (
+ ScaleNorm,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/coam_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/coam_module.py
new file mode 100644
index 0000000000..d1599d00e1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/coam_module.py
@@ -0,0 +1,346 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import numpy as np
+import torch
+import torch.nn as nn
+import torchvision.transforms.functional as TF
+from torch.nn import init
+
+
+class CoAMBlock(nn.Module):
+ """Conditional Attention Module (CoAM) block."""
+
+ def __init__(self, spat_dims, channel_list, cond_enc, n_heads=1, channel_only=False):
+ super().__init__()
+ self.att_layers = []
+ self.spat_dims = spat_dims
+ self.cond_enc = cond_enc
+ d_cond = cond_enc.num_channels
+ for i in range(len(spat_dims)):
+ att_layer = DAModule(
+ d_model=channel_list[i],
+ d_cond=d_cond,
+ kernel_size=3,
+ H=spat_dims[i][1],
+ W=spat_dims[i][0],
+ n_heads=n_heads,
+ channel_only=channel_only,
+ )
+ self.att_layers.append(att_layer)
+ self.att_layers = nn.ModuleList(self.att_layers)
+
+ def forward(self, y_list, cond_hm):
+ # if not isinstance(self.cond_enc, (StackedKeypointEncoder, ColoredKeypointEncoder)):
+ # cond_hm = cond_hm[:,0].unsqueeze(1) # we only want one channel of the heatmap
+ y_list_att = []
+ for i in range(len(y_list)):
+ y_att = self.att_layers[i](
+ y_list[i],
+ TF.resize(cond_hm, (self.spat_dims[i][1], self.spat_dims[i][0])),
+ )
+ y_list_att.append(y_att)
+ return y_list_att
+
+
+# modified from https://github.com/xmu-xiaoma666/External-Attention-pytorch/blob/master/model/attention/DANet.py
+class PositionAttentionModule(nn.Module):
+ def __init__(self, d_model=512, d_cond=3, kernel_size=3, H=7, W=7, n_heads=1, self_att=False):
+ super().__init__()
+ self.cnn = nn.Conv2d(d_model, d_model, kernel_size=kernel_size, padding=(kernel_size - 1) // 2)
+ self.pa = ScaledDotProductAttention(in_dim_q=d_model, in_dim_k=d_model, d_k=d_model, d_v=d_model, h=n_heads)
+ self.self_att = self_att
+ if not self_att:
+ self.cnn_cond = nn.Conv2d(d_cond, d_cond, kernel_size=kernel_size, padding=(kernel_size - 1) // 2)
+ self.pa = ScaledDotProductAttention(in_dim_q=d_cond, in_dim_k=d_model, d_k=d_model, d_v=d_model, h=n_heads)
+
+ def forward(self, x, cond=None):
+ bs, c, h, w = x.shape
+ y = self.cnn(x)
+ y = y.view(bs, c, -1).permute(0, 2, 1) # bs,h*w,c
+
+ if not self.self_att:
+ _, c_cond, _, _ = cond.shape
+ y_cond = self.cnn_cond(cond)
+ y_cond = y_cond.view(bs, c_cond, -1).permute(0, 2, 1)
+ y = self.pa(y_cond, y, y) # bs,h*w,c
+
+ else:
+ y = self.pa(y, y, y)
+
+ return y
+
+
+class ChannelAttentionModule(nn.Module):
+ def __init__(self, d_model=512, d_cond=3, kernel_size=3, H=7, W=7, n_heads=1, self_att=False):
+ super().__init__()
+ self.cnn = nn.Conv2d(d_model, d_model, kernel_size=kernel_size, padding=(kernel_size - 1) // 2)
+ self.self_att = self_att
+ if not self_att:
+ self.cnn_cond = nn.Conv2d(d_cond, d_model, kernel_size=kernel_size, padding=(kernel_size - 1) // 2)
+ self.pa = SimplifiedScaledDotProductAttention(H * W, h=n_heads)
+
+ def forward(self, x, cond=None):
+ bs, c, h, w = x.shape
+ y = self.cnn(x)
+ y = y.view(bs, c, -1) # bs,c,h*w
+
+ if not self.self_att:
+ y_cond = self.cnn_cond(cond)
+ y_cond = y_cond.view(bs, c, -1)
+ y = self.pa(y_cond, y, y) # bs,c_cond,h*w
+ else:
+ y = self.pa(y, y, y) # bs,c,h*w
+
+ return y
+
+
+class DAModule(nn.Module):
+ def __init__(
+ self,
+ d_model=512,
+ d_cond=3,
+ kernel_size=3,
+ H=7,
+ W=7,
+ n_heads=1,
+ channel_only=False,
+ ):
+ super().__init__()
+ self.channel_only = channel_only
+ if not channel_only:
+ self.position_attention_module = PositionAttentionModule(
+ d_model=d_model,
+ d_cond=d_cond,
+ kernel_size=kernel_size,
+ H=H,
+ W=W,
+ n_heads=n_heads,
+ )
+ self.channel_attention_module = ChannelAttentionModule(
+ d_model=d_model,
+ d_cond=d_cond,
+ kernel_size=kernel_size,
+ H=H,
+ W=W,
+ n_heads=n_heads,
+ )
+
+ def forward(self, input, cond):
+
+ bs, c, h, w = input.shape
+
+ c_out = self.channel_attention_module(input, cond)
+ c_out = c_out.view(bs, c, h, w)
+
+ if self.channel_only:
+ return input * c_out
+
+ p_out = self.position_attention_module(input, cond)
+ p_out = p_out.permute(0, 2, 1).view(bs, c, h, w)
+
+ return input + (p_out + c_out)
+
+
+class SelfDAModule(nn.Module):
+ def __init__(self, d_model=512, kernel_size=3, H=7, W=7):
+ super().__init__()
+ self.position_attention_module = PositionAttentionModule(
+ d_model=d_model,
+ d_cond=None,
+ kernel_size=kernel_size,
+ H=H,
+ W=W,
+ self_att=True,
+ )
+ self.channel_attention_module = ChannelAttentionModule(
+ d_model=d_model,
+ d_cond=None,
+ kernel_size=kernel_size,
+ H=H,
+ W=W,
+ self_att=True,
+ )
+
+ def forward(self, input):
+
+ bs, c, h, w = input.shape
+
+ p_out = self.position_attention_module(input)
+ c_out = self.channel_attention_module(input)
+
+ p_out = p_out.permute(0, 2, 1).view(bs, c, h, w)
+ c_out = c_out.view(bs, c, h, w)
+
+ return p_out + c_out
+
+
+class SelfAttentionModule_CoAM(nn.Module):
+ def __init__(self, spat_dims, channel_list):
+ super().__init__()
+ self.att_layers = []
+ for i in range(len(spat_dims)):
+ att_layer = SelfDAModule(
+ d_model=channel_list[i],
+ kernel_size=3,
+ H=spat_dims[i][0],
+ W=spat_dims[i][1],
+ )
+ self.att_layers.append(att_layer)
+ self.att_layers = nn.ModuleList(self.att_layers)
+
+ def forward(self, y_list, *args):
+ y_list_att = []
+ for i in range(len(y_list)):
+ y_att = self.att_layers[i](y_list[i])
+ y_list_att.append(y_att)
+ return y_list_att
+
+
+# taken from: https://github.com/xmu-xiaoma666/External-Attention-pytorch/blob/master/model/attention/SelfAttention.py
+class ScaledDotProductAttention(nn.Module):
+ """Scaled dot-product attention."""
+
+ def __init__(self, in_dim_q, in_dim_k, d_k, d_v, h, dropout=0.1, rev=False):
+ """
+ :param d_model: Output dimensionality of the model
+ :param d_k: Dimensionality of queries and keys
+ :param d_v: Dimensionality of values
+ :param h: Number of heads
+ """
+ super().__init__()
+
+ # 'rev': condition is key/value and orig. feature map is query
+ if rev:
+ d_model = in_dim_q
+ else:
+ d_model = in_dim_k
+ self.fc_q = nn.Linear(in_dim_q, h * d_k)
+ self.fc_k = nn.Linear(in_dim_k, h * d_k)
+ self.fc_v = nn.Linear(in_dim_k, h * d_v)
+ self.fc_o = nn.Linear(h * d_v, d_model)
+ self.dropout = nn.Dropout(dropout)
+
+ self.d_model = d_model
+ self.d_k = d_k
+ self.d_v = d_v
+ self.h = h
+
+ self.init_weights()
+
+ def init_weights(self):
+ for m in self.modules():
+ if isinstance(m, nn.Conv2d):
+ init.kaiming_normal_(m.weight, mode="fan_out")
+ if m.bias is not None:
+ init.constant_(m.bias, 0)
+ elif isinstance(m, nn.BatchNorm2d):
+ init.constant_(m.weight, 1)
+ init.constant_(m.bias, 0)
+ elif isinstance(m, nn.Linear):
+ init.normal_(m.weight, std=0.001)
+ if m.bias is not None:
+ init.constant_(m.bias, 0)
+
+ def forward(self, queries, keys, values, attention_mask=None, attention_weights=None):
+ """Computes :param queries: Queries (b_s, nq, d_model) :param keys: Keys (b_s,
+ nk, d_model) :param values: Values (b_s, nk, d_model) :param attention_mask:
+ Mask over attention values (b_s, h, nq, nk).
+
+ True indicates masking.
+ :param attention_weights: Multiplicative weights for attention values (b_s, h,
+ nq, nk).
+ :return:
+ """
+ b_s, nq = queries.shape[:2]
+ nk = keys.shape[1]
+
+ q = self.fc_q(queries).view(b_s, nq, self.h, self.d_k).permute(0, 2, 1, 3) # (b_s, h, nq, d_k)
+ k = self.fc_k(keys).view(b_s, nk, self.h, self.d_k).permute(0, 2, 3, 1) # (b_s, h, d_k, nk)
+ v = self.fc_v(values).view(b_s, nk, self.h, self.d_v).permute(0, 2, 1, 3) # (b_s, h, nk, d_v)
+
+ att = torch.matmul(q, k) / np.sqrt(self.d_k) # (b_s, h, nq, nk)
+ if attention_weights is not None:
+ att = att * attention_weights
+ if attention_mask is not None:
+ att = att.masked_fill(attention_mask, -np.inf)
+ att = torch.softmax(att, -1)
+ att = self.dropout(att)
+
+ out = torch.matmul(att, v).permute(0, 2, 1, 3).contiguous().view(b_s, nq, self.h * self.d_v) # (b_s, nq, h*d_v)
+ out = self.fc_o(out) # (b_s, nq, d_model)
+ return out
+
+
+# taken from: https://github.com/xmu-xiaoma666/External-Attention-pytorch/blob/master/model/attention/SimplifiedSelfAttention.py
+class SimplifiedScaledDotProductAttention(nn.Module):
+ """Scaled dot-product attention."""
+
+ def __init__(self, d_model, h, dropout=0.1):
+ """
+ :param d_model: Output dimensionality of the model
+ :param d_k: Dimensionality of queries and keys
+ :param d_v: Dimensionality of values
+ :param h: Number of heads
+ """
+ super().__init__()
+
+ self.d_model = d_model
+ self.d_k = d_model // h
+ self.d_v = d_model // h
+ self.h = h
+
+ self.fc_o = nn.Linear(h * self.d_v, d_model)
+ self.dropout = nn.Dropout(dropout)
+
+ self.init_weights()
+
+ def init_weights(self):
+ for m in self.modules():
+ if isinstance(m, nn.Conv2d):
+ init.kaiming_normal_(m.weight, mode="fan_out")
+ if m.bias is not None:
+ init.constant_(m.bias, 0)
+ elif isinstance(m, nn.BatchNorm2d):
+ init.constant_(m.weight, 1)
+ init.constant_(m.bias, 0)
+ elif isinstance(m, nn.Linear):
+ init.normal_(m.weight, std=0.001)
+ if m.bias is not None:
+ init.constant_(m.bias, 0)
+
+ def forward(self, queries, keys, values, attention_mask=None, attention_weights=None):
+ """Computes :param queries: Queries (b_s, nq, d_model) :param keys: Keys (b_s,
+ nk, d_model) :param values: Values (b_s, nk, d_model) :param attention_mask:
+ Mask over attention values (b_s, h, nq, nk).
+
+ True indicates masking.
+ :param attention_weights: Multiplicative weights for attention values (b_s, h,
+ nq, nk).
+ :return:
+ """
+ b_s, nq = queries.shape[:2]
+ nk = keys.shape[1]
+
+ q = queries.view(b_s, nq, self.h, self.d_k).permute(0, 2, 1, 3) # (b_s, h, nq, d_k)
+ k = keys.view(b_s, nk, self.h, self.d_k).permute(0, 2, 3, 1) # (b_s, h, d_k, nk)
+ v = values.view(b_s, nk, self.h, self.d_v).permute(0, 2, 1, 3) # (b_s, h, nk, d_v)
+
+ att = torch.matmul(q, k) / np.sqrt(self.d_k) # (b_s, h, nq, nk)
+ if attention_weights is not None:
+ att = att * attention_weights
+ if attention_mask is not None:
+ att = att.masked_fill(attention_mask, -np.inf)
+ att = torch.softmax(att, -1)
+ att = self.dropout(att)
+
+ out = torch.matmul(att, v).permute(0, 2, 1, 3).contiguous().view(b_s, nq, self.h * self.d_v) # (b_s, nq, h*d_v)
+ out = self.fc_o(out) # (b_s, nq, d_model)
+ return out
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py
new file mode 100644
index 0000000000..529db64956
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.py
@@ -0,0 +1,300 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch
+import torch.nn as nn
+import torchvision.ops as ops
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+BLOCKS = Registry("blocks", build_func=build_from_cfg)
+
+
+class BaseBlock(ABC, nn.Module):
+ """Abstract Base class for defining custom blocks.
+
+ This class defines an abstract base class for creating custom blocks used in the HigherHRNet for Human Pose
+ Estimation.
+
+ Attributes:
+ bn_momentum: Batch normalization momentum.
+
+ Methods:
+ forward(x): Abstract method for defining the forward pass of the block.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.bn_momentum = 0.1
+
+ @abstractmethod
+ def forward(self, x: torch.Tensor):
+ """Abstract method for defining the forward pass of the block.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ pass
+
+ def _init_weights(self, pretrained: str | None):
+ """Method for initializing block weights from pretrained models.
+
+ Args:
+ pretrained: Path to pretrained model weights.
+ """
+ if pretrained:
+ self.load_state_dict(torch.load(pretrained))
+
+
+@BLOCKS.register_module
+class BasicBlock(BaseBlock):
+ """Basic Residual Block.
+
+ This class defines a basic residual block used in HigherHRNet.
+
+ Attributes:
+ expansion: The expansion factor used in the block.
+
+ Args:
+ in_channels: Number of input channels.
+ out_channels: Number of output channels.
+ stride: Stride value for the convolutional layers. Default is 1.
+ downsample: Downsample layer to be used in the residual connection. Default is None.
+ dilation: Dilation rate for the convolutional layers. Default is 1.
+ """
+
+ expansion: int = 1
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ stride: int = 1,
+ downsample: nn.Module | None = None,
+ dilation: int = 1,
+ ):
+ super().__init__()
+ self.conv1 = nn.Conv2d(
+ in_channels,
+ out_channels,
+ kernel_size=3,
+ stride=stride,
+ padding=dilation,
+ bias=False,
+ dilation=dilation,
+ )
+ self.bn1 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum)
+ self.relu = nn.ReLU(inplace=True)
+ self.conv2 = nn.Conv2d(
+ in_channels,
+ out_channels,
+ kernel_size=3,
+ stride=stride,
+ padding=dilation,
+ bias=False,
+ dilation=dilation,
+ )
+ self.bn2 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum)
+ self.downsample = downsample
+ self.stride = stride
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass through the BasicBlock.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ residual = x
+
+ out = self.conv1(x)
+ out = self.bn1(out)
+ out = self.relu(out)
+
+ out = self.conv2(out)
+ out = self.bn2(out)
+
+ if self.downsample is not None:
+ residual = self.downsample(x)
+
+ out += residual
+ out = self.relu(out)
+
+ return out
+
+
+@BLOCKS.register_module
+class Bottleneck(BaseBlock):
+ """Bottleneck Residual Block.
+
+ This class defines a bottleneck residual block used in HigherHRNet.
+
+ Attributes:
+ expansion: The expansion factor used in the block.
+
+ Args:
+ in_channels: Number of input channels.
+ out_channels: Number of output channels.
+ stride: Stride value for the convolutional layers. Default is 1.
+ downsample: Downsample layer to be used in the residual connection. Default is None.
+ dilation: Dilation rate for the convolutional layers. Default is 1.
+ """
+
+ expansion: int = 4
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ stride: int = 1,
+ downsample: nn.Module | None = None,
+ dilation: int = 1,
+ ):
+ super().__init__()
+ self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
+ self.bn1 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum)
+ self.conv2 = nn.Conv2d(
+ out_channels,
+ out_channels,
+ kernel_size=3,
+ stride=stride,
+ padding=dilation,
+ bias=False,
+ dilation=dilation,
+ )
+ self.bn2 = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum)
+ self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False)
+ self.bn3 = nn.BatchNorm2d(out_channels * self.expansion, momentum=self.bn_momentum)
+ self.relu = nn.ReLU(inplace=True)
+ self.downsample = downsample
+ self.stride = stride
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass through the Bottleneck block.
+
+ Args:
+ x : Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ residual = x
+
+ out = self.conv1(x)
+ out = self.bn1(out)
+ out = self.relu(out)
+
+ out = self.conv2(out)
+ out = self.bn2(out)
+ out = self.relu(out)
+
+ out = self.conv3(out)
+ out = self.bn3(out)
+
+ if self.downsample is not None:
+ residual = self.downsample(x)
+
+ out += residual
+ out = self.relu(out)
+
+ return out
+
+
+@BLOCKS.register_module
+class AdaptBlock(BaseBlock):
+ """Adaptive Residual Block with Deformable Convolution.
+
+ This class defines an adaptive residual block with deformable convolution used in HigherHRNet.
+
+ Attributes:
+ expansion: The expansion factor used in the block.
+
+ Args:
+ in_channels: Number of input channels.
+ out_channels: Number of output channels.
+ stride: Stride value for the convolutional layers. Default is 1.
+ downsample: Downsample layer to be used in the residual connection. Default is None.
+ dilation: Dilation rate for the convolutional layers. Default is 1.
+ deformable_groups: Number of deformable groups in the deformable convolution. Default is 1.
+ """
+
+ expansion: int = 1
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ stride: int = 1,
+ downsample: nn.Module | None = None,
+ dilation: int = 1,
+ deformable_groups: int = 1,
+ ):
+ super().__init__()
+ regular_matrix = torch.tensor([[-1, -1, -1, 0, 0, 0, 1, 1, 1], [-1, 0, 1, -1, 0, 1, -1, 0, 1]])
+ self.register_buffer("regular_matrix", regular_matrix.float())
+ self.downsample = downsample
+ self.transform_matrix_conv = nn.Conv2d(in_channels, 4, 3, 1, 1, bias=True)
+ self.translation_conv = nn.Conv2d(in_channels, 2, 3, 1, 1, bias=True)
+ self.adapt_conv = ops.DeformConv2d(
+ in_channels,
+ out_channels,
+ kernel_size=3,
+ stride=stride,
+ padding=dilation,
+ dilation=dilation,
+ bias=False,
+ groups=deformable_groups,
+ )
+ self.bn = nn.BatchNorm2d(out_channels, momentum=self.bn_momentum)
+ self.relu = nn.ReLU(inplace=True)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward pass through the AdaptBlock.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ residual = x
+
+ N, _, H, W = x.shape
+ transform_matrix = self.transform_matrix_conv(x)
+ transform_matrix = transform_matrix.permute(0, 2, 3, 1).reshape((N * H * W, 2, 2))
+ offset = torch.matmul(transform_matrix, self.regular_matrix)
+ offset = offset - self.regular_matrix
+ offset = offset.transpose(1, 2).reshape((N, H, W, 18)).permute(0, 3, 1, 2)
+
+ translation = self.translation_conv(x)
+ offset[:, 0::2, :, :] += translation[:, 0:1, :, :]
+ offset[:, 1::2, :, :] += translation[:, 1:2, :, :]
+
+ out = self.adapt_conv(x, offset)
+ out = self.bn(out)
+
+ if self.downsample is not None:
+ residual = self.downsample(x)
+
+ out += residual
+ out = self.relu(out)
+
+ return out
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py
new file mode 100644
index 0000000000..e000b9f4a7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_module.py
@@ -0,0 +1,223 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""The code is based on DEKR: https://github.com/HRNet/DEKR/tree/main"""
+
+import logging
+
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.models.modules import BasicBlock
+
+BN_MOMENTUM = 0.1
+logger = logging.getLogger(__name__)
+
+
+class HighResolutionModule(nn.Module):
+ """High-Resolution Module.
+
+ This class implements the High-Resolution Module used in HigherHRNet for Human Pose Estimation.
+
+ Args:
+ num_branches: Number of branches in the module.
+ block: The block type used in each branch of the module.
+ num_blocks: List containing the number of blocks in each branch.
+ num_inchannels: List containing the number of input channels for each branch.
+ num_channels: List containing the number of output channels for each branch.
+ fuse_method: The fusion method used in the module.
+ multi_scale_output: Whether to output multi-scale features. Default is True.
+ """
+
+ def __init__(
+ self,
+ num_branches: int,
+ block: BasicBlock,
+ num_blocks: int,
+ num_inchannels: int,
+ num_channels: int,
+ fuse_method: str,
+ multi_scale_output: bool = True,
+ ):
+ super().__init__()
+ self._check_branches(num_branches, block, num_blocks, num_inchannels, num_channels)
+
+ self.num_inchannels = num_inchannels
+ self.fuse_method = fuse_method
+ self.num_branches = num_branches
+
+ self.multi_scale_output = multi_scale_output
+
+ self.branches = self._make_branches(num_branches, block, num_blocks, num_channels)
+ self.fuse_layers = self._make_fuse_layers()
+ self.relu = nn.ReLU(True)
+
+ def _check_branches(
+ self,
+ num_branches: int,
+ block: BasicBlock,
+ num_blocks: int,
+ num_inchannels: int,
+ num_channels: int,
+ ):
+ if num_branches != len(num_blocks):
+ error_msg = f"NUM_BRANCHES({num_branches}) <> NUM_BLOCKS({len(num_blocks)})"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ if num_branches != len(num_channels):
+ error_msg = f"NUM_BRANCHES({num_branches}) <> NUM_CHANNELS({len(num_channels)})"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ if num_branches != len(num_inchannels):
+ error_msg = f"NUM_BRANCHES({num_branches}) <> NUM_INCHANNELS({len(num_inchannels)})"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ def _make_one_branch(
+ self,
+ branch_index: int,
+ block: BasicBlock,
+ num_blocks: int,
+ num_channels: int,
+ stride: int = 1,
+ ) -> nn.Sequential:
+ downsample = None
+ if stride != 1 or self.num_inchannels[branch_index] != num_channels[branch_index] * block.expansion:
+ downsample = nn.Sequential(
+ nn.Conv2d(
+ self.num_inchannels[branch_index],
+ num_channels[branch_index] * block.expansion,
+ kernel_size=1,
+ stride=stride,
+ bias=False,
+ ),
+ nn.BatchNorm2d(num_channels[branch_index] * block.expansion, momentum=BN_MOMENTUM),
+ )
+
+ layers = []
+ layers.append(
+ block(
+ self.num_inchannels[branch_index],
+ num_channels[branch_index],
+ stride,
+ downsample,
+ )
+ )
+ self.num_inchannels[branch_index] = num_channels[branch_index] * block.expansion
+ for _i in range(1, num_blocks[branch_index]):
+ layers.append(block(self.num_inchannels[branch_index], num_channels[branch_index]))
+
+ return nn.Sequential(*layers)
+
+ def _make_branches(self, num_branches: int, block: BasicBlock, num_blocks: int, num_channels: int) -> nn.ModuleList:
+ branches = []
+
+ for i in range(num_branches):
+ branches.append(self._make_one_branch(i, block, num_blocks, num_channels))
+
+ return nn.ModuleList(branches)
+
+ def _make_fuse_layers(self) -> nn.ModuleList:
+ if self.num_branches == 1:
+ return None
+
+ num_branches = self.num_branches
+ num_inchannels = self.num_inchannels
+ fuse_layers = []
+ for i in range(num_branches if self.multi_scale_output else 1):
+ fuse_layer = []
+ for j in range(num_branches):
+ if j > i:
+ fuse_layer.append(
+ nn.Sequential(
+ nn.Conv2d(
+ num_inchannels[j],
+ num_inchannels[i],
+ 1,
+ 1,
+ 0,
+ bias=False,
+ ),
+ nn.BatchNorm2d(num_inchannels[i]),
+ nn.Upsample(scale_factor=2 ** (j - i), mode="nearest"),
+ )
+ )
+ elif j == i:
+ fuse_layer.append(None)
+ else:
+ conv3x3s = []
+ for k in range(i - j):
+ if k == i - j - 1:
+ num_outchannels_conv3x3 = num_inchannels[i]
+ conv3x3s.append(
+ nn.Sequential(
+ nn.Conv2d(
+ num_inchannels[j],
+ num_outchannels_conv3x3,
+ 3,
+ 2,
+ 1,
+ bias=False,
+ ),
+ nn.BatchNorm2d(num_outchannels_conv3x3),
+ )
+ )
+ else:
+ num_outchannels_conv3x3 = num_inchannels[j]
+ conv3x3s.append(
+ nn.Sequential(
+ nn.Conv2d(
+ num_inchannels[j],
+ num_outchannels_conv3x3,
+ 3,
+ 2,
+ 1,
+ bias=False,
+ ),
+ nn.BatchNorm2d(num_outchannels_conv3x3),
+ nn.ReLU(True),
+ )
+ )
+ fuse_layer.append(nn.Sequential(*conv3x3s))
+ fuse_layers.append(nn.ModuleList(fuse_layer))
+
+ return nn.ModuleList(fuse_layers)
+
+ def get_num_inchannels(self) -> int:
+ return self.num_inchannels
+
+ def forward(self, x) -> list:
+ """Forward pass through the HighResolutionModule.
+
+ Args:
+ x: List of input tensors for each branch.
+
+ Returns:
+ List of output tensors after processing through the module.
+ """
+ if self.num_branches == 1:
+ return [self.branches[0](x[0])]
+
+ for i in range(self.num_branches):
+ x[i] = self.branches[i](x[i])
+
+ x_fuse = []
+
+ for i in range(len(self.fuse_layers)):
+ y = x[0] if i == 0 else self.fuse_layers[i][0](x[0])
+ for j in range(1, self.num_branches):
+ if i == j:
+ y = y + x[j]
+ else:
+ y = y + self.fuse_layers[i][j](x[j])
+ x_fuse.append(self.relu(y))
+
+ return x_fuse
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/csp.py b/deeplabcut/pose_estimation_pytorch/models/modules/csp.py
new file mode 100644
index 0000000000..43d6c65370
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/csp.py
@@ -0,0 +1,379 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Implementation of modules needed for the CSPNeXt Backbone. Used in CSP-style models.
+
+Based on the building blocks used for the ``mmdetection`` CSPNeXt implementation. For
+more information, see .
+"""
+
+import torch
+import torch.nn as nn
+
+
+def build_activation(activation_fn: str, *args, **kwargs) -> nn.Module:
+ if activation_fn == "SiLU":
+ return nn.SiLU(*args, **kwargs)
+ elif activation_fn == "ReLU":
+ return nn.ReLU(*args, **kwargs)
+
+ raise NotImplementedError(f"Unknown `CSPNeXT` activation: {activation_fn}. Must be one of 'SiLU', 'ReLU'")
+
+
+def build_norm(norm: str, *args, **kwargs) -> nn.Module:
+ if norm == "SyncBN":
+ return nn.SyncBatchNorm(*args, **kwargs)
+ elif norm == "BN":
+ return nn.BatchNorm2d(*args, **kwargs)
+
+ raise NotImplementedError(f"Unknown `CSPNeXT` norm_layer: {norm}. Must be one of 'SyncBN', 'BN'")
+
+
+class SPPBottleneck(nn.Module):
+ """Spatial pyramid pooling layer used in YOLOv3-SPP and (among others) CSPNeXt.
+
+ Args:
+ in_channels: input channels to the bottleneck
+ out_channels: output channels of the bottleneck
+ kernel_sizes: kernel sizes for the pooling layers
+ norm_layer: norm layer for the bottleneck
+ activation_fn: activation function for the bottleneck
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ kernel_sizes: tuple[int, ...] = (5, 9, 13),
+ norm_layer: str | None = "SyncBN",
+ activation_fn: str | None = "SiLU",
+ ):
+ super().__init__()
+ mid_channels = in_channels // 2
+ self.conv1 = CSPConvModule(
+ in_channels,
+ mid_channels,
+ kernel_size=1,
+ stride=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+
+ self.poolings = nn.ModuleList([nn.MaxPool2d(kernel_size=ks, stride=1, padding=ks // 2) for ks in kernel_sizes])
+ conv2_channels = mid_channels * (len(kernel_sizes) + 1)
+ self.conv2 = CSPConvModule(
+ conv2_channels,
+ out_channels,
+ kernel_size=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+
+ def forward(self, x):
+ x = self.conv1(x)
+ with torch.amp.autocast("cuda", enabled=False):
+ x = torch.cat([x] + [pooling(x) for pooling in self.poolings], dim=1)
+ x = self.conv2(x)
+ return x
+
+
+class ChannelAttention(nn.Module):
+ """Channel attention Module.
+
+ Args:
+ channels: Number of input/output channels of the layer.
+ """
+
+ def __init__(self, channels: int) -> None:
+ super().__init__()
+ self.global_avgpool = nn.AdaptiveAvgPool2d(1)
+ self.fc = nn.Conv2d(channels, channels, 1, 1, 0, bias=True)
+ self.act = nn.Hardsigmoid(inplace=True)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ with torch.amp.autocast("cuda", enabled=False):
+ out = self.global_avgpool(x)
+ out = self.fc(out)
+ out = self.act(out)
+ return x * out
+
+
+class CSPConvModule(nn.Module):
+ """Configurable convolution module used for CSPNeXT.
+
+ Applies sequentially
+ - a convolution
+ - (optional) a norm layer
+ - (optional) an activation function
+
+ Args:
+ in_channels: Input channels of the convolution.
+ out_channels: Output channels of the convolution.
+ kernel_size: Convolution kernel size.
+ stride: Convolution stride.
+ padding: Convolution padding.
+ dilation: Convolution dilation.
+ groups: Number of blocked connections from input to output channels.
+ norm_layer: Norm layer to apply, if any.
+ activation_fn: Activation function to apply, if any.
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ kernel_size: int | tuple[int, int],
+ stride: int | tuple[int, int] = 1,
+ padding: int | tuple[int, int] = 0,
+ dilation: int | tuple[int, int] = 1,
+ groups: int = 1,
+ norm_layer: str | None = None,
+ activation_fn: str | None = "ReLU",
+ ):
+ super().__init__()
+
+ self.with_activation = activation_fn is not None
+ self.with_bias = norm_layer is None
+ self.with_norm = norm_layer is not None
+
+ self.conv = nn.Conv2d(
+ in_channels,
+ out_channels,
+ kernel_size,
+ stride=stride,
+ padding=padding,
+ dilation=dilation,
+ groups=groups,
+ bias=self.with_bias,
+ )
+ self.activate = None
+ self.norm = None
+
+ if self.with_norm:
+ self.norm = build_norm(norm_layer, out_channels)
+
+ if self.with_activation:
+ # Careful when adding activation functions: some should not be in-place
+ self.activate = build_activation(activation_fn, inplace=True)
+
+ self._init_weights()
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ x = self.conv(x)
+ if self.with_norm:
+ x = self.norm(x)
+ if self.with_activation:
+ x = self.activate(x)
+ return x
+
+ def _init_weights(self) -> None:
+ """Same init as in convolutions."""
+ nn.init.kaiming_normal_(self.conv.weight, a=0, nonlinearity="relu")
+ if self.with_bias:
+ nn.init.constant_(self.conv.bias, 0)
+
+ if self.with_norm:
+ nn.init.constant_(self.norm.weight, 1)
+ nn.init.constant_(self.norm.bias, 0)
+
+
+class DepthwiseSeparableConv(nn.Module):
+ """Depth-wise separable convolution module used for CSPNeXT.
+
+ Applies sequentially
+ - a depth-wise conv
+ - a point-wise conv
+
+ Args:
+ in_channels: Input channels of the convolution.
+ out_channels: Output channels of the convolution.
+ kernel_size: Convolution kernel size.
+ stride: Convolution stride.
+ padding: Convolution padding.
+ dilation: Convolution dilation.
+ norm_layer: Norm layer to apply, if any.
+ activation_fn: Activation function to apply, if any.
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ kernel_size: int | tuple[int, int],
+ stride: int | tuple[int, int] = 1,
+ padding: int | tuple[int, int] = 0,
+ dilation: int | tuple[int, int] = 1,
+ norm_layer: str | None = None,
+ activation_fn: str | None = "ReLU",
+ ):
+ super().__init__()
+
+ # depthwise convolution
+ self.depthwise_conv = CSPConvModule(
+ in_channels,
+ in_channels,
+ kernel_size,
+ stride=stride,
+ padding=padding,
+ dilation=dilation,
+ groups=in_channels,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+
+ self.pointwise_conv = CSPConvModule(
+ in_channels,
+ out_channels,
+ kernel_size=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ x = self.depthwise_conv(x)
+ x = self.pointwise_conv(x)
+ return x
+
+
+class CSPNeXtBlock(nn.Module):
+ """Basic bottleneck block used in CSPNeXt.
+
+ Args:
+ in_channels: input channels for the block
+ out_channels: output channels for the block
+ expansion: expansion factor for the hidden channels
+ add_identity: add a skip-connection to the block
+ kernel_size: kernel size for the DepthwiseSeparableConv
+ norm_layer: Norm layer to apply, if any.
+ activation_fn: Activation function to apply, if any.
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ expansion: float = 0.5,
+ add_identity: bool = True,
+ kernel_size: int = 5,
+ norm_layer: str | None = None,
+ activation_fn: str | None = "ReLU",
+ ) -> None:
+ super().__init__()
+ hidden_channels = int(out_channels * expansion)
+ self.conv1 = CSPConvModule(
+ in_channels,
+ hidden_channels,
+ 3,
+ stride=1,
+ padding=1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ self.conv2 = DepthwiseSeparableConv(
+ hidden_channels,
+ out_channels,
+ kernel_size,
+ stride=1,
+ padding=kernel_size // 2,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ self.add_identity = add_identity and in_channels == out_channels
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward function."""
+ identity = x
+ out = self.conv1(x)
+ out = self.conv2(out)
+
+ if self.add_identity:
+ return out + identity
+ else:
+ return out
+
+
+class CSPLayer(nn.Module):
+ """Cross Stage Partial Layer.
+
+ Args:
+ in_channels: input channels for the layer
+ out_channels: output channels for the block
+ expand_ratio: expansion factor for the mid-channels
+ num_blocks: the number of blocks to use
+ add_identity: add a skip-connection to the blocks
+ channel_attention: whether to apply channel attention
+ norm_layer: Norm layer to apply, if any.
+ activation_fn: Activation function to apply, if any.
+ """
+
+ def __init__(
+ self,
+ in_channels: int,
+ out_channels: int,
+ expand_ratio: float = 0.5,
+ num_blocks: int = 1,
+ add_identity: bool = True,
+ channel_attention: bool = False,
+ norm_layer: str | None = None,
+ activation_fn: str | None = "ReLU",
+ ) -> None:
+ super().__init__()
+ mid_channels = int(out_channels * expand_ratio)
+ self.channel_attention = channel_attention
+ self.main_conv = CSPConvModule(
+ in_channels,
+ mid_channels,
+ 1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ self.short_conv = CSPConvModule(
+ in_channels,
+ mid_channels,
+ 1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ self.final_conv = CSPConvModule(
+ 2 * mid_channels,
+ out_channels,
+ 1,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+
+ self.blocks = nn.Sequential(
+ *[
+ CSPNeXtBlock(
+ mid_channels,
+ mid_channels,
+ 1.0,
+ add_identity,
+ norm_layer=norm_layer,
+ activation_fn=activation_fn,
+ )
+ for _ in range(num_blocks)
+ ]
+ )
+ if channel_attention:
+ self.attention = ChannelAttention(2 * mid_channels)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Forward function."""
+ x_short = self.short_conv(x)
+
+ x_main = self.main_conv(x)
+ x_main = self.blocks(x_main)
+
+ x_final = torch.cat((x_main, x_short), dim=1)
+
+ if self.channel_attention:
+ x_final = self.attention(x_final)
+ return self.final_conv(x_final)
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/gated_attention_unit.py b/deeplabcut/pose_estimation_pytorch/models/modules/gated_attention_unit.py
new file mode 100644
index 0000000000..47c5a232d2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/gated_attention_unit.py
@@ -0,0 +1,232 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Gated Attention Unit.
+
+Based on the building blocks used for the ``mmdetection`` CSPNeXt implementation. For
+more information, see .
+"""
+
+from __future__ import annotations
+
+import math
+
+import timm.layers as timm_layers
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.modules.norm import ScaleNorm
+
+
+def rope(x, dim):
+ """Applies Rotary Position Embedding to input tensor."""
+ shape = x.shape
+ if isinstance(dim, int):
+ dim = [dim]
+
+ spatial_shape = [shape[i] for i in dim]
+ total_len = 1
+ for i in spatial_shape:
+ total_len *= i
+
+ position = torch.reshape(torch.arange(total_len, dtype=torch.int, device=x.device), spatial_shape)
+
+ for i in range(dim[-1] + 1, len(shape) - 1, 1):
+ position = torch.unsqueeze(position, dim=-1)
+
+ half_size = shape[-1] // 2
+ freq_seq = -torch.arange(half_size, dtype=torch.int, device=x.device) / float(half_size)
+ inv_freq = 10000**-freq_seq
+
+ sinusoid = position[..., None] * inv_freq[None, None, :]
+
+ sin = torch.sin(sinusoid)
+ cos = torch.cos(sinusoid)
+ x1, x2 = torch.chunk(x, 2, dim=-1)
+
+ return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)
+
+
+class Scale(nn.Module):
+ """Scale vector by element multiplications.
+
+ Args:
+ dim: The dimension of the scale vector.
+ init_value: The initial value of the scale vector.
+ trainable: Whether the scale vector is trainable.
+ """
+
+ def __init__(self, dim, init_value=1.0, trainable=True):
+ super().__init__()
+ self.scale = nn.Parameter(init_value * torch.ones(dim), requires_grad=trainable)
+
+ def forward(self, x):
+ return x * self.scale
+
+
+class GatedAttentionUnit(nn.Module):
+ """Gated Attention Unit (GAU) in RTMBlock."""
+
+ def __init__(
+ self,
+ num_token,
+ in_token_dims,
+ out_token_dims,
+ expansion_factor=2,
+ s=128,
+ eps=1e-5,
+ dropout_rate=0.0,
+ drop_path=0.0,
+ attn_type="self-attn",
+ act_fn="SiLU",
+ bias=False,
+ use_rel_bias=True,
+ pos_enc=False,
+ ):
+ super().__init__()
+ self.s = s
+ self.num_token = num_token
+ self.use_rel_bias = use_rel_bias
+ self.attn_type = attn_type
+ self.pos_enc = pos_enc
+
+ if drop_path > 0.0:
+ self.drop_path = timm_layers.DropPath(drop_path)
+ else:
+ self.drop_path = nn.Identity()
+
+ self.e = int(in_token_dims * expansion_factor)
+ if use_rel_bias:
+ if attn_type == "self-attn":
+ self.w = nn.Parameter(torch.rand([2 * num_token - 1], dtype=torch.float))
+ else:
+ self.a = nn.Parameter(torch.rand([1, s], dtype=torch.float))
+ self.b = nn.Parameter(torch.rand([1, s], dtype=torch.float))
+ self.o = nn.Linear(self.e, out_token_dims, bias=bias)
+
+ if attn_type == "self-attn":
+ self.uv = nn.Linear(in_token_dims, 2 * self.e + self.s, bias=bias)
+ self.gamma = nn.Parameter(torch.rand((2, self.s)))
+ self.beta = nn.Parameter(torch.rand((2, self.s)))
+ else:
+ self.uv = nn.Linear(in_token_dims, self.e + self.s, bias=bias)
+ self.k_fc = nn.Linear(in_token_dims, self.s, bias=bias)
+ self.v_fc = nn.Linear(in_token_dims, self.e, bias=bias)
+ nn.init.xavier_uniform_(self.k_fc.weight)
+ nn.init.xavier_uniform_(self.v_fc.weight)
+
+ self.ln = ScaleNorm(in_token_dims, eps=eps)
+
+ nn.init.xavier_uniform_(self.uv.weight)
+
+ if act_fn == "SiLU" or act_fn == nn.SiLU:
+ self.act_fn = nn.SiLU(True)
+ elif act_fn == "ReLU" or act_fn == nn.ReLU:
+ self.act_fn = nn.ReLU(True)
+ else:
+ raise NotImplementedError
+
+ if in_token_dims == out_token_dims:
+ self.shortcut = True
+ self.res_scale = Scale(in_token_dims)
+ else:
+ self.shortcut = False
+
+ self.sqrt_s = math.sqrt(s)
+
+ self.dropout_rate = dropout_rate
+
+ if dropout_rate > 0.0:
+ self.dropout = nn.Dropout(dropout_rate)
+
+ def rel_pos_bias(self, seq_len, k_len=None):
+ """Add relative position bias."""
+
+ if self.attn_type == "self-attn":
+ t = F.pad(self.w[: 2 * seq_len - 1], [0, seq_len]).repeat(seq_len)
+ t = t[..., :-seq_len].reshape(-1, seq_len, 3 * seq_len - 2)
+ r = (2 * seq_len - 1) // 2
+ t = t[..., r:-r]
+ else:
+ a = rope(self.a.repeat(seq_len, 1), dim=0)
+ b = rope(self.b.repeat(k_len, 1), dim=0)
+ t = torch.bmm(a, b.permute(0, 2, 1))
+ return t
+
+ def _forward(self, inputs):
+ """GAU Forward function."""
+
+ if self.attn_type == "self-attn":
+ x = inputs
+ else:
+ x, k, v = inputs
+
+ x = self.ln(x)
+
+ # [B, K, in_token_dims] -> [B, K, e + e + s]
+ uv = self.uv(x)
+ uv = self.act_fn(uv)
+
+ if self.attn_type == "self-attn":
+ # [B, K, e + e + s] -> [B, K, e], [B, K, e], [B, K, s]
+ u, v, base = torch.split(uv, [self.e, self.e, self.s], dim=2)
+ # [B, K, 1, s] * [1, 1, 2, s] + [2, s] -> [B, K, 2, s]
+ base = base.unsqueeze(2) * self.gamma[None, None, :] + self.beta
+
+ if self.pos_enc:
+ base = rope(base, dim=1)
+ # [B, K, 2, s] -> [B, K, s], [B, K, s]
+ q, k = torch.unbind(base, dim=2)
+
+ else:
+ # [B, K, e + s] -> [B, K, e], [B, K, s]
+ u, q = torch.split(uv, [self.e, self.s], dim=2)
+
+ k = self.k_fc(k) # -> [B, K, s]
+ v = self.v_fc(v) # -> [B, K, e]
+
+ if self.pos_enc:
+ q = rope(q, 1)
+ k = rope(k, 1)
+
+ # [B, K, s].permute() -> [B, s, K]
+ # [B, K, s] x [B, s, K] -> [B, K, K]
+ qk = torch.bmm(q, k.permute(0, 2, 1))
+
+ if self.use_rel_bias:
+ if self.attn_type == "self-attn":
+ bias = self.rel_pos_bias(q.size(1))
+ else:
+ bias = self.rel_pos_bias(q.size(1), k.size(1))
+ qk += bias[:, : q.size(1), : k.size(1)]
+ # [B, K, K]
+ kernel = torch.square(F.relu(qk / self.sqrt_s))
+
+ if self.dropout_rate > 0.0:
+ kernel = self.dropout(kernel)
+ # [B, K, K] x [B, K, e] -> [B, K, e]
+ x = u * torch.bmm(kernel, v)
+
+ # [B, K, e] -> [B, K, out_token_dims]
+ x = self.o(x)
+
+ return x
+
+ def forward(self, x):
+ if self.shortcut:
+ if self.attn_type == "cross-attn":
+ res_shortcut = x[0]
+ else:
+ res_shortcut = x
+ main_branch = self.drop_path(self._forward(x))
+ return self.res_scale(res_shortcut) + main_branch
+ else:
+ return self.drop_path(self._forward(x))
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py b/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py
new file mode 100644
index 0000000000..3654e3e644
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py
@@ -0,0 +1,242 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import cv2
+import matplotlib.pyplot as plt
+import numpy as np
+
+from deeplabcut.pose_estimation_pytorch.data.utils import out_of_bounds_keypoints
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+KEYPOINT_ENCODERS = Registry("kpt_encoders", build_func=build_from_cfg)
+
+
+class BaseKeypointEncoder(ABC):
+ """Encodes keypoints into heatmaps.
+
+ Modified from BUCTD/data/JointsDataset
+ """
+
+ def __init__(
+ self,
+ num_joints: int,
+ kernel_size: tuple[int, int] = (15, 15),
+ img_size: tuple[int, int] = (256, 256),
+ ) -> None:
+ """
+ Args:
+ num_joints: The number of joints to encode
+ kernel_size: The Gaussian kernel size to use when blurring a heatmap
+ img_size: The (height, width) of the input images
+ """
+ self.kernel_size = kernel_size
+ self.num_joints = num_joints
+ self.img_size = img_size
+
+ @property
+ @abstractmethod
+ def num_channels(self):
+ pass
+
+ @abstractmethod
+ def __call__(self, keypoints: np.ndarray, size: tuple[int, int]) -> np.ndarray:
+ """
+ Args:
+ keypoints: the keypoints to encode
+ size: the (height, width) of the heatmap in which the keypoints should
+ be encoded
+
+ Returns:
+ the encoded keypoints
+ """
+ raise NotImplementedError
+
+ def blur_heatmap(self, heatmap: np.ndarray) -> np.ndarray:
+ """Applies a Gaussian blur to a heatmap.
+
+ Taken from BUCTD/data/JointsDataset, generate_heatmap
+
+ Args:
+ heatmap: the heatmap to blur (with values in [0, 1] or [0, 255])
+
+ Returns:
+ The heatmap with a Gaussian blur, such that max(heatmap) = 255
+ """
+ heatmap = cv2.GaussianBlur(heatmap, self.kernel_size, sigmaX=0)
+ am = np.amax(heatmap)
+ if am == 0:
+ return heatmap
+ heatmap /= am / 255
+ return heatmap
+
+ # def blur_heatmap_batch(self, heatmaps: torch.tensor) -> np.ndarray:
+ # heatmaps = TF.gaussian_blur(heatmaps.permute(0,3,1,2), self.kernel_size).permute(0,2,3,1).numpy()
+ # am = np.amax(heatmaps)
+ # if am == 0:
+ # return heatmaps
+ # heatmaps /= (am / 255)
+ # return heatmaps
+
+
+@KEYPOINT_ENCODERS.register_module
+class StackedKeypointEncoder(BaseKeypointEncoder):
+ """Encodes keypoints into heatmaps, where each.
+
+ Modified from BUCTD/data/JointsDataset, get_stacked_condition
+ """
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+
+ @property
+ def num_channels(self):
+ return self.num_joints
+
+ def __call__(self, keypoints: np.ndarray, size: tuple[int, int]) -> np.ndarray:
+ """
+ Args:
+ keypoints: the keypoints to encode
+ size: the (height, width) of the heatmap in which the keypoints should
+ be encoded
+
+ Returns:
+ the encoded keypoints
+ """
+
+ batch_size, _, _ = keypoints.shape
+
+ kpts = keypoints.copy()
+ kpts[keypoints[..., 2] <= 0] = 0
+
+ # Mark keypoints as visible, remove NaNs
+ kpts[kpts[..., 2] > 0, 2] = 2
+ kpts = np.nan_to_num(kpts)
+
+ oob_mask = out_of_bounds_keypoints(kpts, self.img_size)
+ if np.sum(oob_mask) > 0:
+ kpts[oob_mask] = 0
+ kpts = kpts.astype(int)
+
+ zero_matrix = np.zeros((batch_size, size[0], size[1], self.num_channels))
+
+ def _get_condition_matrix(zero_matrix, kpts):
+ for i, pose in enumerate(kpts):
+ x, y, vis = pose.T
+ mask = vis > 0
+ x_masked, y_masked, joint_inds_masked = (
+ x[mask],
+ y[mask],
+ np.arange(self.num_joints)[mask],
+ )
+ zero_matrix[i, y_masked - 1, x_masked - 1, joint_inds_masked] = 255
+ return zero_matrix
+
+ condition = _get_condition_matrix(zero_matrix, kpts)
+
+ for i in range(batch_size):
+ condition_heatmap = self.blur_heatmap(condition[i])
+ condition[i] = condition_heatmap
+
+ return condition
+
+
+@KEYPOINT_ENCODERS.register_module
+class ColoredKeypointEncoder(BaseKeypointEncoder):
+ """Encodes keypoints into a given number of color channels.
+
+ Modified from BUCTD/data/JointsDataset, get_condition_image_colored
+ """
+
+ def __init__(self, colors: list[tuple[int, int, int]] | None = None, **kwargs) -> None:
+ """
+ Args:
+ colors: the color to use for each keypoint
+ """
+ super().__init__(**kwargs)
+ if colors is None:
+ colors = self.get_colors_from_cmap("rainbow", self.num_joints)
+ self.colors = np.array(colors)
+
+ @property
+ def num_channels(self):
+ return 3
+
+ def __call__(self, keypoints: np.ndarray, size: tuple[int, int]) -> np.ndarray:
+ """
+ Args:
+ keypoints: batch of keypoints to encode with shape (batch_size, num_joints, 2)
+ size: the (height, width) of the heatmap in which the keypoints should be encoded
+
+ Returns:
+ encoded keypoints with shape (batch_size, num_joints, height, width, 3)
+ """
+
+ batch_size, num_kpts, _ = keypoints.shape
+
+ if not num_kpts == len(self.colors):
+ raise ValueError(
+ f"Cannot encode the keypoints. Initialized with {len(self.colors)} "
+ f"colors, but there are {num_kpts} to encode"
+ )
+
+ # kpts = keypoints.detach().numpy()
+ kpts = keypoints.copy()
+ kpts[keypoints[..., 2] <= 0] = 0
+
+ # Mark keypoints as visible, remove NaNs
+ kpts[kpts[..., 2] > 0, 2] = 2
+ kpts = np.nan_to_num(kpts)
+
+ oob_mask = out_of_bounds_keypoints(kpts, self.img_size)
+ if np.sum(oob_mask) > 0:
+ kpts[oob_mask] = 0
+ kpts = kpts.astype(int)
+
+ zero_matrix = np.zeros((batch_size, size[0], size[1], self.num_channels))
+
+ def _get_condition_matrix(zero_matrix, kpts):
+ for i, pose in enumerate(kpts):
+ x, y, vis = pose.T
+ mask = vis > 0
+ x_masked, y_masked, colors_masked = x[mask], y[mask], self.colors[mask]
+ zero_matrix[i, y_masked - 1, x_masked - 1] = colors_masked
+ return zero_matrix
+
+ def _get_condition_matrix_optim(zero_matrix, kpts):
+ x, y = np.array(kpts).T
+ mask = (0 < x) & (x < zero_matrix.shape[2]) & (0 < y) & (y < zero_matrix.shape[1])
+ colors_masked = np.repeat(self.colors[:, None, :], len(zero_matrix), 1) * np.repeat(mask[:, :, None], 3, 2)
+ kpt_indices = np.stack([x.T, y.T]).transpose(1, 2, 0)
+ batch_indices = np.repeat(np.arange(len(zero_matrix))[:, None, None], self.num_joints, axis=1)
+ kpt_input = np.concatenate([batch_indices, kpt_indices], dtype=int, axis=2)
+ zero_matrix[kpt_input[..., 0], kpt_input[..., 2] - 1, kpt_input[..., 1] - 1] = colors_masked.transpose(
+ 1, 0, 2
+ )
+ return zero_matrix
+
+ condition = _get_condition_matrix(zero_matrix, kpts)
+ # condition = _get_condition_matrix_optim(zero_matrix, kpts)
+
+ for i in range(batch_size):
+ condition_heatmap = self.blur_heatmap(condition[i])
+ condition[i] = condition_heatmap
+ # condition = self.blur_heatmap_batch(torch.from_numpy(condition))
+
+ return condition
+
+ def get_colors_from_cmap(self, cmap_name, num_colors):
+ cmap = plt.get_cmap(cmap_name)
+ colors_float = [cmap(i) for i in np.linspace(0, 256, num_colors, dtype=int)]
+ colors = [(int(r * 255), int(g * 255), int(b * 255)) for r, g, b, _ in colors_float]
+ return colors
diff --git a/deeplabcut/pose_estimation_pytorch/models/modules/norm.py b/deeplabcut/pose_estimation_pytorch/models/modules/norm.py
new file mode 100644
index 0000000000..e3874846df
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/norm.py
@@ -0,0 +1,42 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Normalization layers."""
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+
+class ScaleNorm(nn.Module):
+ """Implementation of ScaleNorm.
+
+ ScaleNorm was introduced in "Transformers without Tears: Improving the Normalization
+ of Self-Attention".
+
+ Code based on the `mmpose` implementation. See https://github.com/open-mmlab/mmpose
+ for more details.
+
+ Args:
+ dim: The dimension of the scale vector.
+ eps: The minimum value in clamp.
+ """
+
+ def __init__(self, dim: int, eps: float = 1e-5):
+ super().__init__()
+ self.scale = dim**-0.5
+ self.eps = eps
+ self.g = nn.Parameter(torch.ones(1))
+
+ def forward(self, x):
+ norm = torch.linalg.norm(x, dim=-1, keepdim=True)
+ norm = norm * self.scale
+ return x / norm.clamp(min=self.eps) * self.g
diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py
new file mode 100644
index 0000000000..1462f9b213
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/__init__.py
@@ -0,0 +1,12 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS, BaseNeck
+from deeplabcut.pose_estimation_pytorch.models.necks.transformer import Transformer
diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/base.py b/deeplabcut/pose_estimation_pytorch/models/necks/base.py
new file mode 100644
index 0000000000..336b1a9ef4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py
@@ -0,0 +1,48 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from abc import ABC, abstractmethod
+
+import torch
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+NECKS = Registry("necks", build_func=build_from_cfg)
+
+
+class BaseNeck(ABC, torch.nn.Module):
+ """Base Neck class for pose estimation."""
+
+ def __init__(self):
+ super().__init__()
+
+ @abstractmethod
+ def forward(self, x: torch.Tensor):
+ """Abstract method for the forward pass through the Neck.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ pass
+
+ def _init_weights(self, pretrained: str):
+ """Initialize the Neck with pretrained weights.
+
+ Args:
+ pretrained: Path to the pretrained weights.
+
+ Returns:
+ None
+ """
+ if pretrained:
+ self.model.load_state_dict(torch.load(pretrained))
diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/layers.py b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py
new file mode 100644
index 0000000000..7dbd1125e7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/layers.py
@@ -0,0 +1,281 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import torch
+import torch.nn.functional as F
+from einops import rearrange
+
+
+class Residual(torch.nn.Module):
+ """Residual block module.
+
+ This module implements a residual block for the transformer layers.
+
+ Attributes:
+ fn: The function to apply in the residual block.
+ """
+
+ def __init__(self, fn: torch.nn.Module):
+ """Initialize the Residual block.
+
+ Args:
+ fn: The function to apply in the residual block.
+ """
+ super().__init__()
+ self.fn = fn
+
+ def forward(self, x: torch.Tensor, **kwargs):
+ """Forward pass through the Residual block.
+
+ Args:
+ x: Input tensor.
+ **kwargs: Additional keyword arguments for the function.
+
+ Returns:
+ Output tensor.
+ """
+ return self.fn(x, **kwargs) + x
+
+
+class PreNorm(torch.nn.Module):
+ """PreNorm block module.
+
+ This module implements pre-normalization for the transformer layers.
+
+ Attributes:
+ dim: Dimension of the input tensor.
+ fn: The function to apply after normalization.
+ fusion_factor: Fusion factor for layer normalization.
+ Defaults to 1.
+ """
+
+ def __init__(self, dim: int, fn: torch.nn.Module, fusion_factor: int = 1):
+ """Initialize the PreNorm block.
+
+ Args:
+ dim: Dimension of the input tensor.
+ fn: The function to apply after normalization.
+ fusion_factor: Fusion factor for layer normalization.
+ Defaults to 1.
+ """
+ super().__init__()
+ self.norm = torch.nn.LayerNorm(dim * fusion_factor)
+ self.fn = fn
+
+ def forward(self, x, **kwargs):
+ """Forward pass through the PreNorm block.
+
+ Args:
+ x: Input tensor.
+ **kwargs: Additional keyword arguments for the function.
+
+ Returns:
+ Output tensor.
+ """
+ return self.fn(self.norm(x), **kwargs)
+
+
+class FeedForward(torch.nn.Module):
+ """FeedForward block module.
+
+ This module implements the feedforward layer in the transformer layers.
+
+ Attributes:
+ dim: Dimension of the input tensor.
+ hidden_dim: Dimension of the hidden layer.
+ dropout: Dropout rate. Defaults to 0.0.
+ """
+
+ def __init__(self, dim: int, hidden_dim: int, dropout: float = 0.0):
+ """Initialize the FeedForward block.
+
+ Args:
+ dim: Dimension of the input tensor.
+ hidden_dim: Dimension of the hidden layer.
+ dropout: Dropout rate. Defaults to 0.0.
+ """
+ super().__init__()
+ self.net = torch.nn.Sequential(
+ torch.nn.Linear(dim, hidden_dim),
+ torch.nn.GELU(),
+ torch.nn.Dropout(dropout),
+ torch.nn.Linear(hidden_dim, dim),
+ torch.nn.Dropout(dropout),
+ )
+
+ def forward(self, x: torch.Tensor):
+ """Forward pass through the FeedForward block.
+
+ Args:
+ x: Input tensor.
+
+ Returns:
+ Output tensor.
+ """
+ return self.net(x)
+
+
+class Attention(torch.nn.Module):
+ """Attention block module.
+
+ This module implements the attention mechanism in the transformer layers.
+
+ Attributes:
+ dim: Dimension of the input tensor.
+ heads: Number of attention heads. Defaults to 8.
+ dropout: Dropout rate. Defaults to 0.0.
+ num_keypoints: Number of keypoints. Defaults to None.
+ scale_with_head: Scale attention with the number of heads.
+ Defaults to False.
+ """
+
+ def __init__(
+ self,
+ dim: int,
+ heads: int = 8,
+ dropout: float = 0.0,
+ num_keypoints: int = None,
+ scale_with_head: bool = False,
+ ):
+ """Initialize the Attention block.
+
+ Args:
+ dim: Dimension of the input tensor.
+ heads: Number of attention heads. Defaults to 8.
+ dropout: Dropout rate. Defaults to 0.0.
+ num_keypoints: Number of keypoints. Defaults to None.
+ scale_with_head: Scale attention with the number of heads.
+ Defaults to False.
+ """
+ super().__init__()
+ self.heads = heads
+ self.scale = (dim // heads) ** -0.5 if scale_with_head else dim**-0.5
+
+ self.to_qkv = torch.nn.Linear(dim, dim * 3, bias=False)
+ self.to_out = torch.nn.Sequential(torch.nn.Linear(dim, dim), torch.nn.Dropout(dropout))
+ self.num_keypoints = num_keypoints
+
+ def forward(self, x: torch.Tensor, mask: torch.Tensor = None):
+ """Forward pass through the Attention block.
+
+ Args:
+ x: Input tensor.
+ mask: Attention mask. Defaults to None.
+
+ Returns:
+ Output tensor.
+ """
+ _b, _n, _, h = *x.shape, self.heads
+ qkv = self.to_qkv(x).chunk(3, dim=-1)
+ q, k, v = map(lambda t: rearrange(t, "b n (h d) -> b h n d", h=h), qkv)
+
+ dots = torch.einsum("bhid,bhjd->bhij", q, k) * self.scale
+ mask_value = -torch.finfo(dots.dtype).max
+
+ if mask is not None:
+ mask = F.pad(mask.flatten(1), (1, 0), value=True)
+ assert mask.shape[-1] == dots.shape[-1], "mask has incorrect dimensions"
+ mask = mask[:, None, :] * mask[:, :, None]
+ dots.masked_fill_(~mask, mask_value)
+ del mask
+
+ attn = dots.softmax(dim=-1)
+
+ out = torch.einsum("bhij,bhjd->bhid", attn, v)
+
+ out = rearrange(out, "b h n d -> b n (h d)")
+ out = self.to_out(out)
+ return out
+
+
+class TransformerLayer(torch.nn.Module):
+ """TransformerLayer block module.
+
+ This module implements the Transformer layer in the transformer model.
+
+ Attributes:
+ dim: Dimension of the input tensor.
+ depth: Depth of the transformer layer.
+ heads: Number of attention heads.
+ mlp_dim: Dimension of the MLP layer.
+ dropout: Dropout rate.
+ num_keypoints: Number of keypoints. Defaults to None.
+ all_attn: Apply attention to all keypoints.
+ Defaults to False.
+ scale_with_head: Scale attention with the number of heads.
+ Defaults to False.
+ """
+
+ def __init__(
+ self,
+ dim: int,
+ depth: int,
+ heads: int,
+ mlp_dim: int,
+ dropout: float,
+ num_keypoints: int = None,
+ all_attn: bool = False,
+ scale_with_head: bool = False,
+ ):
+ """Initialize the TransformerLayer block.
+
+ Args:
+ dim: Dimension of the input tensor.
+ depth: Depth of the transformer layer.
+ heads: Number of attention heads.
+ mlp_dim: Dimension of the MLP layer.
+ dropout: Dropout rate.
+ num_keypoints: Number of keypoints. Defaults to None.
+ all_attn: Apply attention to all keypoints. Defaults to False.
+ scale_with_head: Scale attention with the number of heads. Defaults to False.
+ """
+ super().__init__()
+ self.layers = torch.nn.ModuleList([])
+ self.all_attn = all_attn
+ self.num_keypoints = num_keypoints
+ for _ in range(depth):
+ self.layers.append(
+ torch.nn.ModuleList(
+ [
+ Residual(
+ PreNorm(
+ dim,
+ Attention(
+ dim,
+ heads=heads,
+ dropout=dropout,
+ num_keypoints=num_keypoints,
+ scale_with_head=scale_with_head,
+ ),
+ )
+ ),
+ Residual(PreNorm(dim, FeedForward(dim, mlp_dim, dropout=dropout))),
+ ]
+ )
+ )
+
+ def forward(self, x: torch.Tensor, mask: torch.Tensor = None, pos: torch.Tensor = None):
+ """Forward pass through the TransformerLayer block.
+
+ Args:
+ x: Input tensor.
+ mask: Attention mask. Defaults to None.
+ pos: Positional encoding. Defaults to None.
+
+ Returns:
+ Output tensor.
+ """
+ for idx, (attn, ff) in enumerate(self.layers):
+ if idx > 0 and self.all_attn:
+ x[:, self.num_keypoints :] += pos
+ x = attn(x, mask=mask)
+ x = ff(x)
+ return x
diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py
new file mode 100644
index 0000000000..0bb2c478c9
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/transformer.py
@@ -0,0 +1,260 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import torch
+from einops import rearrange, repeat
+from timm.layers import trunc_normal_
+
+from deeplabcut.pose_estimation_pytorch.models.necks.base import NECKS, BaseNeck
+from deeplabcut.pose_estimation_pytorch.models.necks.layers import TransformerLayer
+from deeplabcut.pose_estimation_pytorch.models.necks.utils import (
+ make_sine_position_embedding,
+)
+
+MIN_NUM_PATCHES = 16
+BN_MOMENTUM = 0.1
+
+
+@NECKS.register_module
+class Transformer(BaseNeck):
+ """Transformer Neck for pose estimation. title={TokenPose: Learning Keypoint Tokens
+ for Human Pose Estimation}, author={Yanjie Li and Shoukui Zhang and Zhicheng Wang
+ and Sen Yang and Wankou Yang and Shu-Tao Xia and Erjin Zhou}, booktitle={IEEE/CVF
+ International Conference on Computer Vision (ICCV)}, year={2021}
+
+ Args:
+ feature_size: Size of the input feature map (height, width).
+ patch_size: Size of each patch used in the transformer.
+ num_keypoints: Number of keypoints in the pose estimation task.
+ dim: Dimension of the transformer.
+ depth: Number of transformer layers.
+ heads: Number of self-attention heads in the transformer.
+ mlp_dim: Dimension of the MLP used in the transformer.
+ Defaults to 3.
+ apply_init: Whether to apply weight initialization.
+ Defaults to False.
+ heatmap_size: Size of the heatmap. Defaults to [64, 64].
+ channels: Number of channels in each patch. Defaults to 32.
+ dropout: Dropout rate for embeddings. Defaults to 0.0.
+ emb_dropout: Dropout rate for transformer layers.
+ Defaults to 0.0.
+ pos_embedding_type: Type of positional embedding.
+ Either 'sine-full', 'sine', or 'learnable'.
+ Defaults to "sine-full".
+
+ Examples:
+ # Creating a Transformer neck with sine positional embedding
+ transformer = Transformer(
+ feature_size=(128, 128),
+ patch_size=(16, 16),
+ num_keypoints=17,
+ dim=256,
+ depth=6,
+ heads=8,
+ pos_embedding_type="sine"
+ )
+
+ # Creating a Transformer neck with learnable positional embedding
+ transformer = Transformer(
+ feature_size=(256, 256),
+ patch_size=(32, 32),
+ num_keypoints=17,
+ dim=512,
+ depth=12,
+ heads=16,
+ pos_embedding_type="learnable"
+ )
+ """
+
+ def __init__(
+ self,
+ *,
+ feature_size: tuple[int, int],
+ patch_size: tuple[int, int],
+ num_keypoints: int,
+ dim: int,
+ depth: int,
+ heads: int,
+ mlp_dim: int = 3,
+ apply_init: bool = False,
+ heatmap_size: tuple[int, int] = (64, 64),
+ channels: int = 32,
+ dropout: float = 0.0,
+ emb_dropout: float = 0.0,
+ pos_embedding_type: str = "sine-full",
+ ):
+ super().__init__()
+
+ num_patches = (feature_size[0] // (patch_size[0])) * (feature_size[1] // (patch_size[1]))
+ patch_dim = channels * patch_size[0] * patch_size[1]
+
+ self.inplanes = 64
+ self.patch_size = patch_size
+ self.heatmap_size = heatmap_size
+ self.num_keypoints = num_keypoints
+ self.num_patches = num_patches
+ self.pos_embedding_type = pos_embedding_type
+ self.all_attn = self.pos_embedding_type == "sine-full"
+
+ self.keypoint_token = torch.nn.Parameter(torch.zeros(1, self.num_keypoints, dim))
+ h, w = (
+ feature_size[0] // (self.patch_size[0]),
+ feature_size[1] // (self.patch_size[1]),
+ )
+
+ self._make_position_embedding(w, h, dim, pos_embedding_type)
+
+ self.patch_to_embedding = torch.nn.Linear(patch_dim, dim)
+ self.dropout = torch.nn.Dropout(emb_dropout)
+
+ self.transformer1 = TransformerLayer(
+ dim,
+ depth,
+ heads,
+ mlp_dim,
+ dropout,
+ num_keypoints=num_keypoints,
+ scale_with_head=True,
+ )
+ self.transformer2 = TransformerLayer(
+ dim,
+ depth,
+ heads,
+ mlp_dim,
+ dropout,
+ num_keypoints=num_keypoints,
+ all_attn=self.all_attn,
+ scale_with_head=True,
+ )
+ self.transformer3 = TransformerLayer(
+ dim,
+ depth,
+ heads,
+ mlp_dim,
+ dropout,
+ num_keypoints=num_keypoints,
+ all_attn=self.all_attn,
+ scale_with_head=True,
+ )
+
+ self.to_keypoint_token = torch.nn.Identity()
+
+ if apply_init:
+ self.apply(self._init_weights)
+
+ def _make_position_embedding(self, w: int, h: int, d_model: int, pe_type="learnable"):
+ """Create position embeddings for the transformer.
+
+ Args:
+ w: Width of the input feature map.
+ h: Height of the input feature map.
+ d_model: Dimension of the transformer encoder.
+ pe_type: Type of position embeddings.
+ Either "learnable" or "sine". Defaults to "learnable".
+ """
+ with torch.no_grad():
+ self.pe_h = h
+ self.pe_w = w
+ h * w
+ if pe_type != "learnable":
+ self.pos_embedding = torch.nn.Parameter(make_sine_position_embedding(h, w, d_model), requires_grad=False)
+ else:
+ self.pos_embedding = torch.nn.Parameter(torch.zeros(1, self.num_patches + self.num_keypoints, d_model))
+
+ def _make_layer(self, block: torch.nn.Module, planes: int, blocks: int, stride: int = 1) -> torch.nn.Sequential:
+ """Create a layer of the transformer encoder.
+
+ Args:
+ block: The basic building block of the layer.
+ planes: Number of planes in the layer.
+ blocks: Number of blocks in the layer.
+ stride: Stride value. Defaults to 1.
+
+ Returns:
+ The layer of the transformer encoder.
+ """
+ downsample = None
+ if stride != 1 or self.inplanes != planes * block.expansion:
+ downsample = torch.nn.Sequential(
+ torch.nn.Conv2d(
+ self.inplanes,
+ planes * block.expansion,
+ kernel_size=1,
+ stride=stride,
+ bias=False,
+ ),
+ torch.nn.BatchNorm2d(planes * block.expansion, momentum=BN_MOMENTUM),
+ )
+
+ layers = []
+ layers.append(block(self.inplanes, planes, stride, downsample))
+ self.inplanes = planes * block.expansion
+ for _i in range(1, blocks):
+ layers.append(block(self.inplanes, planes))
+
+ return torch.nn.Sequential(*layers)
+
+ def _init_weights(self, m: torch.nn.Module):
+ """Initialize the weights of the model.
+
+ Args:
+ m: A module of the model.
+ """
+ print("Initialization...")
+ if isinstance(m, torch.nn.Linear):
+ trunc_normal_(m.weight, std=0.02)
+ if isinstance(m, torch.nn.Linear) and m.bias is not None:
+ torch.nn.init.constant_(m.bias, 0)
+ elif isinstance(m, torch.nn.LayerNorm):
+ torch.nn.init.constant_(m.bias, 0)
+ torch.nn.init.constant_(m.weight, 1.0)
+
+ def forward(self, feature: torch.Tensor, mask=None) -> torch.Tensor:
+ """Forward pass through the Transformer neck.
+
+ Args:
+ feature: Input feature map.
+ mask: Mask to apply to the transformer.
+ Defaults to None.
+
+ Returns:
+ Output tensor from the transformer neck.
+
+ Examples:
+ # Assuming feature is a torch.Tensor of shape (batch_size, channels, height, width)
+ output = transformer(feature)
+ """
+ p = self.patch_size
+
+ x = rearrange(feature, "b c (h p1) (w p2) -> b (h w) (p1 p2 c)", p1=p[0], p2=p[1])
+ x = self.patch_to_embedding(x)
+
+ b, n, _ = x.shape
+
+ keypoint_tokens = repeat(self.keypoint_token, "() n d -> b n d", b=b)
+ if self.pos_embedding_type in ["sine", "sine-full"]:
+ x += self.pos_embedding[:, :n]
+ x = torch.cat((keypoint_tokens, x), dim=1)
+ else:
+ x = torch.cat((keypoint_tokens, x), dim=1)
+ x += self.pos_embedding[:, : (n + self.num_keypoints)]
+ x = self.dropout(x)
+
+ x1 = self.transformer1(x, mask, self.pos_embedding)
+ x2 = self.transformer2(x1, mask, self.pos_embedding)
+ x3 = self.transformer3(x2, mask, self.pos_embedding)
+
+ x1_out = self.to_keypoint_token(x1[:, 0 : self.num_keypoints])
+ x2_out = self.to_keypoint_token(x2[:, 0 : self.num_keypoints])
+ x3_out = self.to_keypoint_token(x3[:, 0 : self.num_keypoints])
+
+ x = torch.cat((x1_out, x2_out, x3_out), dim=2)
+ return x
diff --git a/deeplabcut/pose_estimation_pytorch/models/necks/utils.py b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py
new file mode 100644
index 0000000000..bbcf81a939
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py
@@ -0,0 +1,56 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import math
+
+import torch
+
+
+def make_sine_position_embedding(
+ h: int, w: int, d_model: int, temperature: int = 10000, scale: float = 2 * math.pi
+) -> torch.Tensor:
+ """Generate sine position embeddings for a given height, width, and model dimension.
+
+ Args:
+ h: Height of the embedding.
+ w: Width of the embedding.
+ d_model: Dimension of the model.
+ temperature: Temperature parameter for position embedding calculation.
+ Defaults to 10000.
+ scale: Scaling factor for position embedding. Defaults to 2 * math.pi.
+
+ Returns:
+ Sine position embeddings with shape (batch_size, d_model, h * w).
+
+ Example:
+ >>> h, w, d_model = 10, 20, 512
+ >>> pos_emb = make_sine_position_embedding(h, w, d_model)
+ >>> print(pos_emb.shape) # Output: torch.Size([1, 512, 200])
+ """
+ area = torch.ones(1, h, w)
+ y_embed = area.cumsum(1, dtype=torch.float32)
+ x_embed = area.cumsum(2, dtype=torch.float32)
+ one_direction_feats = d_model // 2
+ eps = 1e-6
+ y_embed = y_embed / (y_embed[:, -1:, :] + eps) * scale
+ x_embed = x_embed / (x_embed[:, :, -1:] + eps) * scale
+
+ dim_t = torch.arange(one_direction_feats, dtype=torch.float32)
+ dim_t = temperature ** (2 * (dim_t // 2) / one_direction_feats)
+
+ pos_x = x_embed[:, :, :, None] / dim_t
+ pos_y = y_embed[:, :, :, None] / dim_t
+ pos_x = torch.stack((pos_x[:, :, :, 0::2].sin(), pos_x[:, :, :, 1::2].cos()), dim=4).flatten(3)
+ pos_y = torch.stack((pos_y[:, :, :, 0::2].sin(), pos_y[:, :, :, 1::2].cos()), dim=4).flatten(3)
+ pos = torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2)
+ pos = pos.flatten(2).permute(0, 2, 1)
+
+ return pos
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py
new file mode 100644
index 0000000000..59b10222a9
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/__init__.py
@@ -0,0 +1,29 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.predictors.base import (
+ PREDICTORS,
+ BasePredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors.dekr_predictor import (
+ DEKRPredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors.identity_predictor import (
+ IdentityPredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors.paf_predictor import (
+ PartAffinityFieldPredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors.sim_cc import (
+ SimCCPredictor,
+)
+from deeplabcut.pose_estimation_pytorch.models.predictors.single_predictor import (
+ HeatmapPredictor,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/base.py b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py
new file mode 100644
index 0000000000..42c9fb5864
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.py
@@ -0,0 +1,64 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch
+from torch import nn
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+PREDICTORS = Registry("predictors", build_func=build_from_cfg)
+
+
+class BasePredictor(ABC, nn.Module):
+ """The base Predictor class.
+
+ This class is an abstract base class (ABC) for defining predictors used in the DeepLabCut Toolbox.
+ All predictor classes should inherit from this base class and implement the forward method.
+ Regresses keypoint coordinates from a models output maps
+
+ Attributes:
+ num_animals: Number of animals in the project. Should be set in subclasses.
+
+ Example:
+ # Create a subclass that inherits from BasePredictor and implements the forward method.
+ class MyPredictor(BasePredictor):
+ def __init__(self, num_animals):
+ super().__init__()
+ self.num_animals = num_animals
+
+ def forward(self, outputs):
+ # Implement the forward pass of your custom predictor here.
+ pass
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.num_animals = None
+
+ @abstractmethod
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ """Abstract method for the forward pass of the Predictor.
+
+ Args:
+ stride: the stride of the model
+ outputs: outputs of the model heads
+
+ Returns:
+ A dictionary containing a "poses" key with the output tensor as value, and
+ optionally a "unique_bodyparts" with the unique bodyparts tensor as value.
+
+ Raises:
+ NotImplementedError: This method must be implemented in subclasses.
+ """
+ pass
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py
new file mode 100644
index 0000000000..0ef0ea81c8
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py
@@ -0,0 +1,414 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from __future__ import annotations
+
+import torch
+import torch.nn.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.predictors import (
+ PREDICTORS,
+ BasePredictor,
+)
+
+
+@PREDICTORS.register_module
+class DEKRPredictor(BasePredictor):
+ """DEKR Predictor class for multi-animal pose estimation.
+
+ This class regresses keypoints and assembles them (if multianimal project)
+ from the output of DEKR (Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression).
+ Based on:
+ Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression
+ Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang
+ CVPR
+ 2021
+ Code based on:
+ https://github.com/HRNet/DEKR
+
+ Args:
+ num_animals (int): Number of animals in the project.
+ detection_threshold (float, optional): Threshold for detection. Defaults to 0.01.
+ apply_sigmoid (bool, optional): Apply sigmoid to heatmaps. Defaults to True.
+ use_heatmap (bool, optional): Use heatmap to refine keypoint predictions. Defaults to True.
+ keypoint_score_type (str): Type of score to compute for keypoints. "heatmap" applies the heatmap
+ score to each keypoint. "center" applies the score of the center of each individual to
+ all of its keypoints. "combined" multiplies the score of the heatmap and individual
+ center for each keypoint.
+
+ Attributes:
+ num_animals (int): Number of animals in the project.
+ detection_threshold (float): Threshold for detection.
+ apply_sigmoid (bool): Apply sigmoid to heatmaps.
+ use_heatmap (bool): Use heatmap.
+ keypoint_score_type (str): Type of score to compute for keypoints. "heatmap" applies the heatmap
+ score to each keypoint. "center" applies the score of the center of each individual to
+ all of its keypoints. "combined" multiplies the score of the heatmap and individual
+ center for each keypoint.
+
+ Example:
+ # Create a DEKRPredictor instance with 2 animals.
+ predictor = DEKRPredictor(num_animals=2)
+
+ # Make a forward pass with outputs and scale factors.
+ outputs = (heatmaps, offsets) # tuple of heatmaps and offsets
+ scale_factors = (0.5, 0.5) # tuple of scale factors for the poses
+ poses_with_scores = predictor.forward(outputs, scale_factors)
+ """
+
+ default_init = {"apply_sigmoid": True, "detection_threshold": 0.01}
+
+ def __init__(
+ self,
+ num_animals: int,
+ detection_threshold: float = 0.01,
+ apply_sigmoid: bool = True,
+ clip_scores: bool = False,
+ use_heatmap: bool = True,
+ keypoint_score_type: str = "combined",
+ max_absorb_distance: int = 75,
+ nms_threshold: float = 0.05,
+ apply_pose_nms: bool = True,
+ ):
+ """
+ Args:
+ num_animals: Number of animals in the project.
+ detection_threshold: Threshold for detection
+ apply_sigmoid: Apply sigmoid to heatmaps
+ clip_scores: If a sigmoid is not applied, this can be used to clip scores
+ for predicted keypoints to values in [0, 1].
+ use_heatmap: Use heatmap to refine the keypoint predictions.
+ keypoint_score_type: Type of score to compute for keypoints. "heatmap"
+ applies the heatmap score to each keypoint. "center" applies the score
+ of the center of each individual to all of its keypoints. "combined"
+ multiplies the score of the heatmap and individual for each keypoint.
+ nms_threshold: Threshold for NMS of pose.
+ apply_pose_nms: Whether to apply pose NMS
+ """
+ super().__init__()
+ self.num_animals = num_animals
+ self.detection_threshold = detection_threshold
+ self.apply_sigmoid = apply_sigmoid
+ self.clip_scores = clip_scores
+ self.use_heatmap = use_heatmap
+ self.keypoint_score_type = keypoint_score_type
+ if self.keypoint_score_type not in ("heatmap", "center", "combined"):
+ raise ValueError(f"Unknown keypoint score type: {self.keypoint_score_type}")
+
+ self.max_absorb_distance = max_absorb_distance
+ self.nms_threshold = nms_threshold
+ self.apply_pose_nms = apply_pose_nms
+
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ """Forward pass of DEKRPredictor.
+
+ Args:
+ stride: the stride of the model
+ outputs: outputs of the model heads (heatmap, locref)
+
+ Returns:
+ A dictionary containing a "poses" key with the output tensor as value, and
+ optionally a "unique_bodyparts" with the unique bodyparts tensor as value.
+
+ Example:
+ # Assuming you have 'outputs' (heatmaps and offsets) and 'scale_factors' for poses
+ poses_with_scores = predictor.forward(outputs, scale_factors)
+ """
+ heatmaps, offsets = outputs["heatmap"], outputs["offset"]
+ scale_factors = stride, stride
+
+ if self.apply_sigmoid:
+ heatmaps = F.sigmoid(heatmaps)
+
+ posemap = self.offset_to_pose(offsets)
+
+ batch_size, num_joints_with_center, h, w = heatmaps.shape
+ num_joints = num_joints_with_center - 1
+
+ center_heatmaps = heatmaps[:, -1]
+ pose_ind, ctr_scores = self.get_top_values(center_heatmaps)
+
+ posemap = posemap.permute(0, 2, 3, 1).view(batch_size, h * w, -1, 2)
+
+ batch_indices = torch.arange(batch_size, device=pose_ind.device)[:, None]
+ poses = posemap[batch_indices, pose_ind]
+
+ if self.use_heatmap:
+ poses = self._update_pose_with_heatmaps(poses, heatmaps[:, :-1])
+
+ if self.keypoint_score_type == "center":
+ score = ctr_scores.unsqueeze(-1).expand(batch_size, -1, num_joints).unsqueeze(-1)
+ elif self.keypoint_score_type == "heatmap":
+ score = self.get_heat_value(poses, heatmaps).unsqueeze(-1)
+ elif self.keypoint_score_type == "combined":
+ center_score = ctr_scores.unsqueeze(-1).expand(batch_size, -1, num_joints).unsqueeze(-1)
+ htmp_score = self.get_heat_value(poses, heatmaps).unsqueeze(-1)
+ score = center_score * htmp_score
+ else:
+ raise ValueError(f"Unknown keypoint score type: {self.keypoint_score_type}")
+
+ poses[:, :, :, 0] = poses[:, :, :, 0] * scale_factors[1] + 0.5 * scale_factors[1]
+ poses[:, :, :, 1] = poses[:, :, :, 1] * scale_factors[0] + 0.5 * scale_factors[0]
+
+ if self.clip_scores:
+ score = torch.clip(score, min=0, max=1)
+
+ poses_w_scores = torch.cat([poses, score], dim=3)
+ if self.apply_pose_nms:
+ poses_w_scores = self.pose_nms(poses_w_scores)
+
+ return {"poses": poses_w_scores}
+
+ def get_locations(self, height: int, width: int, device: torch.device) -> torch.Tensor:
+ """Get locations for offsets.
+
+ Args:
+ height: Height of the offsets.
+ width: Width of the offsets.
+ device: Device to use.
+
+ Returns:
+ Offset locations.
+
+ Example:
+ # Assuming you have 'height', 'width', and 'device'
+ locations = predictor.get_locations(height, width, device)
+ """
+ shifts_x = torch.arange(0, width, step=1, dtype=torch.float32).to(device)
+ shifts_y = torch.arange(0, height, step=1, dtype=torch.float32).to(device)
+ shift_y, shift_x = torch.meshgrid(shifts_y, shifts_x, indexing="ij")
+ shift_x = shift_x.reshape(-1)
+ shift_y = shift_y.reshape(-1)
+ locations = torch.stack((shift_x, shift_y), dim=1)
+ return locations
+
+ def get_reg_poses(self, offsets: torch.Tensor, num_joints: int) -> torch.Tensor:
+ """Get the regression poses from offsets.
+
+ Args:
+ offsets: Offsets tensor.
+ num_joint: Number of joints.
+
+ Returns:
+ Regression poses.
+
+ Example:
+ # Assuming you have 'offsets' tensor and 'num_joints'
+ regression_poses = predictor.get_reg_poses(offsets, num_joints)
+ """
+ batch_size, _, h, w = offsets.shape
+ offsets = offsets.permute(0, 2, 3, 1).reshape(batch_size, h * w, num_joints, 2)
+ locations = self.get_locations(h, w, offsets.device)
+ locations = locations[None, :, None, :].expand(batch_size, -1, num_joints, -1)
+ poses = locations - offsets
+
+ return poses
+
+ def offset_to_pose(self, offsets: torch.Tensor) -> torch.Tensor:
+ """Convert offsets to poses.
+
+ Args:
+ offsets: Offsets tensor.
+
+ Returns:
+ Poses from offsets.
+
+ Example:
+ # Assuming you have 'offsets' tensor
+ poses = predictor.offset_to_pose(offsets)
+ """
+ batch_size, num_offset, h, w = offsets.shape
+ num_joints = int(num_offset / 2)
+ reg_poses = self.get_reg_poses(offsets, num_joints)
+
+ reg_poses = reg_poses.contiguous().view(batch_size, h * w, 2 * num_joints).permute(0, 2, 1)
+ reg_poses = reg_poses.contiguous().view(batch_size, -1, h, w).contiguous()
+
+ return reg_poses
+
+ def max_pool(self, heatmap: torch.Tensor) -> torch.Tensor:
+ """Apply max pooling to the heatmap.
+
+ Args:
+ heatmap: Heatmap tensor.
+
+ Returns:
+ Max pooled heatmap.
+
+ Example:
+ # Assuming you have 'heatmap' tensor
+ max_pooled_heatmap = predictor.max_pool(heatmap)
+ """
+ torch.nn.MaxPool2d(3, 1, 1)
+ pool2 = torch.nn.MaxPool2d(5, 1, 2)
+ torch.nn.MaxPool2d(7, 1, 3)
+ (heatmap.shape[1] + heatmap.shape[2]) / 2.0
+ maxm = pool2(heatmap) # Here I think pool 2 is a good match for default 17 pos_dist_tresh
+
+ return maxm
+
+ def get_top_values(self, heatmap: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
+ """Get top values from the heatmap.
+
+ Args:
+ heatmap: Heatmap tensor.
+
+ Returns:
+ Position indices and scores.
+
+ Example:
+ # Assuming you have 'heatmap' tensor
+ positions, scores = predictor.get_top_values(heatmap)
+ """
+ maximum = self.max_pool(heatmap)
+ maximum = torch.eq(maximum, heatmap)
+ heatmap *= maximum
+
+ batchsize, ny, nx = heatmap.shape
+ heatmap_flat = heatmap.reshape(batchsize, nx * ny)
+
+ scores, pos_ind = torch.topk(heatmap_flat, self.num_animals, dim=1)
+
+ return pos_ind, scores
+
+ def _update_pose_with_heatmaps(self, _poses: torch.Tensor, kpt_heatmaps: torch.Tensor):
+ """If a heatmap center is close enough from the regressed point, the final
+ prediction is the center of this heatmap.
+
+ Args:
+ poses: poses tensor, shape (batch_size, num_animals, num_keypoints, 2)
+ kpt_heatmaps: heatmaps (does not contain the center heatmap), shape (batch_size, num_keypoints, h, w)
+ """
+ poses = _poses.clone()
+ maxm = self.max_pool(kpt_heatmaps)
+ maxm = torch.eq(maxm, kpt_heatmaps).float()
+ kpt_heatmaps *= maxm
+ batch_size, num_keypoints, h, w = kpt_heatmaps.shape
+ kpt_heatmaps = kpt_heatmaps.view(batch_size, num_keypoints, -1)
+ _val_k, ind = kpt_heatmaps.topk(self.num_animals, dim=2)
+
+ x = ind % w
+ y = (ind / w).long()
+ heats_ind = torch.stack((x, y), dim=3) # (batch_size, num_keypoints, num_animals, 2)
+
+ # Calculate differences between all pose-heat pairs
+ # (batch_size, num_animals, num_keypoints, 1, 2) - (batch_size, 1, num_keypoints, num_animals, 2)
+ pose_heat_diff = poses.unsqueeze(3) - heats_ind.unsqueeze(
+ 1
+ ) # (batch_size, num_animals, num_keypoints, num_animals, 2)
+
+ pose_heat_dist = torch.norm(pose_heat_diff, dim=-1) # (batch_size, num_animals, num_keypoints, num_animals)
+
+ # Find closest heat point for each pose
+ keep_ind = torch.argmin(pose_heat_dist, dim=-1) # (batch_size, num_animals, num_keypoints)
+
+ # Get minimum distances for filtering
+ min_distances = torch.gather(pose_heat_dist, 3, keep_ind.unsqueeze(-1)).squeeze(
+ -1
+ ) # (batch_size, num_animals, num_keypoints)
+
+ absorb_mask = min_distances < self.max_absorb_distance # (batch_size, num_animals, num_keypoints)
+
+ # Create indices for gathering the correct heat points
+ batch_indices = torch.arange(batch_size, device=poses.device).view(-1, 1, 1)
+ keypoint_indices = torch.arange(num_keypoints, device=poses.device).view(1, 1, -1)
+
+ selected_heat_points = heats_ind[
+ batch_indices, keypoint_indices, keep_ind
+ ] # (batch_size, num_animals, num_keypoints, 2)
+
+ poses = torch.where(absorb_mask.unsqueeze(-1), selected_heat_points, poses)
+
+ return poses
+
+ def get_heat_value(self, pose_coords: torch.Tensor, heatmaps: torch.Tensor) -> torch.Tensor:
+ """Get heat values for pose coordinates and heatmaps.
+
+ Args:
+ pose_coords: Pose coordinates tensor (batch_size, num_animals, num_joints, 2)
+ heatmaps: Heatmaps tensor (batch_size, 1+num_joints, h, w).
+
+ Returns:
+ Heat values.
+
+ Example:
+ # Assuming you have 'pose_coords' and 'heatmaps' tensors
+ heat_values = predictor.get_heat_value(pose_coords, heatmaps)
+ """
+ h, w = heatmaps.shape[2:]
+ heatmaps_nocenter = heatmaps[:, :-1].flatten(2, 3) # (batch_size, num_joints, h*w)
+
+ # Predicted poses based on the offset can be outside the image
+ x = torch.clamp(torch.floor(pose_coords[:, :, :, 0]), 0, w - 1).long()
+ y = torch.clamp(torch.floor(pose_coords[:, :, :, 1]), 0, h - 1).long()
+ keypoint_poses = (y * w + x).mT # (batch, num_joints, num_individuals)
+ scores = torch.gather(heatmaps_nocenter, 2, keypoint_poses)
+ return scores.mT # (batch, num_individuals, num_joints)
+
+ def pose_nms(self, poses: torch.Tensor) -> torch.Tensor:
+ """Non-Maximum Suppression (NMS) for regressed poses.
+
+ Args:
+ poses: Pose proposals of shape (batch_size, num_people, num_joints, 3).
+ The poses for each element in the batch should be sorted by score (the
+ highest score prediction should be first).
+
+ Returns:
+ Pose proposals after non-maximum suppression.
+ """
+ batch_size, num_people, num_joints, _ = poses.shape
+ device = poses.device
+ if num_people == 0:
+ return poses
+
+ xy = poses[:, :, :, :2]
+ w = xy[..., 0].max(dim=-1)[0] - xy[..., 0].min(dim=-1)[0]
+ h = xy[..., 1].max(dim=-1)[0] - xy[..., 1].min(dim=-1)[0]
+ area = torch.clamp((w * w) + (h * h), min=1)
+ area = area.unsqueeze(1).unsqueeze(3).expand(batch_size, num_people, num_people, num_joints)
+
+ # compute the difference between keypoints
+ pose_diff = xy.unsqueeze(2) - xy.unsqueeze(1)
+ pose_diff.pow_(2)
+
+ # Compute error between people pairs
+ pose_dist = pose_diff.sum(dim=-1)
+ pose_dist.sqrt_()
+
+ pose_thresh = self.nms_threshold * torch.sqrt(area)
+ pose_dist = (pose_dist < pose_thresh).sum(dim=-1)
+ nms_pose = pose_dist > self.nms_threshold # shape (b, num_people, num_people)
+
+ # Upper triangular mask matrix to avoid double processing
+ triu_mask = torch.triu(torch.ones(num_people, num_people, device=device), diagonal=1).bool()
+
+ suppress_pairs = nms_pose & triu_mask.unsqueeze(0) # (batch_size, num_people, num_people)
+
+ # For each batch, determine which poses to suppress
+ suppressed = suppress_pairs.any(dim=1) # (batch_size, num_people)
+
+ kept = ~suppressed # (batch_size, num_people)
+
+ # Indices for reordering
+ batch_indices = torch.arange(batch_size, device=device).unsqueeze(1)
+ people_indices = torch.arange(num_people, device=device).unsqueeze(0).expand(batch_size, -1)
+
+ # non-suppressed first, then suppressed
+ sort_keys = kept.float() + (people_indices.float() + 1) / (num_people + 1)
+ _, sort_indices = torch.sort(sort_keys, dim=1, descending=True)
+
+ # Mask out suppressed predictions
+ poses[~kept] = -1
+
+ # Re-order predictions so the non-suppressed ones are up top
+ poses = poses[batch_indices, sort_indices]
+
+ return poses
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py
new file mode 100644
index 0000000000..a70d06c213
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/identity_predictor.py
@@ -0,0 +1,67 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Predictor to generate identity maps from head outputs."""
+
+import torch
+import torch.nn as nn
+import torchvision.transforms.functional as F
+
+from deeplabcut.pose_estimation_pytorch.models.predictors.base import (
+ PREDICTORS,
+ BasePredictor,
+)
+
+
+@PREDICTORS.register_module
+class IdentityPredictor(BasePredictor):
+ """Predictor to generate identity maps from head outputs.
+
+ Attributes:
+ apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True.
+ """
+
+ def __init__(self, apply_sigmoid: bool = True):
+ """
+ Args:
+ apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True.
+ """
+ super().__init__()
+ self.apply_sigmoid = apply_sigmoid
+ self.sigmoid = nn.Sigmoid()
+
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ """Swaps the dimensions so the heatmap are (batch_size, h, w, num_individuals),
+ optionally applies a sigmoid to the heatmaps, and rescales it to be the size of
+ the original image (so that the identity scores of keypoints can be computed)
+
+ Args:
+ stride: the stride of the model
+ outputs: output of the model identity head, of shape (b, num_idv, w', h')
+
+ Returns:
+ A dictionary containing a "heatmap" key with the identity heatmap tensor as
+ value.
+ """
+ heatmaps = outputs["heatmap"]
+ h_out, w_out = heatmaps.shape[2:]
+ h_in, w_in = int(h_out * stride), int(w_out * stride)
+ heatmaps = F.resize(
+ heatmaps,
+ size=[h_in, w_in],
+ interpolation=F.InterpolationMode.BILINEAR,
+ antialias=True,
+ )
+ if self.apply_sigmoid:
+ heatmaps = self.sigmoid(heatmaps)
+
+ # permute to have shape (batch_size, h, w, num_individuals)
+ heatmaps = heatmaps.permute((0, 2, 3, 1))
+ return {"heatmap": heatmaps}
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py
new file mode 100644
index 0000000000..305195d628
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py
@@ -0,0 +1,529 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from collections import defaultdict
+
+import numpy as np
+import torch
+import torch.nn.functional as F
+from numpy.typing import NDArray
+
+from deeplabcut.core import inferenceutils
+from deeplabcut.pose_estimation_pytorch.models.predictors.base import (
+ PREDICTORS,
+ BasePredictor,
+)
+
+Graph = list[tuple[int, int]]
+
+
+@PREDICTORS.register_module
+class PartAffinityFieldPredictor(BasePredictor):
+ """Predictor class for multiple animal pose estimation with part affinity fields.
+
+ Args:
+ num_animals: Number of animals in the project.
+ num_multibodyparts: Number of animal's body parts (ignoring unique body parts).
+ num_uniquebodyparts: Number of unique body parts. # FIXME - should not be needed here if we separate the unique
+ bodypart head
+ graph: Part affinity field graph edges.
+ edges_to_keep: List of indices in `graph` of the edges to keep.
+ locref_stdev: Standard deviation for location refinement.
+ nms_radius: Radius of the Gaussian kernel.
+ sigma: Width of the 2D Gaussian distribution.
+ min_affinity: Minimal edge affinity to add a body part to an Assembly.
+
+ Returns:
+ Regressed keypoints from heatmaps, locref_maps and part affinity fields, as in Tensorflow maDLC.
+ """
+
+ default_init = {
+ "locref_stdev": 7.2801,
+ "nms_radius": 5,
+ "sigma": 1,
+ "min_affinity": 0.05,
+ }
+
+ def __init__(
+ self,
+ num_animals: int,
+ num_multibodyparts: int,
+ num_uniquebodyparts: int,
+ graph: Graph,
+ edges_to_keep: list[int],
+ locref_stdev: float,
+ nms_radius: int,
+ sigma: float,
+ min_affinity: float,
+ add_discarded: bool = False,
+ apply_sigmoid: bool = True,
+ clip_scores: bool = False,
+ force_fusion: bool = False,
+ return_preds: bool = False,
+ ):
+ """Initialize the PartAffinityFieldPredictor class.
+
+ Args:
+ num_animals: Number of animals in the project.
+ num_multibodyparts: Number of animal's body parts (ignoring unique body parts).
+ num_uniquebodyparts: Number of unique body parts.
+ graph: Part affinity field graph edges.
+ edges_to_keep: List of indices in `graph` of the edges to keep.
+ locref_stdev: Standard deviation for location refinement.
+ nms_radius: Radius of the Gaussian kernel.
+ sigma: Width of the 2D Gaussian distribution.
+ min_affinity: Minimal edge affinity to add a body part to an Assembly.
+ return_preds: Whether to return predictions alongside the animals' poses
+
+ Returns:
+ None
+ """
+ super().__init__()
+ self.num_animals = num_animals
+ self.num_multibodyparts = num_multibodyparts
+ self.num_uniquebodyparts = num_uniquebodyparts
+ self.graph = graph
+ self.edges_to_keep = edges_to_keep
+ self.locref_stdev = locref_stdev
+ self.nms_radius = nms_radius
+ self.return_preds = return_preds
+ self.sigma = sigma
+ self.apply_sigmoid = apply_sigmoid
+ self.clip_scores = clip_scores
+ self.sigmoid = torch.nn.Sigmoid()
+ self.assembler = inferenceutils.Assembler.empty(
+ num_animals,
+ n_multibodyparts=num_multibodyparts,
+ n_uniquebodyparts=num_uniquebodyparts,
+ graph=graph,
+ paf_inds=edges_to_keep,
+ min_affinity=min_affinity,
+ add_discarded=add_discarded,
+ force_fusion=force_fusion,
+ )
+
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ """Forward pass of PartAffinityFieldPredictor. Gets predictions from model
+ output.
+
+ Args:
+ stride: the stride of the model
+ outputs: Output tensors from previous layers.
+ output = heatmaps, locref, pafs
+ heatmaps: torch.Tensor([batch_size, num_joints, height, width])
+ locref: torch.Tensor([batch_size, num_joints, height, width])
+
+ Returns:
+ A dictionary containing a "poses" key with the output tensor as value.
+
+ Example:
+ >>> predictor = PartAffinityFieldPredictor(num_animals=3, location_refinement=True, locref_stdev=7.2801)
+ >>> output = (torch.rand(32, 17, 64, 64), torch.rand(32, 34, 64, 64), torch.rand(32, 136, 64, 64))
+ >>> stride = 8
+ >>> poses = predictor.forward(stride, output)
+ """
+ heatmaps = outputs["heatmap"] # (batch_size, num_joints, height, width)
+ locrefs = outputs["locref"] # (batch_size, num_joints*2, height, width)
+ pafs = outputs["paf"] # (batch_size, num_edges*2, height, width)
+ scale_factors = stride, stride
+ batch_size, n_channels, height, width = heatmaps.shape
+
+ if self.apply_sigmoid:
+ heatmaps = self.sigmoid(heatmaps)
+
+ # Filter predicted heatmaps with a 2D Gaussian kernel as in:
+ # https://openaccess.thecvf.com/content_CVPR_2020/papers/Huang_The_Devil_Is_in_the_Details_Delving_Into_Unbiased_Data_CVPR_2020_paper.pdf
+ kernel = self.make_2d_gaussian_kernel(sigma=self.sigma, size=self.nms_radius * 2 + 1)[None, None]
+ kernel = kernel.repeat(n_channels, 1, 1, 1).to(heatmaps.device)
+ heatmaps = F.conv2d(heatmaps, kernel, stride=1, padding="same", groups=n_channels)
+
+ peaks = self.find_local_peak_indices_maxpool_nms(
+ heatmaps, self.nms_radius, threshold=0.01
+ ) # (n_peaks, 4) -> columns: (batch, part, height, width)
+ if ~torch.any(peaks):
+ poses = -torch.ones((batch_size, self.num_animals, self.num_multibodyparts, 5))
+ results = dict(poses=poses)
+ if self.return_preds:
+ results["preds"] = ([dict(coordinates=[[]], costs=[])],)
+
+ return results
+
+ locrefs = locrefs.reshape(batch_size, n_channels, 2, height, width)
+ locrefs = locrefs * self.locref_stdev # (batch_size, num_joints, 2, height, width)
+ pafs = pafs.reshape(batch_size, -1, 2, height, width) # (batch_size, num_edges, 2, height, width)
+
+ # Use only the minimal tree edges for efficiency
+ graph = [self.graph[ind] for ind in self.edges_to_keep]
+ # Compute refined peak coords + PAF line-integral costs
+ preds = self.compute_peaks_and_costs(
+ heatmaps,
+ locrefs,
+ pafs,
+ peaks,
+ graph,
+ self.edges_to_keep,
+ scale_factors,
+ n_id_channels=0, # FIXME Handle identity training
+ )
+ # Initialize output tensors
+ poses = -torch.ones(
+ (batch_size, self.num_animals, self.num_multibodyparts, 5)
+ ) # (batch_size, num_animals, num_joints, [x, y, prob, id, affinity])
+ poses_unique = -torch.ones(
+ (batch_size, 1, self.num_uniquebodyparts, 4)
+ ) # (batch_size, 1, num_unique_joints, [x, y, prob, id])
+ # Greedy bipartite assembly per frame
+ for i, data_dict in enumerate(preds):
+ assemblies, unique = self.assembler._assemble(data_dict, ind_frame=0)
+ if assemblies is not None:
+ for j, assembly in enumerate(assemblies):
+ poses[i, j, :, :4] = torch.from_numpy(assembly.data)
+ poses[i, j, :, 4] = assembly.affinity
+ if unique is not None:
+ poses_unique[i, 0, :, :4] = torch.from_numpy(unique)
+
+ if self.clip_scores:
+ poses[..., 2] = torch.clip(poses[..., 2], min=0, max=1)
+
+ out = {"poses": poses}
+ if self.return_preds:
+ out["preds"] = preds
+ return out
+
+ @staticmethod
+ def find_local_peak_indices_maxpool_nms(input_: torch.Tensor, radius: int, threshold: float) -> torch.Tensor:
+ pooled = F.max_pool2d(input_, kernel_size=radius, stride=1, padding=radius // 2)
+ maxima = input_ * torch.eq(input_, pooled).float()
+ peak_indices = torch.nonzero(maxima >= threshold, as_tuple=False)
+ return peak_indices.int()
+
+ @staticmethod
+ def make_2d_gaussian_kernel(sigma: float, size: int) -> torch.Tensor:
+ k = torch.arange(-size // 2 + 1, size // 2 + 1, dtype=torch.float32) ** 2
+ k = F.softmax(-k / (2 * (sigma**2)), dim=0)
+ return torch.einsum("i,j->ij", k, k)
+
+ @staticmethod
+ def calc_peak_locations(
+ locrefs: torch.Tensor,
+ peak_inds_in_batch: torch.Tensor,
+ strides: tuple[float, float],
+ ) -> torch.Tensor:
+ """Refine peak coordinates to input-image pixels using locrefs and stride."""
+ s, b, r, c = peak_inds_in_batch.T
+ stride_y, stride_x = strides
+ strides = torch.Tensor((stride_x, stride_y)).to(locrefs.device)
+ off = locrefs[s, b, :, r, c]
+ loc = strides * peak_inds_in_batch[:, [3, 2]] + strides // 2 + off
+ return loc
+
+ @staticmethod
+ def compute_edge_costs(
+ pafs: torch.Tensor,
+ peak_inds: torch.Tensor,
+ graph: Graph,
+ paf_limb_inds: list[int],
+ n_bodyparts: int,
+ n_points: int = 10,
+ n_decimals: int = 3,
+ ) -> list[dict[int, NDArray]]:
+ """Compute PAF line-integral affinities per limb.
+
+ Args:
+ pafs: Part Affinity Fields tensor with shape (batch_size, num_edges, 2, height, width).
+ Contains vector fields representing limb orientations between body parts.
+ peak_inds: Peak indices array with shape (n_peaks, 4) containing
+ [batch_index, bodypart_index, row, col] for each detected peak.
+ graph: List of tuples representing edges in the pose graph. Each tuple contains
+ (source_bodypart_index, target_bodypart_index).
+ paf_limb_inds: List of indices specifying which edges from the graph to use for
+ PAF computation. Length should match the number of PAF channels.
+ n_bodyparts: Total number of body parts in the pose model.
+ n_points: Number of points to sample along each limb segment for PAF integration.
+ Default is 10.
+ n_decimals: Number of decimal places to round affinity and distance values.
+ Default is 3.
+
+ Returns:
+ List of per-image cost dictionaries, one for each image in the batch. Each
+ dictionary maps PAF edge indices to cost matrices with keys:
+ - "m1": Affinity matrix with shape (n_source_peaks, n_target_peaks)
+ - "distance": Distance matrix with shape (n_source_peaks, n_target_peaks)
+ """
+ device = pafs.device
+ graph = torch.tensor(graph, dtype=torch.long, device=device).T # (2, num_edges)
+ paf_limb_inds = torch.tensor(paf_limb_inds, dtype=torch.long, device=device)
+ batch_size = pafs.shape[0]
+ h, w = pafs.shape[-2:]
+
+ # Clip peak locations to PAF map bounds
+ peak_inds[:, 2] = torch.clamp(peak_inds[:, 2], 0, h - 1)
+ peak_inds[:, 3] = torch.clamp(peak_inds[:, 3], 0, w - 1)
+
+ peak_batches = peak_inds[:, 0]
+ peak_bodyparts = peak_inds[:, 1]
+ peak_rows = peak_inds[:, 2]
+ peak_cols = peak_inds[:, 3]
+
+ src_bodypart_id = graph[0] # (n_edges,)
+ dst_bodypart_id = graph[1] # (n_edges,)
+
+ # Process each batch separately to reduce memory usage
+ all_edge_idx = []
+ all_src_idx = []
+ all_dst_idx = []
+ all_batch_inds = []
+
+ for batch_idx in range(batch_size):
+ # Get peaks for this batch only
+ batch_mask = peak_batches == batch_idx
+ if not torch.any(batch_mask):
+ continue
+
+ batch_peak_indices = torch.nonzero(batch_mask, as_tuple=False).squeeze(-1)
+ batch_bodyparts = peak_bodyparts[batch_mask]
+
+ # Masks of peaks that match each edge's source/dest bodypart for this batch
+ src_mask = batch_bodyparts.unsqueeze(0) == src_bodypart_id.unsqueeze(1) # (n_edges, n_batch_peaks)
+ dst_mask = batch_bodyparts.unsqueeze(0) == dst_bodypart_id.unsqueeze(1) # (n_edges, n_batch_peaks)
+
+ # Valid src/dst peaks for each edge in this batch: (n_edges, n_batch_peaks, n_batch_peaks)
+ valid_pairs = src_mask.unsqueeze(2) & dst_mask.unsqueeze(1)
+
+ # Indices of all valid pairs for this batch
+ edge_idx, src_idx, dst_idx = valid_pairs.nonzero(as_tuple=True)
+
+ if len(edge_idx) > 0:
+ # Map back to original peak indices
+ src_idx = batch_peak_indices[src_idx]
+ dst_idx = batch_peak_indices[dst_idx]
+
+ all_edge_idx.append(edge_idx)
+ all_src_idx.append(src_idx)
+ all_dst_idx.append(dst_idx)
+ all_batch_inds.append(torch.full_like(edge_idx, batch_idx))
+
+ if not all_edge_idx:
+ return [{} for _ in range(batch_size)]
+
+ # Concatenate results from all batches
+ edge_idx = torch.cat(all_edge_idx)
+ src_idx = torch.cat(all_src_idx)
+ dst_idx = torch.cat(all_dst_idx)
+ batch_inds = torch.cat(all_batch_inds)
+
+ edge_idx = paf_limb_inds[edge_idx] # Map back to original PAF indices
+
+ # Gather coordinates
+ src_coords = torch.stack([peak_rows[src_idx], peak_cols[src_idx]], dim=1) # (found_pairs, 2)
+ dst_coords = torch.stack([peak_rows[dst_idx], peak_cols[dst_idx]], dim=1) # (found_pairs, 2)
+
+ vecs_s = src_coords.float() # (found_pairs, 2)
+ vecs_t = dst_coords.float() # (found_pairs, 2)
+ vecs = vecs_t - vecs_s
+ lengths = torch.norm(vecs, dim=1)
+ lengths += torch.tensor(np.spacing(1, dtype=np.float32), device=device)
+
+ # Sample n_points along the segments
+ t_vals = torch.linspace(0, 1, n_points, device=device, dtype=torch.float32)
+ t_vals = t_vals.unsqueeze(0).unsqueeze(-1) # (1, n_points, 1)
+
+ # Interpolate points along each segment: (n_edges, n_points, 2)
+ xy = vecs_s.unsqueeze(1) + t_vals * (vecs_t - vecs_s).unsqueeze(1)
+ xy = xy.to(torch.int32)
+ xy[..., 0] = torch.clamp(xy[..., 0], 0, h - 1)
+ xy[..., 1] = torch.clamp(xy[..., 1], 0, w - 1)
+
+ # Gather PAF vectors at sampled pixels: (n_edges, n_points, 2)
+ y = pafs[
+ batch_inds.unsqueeze(1).expand(-1, n_points), # (n_edges, n_points)
+ edge_idx.unsqueeze(1).expand(-1, n_points), # (n_edges, n_points)
+ :, # both x and y components of each vector
+ xy[..., 0], # row coordinates
+ xy[..., 1], # col coordinates
+ ]
+
+ # Integrate PAF along segment using trapezoidal rule
+ xy_reversed = torch.flip(xy.float(), dims=[-1])
+ integ = torch.trapz(y, xy_reversed, dim=1) # (n_edges, 2)
+ affinities = torch.norm(integ, dim=1) # (n_edges,)
+ affinities = affinities / lengths
+ affinities = torch.round(affinities * (10**n_decimals)) / (10**n_decimals)
+ lengths = torch.round(lengths * (10**n_decimals)) / (10**n_decimals)
+
+ edge_idx = edge_idx.cpu().numpy()
+ src_idx = src_idx.cpu().numpy()
+ dst_idx = dst_idx.cpu().numpy()
+ batch_inds = batch_inds.cpu().numpy()
+ affinities = affinities.cpu().numpy()
+ lengths = lengths.cpu().numpy()
+ paf_limb_inds = paf_limb_inds.cpu().numpy()
+
+ # Form per-image, per-limb cost matrices for bipartite matching
+ order = np.lexsort((edge_idx, batch_inds))
+ batch_inds = batch_inds[order]
+ edge_idx = edge_idx[order]
+ src_idx = src_idx[order]
+ dst_idx = dst_idx[order]
+ affinities = affinities[order]
+ lengths = lengths[order]
+
+ # Run-length encode on (batch, limb) boundaries where (batch, limb) changes
+ change = np.empty(batch_inds.size, dtype=bool)
+ change[0] = True
+ change[1:] = (batch_inds[1:] != batch_inds[:-1]) | (edge_idx[1:] != edge_idx[:-1])
+ group_starts = np.flatnonzero(change)
+ # Add sentinel end
+ group_ends = np.r_[group_starts[1:], batch_inds.size]
+
+ # Build an index dict of slices for group lookup by (batch, limb)
+ batch_groups = defaultdict(list) # (batch)->list of (limb, start, end)
+ for st, en in zip(group_starts, group_ends, strict=False):
+ b = batch_inds[st]
+ k = edge_idx[st]
+ batch_groups[b].append((k, st, en))
+
+ paf_limb_inds = set(paf_limb_inds.tolist())
+
+ all_costs = []
+ for b in range(batch_size):
+ costs = {}
+ # find this batch's groups
+ for k, st, en in batch_groups.get(b, []):
+ if k not in paf_limb_inds:
+ continue
+ s = src_idx[st:en]
+ t = dst_idx[st:en]
+
+ n_s = np.unique(s).size
+ n_t = np.unique(t).size
+
+ m1 = affinities[st:en].reshape((n_s, n_t))
+ dist = lengths[st:en].reshape((n_s, n_t))
+
+ costs[k] = {"m1": m1, "distance": dist}
+
+ all_costs.append(costs)
+
+ return all_costs
+
+ @staticmethod
+ def _linspace(start: torch.Tensor, stop: torch.Tensor, num: int) -> torch.Tensor:
+ # Taken from https://github.com/pytorch/pytorch/issues/61292#issue-937937159
+ steps = torch.linspace(0, 1, num, dtype=torch.float32, device=start.device)
+ steps = steps.reshape([-1, *([1] * start.ndim)])
+ out = start[None] + steps * (stop - start)[None]
+ return out.swapaxes(0, 1)
+
+ def compute_peaks_and_costs(
+ self,
+ heatmaps: torch.Tensor,
+ locrefs: torch.Tensor,
+ pafs: torch.Tensor,
+ peak_inds_in_batch: torch.Tensor,
+ graph: Graph,
+ paf_limb_inds: list[int],
+ strides: tuple[float, float],
+ n_id_channels: int,
+ n_points: int = 10,
+ n_decimals: int = 3,
+ ) -> list[dict[str, NDArray]]:
+ """Compute refined peak coordinates, confidence scores, and PAF edge costs for
+ pose estimation.
+
+ Args:
+ heatmaps: Smoothed heatmaps tensor with shape (batch_size, num_joints, height, width).
+ Contains confidence scores for each body part at each spatial location.
+ locrefs: Location refinement maps with shape (batch_size, num_joints, 2, height, width).
+ Contains sub-pixel offset corrections for precise keypoint localization.
+ pafs: Part Affinity Fields tensor with shape (batch_size, num_edges, 2, height, width).
+ Contains vector fields representing limb orientations between body parts.
+ peak_inds_in_batch: Peak indices tensor with shape (n_peaks, 4) containing
+ [batch_index, bodypart_index, row, col] for each detected peak.
+ graph: List of tuples representing edges in the pose graph. Each tuple contains
+ (source_bodypart_index, target_bodypart_index).
+ paf_limb_inds: List of indices specifying which edges from the graph to use for
+ PAF computation. Length should match the number of PAF channels.
+ strides: Tuple of (stride_y, stride_x) representing the downsampling factor
+ from input image to feature maps.
+ n_id_channels: Number of identity channels in the heatmaps for individual
+ identification. These channels are located at the end of the heatmap tensor.
+ n_points: Number of points to sample along each limb segment for PAF integration.
+ Default is 10.
+ n_decimals: Number of decimal places to round coordinate and confidence values.
+ Default is 3.
+
+ Returns:
+ List of dictionaries, one per image in the batch. Each dictionary contains:
+ - "coordinates": Tuple containing a list of numpy arrays, one per body part.
+ Each array has shape (n_peaks_for_bodypart, 2) with [x, y] coordinates.
+ - "confidence": List of numpy arrays, one per body part. Each array has shape
+ (n_peaks_for_bodypart, 1) containing confidence scores.
+ - "costs": (Optional) Cost matrix for PAF edge connections between body parts.
+ Only present if PAF computation is successful.
+ - "identity": (Optional) List of numpy arrays containing identity features,
+ one per body part. Only present if n_id_channels > 0.
+ """
+ batch_size, n_channels = heatmaps.shape[:2]
+ n_bodyparts = n_channels - n_id_channels
+ # Refine peak positions to input-image pixels
+ pos = self.calc_peak_locations(locrefs, peak_inds_in_batch, strides) # (n_peaks, 2)
+
+ # Compute per-limb affinity matrices via PAF line integral
+ costs = self.compute_edge_costs(
+ pafs,
+ peak_inds_in_batch,
+ graph,
+ paf_limb_inds,
+ n_bodyparts,
+ n_points,
+ n_decimals,
+ )
+ s, b, r, c = peak_inds_in_batch.T
+ # Extract confidence at each peak from smoothed heatmap
+ prob = heatmaps[s, b, r, c].unsqueeze(-1)
+ if n_id_channels:
+ ids = heatmaps[s, -n_id_channels:, r, c]
+
+ peak_inds_in_batch = peak_inds_in_batch.cpu().numpy()
+ peaks_and_costs = []
+ pos = pos.cpu().numpy()
+ prob = prob.cpu().numpy()
+ for batch_idx in range(batch_size):
+ xy = []
+ p = []
+ id_ = []
+ samples_i_mask = peak_inds_in_batch[:, 0] == batch_idx
+ for j in range(n_bodyparts):
+ bpts_j_mask = peak_inds_in_batch[:, 1] == j
+ idx = np.flatnonzero(samples_i_mask & bpts_j_mask)
+ xy.append(pos[idx])
+ p.append(prob[idx])
+ if n_id_channels:
+ id_.append(ids[idx])
+ dict_ = {"coordinates": (xy,), "confidence": p}
+ if costs is not None:
+ dict_["costs"] = costs[batch_idx]
+ if n_id_channels:
+ dict_["identity"] = id_
+ peaks_and_costs.append(dict_)
+
+ return peaks_and_costs
+
+ def set_paf_edges_to_keep(self, edge_indices: list[int]) -> None:
+ """Sets the PAF edge indices to use to assemble individuals.
+
+ Args:
+ edge_indices: The indices of edges in the graph to keep.
+ """
+ self.edges_to_keep = edge_indices
+ self.assembler.paf_inds = edge_indices
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/sim_cc.py b/deeplabcut/pose_estimation_pytorch/models/predictors/sim_cc.py
new file mode 100644
index 0000000000..b32f2f45dc
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/sim_cc.py
@@ -0,0 +1,173 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""SimCC predictor for the RTMPose model.
+
+Based on the official ``mmpose`` SimCC codec and RTMCC head implementation. For more
+information, see .
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.predictors.base import (
+ PREDICTORS,
+ BasePredictor,
+)
+
+
+@PREDICTORS.register_module
+class SimCCPredictor(BasePredictor):
+ """Class used to make pose predictions from RTMPose head outputs.
+
+ The RTMPose model uses coordinate classification for pose estimation. For more
+ information, see "SimCC: a Simple Coordinate Classification Perspective for Human
+ Pose Estimation" () and "RTMPose: Real-Time
+ Multi-Person Pose Estimation based on MMPose" ().
+
+ Args:
+ simcc_split_ratio: The split ratio of pixels, as described in SimCC.
+ apply_softmax: Whether to apply softmax on the scores.
+ normalize_outputs: Whether to normalize the outputs before predicting maximums.
+ """
+
+ def __init__(
+ self,
+ simcc_split_ratio: float = 2.0,
+ apply_softmax: bool = True,
+ normalize_outputs: bool = False,
+ sigma: float | int | tuple[float, ...] = 6.0,
+ decode_beta: float = 150.0,
+ ) -> None:
+ super().__init__()
+ self.simcc_split_ratio = simcc_split_ratio
+ self.apply_softmax = apply_softmax
+ self.normalize_outputs = normalize_outputs
+
+ if isinstance(sigma, (float, int)):
+ self.sigma = np.array([sigma, sigma])
+ else:
+ self.sigma = np.array(sigma)
+ self.decode_beta = decode_beta
+
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ x, y = outputs["x"].detach(), outputs["y"].detach()
+
+ if self.normalize_outputs:
+ x = get_simcc_normalized(x)
+ y = get_simcc_normalized(y)
+ else:
+ x = x * (self.sigma[0] * self.decode_beta)
+ y = y * (self.sigma[1] * self.decode_beta)
+
+ keypoints, scores = get_simcc_maximum(x.cpu().numpy(), y.cpu().numpy(), self.apply_softmax)
+
+ if keypoints.ndim == 2:
+ keypoints = keypoints[None, :]
+ scores = scores[None, :]
+
+ keypoints /= self.simcc_split_ratio
+ scores = scores.reshape((*scores.shape, -1))
+ keypoints_with_score = np.concatenate([keypoints, scores], axis=-1)
+ keypoints_with_score = torch.tensor(keypoints_with_score).unsqueeze(1)
+ return dict(poses=keypoints_with_score)
+
+
+def get_simcc_maximum(
+ simcc_x: np.ndarray,
+ simcc_y: np.ndarray,
+ apply_softmax: bool = False,
+) -> tuple[np.ndarray, np.ndarray]:
+ """Get maximum response location and value from SimCC representations.
+
+ Note:
+ instance number: N
+ num_keypoints: K
+ heatmap height: H
+ heatmap width: W
+
+ Args:
+ simcc_x (np.ndarray): x-axis SimCC in shape (K, Wx) or (N, K, Wx)
+ simcc_y (np.ndarray): y-axis SimCC in shape (K, Wy) or (N, K, Wy)
+ apply_softmax (bool): whether to apply softmax on the heatmap.
+ Defaults to False.
+
+ Returns:
+ tuple:
+ - locs (np.ndarray): locations of maximum heatmap responses in shape
+ (K, 2) or (N, K, 2)
+ - vals (np.ndarray): values of maximum heatmap responses in shape
+ (K,) or (N, K)
+ """
+
+ assert isinstance(simcc_x, np.ndarray), "simcc_x should be numpy.ndarray"
+ assert isinstance(simcc_y, np.ndarray), "simcc_y should be numpy.ndarray"
+ assert simcc_x.ndim == 2 or simcc_x.ndim == 3, f"Invalid shape {simcc_x.shape}"
+ assert simcc_y.ndim == 2 or simcc_y.ndim == 3, f"Invalid shape {simcc_y.shape}"
+ assert simcc_x.ndim == simcc_y.ndim, f"{simcc_x.shape} != {simcc_y.shape}"
+
+ if simcc_x.ndim == 3:
+ N, K, Wx = simcc_x.shape
+ simcc_x = simcc_x.reshape(N * K, -1)
+ simcc_y = simcc_y.reshape(N * K, -1)
+ else:
+ N = None
+
+ if apply_softmax:
+ simcc_x = simcc_x - np.max(simcc_x, axis=1, keepdims=True)
+ simcc_y = simcc_y - np.max(simcc_y, axis=1, keepdims=True)
+ ex, ey = np.exp(simcc_x), np.exp(simcc_y)
+ simcc_x = ex / np.sum(ex, axis=1, keepdims=True)
+ simcc_y = ey / np.sum(ey, axis=1, keepdims=True)
+
+ x_locs = np.argmax(simcc_x, axis=1)
+ y_locs = np.argmax(simcc_y, axis=1)
+ locs = np.stack((x_locs, y_locs), axis=-1).astype(np.float32)
+ max_val_x = np.amax(simcc_x, axis=1)
+ max_val_y = np.amax(simcc_y, axis=1)
+
+ mask = max_val_x > max_val_y
+ max_val_x[mask] = max_val_y[mask]
+ vals = max_val_x
+ threshold = 1.0 / simcc_x.shape[-1] if apply_softmax else 0.0
+ locs[vals <= threshold] = -1
+
+ if N:
+ locs = locs.reshape(N, K, 2)
+ vals = vals.reshape(N, K)
+
+ return locs, vals
+
+
+def get_simcc_normalized(pred: torch.Tensor) -> torch.Tensor:
+ """Normalize the predicted SimCC.
+
+ See:
+ github.com/open-mmlab/mmpose/blob/main/mmpose/codecs/utils/post_processing.py#L12
+
+ Args:
+ pred: The predicted output.
+
+ Returns:
+ The normalized output.
+ """
+ b, k, _ = pred.shape
+ pred = pred.clamp(min=0)
+
+ # Compute the binary mask
+ mask = (pred.amax(dim=-1) > 1).reshape(b, k, 1)
+
+ # Normalize the tensor using the maximum value
+ norm = pred / pred.amax(dim=-1).reshape(b, k, 1)
+
+ # return the normalized tensor
+ return torch.where(mask, norm, pred)
diff --git a/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py
new file mode 100644
index 0000000000..c379a7f213
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/single_predictor.py
@@ -0,0 +1,163 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.predictors.base import (
+ PREDICTORS,
+ BasePredictor,
+)
+
+
+@PREDICTORS.register_module
+class HeatmapPredictor(BasePredictor):
+ """Predictor class for pose estimation from heatmaps (and optionally locrefs).
+
+ Args:
+ location_refinement: Enable location refinement.
+ locref_std: Standard deviation for location refinement.
+ apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True.
+
+ Returns:
+ Regressed keypoints from heatmaps and locref_maps of baseline DLC model (ResNet + Deconv).
+ """
+
+ def __init__(
+ self,
+ apply_sigmoid: bool = True,
+ clip_scores: bool = False,
+ location_refinement: bool = True,
+ locref_std: float = 7.2801,
+ ):
+ """
+ Args:
+ apply_sigmoid: Apply sigmoid to heatmaps. Defaults to True.
+ clip_scores: If a sigmoid is not applied, this can be used to clip scores
+ for predicted keypoints to values in [0, 1].
+ location_refinement : Enable location refinement.
+ locref_std: Standard deviation for location refinement.
+ """
+ super().__init__()
+ self.apply_sigmoid = apply_sigmoid
+ self.clip_scores = clip_scores
+ self.sigmoid = torch.nn.Sigmoid()
+ self.location_refinement = location_refinement
+ self.locref_std = locref_std
+
+ def forward(self, stride: float, outputs: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
+ """Forward pass of SinglePredictor. Gets predictions from model output.
+
+ Args:
+ stride: the stride of the model
+ outputs: output of the model heads (heatmap, locref)
+
+ Returns:
+ A dictionary containing a "poses" key with the output tensor as value.
+
+ Example:
+ >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801)
+ >>> stride = 8
+ >>> output = {"heatmap": torch.rand(32, 17, 64, 64), "locref": torch.rand(32, 17, 64, 64)}
+ >>> poses = predictor.forward(stride, output)
+ """
+ heatmaps = outputs["heatmap"]
+ scale_factors = stride, stride
+
+ if self.apply_sigmoid:
+ heatmaps = self.sigmoid(heatmaps)
+
+ heatmaps = heatmaps.permute(0, 2, 3, 1)
+ batch_size, height, width, num_joints = heatmaps.shape
+
+ locrefs = None
+ if self.location_refinement:
+ locrefs = outputs["locref"]
+ locrefs = locrefs.permute(0, 2, 3, 1).reshape(batch_size, height, width, num_joints, 2)
+ locrefs = locrefs * self.locref_std
+
+ poses = self.get_pose_prediction(heatmaps, locrefs, scale_factors)
+
+ if self.clip_scores:
+ poses[..., 2] = torch.clip(poses[..., 2], min=0, max=1)
+
+ return {"poses": poses}
+
+ def get_top_values(self, heatmap: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
+ """Get the top values from the heatmap.
+
+ Args:
+ heatmap: Heatmap tensor.
+
+ Returns:
+ Y and X indices of the top values.
+
+ Example:
+ >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801)
+ >>> heatmap = torch.rand(32, 17, 64, 64)
+ >>> Y, X = predictor.get_top_values(heatmap)
+ """
+ batchsize, ny, nx, num_joints = heatmap.shape
+ heatmap_flat = heatmap.reshape(batchsize, nx * ny, num_joints)
+ heatmap_top = torch.argmax(heatmap_flat, dim=1)
+ y, x = heatmap_top // nx, heatmap_top % nx
+ return y, x
+
+ def get_pose_prediction(self, heatmap: torch.Tensor, locref: torch.Tensor | None, scale_factors) -> torch.Tensor:
+ """Gets the pose prediction given the heatmaps and locref.
+
+ Args:
+ heatmap: Heatmap tensor with the following format (batch_size, height, width, num_joints)
+ locref: Locref tensor with the following format (batch_size, height, width, num_joints, 2)
+ scale_factors: Scale factors for the poses.
+
+ Returns:
+ Pose predictions of the format: (batch_size, num_people = 1, num_joints, 3)
+
+ Example:
+ >>> predictor = HeatmapPredictor(location_refinement=True, locref_std=7.2801)
+ >>> heatmap = torch.rand(32, 64, 64, 17)
+ >>> locref = torch.rand(32, 64, 64, 17, 2)
+ >>> scale_factors = (0.5, 0.5)
+ >>> poses = predictor.get_pose_prediction(heatmap, locref, scale_factors)
+ """
+ y, x = self.get_top_values(heatmap) # y, x: (batch_size, num_joints)
+
+ batch_size, num_joints = x.shape
+
+ # Create batch and joint indices for indexing
+ # batch_idx: [[0,0,0,...], [1,1,1,...], [2,2,2,...], ...]
+ batch_idx = (
+ torch.arange(batch_size, device=heatmap.device).unsqueeze(1).expand(-1, num_joints)
+ ) # (batch_size, num_joints)
+ # joint_idx: [[0,1,2,...], [0,1,2,...], [0,1,2,...], ...]
+ joint_idx = (
+ torch.arange(num_joints, device=heatmap.device).unsqueeze(0).expand(batch_size, -1)
+ ) # (batch_size, num_joints)
+
+ # Vectorized extraction of heatmap scores and locref offsets
+ scores = heatmap[batch_idx, y, x, joint_idx] # (batch_size, num_joints)
+
+ dz = torch.zeros((batch_size, 1, num_joints, 3), device=heatmap.device)
+ dz[:, 0, :, 2] = scores
+
+ if locref is not None:
+ offsets = locref[batch_idx, y, x, joint_idx, :] # (batch_size, num_joints, 2)
+ dz[:, 0, :, :2] = offsets
+
+ x, y = x.unsqueeze(1), y.unsqueeze(1) # x, y: (batch_size, 1, num_joints)
+
+ x = x * scale_factors[1] + 0.5 * scale_factors[1] + dz[:, :, :, 0]
+ y = y * scale_factors[0] + 0.5 * scale_factors[0] + dz[:, :, :, 1]
+
+ pose = torch.stack([x, y, dz[:, :, :, 2]], dim=-1) # (batch_size, 1, num_joints, 3)
+
+ return pose
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py
new file mode 100644
index 0000000000..7b7389588b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/__init__.py
@@ -0,0 +1,28 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.models.target_generators.base import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+ SequentialGenerator,
+)
+from deeplabcut.pose_estimation_pytorch.models.target_generators.dekr_targets import (
+ DEKRGenerator,
+)
+from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import (
+ HeatmapGaussianGenerator,
+ HeatmapPlateauGenerator,
+)
+from deeplabcut.pose_estimation_pytorch.models.target_generators.pafs_targets import (
+ PartAffinityFieldGenerator,
+)
+from deeplabcut.pose_estimation_pytorch.models.target_generators.sim_cc import (
+ SimCCGenerator,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py
new file mode 100644
index 0000000000..9802ced451
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/base.py
@@ -0,0 +1,90 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+TARGET_GENERATORS = Registry("target_generators", build_func=build_from_cfg)
+
+
+class BaseGenerator(ABC, nn.Module): # TODO: Should this really be a module?
+ """Generates target maps from ground truth annotations to train models.
+
+ The outputs of the target generator are used to compute losses for model heads. If
+ the head outputs "heatmap" and "offset" tensors, then the corresponding generator
+ must output target "heatmap" and "offset" tensors. The targets themselves are
+ dictionaries, and passed as keyword-arguments to the criterions. This allows to pass
+ masks to the criterions.
+
+ Generally, this means that for each head output (such as "heatmap"), a dict will be
+ generated with a "target" key (for the target heatmap) and optionally a "weights"
+ key (see the WeightedCriterion classes).
+ """
+
+ def __init__(self, label_keypoint_key: str = "keypoints"):
+ super().__init__()
+ self.label_keypoint_key = label_keypoint_key
+
+ @abstractmethod
+ def forward(
+ self, stride: float, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ """Generates targets.
+
+ Args:
+ stride: the stride of the model
+ outputs: output of a model head
+ labels: the labels for the inputs (each tensor should have shape (b, ...))
+
+ Returns:
+ a dictionary mapping the heads to the inputs of the criterion
+ {
+ "heatmap": {
+ "target": heatmaps,
+ "weights": heatmap_weights,
+ },
+ "locref": {
+ "target": locref_map,
+ "weights": locref_weights,
+ }
+ }
+ """
+
+
+@TARGET_GENERATORS.register_module
+class SequentialGenerator(BaseGenerator):
+ def __init__(self, generators: list[dict], label_keypoint_key: str = "keypoints"):
+ super().__init__(label_keypoint_key)
+ self._generators = [TARGET_GENERATORS.build(dict_) for dict_ in generators]
+
+ @property
+ def generators(self):
+ return self._generators
+
+ def forward(
+ self, stride: int, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ dict_ = {}
+ for gen in self.generators:
+ dict_.update(gen(stride, outputs, labels))
+ return dict_
+
+ def __repr__(self):
+ generators_repr = ", ".join(repr(gen) for gen in self._generators)
+ return (
+ f"<{self.__class__.__name__}(generators=[{generators_repr}], "
+ f"label_keypoint_key='{self.label_keypoint_key}')>"
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py
new file mode 100644
index 0000000000..84801df366
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py
@@ -0,0 +1,204 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.base import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+)
+
+
+@TARGET_GENERATORS.register_module
+class DEKRGenerator(BaseGenerator):
+ """
+ Generate ground truth target for DEKR model training based on:
+ Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression
+ Zigang Geng, Ke Sun, Bin Xiao, Zhaoxiang Zhang, Jingdong Wang, CVPR 2021
+ Code based on:
+ https://github.com/HRNet/DEKR
+ """
+
+ def __init__(self, num_joints: int, pos_dist_thresh: int, bg_weight: float = 0.1, **kwargs):
+ """
+ Args:
+ num_joints: number of keypoints
+ pos_dist_thresh: 3*std of the gaussian
+ bg_weight:background weight. Defaults to 0.1.
+ """
+ super().__init__(**kwargs)
+
+ self.num_joints = num_joints
+ self.num_heatmaps = self.num_joints + 1
+ self.pos_dist_thresh = pos_dist_thresh
+ self.bg_weight = bg_weight
+
+ def forward(
+ self, stride: float, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ """
+ Given the annotations and predictions of your keypoints, this function returns the targets,
+ a dictionary containing the heatmaps, locref_maps and locref_masks.
+ Args:
+ stride: the stride of the model
+ outputs: output of each model head
+ labels: the labels for the inputs (each tensor should have shape (b, ...))
+
+ Returns:
+ The targets for the DEKR heatmap and offset heads:
+ {
+ "heatmap": {
+ "target": heatmaps,
+ "weights": heatmap_weights,
+ },
+ "offset": {
+ "target": offset_map,
+ "weights": offset_weights,
+ }
+ }
+
+ Examples:
+ input:
+ labels = {"keypoints":torch.randint(1,min(image_size),(batch_size, num_animals, num_joints, 2))}
+ prediction = [torch.rand((batch_size, num_joints, image_size[0], image_size[1]))]
+ image_size = (256, 256)
+ output:
+ targets = {
+ "heatmap": {"target": heatmaps, "weights": heatmap_weights},
+ "offset": {"target": offset_map, "weights": offset_masks}
+ }
+ """
+ stride_y, stride_x = stride, stride
+ batch_size, _, output_h, output_w = outputs["heatmap"].shape
+ coords = labels[self.label_keypoint_key].cpu().numpy()
+ area = labels["area"].cpu().numpy()
+
+ assert self.num_joints + 1 == coords.shape[2], f"the number of joints should be {coords.shape}"
+
+ # TODO make it possible to differentiate between center sigma and other sigmas
+ scale = max(1 / stride_x, 1 / stride_y)
+ sgm, ct_sgm = (self.pos_dist_thresh / 2) * scale, self.pos_dist_thresh * scale
+ radius = self.pos_dist_thresh * scale
+
+ heatmap_shape = batch_size, self.num_heatmaps, output_h, output_w
+ heatmaps = np.zeros(heatmap_shape, dtype=np.float32)
+ heatmap_weights = 2 * np.ones(heatmap_shape, dtype=np.float32)
+
+ offset_shape = batch_size, self.num_joints * 2, output_h, output_w
+ offset_map = np.zeros(offset_shape, dtype=np.float32)
+ weight_map = np.zeros(offset_shape, dtype=np.float32)
+
+ area_map = np.zeros((batch_size, output_h, output_w), dtype=np.float32)
+ for b in range(batch_size):
+ for person_id, p in enumerate(coords[b]):
+ idx_center = len(p) - 1
+ ct_x = int(p[-1, 0])
+ ct_y = int(p[-1, 1])
+
+ ct_x_sm = (ct_x - stride_x / 2) / stride_x
+ ct_y_sm = (ct_y - stride_y / 2) / stride_y
+ for idx, pt in enumerate(p):
+ if pt[-1] == -1:
+ # full gradient masking
+ heatmap_weights[b, idx] = 0.0
+ continue
+ elif pt[-1] <= 0:
+ continue
+
+ if idx == idx_center:
+ sigma = ct_sgm
+ else:
+ sigma = sgm
+
+ x, y = pt[0], pt[1]
+ x_sm, y_sm = (
+ (x - stride_x / 2) / stride_x,
+ (y - stride_y / 2) / stride_y,
+ )
+
+ if x_sm < 0 or y_sm < 0 or x_sm >= output_w or y_sm >= output_h:
+ continue
+
+ # HEATMAP COMPUTATION
+ ul = (
+ int(np.floor(x_sm - 3 * sigma - 1)),
+ int(np.floor(y_sm - 3 * sigma - 1)),
+ )
+ br = (
+ int(np.ceil(x_sm + 3 * sigma + 2)),
+ int(np.ceil(y_sm + 3 * sigma + 2)),
+ )
+
+ cc, dd = max(0, ul[0]), min(br[0], output_w)
+ aa, bb = max(0, ul[1]), min(br[1], output_h)
+
+ joint_rg = np.zeros((bb - aa, dd - cc))
+ for sy in range(aa, bb):
+ for sx in range(cc, dd):
+ joint_rg[sy - aa, sx - cc] = dekr_heatmap_val(sigma, sx, sy, x_sm, y_sm)
+
+ heatmaps[b, idx, aa:bb, cc:dd] = np.maximum(heatmaps[b, idx, aa:bb, cc:dd], joint_rg)
+ heatmap_weights[b, idx, aa:bb, cc:dd] = 1.0
+
+ # OFFSET COMPUTATION
+ if idx != idx_center:
+ start_x = max(int(ct_x_sm - radius), 0)
+ start_y = max(int(ct_y_sm - radius), 0)
+ end_x = min(int(ct_x_sm + radius), output_w)
+ end_y = min(int(ct_y_sm + radius), output_h)
+
+ for pos_x in range(start_x, end_x):
+ for pos_y in range(start_y, end_y):
+ offset_x = pos_x - x_sm
+ offset_y = pos_y - y_sm
+ if (
+ offset_map[b, idx * 2, pos_y, pos_x] != 0
+ or offset_map[b, idx * 2 + 1, pos_y, pos_x] != 0
+ ):
+ if area_map[b, pos_y, pos_x] < area[b, person_id]:
+ continue
+ offset_map[b, idx * 2, pos_y, pos_x] = offset_x
+ offset_map[b, idx * 2 + 1, pos_y, pos_x] = offset_y
+ # TODO find a decent constant make weights vary giving animal area
+ weight_map[b, idx * 2, pos_y, pos_x] = 1.0 / np.sqrt(area[b, person_id])
+ weight_map[b, idx * 2 + 1, pos_y, pos_x] = 1.0 / np.sqrt(area[b, person_id])
+ area_map[b, pos_y, pos_x] = area[b, person_id]
+
+ heatmap_weights[heatmap_weights == 2] = self.bg_weight
+ return {
+ "heatmap": {
+ "target": torch.tensor(heatmaps, device=outputs["heatmap"].device),
+ "weights": torch.tensor(heatmap_weights, device=outputs["heatmap"].device),
+ },
+ "offset": {
+ "target": torch.tensor(offset_map, device=outputs["offset"].device),
+ "weights": torch.tensor(weight_map, device=outputs["offset"].device),
+ },
+ }
+
+
+def dekr_heatmap_val(sigma: float, x: float, y: float, x0: float, y0: float) -> float:
+ """Calculates the corresponding heat value of point (x,y) given the heat
+ distribution centered at (x0,y0) and spread value of sigma.
+
+ Args:
+ sigma: controls the spread or width of the heat distribution
+ x: x coord of a point on the image grid
+ y: y coord of a point on the image grid
+ x0: x center coordinate of the heat distribution
+ y0: y center coordinate of the heat distribution
+
+ Returns:
+ g: calculated heat value represents the intensity of the heat at a given position
+ """
+ return np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2))
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py
new file mode 100644
index 0000000000..93464ca5ee
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py
@@ -0,0 +1,330 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from abc import abstractmethod
+from enum import Enum
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.base import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+)
+
+
+class HeatmapGenerator(BaseGenerator):
+ """Abstract class to generate target heatmap targets (with/without locref)
+
+ Can generate target heatmaps either for pose estimation (one keypoint), or for
+ individual identification.
+
+ This class is abstract, and heatmap targets should be generated through its
+ subclasses (such as HeatmapPlateauGenerator)
+ """
+
+ class Mode(Enum):
+ """KEYPOINT generates one heatmap per type of keypoint (for pose estimation
+ heads) INDIVIDUAL generates one heatmap per individual (for identification
+ heads)"""
+
+ INDIVIDUAL = "INDIVIDUAL"
+ KEYPOINT = "KEYPOINT"
+
+ @classmethod
+ def _missing_(cls, value):
+ if isinstance(value, str):
+ value = value.upper()
+ for member in cls:
+ if member.value == value:
+ return member
+ return None
+
+ def __init__(
+ self,
+ num_heatmaps: int,
+ pos_dist_thresh: int,
+ heatmap_mode: str | Mode = Mode.KEYPOINT,
+ gradient_masking: bool = False,
+ background_weight: float = 0.1,
+ generate_locref: bool = True,
+ locref_std: float = 7.2801,
+ **kwargs,
+ ):
+ """
+ Args:
+ num_heatmaps: the number of heatmaps to generate
+ pos_dist_thresh: 3*std of the gaussian. We think of dist_thresh as a radius
+ and std is a 'diameter'.
+ mode: the mode to generate heatmaps for
+ gradient_masking: Whether to mask the gradient when a bodypart is undefined
+ (has visibility ``0`` in the dataset). WARNING: Do not set this option
+ for bottom-up models, as a keypoint missing for one animal means the
+ gradients for all animals will be set to 0 for that image.
+ Gradients for inputs that have the visibility flag ``-1`` will always be
+ masked, as this flag indicates that the keypoint is not defined for the
+ image.
+ background_weight: If ``gradient_masking == True`, the weight to apply to
+ the loss for background pixels.
+ learned_id_target: whether to generate the heatmap for keypoints
+ or for learned IDs
+ generate_locref: whether to generate location refinement maps
+ locref_std: the STD for the location refinement maps, if defined
+
+ Examples:
+ input:
+ locref_std = 7.2801, default value in pytorch config
+ num_joints = 6
+ po_dist_thresh = 17, default value in pytorch config
+ """
+ super().__init__(**kwargs)
+ self.num_heatmaps = num_heatmaps
+ self.dist_thresh = float(pos_dist_thresh)
+ self.dist_thresh_sq = self.dist_thresh**2
+ self.std = 2 * self.dist_thresh / 3
+
+ if isinstance(heatmap_mode, str):
+ heatmap_mode = HeatmapGenerator.Mode(heatmap_mode)
+ self.heatmap_mode = heatmap_mode
+
+ self.gradient_masking = gradient_masking
+ self.background_weight = background_weight
+
+ self.generate_locref = generate_locref
+ self.locref_scale = 1.0 / locref_std
+
+ def forward(
+ self, stride: float, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ """Given the annotations and predictions of your keypoints, this function
+ returns the targets, a dictionary containing the heatmaps, locref_maps and
+ locref_masks.
+
+ Args:
+ stride: the stride of the model
+ outputs: output of each model head
+ labels: the labels for the inputs (each tensor should have shape (b, ...))
+
+ Returns:
+ The targets for the heatmap and locref heads:
+ {
+ "heatmap": {
+ "target": heatmaps,
+ "weights": heatmap_weights,
+ },
+ "locref": { # optional
+ "target": locref_map,
+ "weights": locref_weights,
+ }
+ }
+
+ Examples:
+ input:
+ annotations = {
+ "keypoints": torch.randint(
+ 1, min(image_size), (batch_size, num_animals, num_joints, 2)
+ )
+ }
+ image_size = (256, 256)
+ model_stride = 4
+ output:
+ targets = {
+ "heatmap": {
+ "target": array of shape (batch_size, 64, 64, num_joints),
+ "weights": array of shape (batch_size, 64, 64, num_joints),
+ },
+ "locref": {
+ "target": array of shape (batch_size, 64, 64, num_joints),
+ "weights": array of shape (batch_size, 64, 64, num_joints),
+ }
+ }
+ """
+ stride_y, stride_x = stride, stride
+ batch_size, _, height, width = outputs["heatmap"].shape
+ coords = labels[self.label_keypoint_key].cpu().numpy()
+ if len(coords.shape) == 3: # for single animal: add individual dimension
+ coords = coords.reshape((batch_size, 1, *coords.shape[1:]))
+
+ if self.heatmap_mode == HeatmapGenerator.Mode.KEYPOINT:
+ # transpose the individuals and keypoints to iterate over bodyparts
+ coords = coords.transpose((0, 2, 1, 3))
+ if self.heatmap_mode == HeatmapGenerator.Mode.INDIVIDUAL:
+ # re-order the individuals to always have the same order
+ # TODO: Optimize
+ sorted_coords = -np.ones_like(coords)
+ for i, batch_individuals in enumerate(labels["individual_ids"]):
+ for j, individual_id in enumerate(batch_individuals):
+ if individual_id >= 0:
+ sorted_coords[i, individual_id] = coords[i, j]
+ coords = sorted_coords
+
+ map_size = batch_size, height, width
+ heatmap = np.zeros((*map_size, self.num_heatmaps), dtype=np.float32)
+ weights = np.ones(
+ (batch_size, self.num_heatmaps, height, width),
+ dtype=np.float32,
+ )
+
+ locref_map, locref_mask = None, None
+ if self.generate_locref:
+ locref_map = np.zeros((*map_size, self.num_heatmaps * 2), dtype=np.float32)
+ locref_mask = np.zeros_like(locref_map, dtype=int)
+
+ grid = np.mgrid[:height, :width].transpose((1, 2, 0))
+ grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2
+ grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2
+
+ # heatmap (batch_size, height, width, num_kpts)
+ # coords (batch_size, num_kpts, num_individuals, 3)
+ for b in range(batch_size):
+ for heatmap_idx, group_keypoints in enumerate(coords[b]):
+ for keypoint in group_keypoints:
+ if self.gradient_masking and keypoint[-1] == 0:
+ # apply background weight if keypoints are missing
+ weights[b, heatmap_idx] = self.background_weight
+ elif keypoint[-1] == -1:
+ # always mask weights when the keypoint is undefined
+ weights[b, heatmap_idx] = 0.0
+ elif keypoint[-1] > 0:
+ # keypoint visible
+ self.update(
+ heatmap=heatmap[b, :, :, heatmap_idx],
+ grid=grid,
+ keypoint=keypoint[..., :2],
+ locref_map=self.get_locref(locref_map, b, heatmap_idx),
+ locref_mask=self.get_locref(locref_mask, b, heatmap_idx),
+ )
+
+ hm_device = outputs["heatmap"].device
+ heatmap = heatmap.transpose((0, 3, 1, 2))
+ target = {
+ "heatmap": {
+ "target": torch.tensor(heatmap, device=hm_device),
+ "weights": torch.tensor(weights, device=hm_device),
+ }
+ }
+
+ if self.generate_locref:
+ locref_map = locref_map.transpose((0, 3, 1, 2))
+ locref_mask = locref_mask.transpose((0, 3, 1, 2))
+ target["locref"] = {
+ "target": torch.tensor(locref_map, device=outputs["locref"].device),
+ "weights": torch.tensor(locref_mask, device=outputs["locref"].device),
+ }
+
+ return target
+
+ def get_locref(
+ self,
+ locref_map_or_mask: np.ndarray | None,
+ batch_idx: int,
+ heatmap_idx: int,
+ ) -> np.ndarray | None:
+ """
+ Args:
+ locref_map_or_mask: the locref array to return (either the map or mask), of
+ shape (batch_size, height, width, num_heatmaps)
+ batch_idx: the index of the batch
+ heatmap_idx: the index of the heatmap for which we want the location
+ refinement maps or masks
+
+ Returns:
+ the location refinement maps/masks of shape (height, width, 2)
+ """
+ if not self.generate_locref:
+ return None
+
+ start_idx = 2 * heatmap_idx
+ end_idx = start_idx + 2
+ return locref_map_or_mask[batch_idx, :, :, start_idx:end_idx]
+
+ @abstractmethod
+ def update(
+ self,
+ heatmap: np.ndarray,
+ grid: np.mgrid,
+ keypoint: np.ndarray,
+ locref_map: np.ndarray | None,
+ locref_mask: np.ndarray | None,
+ ) -> None:
+ """Updates the heatmap and locref targets in-place following an update rule
+ (e.g., Gaussian or Plateau).
+
+ Args:
+ heatmap: the heatmap to update of shape (height, width)
+ grid: the grid for ???
+ keypoint: the keypoint with which to update the maps
+ locref_map: the location refinement maps of shape (height, width, 2), if
+ self.generate_locref = True
+ locref_mask: the location refinement masks of shape (height, width, 2), if
+ self.generate_locref = True
+ """
+ raise NotImplementedError
+
+
+@TARGET_GENERATORS.register_module
+class HeatmapGaussianGenerator(HeatmapGenerator):
+ """Generates gaussian heatmaps (and locref) targets from keypoints."""
+
+ def update(
+ self,
+ heatmap: np.ndarray,
+ grid: np.mgrid,
+ keypoint: np.ndarray,
+ locref_map: np.ndarray | None,
+ locref_mask: np.ndarray | None,
+ ) -> None:
+ """Updates the heatmap (and locref if defined) with gaussian values."""
+ # revert keypoints to follow image convention: from x,y to y,x
+ keypoint = keypoint.copy()[::-1]
+
+ dist = np.linalg.norm(grid - keypoint, axis=2) ** 2
+ heatmap_j = np.exp(-dist / (2 * self.std**2))
+ heatmap[:, :] = np.maximum(heatmap, heatmap_j)
+
+ if locref_map is not None:
+ dx = keypoint[1] - grid.copy()[:, :, 1]
+ dy = keypoint[0] - grid.copy()[:, :, 0]
+ locref_map[:, :, 0] = dx * self.locref_scale
+ locref_map[:, :, 1] = dy * self.locref_scale
+
+ if locref_mask is not None:
+ locref_mask[dist <= self.dist_thresh_sq] = 1
+
+
+@TARGET_GENERATORS.register_module
+class HeatmapPlateauGenerator(HeatmapGenerator):
+ """Generates plateau heatmaps (and locref) targets from keypoints."""
+
+ def update(
+ self,
+ heatmap: np.ndarray,
+ grid: np.mgrid,
+ keypoint: np.ndarray,
+ locref_map: np.ndarray | None,
+ locref_mask: np.ndarray | None,
+ ) -> None:
+ """Updates the heatmap (and locref if defined) with plateau values."""
+ # revert keypoints to follow image convention: from x,y to y,x
+ keypoint = keypoint.copy()[::-1]
+ dist = np.sum((grid - keypoint) ** 2, axis=2)
+ mask = dist <= self.dist_thresh_sq
+ heatmap[mask] = 1
+
+ if locref_map is not None:
+ dx = keypoint[1] - grid.copy()[:, :, 1]
+ dy = keypoint[0] - grid.copy()[:, :, 0]
+ locref_map[mask, 0] = (dx * self.locref_scale)[mask]
+ locref_map[mask, 1] = (dy * self.locref_scale)[mask]
+
+ if locref_mask is not None:
+ locref_mask[mask] = 1
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py
new file mode 100644
index 0000000000..a3dab0f945
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.py
@@ -0,0 +1,93 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from math import sqrt
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.base import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+)
+
+
+@TARGET_GENERATORS.register_module
+class PartAffinityFieldGenerator(BaseGenerator):
+ """Generate part affinity field targets from ground truth keypoints in order to
+ train baseline multi-animal deeplabcut model (ResNet + Deconv)"""
+
+ def __init__(self, graph: list[list[int, int]], width: float):
+ """
+ Args:
+ graph: list of pairs of keypoint indices forming
+ the graph edges
+ width: width of the vector field in pixels
+
+ Examples:
+ input:
+ graph = [(0, 1), (0, 2), (1, 2)]
+ width = 20.0, default value in pytorch config
+ """
+ super().__init__()
+ self.graph = graph
+ self.width = width
+ self.num_limbs = len(graph)
+
+ def forward(
+ self, stride: float, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ stride_y, stride_x = stride, stride
+ batch_size, _, height, width = outputs["heatmap"].shape
+ coords = labels[self.label_keypoint_key].cpu().numpy()
+
+ paf_map = np.zeros((batch_size, height, width, self.num_limbs * 2), dtype=np.float32)
+ grid = np.mgrid[:height, :width].transpose((1, 2, 0))
+ grid[:, :, 0] = grid[:, :, 0] * stride_y + stride_y / 2
+ grid[:, :, 1] = grid[:, :, 1] * stride_x + stride_x / 2
+ y, x = np.rollaxis(grid, 2)
+
+ for b in range(batch_size):
+ for _, kpts_animal in enumerate(coords[b]):
+ visible = set(np.flatnonzero(kpts_animal[..., -1] > 0))
+ kpts_animal = kpts_animal[..., :2]
+ for l, (bp1, bp2) in enumerate(self.graph):
+ if not (bp1 in visible and bp2 in visible):
+ continue
+
+ j1_x, j1_y = kpts_animal[bp1]
+ j2_x, j2_y = kpts_animal[bp2]
+ vec_x = j2_x - j1_x
+ vec_y = j2_y - j1_y
+ dist = sqrt(vec_x**2 + vec_y**2)
+ if dist > 0:
+ vec_x_norm = vec_x / dist
+ vec_y_norm = vec_y / dist
+ vec = [
+ vec_x_norm * j1_x + vec_y_norm * j1_y,
+ vec_x_norm * j2_x + vec_y_norm * j2_y,
+ ]
+ vec_ortho = j1_y * vec_x_norm - j1_x * vec_y_norm
+
+ distance_along = vec_x_norm * x + vec_y_norm * y
+ distance_across = ((y * vec_x_norm - x * vec_y_norm) - vec_ortho) * 1.0 / self.width
+
+ mask1 = (distance_along >= min(vec)) & (distance_along <= max(vec))
+ distance_across_abs = np.abs(distance_across)
+ mask2 = distance_across_abs <= 1
+ mask = mask1 & mask2
+ temp = 1 - distance_across_abs[mask]
+ paf_map[b, mask, l * 2 + 0] = vec_x_norm * temp
+ paf_map[b, mask, l * 2 + 1] = vec_y_norm * temp
+
+ paf_map = paf_map.transpose((0, 3, 1, 2))
+ return {"paf": {"target": torch.tensor(paf_map, device=outputs["paf"].device)}}
diff --git a/deeplabcut/pose_estimation_pytorch/models/target_generators/sim_cc.py b/deeplabcut/pose_estimation_pytorch/models/target_generators/sim_cc.py
new file mode 100644
index 0000000000..da938f3986
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/sim_cc.py
@@ -0,0 +1,226 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Modified SimCC target generator for the RTMPose model.
+
+Based on the official ``mmpose`` SimCC codec and RTMCC head implementation. For more
+information, see .
+"""
+
+from __future__ import annotations
+
+from itertools import product
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.base import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+)
+
+
+@TARGET_GENERATORS.register_module
+class SimCCGenerator(BaseGenerator):
+ """Class used generate targets from RTMPose head outputs.
+
+ The RTMPose model uses coordinate classification for pose estimation. For more
+ information, see "SimCC: a Simple Coordinate Classification Perspective for Human
+ Pose Estimation" () and "RTMPose: Real-Time
+ Multi-Person Pose Estimation based on MMPose" ().
+
+ Args:
+ input_size: The size of images given to the pose estimation model.
+ smoothing_type: Smoothing strategy ("gaussian" or "standard")
+ sigma: The sigma value in the Gaussian SimCC label. If a single value, used for
+ both x and y. If two values, the sigmas for (x, y).
+ simcc_split_ratio: The split ratio of pixels, as described in SimCC.
+ label_smooth_weight: Label Smoothing weight.
+ normalize: Normalize the heatmaps before returning.
+ **kwargs,
+ """
+
+ def __init__(
+ self,
+ input_size: tuple[int, int],
+ smoothing_type: str = "gaussian",
+ sigma: float | int | tuple[float, ...] = 6.0,
+ simcc_split_ratio: float = 2.0,
+ label_smooth_weight: float = 0.0,
+ normalize: bool = True,
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.input_size = input_size
+ self.smoothing_type = smoothing_type
+ self.simcc_split_ratio = simcc_split_ratio
+ self.label_smooth_weight = label_smooth_weight
+ self.normalize = normalize
+
+ if isinstance(sigma, (float, int)):
+ self.sigma = np.array([sigma, sigma])
+ else:
+ self.sigma = np.array(sigma)
+
+ if self.smoothing_type not in {"gaussian", "standard"}:
+ raise ValueError(
+ f"{self.__class__.__name__} got invalid `smoothing_type` value"
+ f"{self.smoothing_type}. Should be one of "
+ '{"gaussian", "standard"}'
+ )
+
+ if self.smoothing_type == "gaussian" and self.label_smooth_weight > 0:
+ raise ValueError("Attribute `label_smooth_weight` is only used for `standard` mode.")
+
+ if self.label_smooth_weight < 0.0 or self.label_smooth_weight > 1.0:
+ raise ValueError("`label_smooth_weight` should be in range [0, 1]")
+
+ if self.smoothing_type == "gaussian":
+ self.generator = self._generate_gaussian
+ elif self.smoothing_type == "standard":
+ self.generator = self._generate_standard
+ else:
+ raise ValueError(
+ f"{self.__class__.__name__} got invalid `smoothing_type` value"
+ f"{self.smoothing_type}. Should be one of "
+ '{"gaussian", "standard"}'
+ )
+
+ def forward(
+ self, stride: float, outputs: dict[str, torch.Tensor], labels: dict
+ ) -> dict[str, dict[str, torch.Tensor]]:
+ device = outputs["x"].device
+ keypoints = labels[self.label_keypoint_key].cpu().numpy()
+ batch_size = len(keypoints)
+
+ if len(keypoints.shape) == 3: # for single animal: add individual dimension
+ keypoints = keypoints.reshape((batch_size, 1, *keypoints.shape[1:]))
+
+ xs, ys, ws = [], [], []
+ for batch_keypoints in keypoints:
+ keypoints = batch_keypoints[:, :, :2]
+ keypoints_visible = batch_keypoints[:, :, 2]
+ x_labels, y_labels, weights = self.generator(keypoints, keypoints_visible)
+ xs.append(x_labels)
+ ys.append(y_labels)
+ ws.append(weights)
+
+ x_labels = np.stack(xs)
+ y_labels = np.stack(ys)
+ weights = np.stack(ws)
+ return dict(
+ x=dict(
+ target=torch.tensor(x_labels, device=device),
+ weights=torch.tensor(weights, device=device),
+ ),
+ y=dict(
+ target=torch.tensor(y_labels, device=device),
+ weights=torch.tensor(weights, device=device),
+ ),
+ )
+
+ def _generate_standard(
+ self, keypoints: np.ndarray, keypoints_visible: np.ndarray | None = None
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Encoding keypoints into SimCC labels with Standard Label Smoothing.
+
+ Labels will be one-hot vectors if self.label_smooth_weight==0.0
+ """
+ N, K, _ = keypoints.shape
+ w, h = self.input_size
+ W = np.around(w * self.simcc_split_ratio).astype(int)
+ H = np.around(h * self.simcc_split_ratio).astype(int)
+
+ keypoints_split, keypoint_weights = self._map_coordinates(keypoints, keypoints_visible)
+
+ target_x = np.zeros((N, K, W), dtype=np.float32)
+ target_y = np.zeros((N, K, H), dtype=np.float32)
+
+ for n, k in product(range(N), range(K)):
+ # skip unlabeled keypoints
+ if keypoints_visible[n, k] < 0.5:
+ continue
+
+ # get center coordinates
+ mu_x, mu_y = keypoints_split[n, k].astype(np.int64)
+
+ # detect abnormal coords and assign the weight 0
+ if mu_x >= W or mu_y >= H or mu_x < 0 or mu_y < 0:
+ keypoint_weights[n, k] = 0
+ continue
+
+ if self.label_smooth_weight > 0:
+ target_x[n, k] = self.label_smooth_weight / (W - 1)
+ target_y[n, k] = self.label_smooth_weight / (H - 1)
+
+ target_x[n, k, mu_x] = 1.0 - self.label_smooth_weight
+ target_y[n, k, mu_y] = 1.0 - self.label_smooth_weight
+
+ return target_x, target_y, keypoint_weights
+
+ def _map_coordinates(
+ self, keypoints: np.ndarray, keypoints_visible: np.ndarray | None = None
+ ) -> tuple[np.ndarray, np.ndarray]:
+ """Mapping keypoint coordinates into SimCC space."""
+ keypoints_split = keypoints.copy()
+ # set non-visible keypoints to 0; deals with NaNs
+ keypoints_split[keypoints_visible <= 0] = 0
+ keypoints_split = np.around(keypoints_split * self.simcc_split_ratio)
+ keypoints_split = keypoints_split.astype(np.int64)
+ keypoint_weights = (keypoints_visible > 0).astype(keypoints_split.dtype)
+ return keypoints_split, keypoint_weights
+
+ def _generate_gaussian(
+ self, keypoints: np.ndarray, keypoints_visible: np.ndarray | None = None
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ """Encoding keypoints into SimCC labels with Gaussian Label Smoothing."""
+ N, K, _ = keypoints.shape
+ w, h = self.input_size
+ W = np.around(w * self.simcc_split_ratio).astype(int)
+ H = np.around(h * self.simcc_split_ratio).astype(int)
+
+ keypoints_split, keypoint_weights = self._map_coordinates(keypoints, keypoints_visible)
+
+ target_x = np.zeros((N, K, W), dtype=np.float32)
+ target_y = np.zeros((N, K, H), dtype=np.float32)
+
+ # 3-sigma rule
+ radius = self.sigma * 3
+
+ # xy grid
+ x = np.arange(0, W, 1, dtype=np.float32)
+ y = np.arange(0, H, 1, dtype=np.float32)
+
+ for n, k in product(range(N), range(K)):
+ # skip unlabeled keypoints
+ if keypoints_visible[n, k] < 0.5:
+ continue
+
+ mu = keypoints_split[n, k]
+
+ # check that the gaussian has in-bounds part
+ left, top = mu - radius
+ right, bottom = mu + radius + 1
+
+ if left >= W or top >= H or right < 0 or bottom < 0:
+ keypoint_weights[n, k] = 0
+ continue
+
+ mu_x, mu_y = mu
+
+ target_x[n, k] = np.exp(-((x - mu_x) ** 2) / (2 * self.sigma[0] ** 2))
+ target_y[n, k] = np.exp(-((y - mu_y) ** 2) / (2 * self.sigma[1] ** 2))
+
+ if self.normalize:
+ norm_value = self.sigma * np.sqrt(np.pi * 2)
+ target_x /= norm_value[0]
+ target_y /= norm_value[1]
+
+ return target_x, target_y, keypoint_weights
diff --git a/deeplabcut/pose_estimation_pytorch/models/weight_init.py b/deeplabcut/pose_estimation_pytorch/models/weight_init.py
new file mode 100644
index 0000000000..6040b3b4c7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/weight_init.py
@@ -0,0 +1,105 @@
+"""Ways to initialize weights for PyTorch modules."""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+import torch.nn as nn
+
+from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+
+
+def _build_weight_init(cfg: str | dict, **kwargs) -> BaseWeightInitializer:
+ """Builds a BaseWeightInitializer using its config or the name of the initializer.
+
+ Args:
+ cfg: Either the name of the initializer (e.g. 'normal') or the config
+ **kwargs: Other parameters given by the Registry.
+
+ Returns:
+ the built BaseWeightInitializer
+ """
+ if isinstance(cfg, str):
+ cfg = {"type": cfg.title().replace("_", "")}
+ return build_from_cfg(cfg, **kwargs)
+
+
+WEIGHT_INIT = Registry("weight_init", build_func=_build_weight_init)
+
+
+class BaseWeightInitializer(ABC):
+ """Class to used to initialize model weights."""
+
+ @abstractmethod
+ def init_weights(self, model: nn.Module) -> None:
+ """Initializes weights for a model.
+
+ Args:
+ model: The model for which to initialize weights
+ """
+
+
+@WEIGHT_INIT.register_module
+class Normal(BaseWeightInitializer):
+ """Class to used to initialize model weights using a normal distribution.
+
+ Weights are initialized with a normal distribution, and biases are initialized to 0.
+
+ Attributes:
+ std: the standard deviation to use to initialize weights
+ """
+
+ def __init__(self, std: float = 0.001):
+ self.std = std
+
+ def init_weights(self, model: nn.Module) -> None:
+ for name, module in model.named_parameters():
+ if "bias" in name:
+ nn.init.constant_(module, 0)
+ else:
+ nn.init.normal_(module, std=self.std)
+
+
+@WEIGHT_INIT.register_module
+class Dekr(BaseWeightInitializer):
+ """Class to used to initialize model weights in the same way as DEKR.
+
+ Attributes:
+ std: the standard deviation to use to initialize weights
+ """
+
+ def __init__(self, std: float = 0.001):
+ self.std = std
+
+ def init_weights(self, model: nn.Module) -> None:
+ for name, module in model.named_parameters():
+ if "bias" in name:
+ nn.init.constant_(module, 0)
+ else:
+ nn.init.normal_(module, std=self.std)
+
+ if hasattr(module, "transform_matrix_conv"):
+ nn.init.constant_(module.transform_matrix_conv.weight, 0)
+ if hasattr(module, "bias"):
+ nn.init.constant_(module.transform_matrix_conv.bias, 0)
+ if hasattr(module, "translation_conv"):
+ nn.init.constant_(module.translation_conv.weight, 0)
+ if hasattr(module, "bias"):
+ nn.init.constant_(module.translation_conv.bias, 0)
+
+
+@WEIGHT_INIT.register_module
+class Rtmpose(BaseWeightInitializer):
+ """Class to used to initialize head weights in the same way as RTMPose."""
+
+ def init_weights(self, model: nn.Module) -> None:
+ for module in model.modules():
+ if isinstance(module, nn.Conv2d):
+ nn.init.normal_(module.weight, std=0.001)
+ nn.init.constant_(module.bias, 0)
+ elif isinstance(module, nn.BatchNorm2d):
+ nn.init.constant_(module.weight, 1)
+ nn.init.constant_(module.bias, 1)
+ elif isinstance(module, nn.Linear):
+ nn.init.normal_(module.weight, std=0.01)
+ nn.init.constant_(module.bias, 0)
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py
new file mode 100644
index 0000000000..51459a5087
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/__init__.py
@@ -0,0 +1,62 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Public API for PyTorch modelzoo.
+
+Exports are resolved lazily to avoid import cycles between helpers and package
+initialization.
+"""
+
+from importlib import import_module
+
+_EXPORTS = {
+ "download_super_animal_snapshot": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "download_super_animal_snapshot",
+ ),
+ "get_snapshot_folder_path": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "get_snapshot_folder_path",
+ ),
+ "get_super_animal_model_config_path": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "get_super_animal_model_config_path",
+ ),
+ "get_super_animal_project_config_path": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "get_super_animal_project_config_path",
+ ),
+ "get_super_animal_snapshot_path": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "get_super_animal_snapshot_path",
+ ),
+ "load_super_animal_config": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.utils",
+ "load_super_animal_config",
+ ),
+ "create_superanimal_inference_runners": (
+ "deeplabcut.pose_estimation_pytorch.modelzoo.inference_helpers",
+ "create_superanimal_inference_runners",
+ ),
+}
+
+__all__ = sorted(_EXPORTS)
+
+
+def __getattr__(name: str):
+ try:
+ module_name, attr_name = _EXPORTS[name]
+ except KeyError as exc:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
+ return getattr(import_module(module_name), attr_name)
+
+
+def __dir__():
+ return sorted(set(globals()) | set(__all__))
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/config.py b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py
new file mode 100644
index 0000000000..697231f6e3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/config.py
@@ -0,0 +1,180 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Methods to create the configuration files to fine-tune SuperAnimal models."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from ruamel.yaml import YAML
+
+import deeplabcut.pose_estimation_pytorch.config.utils as config_utils
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.core.config import (
+ read_config_as_dict,
+ write_config,
+)
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ get_super_animal_model_config_path,
+ get_super_animal_project_config_path,
+)
+from deeplabcut.pose_estimation_pytorch.runners.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def make_super_animal_finetune_config(
+ weight_init: WeightInitialization,
+ project_config: dict,
+ pose_config_path: str | Path,
+ model_name: str,
+ detector_name: str | None,
+ save: bool = False,
+) -> dict:
+ """Creates a PyTorch pose configuration file to finetune a SuperAnimal model on a
+ downstream project.
+
+ Args:
+ weight_init: The weight initialization configuration.
+ project_config: The project configuration.
+ pose_config_path: The path where the pose configuration file will be saved
+ model_name: The type of neural net to finetune.
+ detector_name: The type of detector to use for the SuperAnimal model. If None is
+ given, the model will be set to a Bottom-Up framework.
+ save: Whether to save the model configuration file to the ``pose_config_path``.
+
+ Returns:
+ The generated pose configuration file.
+
+ Raises:
+ ValueError: If `weight_init.with_decoder = False`. This method only creates
+ configs to fine-tune SuperAnimal models. Call `make_pytorch_pose_config`
+ to create configuration files for transfer learning.
+ """
+ bodyparts = af.get_bodyparts(project_config)
+ if weight_init.dataset is None:
+ raise ValueError("You must set the ``WeightInitialization.dataset`` when fine-tuning SuperAnimal models.")
+
+ if not weight_init.with_decoder:
+ raise ValueError(
+ "Can only call ``make_super_animal_finetune_config`` when "
+ f" `with_decoder=True`, but you had {weight_init}. Please set "
+ "`with_decoder=True` to fine-tune a model or call "
+ "`make_pytorch_pose_config` to create a transfer learning "
+ "pose configuration file."
+ )
+
+ converted_bodyparts = bodyparts
+ if weight_init.bodyparts is not None:
+ assert len(weight_init.bodyparts) == len(weight_init.conversion_array)
+ converted_bodyparts = weight_init.bodyparts
+ elif len(bodyparts) != len(weight_init.conversion_array):
+ raise ValueError(
+ "You don't have the same number of bodyparts in your project config as "
+ f"number of entries your conversion array ({bodyparts} vs "
+ f"{weight_init.conversion_array}). If you're fine-tuning from "
+ "SuperAnimal on a subset of your bodyparts, you must specify which "
+ "ones in `WeightInitialization.bodyparts`. This should be done "
+ "automatically when creating the `weight_init` with "
+ "`WeightInitialization.build`."
+ )
+
+ # Load the exact pose configuration file for the model to fine-tune
+ pose_config = create_config_from_modelzoo(
+ super_animal=weight_init.dataset,
+ model_name=model_name,
+ detector_name=detector_name,
+ converted_bodyparts=converted_bodyparts,
+ weight_init=weight_init,
+ project_config=project_config,
+ pose_config_path=pose_config_path,
+ )
+ if save:
+ write_config(pose_config_path, pose_config, overwrite=True)
+
+ return pose_config
+
+
+def create_config_from_modelzoo(
+ super_animal: str,
+ model_name: str,
+ detector_name: str | None,
+ converted_bodyparts: list[str],
+ weight_init: WeightInitialization,
+ project_config: dict,
+ pose_config_path: str | Path,
+) -> dict:
+ """Creates a model configuration file to fine-tune a SuperAnimal model.
+
+ Args:
+ super_animal: The SuperAnimal dataset on which the model was trained.
+ model_name: The type of neural net to finetune.
+ detector_name: The type of detector to use for the SuperAnimal model. If None is
+ given, the model will be set to a Bottom-Up framework.
+ converted_bodyparts: The project bodyparts that the model will learn.
+ weight_init: The weight initialization to use.
+ project_config: The project configuration.
+ pose_config_path: The path where the pose configuration file will be saved.
+
+ Returns:
+ The generated pose configuration file.
+ """
+ # load the model configuration
+ model_cfg = read_config_as_dict(get_super_animal_model_config_path(model_name))
+ if detector_name is None:
+ model_cfg["method"] = Task.BOTTOM_UP.aliases[0].lower()
+ # Use default bottom-up image augmentation if no detector is given (the collate
+ # function might be needed).
+ config_dir = config_utils.get_config_folder_path()
+ aug = read_config_as_dict(config_dir / "base" / "aug_default.yaml")
+ model_cfg["data"]["train"] = aug["train"]
+ else:
+ model_cfg["method"] = Task.TOP_DOWN.aliases[0].lower()
+ model_cfg["detector"] = read_config_as_dict(get_super_animal_model_config_path(detector_name))
+
+ # use SuperAnimal bodyparts
+ if weight_init.memory_replay:
+ super_animal_project_config = read_config_as_dict(get_super_animal_project_config_path(super_animal))
+ converted_bodyparts = super_animal_project_config["bodyparts"]
+
+ model_cfg["net_type"] = model_name
+ model_cfg["metadata"] = {
+ "project_path": project_config["project_path"],
+ "pose_config_path": str(pose_config_path),
+ "bodyparts": converted_bodyparts,
+ "unique_bodyparts": [],
+ "individuals": project_config.get("individuals", ["animal"]),
+ "with_identity": False,
+ }
+
+ model_cfg["model"] = config_utils.replace_default_values(model_cfg["model"], num_bodyparts=len(converted_bodyparts))
+ model_cfg["train_settings"]["weight_init"] = weight_init.to_dict()
+
+ model_cfg["inference"] = InferenceConfig().to_dict()
+
+ # sort first-level keys to make it prettier
+ return dict(sorted(model_cfg.items()))
+
+
+def write_pytorch_config_for_memory_replay(config_path, shuffle, pytorch_config):
+ cfg = af.read_config(config_path)
+ trainIndex = 0
+ dlc_proj_root = Path(config_path).parent
+ model_folder = dlc_proj_root / af.get_model_folder(
+ cfg["TrainingFraction"][trainIndex], shuffle, cfg, engine=Engine.PYTORCH
+ )
+ os.makedirs(model_folder / "train", exist_ok=True)
+ out_path = model_folder / "train" / "pytorch_config.yaml"
+ with open(str(out_path), "w") as f:
+ yaml = YAML()
+ yaml.dump(pytorch_config, f)
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/README.md b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/README.md
new file mode 100644
index 0000000000..f72d35f9af
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/README.md
@@ -0,0 +1,12 @@
+# FMPose3D
+
+## Overview
+
+**FMPose3D** is a monocular 3D pose estimation library that lifts 2D keypoints from images into 3D poses using *flow matching* — a generative modeling technique based on ODE sampling. It supports two pipelines:
+
+- **Human pose estimation** — Uses HRNet + YOLO for 2D detection (17 H36M joints), then a flow-matching 3D lifter with optional flip test-time augmentation and camera-to-world transformation.
+- **Animal pose estimation** — Uses DeepLabCut SuperAnimal for 2D detection (26 Animal3D joints), then a flow-matching 3D lifter with limb regularization post-processing.
+
+Model weights are hosted on HuggingFace Hub and are downloaded automatically when no local path is provided. The library is installable via `pip install fmpose3d` and requires Python >= 3.8.
+
+For a full overview and documentation on the API, see https://github.com/AdaptiveMotorControlLab/FMPose3D.
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/__init__.py b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/__init__.py
new file mode 100644
index 0000000000..1ea902fbca
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/__init__.py
@@ -0,0 +1,15 @@
+"""
+DeepLabCut2.0-3.0 Toolbox (deeplabcut.org)
+© A. & M. Mathis Labs
+https://github.com/DeepLabCut/DeepLabCut
+Please see AUTHORS for contributors.
+https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+Licensed under GNU Lesser General Public License v3.0
+"""
+
+# NOTE: this module may contain items that need refactoring during
+# the keypoint migration.
+
+# kpt_refactor - Needs attention when refactoring keypoints
+# i_o - This module writes keypoints to disk
+# pandas - This module relies on pandas (might be moved to polars)
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/fmpose3d.py b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/fmpose3d.py
new file mode 100644
index 0000000000..bb78a1c93a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/fmpose3d.py
@@ -0,0 +1,139 @@
+"""
+DeepLabCut2.0-3.0 Toolbox (deeplabcut.org)
+© A. & M. Mathis Labs
+https://github.com/DeepLabCut/DeepLabCut
+Please see AUTHORS for contributors.
+https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+Licensed under GNU Lesser General Public License v3.0
+"""
+
+from dataclasses import dataclass
+
+from fmpose3d import (
+ FMPose3DConfig,
+ FMPose3DInference,
+ SupportedModel,
+)
+
+
+@dataclass(frozen=True)
+class FMPose3DModelMetadata:
+ """Metadata for an FMPose3D model variant."""
+
+ superanimal_name: str
+ bodyparts: tuple[str, ...]
+
+ @property
+ def num_bodyparts(self) -> int:
+ return len(self.bodyparts)
+
+ def build_model_cfg(self, max_individuals: int) -> dict:
+ """Build a DLC-compatible model_cfg dict for create_df_from_prediction."""
+ return {
+ "metadata": {
+ "bodyparts": list(self.bodyparts),
+ "unique_bodyparts": [],
+ "individuals": [f"individual{i + 1}" for i in range(max_individuals)],
+ },
+ }
+
+
+FMPOSE3D_MODEL_METADATA: dict[str, FMPose3DModelMetadata] = {
+ "fmpose3d_humans": FMPose3DModelMetadata(
+ superanimal_name="superanimal_humanbody",
+ bodyparts=(
+ "pelvis",
+ "right_hip",
+ "right_knee",
+ "right_ankle",
+ "left_hip",
+ "left_knee",
+ "left_ankle",
+ "spine",
+ "thorax",
+ "neck",
+ "head",
+ "left_shoulder",
+ "left_elbow",
+ "left_wrist",
+ "right_shoulder",
+ "right_elbow",
+ "right_wrist",
+ ),
+ ),
+ "fmpose3d_animals": FMPose3DModelMetadata(
+ superanimal_name="superanimal_quadruped",
+ bodyparts=(
+ "left_eye",
+ "right_eye",
+ "nose",
+ "neck",
+ "root_of_tail",
+ "left_shoulder",
+ "left_elbow",
+ "left_front_paw",
+ "right_shoulder",
+ "right_elbow",
+ "right_front_paw",
+ "left_hip",
+ "left_knee",
+ "left_back_paw",
+ "right_hip",
+ "right_knee",
+ "right_back_paw",
+ "withers",
+ "throat",
+ "left_ear",
+ "right_ear",
+ "mouth",
+ "chin",
+ "left_hock",
+ "right_hock",
+ "tail_tip",
+ ),
+ ),
+}
+
+
+def get_fmpose3d_inference_api(
+ model_type: SupportedModel = "fmpose3d_humans",
+ snapshot_path: str | None = None,
+ device: str | None = None,
+ config_kwargs: dict = None,
+) -> FMPose3DInference:
+ """
+ Get a FMPose3DInference API for a given model type and snapshot path.
+
+ Args:
+ model_type: one of the supported model types: "fmpose3d_humans", "fmpose3d_animals",
+ snapshot_path: The path to the snapshot file. If None, FMPose3D will download the default snapshot.
+ device: The device to use. If None, the device will be inferred from the environment.
+ config_kwargs: Additional keyword arguments to pass to the FMPose3DConfig.
+ Returns:
+ FMPose3DInference: An FMPose3DInference API runner.
+
+ Example Usages
+ ```python
+ # Initialize the API (downloads the default weights automatically from huggingface)
+ fmpose = get_fmpose3d_inference_api(
+ model_type="fmpose3d_animals",
+ device="cuda:0",
+ )
+
+ # Run inference on an image
+ predictions_3d = fmpose.predict(source="path/to/image.jpg") # or (H, W, 3) numpy array
+
+ # Lift 2d predictions to 3d
+ keypoints_2d = np.random.rand(num_frames, num_joints, 2)
+ predictions_3d = fmpose.pose_3d(keypoints_2d=keypoints_2d)
+ ```
+ """
+ if config_kwargs is None:
+ config_kwargs = {}
+ model_config = FMPose3DConfig(model_type=model_type, **config_kwargs)
+ fmpose3d_api = FMPose3DInference(
+ model_config,
+ model_weights_path=snapshot_path,
+ device=device,
+ )
+ return fmpose3d_api
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/inference.py
new file mode 100644
index 0000000000..df993b332f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/inference.py
@@ -0,0 +1,257 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import json
+import logging
+from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.modelzoo.utils import get_superanimal_colormaps
+from deeplabcut.pose_estimation_pytorch.apis.videos import (
+ VideoIterator,
+ create_df_from_prediction,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo.fmpose_3d.fmpose3d import (
+ FMPOSE3D_MODEL_METADATA,
+ get_fmpose3d_inference_api,
+)
+from deeplabcut.utils import auxiliaryfunctions_3d
+from deeplabcut.utils.make_labeled_video import create_video
+
+logger = logging.getLogger(__name__)
+
+
+class NumpyEncoder(json.JSONEncoder):
+ """Special json encoder for numpy types."""
+
+ def default(self, obj):
+ if isinstance(obj, np.ndarray):
+ return obj.tolist() # Convert ndarray to list
+ return json.JSONEncoder.default(self, obj)
+
+
+def _pose2d_to_dlc_predictions(
+ pose_2d,
+ max_individuals: int,
+ num_bodyparts: int,
+) -> list[dict[str, np.ndarray]]:
+ """Convert FMPose3D 2D output to DLC per-frame prediction format."""
+ all_kpts = np.asarray(pose_2d.keypoints)
+ all_scores = np.asarray(pose_2d.scores)
+ if all_kpts.ndim != 4 or all_scores.ndim != 3:
+ raise ValueError(
+ "Expected pose_2d keypoints/scores shaped as (num_persons, num_frames, num_bodyparts, {2 or score})."
+ )
+
+ num_frames = all_kpts.shape[1]
+ num_persons = all_kpts.shape[0]
+ per_frame: list[dict[str, np.ndarray]] = []
+ for frame_idx in range(num_frames):
+ n_det = min(num_persons, max_individuals)
+ bodyparts_array = np.zeros((max_individuals, num_bodyparts, 3))
+ bodyparts_array[:n_det, :, :2] = all_kpts[:n_det, frame_idx, :num_bodyparts, :2]
+ bodyparts_array[:n_det, :, 2] = all_scores[:n_det, frame_idx, :num_bodyparts]
+ per_frame.append({"bodyparts": bodyparts_array})
+ return per_frame
+
+
+# NOTE: i_o; pandas; kpt_refactor; this function may need to change in the future, to improve dataframe
+# i/o migration to validated keypoint schemas (parquet)
+def _poses3d_to_dataframe(poses_3d: list[np.ndarray], df_2d, scorer_3d: str):
+ """Create and fill a 3D dataframe using the shared auxiliary helper."""
+ df_3d, scorer_3d, bodyparts = auxiliaryfunctions_3d.create_empty_df(df_2d, scorer_3d, "3d")
+ n_frames = len(poses_3d)
+ n_bodyparts = len(bodyparts)
+ arr = np.full((n_frames, n_bodyparts, 3), np.nan, dtype=float)
+
+ for frame_idx, pose in enumerate(poses_3d):
+ pose_np = np.asarray(pose)
+ if pose_np.ndim == 3:
+ if pose_np.shape[0] == 0:
+ continue
+ pose_np = pose_np[0]
+ if pose_np.ndim != 2 or pose_np.shape[-1] != 3:
+ continue
+
+ n = min(n_bodyparts, pose_np.shape[0])
+ arr[frame_idx, :n] = pose_np[:n]
+
+ xyz_cols = [(scorer_3d, bp, coord) for bp in bodyparts for coord in ("x", "y", "z")]
+ df_3d.loc[:, xyz_cols] = arr.reshape(n_frames, -1)
+
+ return df_3d
+
+
+def _video_inference_fmpose3d(
+ video_paths: str | Path | list[str | Path],
+ model_name: str,
+ max_individuals: int = 1,
+ pcutoff: float = 0.1,
+ batch_size: int = 1,
+ dest_folder: str | Path | None = None,
+ device: str | None = None,
+ create_labeled_video: bool = True,
+ cropping: list[int] | None = None,
+ include_3d_in_return: bool = False,
+) -> dict:
+ """Perform FMPose3D video inference with a lightweight DLC loop."""
+ import torch
+ from tqdm import tqdm
+
+ if max_individuals != 1:
+ logger.warning(
+ "FMPose3D 3D lifting currently supports only one individual. "
+ "Clamping max_individuals=%s to 1 for this pipeline.",
+ max_individuals,
+ )
+ max_individuals = 1
+
+ if device is None or device == "auto":
+ device = "cuda:0" if torch.cuda.is_available() else "cpu"
+
+ if isinstance(video_paths, (str, Path)):
+ video_paths = [video_paths]
+
+ if model_name not in FMPOSE3D_MODEL_METADATA:
+ raise ValueError(
+ f"Unsupported FMPose3D model '{model_name}'. "
+ "Use one of: " + ", ".join(sorted(FMPOSE3D_MODEL_METADATA.keys()))
+ )
+ metadata = FMPOSE3D_MODEL_METADATA[model_name]
+ model_cfg = metadata.build_model_cfg(max_individuals)
+ bodyparts = list(metadata.bodyparts)
+ num_bodyparts = metadata.num_bodyparts
+ superanimal_name = metadata.superanimal_name
+
+ api = get_fmpose3d_inference_api(model_type=model_name, device=device)
+
+ dest_folder = Path(video_paths[0]).parent if dest_folder is None else Path(dest_folder)
+ dest_folder.mkdir(parents=True, exist_ok=True)
+
+ if create_labeled_video:
+ superanimal_colormaps = get_superanimal_colormaps()
+ colormap = superanimal_colormaps[superanimal_name]
+
+ dlc_scorer = f"DLC_{model_name}"
+ results = {}
+
+ for video_path in video_paths:
+ print(f"Processing video {video_path} with {model_name}")
+ video = VideoIterator(video_path, cropping=cropping)
+ vid_w, vid_h = video.dimensions
+
+ predictions_2d: list[dict[str, np.ndarray]] = []
+ all_poses_3d: list[np.ndarray] = []
+ warned_multi_person_2d = False
+
+ def _process_batch(
+ frames: list[np.ndarray],
+ predictions_2d=predictions_2d,
+ all_poses_3d=all_poses_3d,
+ ) -> None:
+ nonlocal warned_multi_person_2d
+ pose_2d = api.prepare_2d(source=np.stack(frames))
+ num_detected = int(np.asarray(pose_2d.keypoints).shape[0])
+ if num_detected > 1 and not warned_multi_person_2d:
+ logger.warning(
+ "Multiple 2D detections (%s) were found, but FMPose3D 3D lifting uses only the first individual.",
+ num_detected,
+ )
+ warned_multi_person_2d = True
+ predictions_2d.extend(
+ _pose2d_to_dlc_predictions(
+ pose_2d,
+ max_individuals=max_individuals,
+ num_bodyparts=num_bodyparts,
+ )
+ )
+ try:
+ pose_3d = api.pose_3d(
+ keypoints_2d=pose_2d.keypoints,
+ image_size=pose_2d.image_size,
+ )
+ all_poses_3d.extend(np.asarray(pose_3d.poses_3d))
+ except ValueError as e:
+ logger.info("Skipping 3D lifting for batch due to invalid 2D result: %s", e)
+ all_poses_3d.extend([np.zeros((0, num_bodyparts, 3)) for _ in frames])
+
+ batch: list[np.ndarray] = []
+ for frame in tqdm(video, desc="FMPose3D inference"):
+ batch.append(frame)
+ if len(batch) == batch_size:
+ _process_batch(batch)
+ batch.clear()
+ if batch:
+ _process_batch(batch)
+
+ output_prefix = f"{Path(video_path).stem}_{dlc_scorer}"
+ output_h5 = dest_folder / f"{output_prefix}.h5"
+
+ print(f"Saving 2D results to {dest_folder}")
+ df = create_df_from_prediction(
+ predictions=predictions_2d,
+ dlc_scorer=dlc_scorer,
+ multi_animal=True,
+ model_cfg=model_cfg,
+ output_path=dest_folder,
+ output_prefix=output_prefix,
+ )
+ scorer_3d = f"{dlc_scorer}_3d"
+ df_3d = _poses3d_to_dataframe(all_poses_3d, df, scorer_3d)
+ output_3d_h5 = dest_folder / f"{output_prefix}_3d.h5"
+ df_3d.to_hdf(output_3d_h5, key="df_with_missing", mode="w", format="table")
+ print(f"3D dataframe saved to {output_3d_h5}")
+
+ if include_3d_in_return:
+ results[video_path] = {
+ "df_2d": df,
+ "df_3d": df_3d,
+ }
+ else:
+ results[video_path] = df
+
+ output_json = dest_folder / f"{output_prefix}.json"
+ with open(output_json, "w") as f:
+ json.dump(predictions_2d, f, cls=NumpyEncoder)
+
+ poses_3d_serialisable = [pose.tolist() if isinstance(pose, np.ndarray) else pose for pose in all_poses_3d]
+ output_3d_json = dest_folder / f"{output_prefix}_3d.json"
+ with open(output_3d_json, "w") as f:
+ json.dump(
+ {
+ "model": model_name,
+ "bodyparts": bodyparts,
+ "poses_3d": poses_3d_serialisable,
+ },
+ f,
+ )
+ print(f"3D predictions saved to {output_3d_json}")
+
+ if create_labeled_video:
+ bbox = cropping
+ if cropping is None:
+ bbox = (0, vid_w, 0, vid_h)
+ output_video = dest_folder / f"{output_prefix}_labeled.mp4"
+ create_video(
+ video_path,
+ output_h5,
+ pcutoff=pcutoff,
+ fps=video.fps,
+ bbox=bbox,
+ cmap=colormap,
+ output_path=output_video,
+ plot_bboxes=False,
+ bboxes_list=[],
+ bboxes_pcutoff=0.0,
+ )
+ print(f"Video with predictions was saved as {output_video}")
+
+ return results
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py
new file mode 100644
index 0000000000..1dafa68a0e
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference.py
@@ -0,0 +1,218 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import json
+from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.modelzoo.utils import get_super_animal_scorer, get_superanimal_colormaps
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ get_filtered_coco_detector_inference_runner,
+ get_inference_runners,
+ get_pose_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.apis.videos import (
+ VideoIterator,
+ create_df_from_prediction,
+ video_inference,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ COCO_PERSON_CATEGORY_ID,
+ raise_warning_if_called_directly,
+)
+from deeplabcut.utils.make_labeled_video import create_video
+
+
+class NumpyEncoder(json.JSONEncoder):
+ """Special json encoder for numpy types."""
+
+ def default(self, obj):
+ if isinstance(obj, np.ndarray):
+ return obj.tolist() # Convert ndarray to list
+ return json.JSONEncoder.default(self, obj)
+
+
+def construct_bodypart_names(max_individuals, bodyparts):
+ multianimalbodyparts = []
+ for i in range(max_individuals):
+ for bodypart in bodyparts:
+ multianimalbodyparts.append(f"{bodypart}_{i}")
+ return multianimalbodyparts
+
+
+def _video_inference_superanimal(
+ video_paths: str | list,
+ superanimal_name: str,
+ model_cfg: dict,
+ model_snapshot_path: str | Path,
+ detector_snapshot_path: str | Path | None,
+ max_individuals: int,
+ pcutoff: float,
+ batch_size: int = 1,
+ detector_batch_size: int = 1,
+ cropping: list[int] | None = None,
+ dest_folder: str | Path | None = None,
+ output_suffix: str = "",
+ plot_bboxes: bool = True,
+ bboxes_pcutoff: float = 0.9,
+ create_labeled_video: bool = True,
+ torchvision_detector_name: str | None = None,
+) -> dict:
+ """Perform inference on a video using a superanimal model from the model zoo
+ specified by `superanimal_name`. During inference, the video is analyzed using the
+ specified model and the results are saved in the specified destination folder. The
+ predictions are saved in the form of a .h5 file. The video with the predictions is
+ saved in the form of a .mp4 file.
+
+ WARNING: This function is an internal utility function and should not be
+ called directly. It is designed to be used by deeplabcut.modelzoo.api.video_inference.py
+
+ Args:
+ video_paths: Path to the video to be analyzed or list of paths to videos to be
+ analyzed
+ superanimal_name: Name of the SuperAnimal project (e.g. superanimal_quadruped)
+ model_cfg: The name of the pose model architecture to use for inference.
+ model_snapshot_path: The path to the pose model snapshot to use for inference.
+ detector_snapshot_path: The path to the detector snapshot to use for inference.
+ max_individuals: Maximum number of individuals in the video
+ pcutoff: Cutoff for cutting off the predicted keypoints with probability lower
+ than pcutoff
+ batch_size: The batch size to use for video inference.
+ cropping: List of cropping coordinates as [x1, x2, y1, y2]. Note that the same
+ cropping parameters will then be used for all videos. If different video
+ crops are desired, run ``video_inference_superanimal`` on individual videos
+ with the corresponding cropping coordinates.
+ detector_batch_size: The batch size to use for the detector for video inference.
+ dest_folder: Destination folder for the results. If not specified, the
+ results are saved in the same folder as the video. Defaults to None.
+ output_suffix: The suffix to add to output file names (e.g. _before_adapt)
+ plot_bboxes: Whether to plot bounding boxes in the output video
+ bboxes_pcutoff: Confidence threshold for bounding box plotting
+ create_labeled_video (bool):
+ Specifies if a labeled video needs to be created, True by default.
+ torchvision_detector_name: If using a filtered torchvision detector, the torchvision model name
+
+ Returns:
+ results: Dictionary with the result pd.DataFrame for each video
+
+ Raises:
+ Warning: If the function is called directly.
+ """
+ raise_warning_if_called_directly()
+
+ if superanimal_name == "superanimal_humanbody":
+ if not torchvision_detector_name:
+ torchvision_detector_name = "fasterrcnn_mobilenet_v3_large_fpn"
+ detector_runner = get_filtered_coco_detector_inference_runner(
+ model_name=torchvision_detector_name,
+ category_id=COCO_PERSON_CATEGORY_ID,
+ batch_size=detector_batch_size,
+ max_individuals=max_individuals,
+ model_config=model_cfg,
+ )
+ pose_runner = get_pose_inference_runner(
+ model_cfg,
+ snapshot_path=model_snapshot_path,
+ batch_size=batch_size,
+ max_individuals=max_individuals,
+ )
+ else:
+ pose_runner, detector_runner = get_inference_runners(
+ model_config=model_cfg,
+ snapshot_path=model_snapshot_path,
+ max_individuals=max_individuals,
+ num_bodyparts=len(model_cfg["metadata"]["bodyparts"]),
+ num_unique_bodyparts=0,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ detector_path=detector_snapshot_path,
+ )
+
+ results = {}
+
+ if isinstance(video_paths, str):
+ video_paths = [video_paths]
+
+ dest_folder = Path(video_paths[0]).parent if dest_folder is None else Path(dest_folder)
+ dest_folder.mkdir(parents=True, exist_ok=True)
+ if create_labeled_video:
+ superanimal_colormaps = get_superanimal_colormaps()
+ colormap = superanimal_colormaps[superanimal_name]
+
+ for video_path in video_paths:
+ print(f"Processing video {video_path}")
+
+ dlc_scorer = get_super_animal_scorer(
+ superanimal_name,
+ model_snapshot_path,
+ detector_snapshot_path,
+ torchvision_detector_name,
+ )
+
+ output_prefix = f"{Path(video_path).stem}_{dlc_scorer}"
+ output_h5 = dest_folder / f"{output_prefix}.h5"
+
+ output_json = output_h5.with_suffix(".json")
+ if len(output_suffix) > 0:
+ output_json = output_json.with_stem(output_h5.stem + output_suffix)
+
+ video = VideoIterator(video_path, cropping=cropping)
+ predictions = video_inference(
+ video,
+ pose_runner=pose_runner,
+ detector_runner=detector_runner,
+ )
+
+ bbox_keys_in_predictions = {"bboxes", "bbox_scores"}
+ bboxes_list = [
+ {key: value for key, value in p.items() if key in bbox_keys_in_predictions}
+ for i, p in enumerate(predictions)
+ ]
+
+ bbox = cropping
+ if cropping is None:
+ vid_w, vid_h = video.dimensions
+ bbox = (0, vid_w, 0, vid_h)
+
+ print(f"Saving results to {dest_folder}")
+ df = create_df_from_prediction(
+ predictions=predictions,
+ dlc_scorer=dlc_scorer,
+ multi_animal=True,
+ model_cfg=model_cfg,
+ output_path=dest_folder,
+ output_prefix=output_prefix,
+ )
+
+ results[video_path] = df
+ with open(output_json, "w") as f:
+ json.dump(predictions, f, cls=NumpyEncoder)
+
+ if create_labeled_video:
+ output_video = dest_folder / f"{output_prefix}_labeled.mp4"
+ if len(output_suffix) > 0:
+ output_video = output_video.with_stem(output_video.stem + output_suffix)
+
+ create_video(
+ video_path,
+ output_h5,
+ pcutoff=pcutoff,
+ fps=video.fps,
+ bbox=bbox,
+ cmap=colormap,
+ output_path=output_video,
+ plot_bboxes=plot_bboxes,
+ bboxes_list=bboxes_list,
+ bboxes_pcutoff=bboxes_pcutoff,
+ )
+ print(f"Video with predictions was saved as {output_video}")
+
+ return results
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/inference_helpers.py b/deeplabcut/pose_estimation_pytorch/modelzoo/inference_helpers.py
new file mode 100644
index 0000000000..8f75401cf4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference_helpers.py
@@ -0,0 +1,212 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""PyTorch-specific helper entrypoints for model zoo inference."""
+
+from __future__ import annotations
+
+import copy
+import logging
+from pathlib import Path
+
+import deeplabcut.modelzoo.weight_initialization as weight_initialization
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ get_filtered_coco_detector_inference_runner,
+ get_inference_runners,
+ get_pose_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ COCO_PERSON_CATEGORY_ID,
+ get_super_animal_snapshot_path,
+ load_super_animal_config,
+ update_config,
+)
+from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def _build_humanbody_inference_runners(
+ model_cfg: dict,
+ model_name: str,
+ detector_name: str | None,
+ max_individuals: int,
+ batch_size: int,
+ detector_batch_size: int,
+ customized_pose_checkpoint: str | Path | None,
+ customized_detector_checkpoint: str | Path | None,
+) -> tuple[InferenceRunner, InferenceRunner, dict]:
+ if customized_detector_checkpoint is not None:
+ logging.warning(
+ "customized_detector_checkpoint is ignored for superanimal_humanbody. "
+ "A filtered torchvision detector runner is used instead."
+ )
+
+ torchvision_detector_name = detector_name if detector_name is not None else "fasterrcnn_mobilenet_v3_large_fpn"
+
+ pose_snapshot_path = customized_pose_checkpoint
+ if pose_snapshot_path is None:
+ pose_snapshot_path = get_super_animal_snapshot_path(
+ dataset="superanimal_humanbody",
+ model_name=model_name,
+ download=True,
+ )
+
+ detector_runner = get_filtered_coco_detector_inference_runner(
+ model_name=torchvision_detector_name,
+ category_id=COCO_PERSON_CATEGORY_ID,
+ batch_size=detector_batch_size,
+ max_individuals=max_individuals,
+ model_config=model_cfg,
+ )
+ pose_runner = get_pose_inference_runner(
+ model_cfg,
+ snapshot_path=pose_snapshot_path,
+ batch_size=batch_size,
+ max_individuals=max_individuals,
+ )
+ return pose_runner, detector_runner, model_cfg
+
+
+def create_superanimal_inference_runners(
+ superanimal_name: str,
+ model_name: str,
+ detector_name: str | None = None,
+ max_individuals: int = 10,
+ batch_size: int = 1,
+ detector_batch_size: int = 1,
+ device: str | None = "auto",
+ customized_model_config: str | Path | dict | None = None,
+ customized_pose_checkpoint: str | Path | None = None,
+ customized_detector_checkpoint: str | Path | None = None,
+) -> tuple[InferenceRunner, InferenceRunner | None, dict]:
+ """Create SuperAnimal inference runners for in-memory batched inference.
+
+ This helper is intended for Model Zoo inference pipelines that run directly on
+ arrays. It prepares pose/detector runners and returns them with the resolved
+ model config.
+
+ Args:
+ superanimal_name: Name of the SuperAnimal dataset, e.g.
+ ``"superanimal_quadruped"``.
+ model_name: Pose model architecture name, e.g. ``"hrnet_w32"``.
+ detector_name: Detector architecture name. For top-down SuperAnimal models,
+ use detector names such as ``"fasterrcnn_resnet50_fpn_v2"``. For
+ ``superanimal_humanbody``, this is interpreted as a torchvision detector
+ name (default: ``"fasterrcnn_mobilenet_v3_large_fpn"``). Can be ``None``
+ for bottom-up models.
+ max_individuals: Maximum number of individuals to keep per frame.
+ batch_size: Batch size for pose inference.
+ detector_batch_size: Batch size for detector inference.
+ device: Device for inference. If ``"auto"`` or ``None``, resolves to CUDA
+ when available, else CPU.
+ customized_model_config: Optional path or dict for a custom model config.
+ If not provided, uses the default SuperAnimal config. Note that this config
+ determines whether the model is top-down or bottom-up; for bottom-up models,
+ ``detector_runner`` will be ``None`` even if ``detector_name`` is set.
+ customized_pose_checkpoint: Optional custom pose checkpoint path.
+ customized_detector_checkpoint: Optional custom detector checkpoint path.
+
+ Returns:
+ tuple: ``(pose_runner, detector_runner, model_cfg)`` where:
+ - ``pose_runner`` is the pose inference runner
+ - ``detector_runner`` is the detector inference runner or ``None`` if no
+ detector is configured
+ - ``model_cfg`` is the resolved model configuration dict
+
+ Example:
+ >>> from pathlib import Path
+ >>> import numpy as np
+ >>> from PIL import Image
+ >>> from deeplabcut.pose_estimation_pytorch.modelzoo.inference_helpers import (
+ ... create_superanimal_inference_runners,
+ ... )
+ >>>
+ >>> img_paths = [
+ ... "/path/to/images/frame_0000.png",
+ ... "/path/to/images/frame_0001.png",
+ ... "/path/to/images/frame_0002.png",
+ ... ]
+ >>> images = [np.asarray(Image.open(Path(p)).convert("RGB")) for p in img_paths]
+ >>>
+ >>> pose_runner, det_runner, model_cfg = create_superanimal_inference_runners(
+ ... superanimal_name="superanimal_quadruped",
+ ... model_name="hrnet_w32",
+ ... detector_name="fasterrcnn_resnet50_fpn_v2",
+ ... max_individuals=10,
+ ... batch_size=1,
+ ... detector_batch_size=1,
+ ... )
+ >>>
+ >>> det_preds = det_runner.inference(images) if det_runner is not None else None
+ >>> pose_inputs = list(zip(images, det_preds)) if det_preds is not None else images
+ >>> pose_preds = pose_runner.inference(pose_inputs)
+ >>> print(len(pose_preds))
+ """
+ if model_name.lower().startswith("fmpose3d"):
+ raise NotImplementedError("FMPose3D is not supported in this helper. Use the FMPose3D inference API.")
+
+ if device is None:
+ device = "auto"
+
+ if customized_model_config is not None:
+ if isinstance(customized_model_config, (str, Path)):
+ model_cfg = read_config_as_dict(customized_model_config)
+ else:
+ model_cfg = copy.deepcopy(customized_model_config)
+ else:
+ model_cfg = load_super_animal_config(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=detector_name,
+ )
+ model_cfg = update_config(model_cfg, max_individuals=max_individuals, device=device)
+
+ if superanimal_name == "superanimal_humanbody":
+ return _build_humanbody_inference_runners(
+ model_cfg=model_cfg,
+ model_name=model_name,
+ detector_name=detector_name,
+ max_individuals=max_individuals,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ customized_pose_checkpoint=customized_pose_checkpoint,
+ customized_detector_checkpoint=customized_detector_checkpoint,
+ )
+
+ # Top-down models typically need a detector for bbox generation. If no detector
+ # is configured, the returned detector_runner will be None and callers should
+ # provide bboxes in the pose input context.
+ if Task(model_cfg["method"]) == Task.TOP_DOWN and detector_name is None and customized_detector_checkpoint is None:
+ logging.warning(
+ "Top-down model configured without a detector. "
+ "Returning detector_runner=None; pass bboxes in pose input context."
+ )
+
+ weight_init = weight_initialization.build_weight_init(
+ cfg=model_cfg,
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=detector_name,
+ with_decoder=False,
+ memory_replay=False,
+ customized_pose_checkpoint=customized_pose_checkpoint,
+ customized_detector_checkpoint=customized_detector_checkpoint,
+ )
+
+ pose_runner, detector_runner = get_inference_runners(
+ model_config=model_cfg,
+ snapshot_path=weight_init.snapshot_path,
+ max_individuals=max_individuals,
+ batch_size=batch_size,
+ detector_batch_size=detector_batch_size,
+ detector_path=weight_init.detector_snapshot_path,
+ )
+ return pose_runner, detector_runner, model_cfg
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py
new file mode 100644
index 0000000000..c0baeca0b3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py
@@ -0,0 +1,354 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import json
+import os
+from collections import defaultdict
+from pathlib import Path
+
+import numpy as np
+from scipy.optimize import linear_sum_assignment
+from scipy.spatial import distance
+
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.modelzoo.generalized_data_converter.datasets import (
+ COCOPoseDataset,
+ MaDLCPoseDataset,
+ SingleDLCPoseDataset,
+)
+from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+from deeplabcut.pose_estimation_pytorch.modelzoo import (
+ get_super_animal_project_config_path,
+)
+from deeplabcut.utils.pseudo_label import calculate_iou
+
+
+def get_pose_predictions(
+ loader: DLCLoader,
+ images: list[str],
+ bboxes: dict[str, list],
+ superanimal_name: str,
+ model_snapshot_path: str | Path,
+ detector_snapshot_path: str | Path,
+ max_individuals: int,
+ device: str | None = None,
+) -> dict[str, dict]:
+ """Gets predictions made by a SuperAnimal model on a DeepLabCut project.
+
+ Args:
+ loader: The path to the root of the project.
+ images: The images on which to run inference with the SuperAnimal model.
+ bboxes: The ground truth bounding boxes for each image in the project.
+ superanimal_name: The name of the SuperAnimal dataset being used.
+ model_snapshot_path: The path to the SuperAnimal pose snapshot.
+ detector_snapshot_path: The path to the SuperAnimal detector snapshot.
+ max_individuals: The maximum number of individuals to detect per image.
+ device: The CUDA device to use.
+
+ Returns:
+ The predictions made by the SuperAnimal model on each image in the images list.
+ """
+ model_name = detector_snapshot_path.stem + "-" + model_snapshot_path.stem
+ predictions_folder = loader.project_path / "memory_replay" / superanimal_name / model_name
+ predictions_folder.mkdir(exist_ok=True, parents=True)
+ predictions_file = predictions_folder / "pseudo-labels.json"
+
+ # COCO-format annotations file containing predictions made by the SuperAnimal model
+ sa_predictions = {}
+ if predictions_file.exists():
+ with open(predictions_file) as f:
+ raw_sa_predictions = json.load(f)
+
+ # parse predictions to convert lists to numpy arrays
+ for image, predictions in raw_sa_predictions.items():
+ sa_predictions[image] = {
+ "bodyparts": np.array(predictions["bodyparts"]),
+ "bboxes": np.array(predictions["bboxes"]),
+ # "bbox_scores": np.array(predictions["bbox_scores"]),
+ }
+
+ # get images that need to be processed
+ processed_images = set(sa_predictions.keys())
+ images_to_process = [image for image in (set(images) - processed_images)]
+
+ # if all images have been processed by the SuperAnimal model, return the predictions
+ if len(images_to_process) == 0:
+ return sa_predictions
+
+ pose_runner, detector_runner = get_inference_runners(
+ loader.model_cfg,
+ snapshot_path=model_snapshot_path,
+ max_individuals=max_individuals,
+ num_bodyparts=len(loader.model_cfg["metadata"]["bodyparts"]),
+ num_unique_bodyparts=len(loader.model_cfg["metadata"]["unique_bodyparts"]),
+ device=device,
+ detector_path=detector_snapshot_path,
+ )
+
+ # FIXME(niels, yeshaokai) - Use the detector to combine GT-keypoint created bounding
+ # boxes and predicted bounding boxes - keep the larger of the two
+ # bbox_predictions = detector_runner.inference(images=images_to_process)
+ pose_inputs = [
+ (str(loader.project_path / Path(image)), {"bboxes": np.array(bboxes[image])}) for image in images_to_process
+ ]
+ predictions = pose_runner.inference(pose_inputs)
+
+ for image, prediction in zip(images_to_process, predictions, strict=False):
+ sa_predictions[image] = prediction
+
+ # save the updated SuperAnimal predictions
+ json_sa_predictions = {
+ image: {
+ "bodyparts": predictions["bodyparts"].tolist(),
+ "bboxes": predictions["bboxes"].tolist(),
+ # "bbox_scores": predictions["bbox_scores"].tolist(),
+ }
+ for image, predictions in sa_predictions.items()
+ }
+ with open(predictions_file, "w") as f:
+ json.dump(json_sa_predictions, f, indent=2)
+
+ return sa_predictions
+
+
+# this is reading from a coco project
+def prepare_memory_replay_dataset(
+ loader: DLCLoader,
+ source_dataset_folder: str | Path,
+ superanimal_name: str,
+ model_snapshot_path: str,
+ detector_snapshot_path: str,
+ max_individuals: int = 1,
+ train_file: str = "train.json",
+ pose_threshold: float = 0.0,
+ device: str | None = None,
+):
+ """Need to first run inference on the source project train file."""
+ project_root = loader.project_path.resolve()
+ source_dataset_folder = Path(source_dataset_folder).resolve()
+
+ # Contains the ground truth annotations for the DeepLabCut project
+ # .../dlc-models-pytorch/.../...shuffle0/train/memory_replay/annotations/train.json
+ with open(source_dataset_folder / "annotations" / train_file) as f:
+ project_gt = json.load(f)
+
+ # parse the GT so that image paths are in the format (no matter the OS):
+ # "labeled-data/{video_name}/{image_name}"
+ for image in project_gt["images"]:
+ image["file_name"] = "/".join(Path(image["file_name"]).parts[-3:])
+
+ image_id_to_name = {}
+ image_id_to_annotations = defaultdict(list)
+
+ image_name_to_id = {}
+ image_name_to_gt = defaultdict(list)
+ image_name_to_bbox = defaultdict(list)
+
+ for image in project_gt["images"]:
+ image_name_to_id[image["file_name"]] = image["id"]
+ image_id_to_name[image["id"]] = image["file_name"]
+
+ for anno in project_gt["annotations"]:
+ name = image_id_to_name[anno["image_id"]]
+ image_name_to_gt[name].append(anno)
+ image_name_to_bbox[name].append(anno["bbox"])
+
+ image_ids = list(image_name_to_id.values())
+ for annotation in project_gt["annotations"]:
+ image_id = annotation["image_id"]
+ if annotation["image_id"] in image_ids:
+ image_id_to_annotations[image_id].append(annotation)
+
+ image_name_to_prediction = get_pose_predictions(
+ loader=loader,
+ images=[image["file_name"] for image in project_gt["images"]],
+ bboxes=image_name_to_bbox,
+ superanimal_name=superanimal_name,
+ model_snapshot_path=model_snapshot_path,
+ detector_snapshot_path=detector_snapshot_path,
+ max_individuals=max_individuals,
+ device=device,
+ )
+
+ def xywh2xyxy(bbox):
+ temp_bbox = np.copy(bbox)
+ temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:]
+ return temp_bbox
+
+ def optimal_match(gts_list, preds_list):
+ num_gts = len(gts_list)
+ num_preds = len(preds_list)
+ cost_matrix = np.zeros((num_gts, num_preds))
+
+ for i in range(num_gts):
+ for j in range(num_preds):
+ cost_matrix[i, j] = distance.euclidean(gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten())
+ row_ind, col_ind = linear_sum_assignment(cost_matrix)
+
+ return col_ind
+
+ num_bodyparts = len(project_gt["categories"][0]["keypoints"])
+ for image_name, gts in image_name_to_gt.items():
+ bbox_gts = [np.array(gt["bbox"]) for gt in gts]
+ bbox_gts = [xywh2xyxy(e) for e in bbox_gts]
+ prediction = image_name_to_prediction[image_name]
+ bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]]
+ optimal_pred_indices = optimal_match(bbox_gts, bbox_preds)
+
+ for idx in range(len(bbox_gts)):
+ if idx == len(optimal_pred_indices):
+ break
+
+ optimal_index = optimal_pred_indices[idx]
+ matched_gt = np.array(gts[idx]["keypoints"])
+ matched_pred = prediction["bodyparts"][optimal_index]
+ bbox_gt = bbox_gts[idx]
+ bbox_pred = bbox_preds[idx]
+
+ # maybe check iou of two bbox
+ iou = calculate_iou(bbox_gt, bbox_pred)
+ if iou < 0.7:
+ matched_gt = np.ones_like(matched_gt) * -1
+ gts[idx]["keypoints"] = list(matched_gt.flatten())
+ else:
+ matched_gt = matched_gt.reshape(num_bodyparts, -1)
+ matched_pred = matched_pred.reshape(num_bodyparts, -1)
+ mask = matched_gt == -1
+ matched_gt[mask] = matched_pred[mask]
+ # after the mixing, we don't care about confidence anymore
+
+ for kpt_idx in range(len(matched_gt)):
+ if 0 < matched_gt[kpt_idx][2] < pose_threshold:
+ matched_gt[kpt_idx][2] = -1
+ elif matched_gt[kpt_idx][2] > 0:
+ matched_gt[kpt_idx][2] = 2
+
+ gts[idx]["keypoints"] = list(matched_gt.flatten())
+
+ # memory replay path
+ memory_replay_train_file_path = os.path.join(source_dataset_folder, "annotations", "memory_replay_train.json")
+
+ # parse the GT to put the image paths back into OS-specific format
+ for image in project_gt["images"]:
+ image_rel_path = image["file_name"].split("/")
+ image["file_name"] = str(project_root.resolve() / Path(*image_rel_path))
+
+ with open(memory_replay_train_file_path, "w") as f:
+ json.dump(project_gt, f, indent=4)
+
+
+def prepare_memory_replay(
+ config: str | Path,
+ loader: DLCLoader,
+ superanimal_name: str,
+ model_snapshot_path: str | Path,
+ detector_snapshot_path: str | Path,
+ device: str,
+ max_individuals: int = 3,
+ train_file: str = "train.json",
+ pose_threshold: float = 0.1,
+) -> None:
+ """Prepares a shuffle to be trained with memory replay.
+
+ To be trained using memory replay, predictions must be made on all images in the
+ dataset using the SuperAnimal model. Predictions for bodyparts that aren't labeled
+ in the DeepLabCut project are then used as pseudo-labels during training.
+
+ This method will create a COCO-format dataset in the same folder as the
+ ``pytorch_config.yaml`` (the model folder).
+
+ Args:
+ config: Path to the DeepLabCut project configuration file.
+ loader: The loader used to load the training/test data on which a model will
+ be fine-tuned with memory replay.
+ superanimal_name: The name of the SuperAnimal model that is being fine-tuned.
+ model_snapshot_path: Path to the SuperAnimal pose snapshot to fine-tune.
+ detector_snapshot_path: Path to the SuperAnimal detector snapshot to fine-tune.
+ device: Device to use to run inference using the SuperAnimal model.
+ max_individuals: Maximum number of animals that can be present in a frame.
+ train_file: Name of the file containing train annotations (e.g. `train.json`).
+ pose_threshold: The minimum score for a prediction to be used as a pseudo-label.
+ """
+ cfg = af.read_config(config)
+ super_animal_cfg = af.read_plainconfig(get_super_animal_project_config_path(super_animal=superanimal_name))
+
+ if "individuals" in cfg:
+ temp_dataset = MaDLCPoseDataset(str(loader.project_path), "temp_dataset", shuffle=loader.shuffle)
+ else:
+ temp_dataset = SingleDLCPoseDataset(str(loader.project_path), "temp_dataset", shuffle=loader.shuffle)
+
+ memory_replay_folder = loader.model_folder / "memory_replay"
+ temp_dataset.materialize(
+ memory_replay_folder,
+ framework="coco",
+ append_image_id=False,
+ no_image_copy=True, # use the images in the labeled-data folder
+ )
+
+ weight_init_cfg = loader.model_cfg["train_settings"].get("weight_init")
+ if weight_init_cfg is None:
+ raise ValueError(
+ "You can only train models with memory replay when you are fine-tuning a "
+ "SuperAnimal model. Please look at the documentation to see how to create "
+ "a training dataset to fine-tune one of the SuperAnimal models."
+ )
+
+ weight_init = WeightInitialization.from_dict(weight_init_cfg)
+ if not weight_init.with_decoder:
+ raise ValueError(
+ "You can only train models with memory replay when you are fine-tuning a "
+ "SuperAnimal model. Please look at the documentation to see how to create "
+ "a training dataset to fine-tune one of the SuperAnimal models. Ensure "
+ "that a conversion table is specified for your project and that you select"
+ "``with_decoder=True`` for your ``WeightInitialization``."
+ )
+
+ dataset = COCOPoseDataset(memory_replay_folder, "memory_replay_dataset")
+
+ # here we project the original DLC projects to superanimal space and save them into
+ # a coco project format
+ bodyparts = af.get_bodyparts(cfg)
+ sa_bodyparts = af.get_bodyparts(super_animal_cfg)
+ conversion_table = {}
+ for idx, bpt in enumerate(bodyparts):
+ conversion_table[bpt] = sa_bodyparts[weight_init.conversion_array[idx]]
+
+ dataset.project_with_conversion_table(
+ table_path=None,
+ table_dict=dict(
+ master_keypoints=sa_bodyparts,
+ conversion_table=conversion_table,
+ ),
+ )
+
+ dataset.materialize(
+ memory_replay_folder,
+ framework="coco",
+ deepcopy=False,
+ no_image_copy=True,
+ )
+
+ # then in this function, we do pseudo label to match prediction and gts to create
+ # memory-replay dataset that will be named memory_replay_train.json
+ prepare_memory_replay_dataset(
+ loader,
+ memory_replay_folder,
+ superanimal_name,
+ model_snapshot_path,
+ detector_snapshot_path,
+ max_individuals=max_individuals,
+ device=device,
+ train_file=train_file,
+ pose_threshold=pose_threshold,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py
new file mode 100644
index 0000000000..2c722676c4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.py
@@ -0,0 +1,99 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""File to train a model on a COCO dataset."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+
+from deeplabcut.pose_estimation_pytorch import COCOLoader, utils
+from deeplabcut.pose_estimation_pytorch.apis.training import train
+from deeplabcut.pose_estimation_pytorch.runners.logger import setup_file_logging
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def adaptation_train(
+ project_root: str | Path,
+ model_folder: str | Path,
+ train_file: str,
+ test_file: str,
+ model_config_path: str | Path,
+ device: str | None,
+ epochs: int | None,
+ save_epochs: int | None,
+ detector_epochs: int | None,
+ detector_save_epochs: int | None,
+ snapshot_path: str | None,
+ detector_path: str | None,
+ batch_size: int = 8,
+ detector_batch_size: int = 8,
+ eval_interval: int | None = None,
+ skip_detector: bool = False,
+):
+ setup_file_logging(Path(model_folder) / "log.txt")
+ loader = COCOLoader(
+ project_root=project_root,
+ model_config_path=model_config_path,
+ train_json_filename=train_file,
+ test_json_filename=test_file,
+ )
+
+ utils.fix_seeds(loader.model_cfg["train_settings"]["seed"])
+
+ updates = {
+ "model.backbone.freeze_bn_stats": True,
+ "runner.snapshots.max_snapshots": 5,
+ "runner.snapshots.save_epochs": save_epochs or 1,
+ "train_settings.batch_size": batch_size,
+ "train_settings.epochs": epochs or 4,
+ }
+ if not skip_detector:
+ updates.update(
+ {
+ "detector.model.freeze_bn_stats": True,
+ "detector.runner.snapshots.max_snapshots": 5,
+ "detector.runner.snapshots.save_epochs": detector_save_epochs or 1,
+ "detector.train_settings.batch_size": detector_batch_size,
+ "detector.train_settings.epochs": detector_epochs or 4,
+ }
+ )
+
+ if eval_interval is not None:
+ updates["runner.eval_interval"] = eval_interval
+
+ loader.update_model_cfg(updates)
+
+ pose_task = Task(loader.model_cfg["method"])
+ if pose_task == Task.TOP_DOWN and not skip_detector:
+ logger_config = None
+ if loader.model_cfg.get("logger"):
+ logger_config = copy.deepcopy(loader.model_cfg["logger"])
+ logger_config["run_name"] += "-detector"
+
+ if loader.model_cfg["detector"]["train_settings"]["epochs"] > 0:
+ train(
+ loader=loader,
+ run_config=loader.model_cfg["detector"],
+ task=Task.DETECT,
+ device=device,
+ logger_config=logger_config,
+ snapshot_path=detector_path,
+ )
+
+ train(
+ loader=loader,
+ run_config=loader.model_cfg,
+ task=pose_task,
+ device=device,
+ logger_config=loader.model_cfg.get("logger"),
+ snapshot_path=snapshot_path,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py
new file mode 100644
index 0000000000..80288040c6
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py
@@ -0,0 +1,211 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import inspect
+import subprocess
+import warnings
+from pathlib import Path
+
+import torch
+from dlclibrary import download_huggingface_model
+
+import deeplabcut.pose_estimation_pytorch.config.utils as config_utils
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import add_metadata
+from deeplabcut.utils import auxiliaryfunctions
+
+# COCO category ID for the "person" class.
+COCO_PERSON_CATEGORY_ID = 1
+
+
+def get_model_configs_folder_path() -> Path:
+ """Returns: the folder containing the SuperAnimal model configuration files"""
+ return Path(auxiliaryfunctions.get_deeplabcut_path()) / "modelzoo" / "model_configs"
+
+
+def get_project_configs_folder_path() -> Path:
+ """Returns: the folder containing the SuperAnimal project configuration files"""
+ return Path(auxiliaryfunctions.get_deeplabcut_path()) / "modelzoo" / "project_configs"
+
+
+def get_snapshot_folder_path() -> Path:
+ """Returns: the path to the folder containing the SuperAnimal model snapshots"""
+ return Path(auxiliaryfunctions.get_deeplabcut_path()) / "modelzoo" / "checkpoints"
+
+
+def get_super_animal_model_config_path(model_name: str) -> Path:
+ """Gets the path to the configuration file for a SuperAnimal model.
+
+ Args:
+ model_name: The name of the model for which to get the path.
+
+ Returns:
+ The path to the config file for a SuperAnimal model.
+ """
+ return get_model_configs_folder_path() / f"{model_name}.yaml"
+
+
+def get_super_animal_project_config_path(super_animal: str) -> Path:
+ """Gets the path to a SuperAnimal project configuration file.
+
+ Args:
+ super_animal: The name of the SuperAnimal for which to get the config path.
+
+ Returns:
+ The path to the config file for a SuperAnimal project.
+ """
+ return get_project_configs_folder_path() / f"{super_animal}.yaml"
+
+
+def get_super_animal_snapshot_path(
+ dataset: str,
+ model_name: str,
+ download: bool = True,
+) -> Path:
+ """Gets the path to the snapshot containing SuperAnimal model weights.
+
+ Args:
+ dataset: The name of the SuperAnimal dataset.
+ model_name: The name of the model.
+ download: Whether to download the weights if they aren't already there.
+
+ Returns:
+ The path to the weights for a SuperAnimal model.
+ """
+ model_path = get_snapshot_folder_path() / f"{dataset}_{model_name}.pt"
+ if download and not model_path.exists():
+ download_super_animal_snapshot(dataset, model_name)
+
+ return model_path
+
+
+def load_super_animal_config(
+ super_animal: str,
+ model_name: str,
+ detector_name: str | None = None,
+ max_individuals: int = 30,
+ device: str | None = None,
+) -> dict:
+ """Loads the model configuration file for a model, detector and SuperAnimal.
+
+ Args:
+ super_animal: The name of the SuperAnimal for which to create the model config.
+ model_name: The name of the model for which to create the model config.
+ detector_name: The name of the detector for which to create the model config.
+ max_individuals: The maximum number of detections to make in an image
+ device: The device to use to train/run inference on the model
+
+ Returns:
+ The model configuration for a SuperAnimal-pretrained model.
+ """
+ project_cfg_path = get_super_animal_project_config_path(super_animal=super_animal)
+ project_config = read_config_as_dict(project_cfg_path)
+
+ model_cfg_path = get_super_animal_model_config_path(model_name=model_name)
+ model_config = read_config_as_dict(model_cfg_path)
+ model_config = add_metadata(project_config, model_config, model_cfg_path)
+ model_config = update_config(model_config, max_individuals, device)
+
+ if detector_name is None and super_animal != "superanimal_humanbody":
+ model_config["method"] = "BU"
+ else:
+ model_config["method"] = "TD"
+ if super_animal != "superanimal_humanbody":
+ detector_cfg_path = get_super_animal_model_config_path(model_name=detector_name)
+ detector_cfg = read_config_as_dict(detector_cfg_path)
+ model_config["detector"] = detector_cfg
+ return model_config
+
+
+def download_super_animal_snapshot(dataset: str, model_name: str) -> Path:
+ """Downloads a SuperAnimal snapshot.
+
+ Args:
+ dataset: The name of the SuperAnimal dataset for which to download a snapshot.
+ model_name: The name of the model for which to download a snapshot.
+
+ Returns:
+ The path to the downloaded snapshot.
+
+ Raises:
+ RuntimeError if the model fails to download.
+ """
+ snapshot_dir = get_snapshot_folder_path()
+ model_name = f"{dataset}_{model_name}"
+ model_filename = f"{model_name}.pt"
+ model_path = snapshot_dir / model_filename
+
+ download_huggingface_model(
+ model_name,
+ target_dir=str(snapshot_dir),
+ rename_mapping={model_filename: model_filename},
+ )
+ if not model_path.exists():
+ raise RuntimeError(f"Failed to download {model_name} to {model_path}")
+
+ return snapshot_dir / f"{model_name}.pt"
+
+
+def get_gpu_memory_map():
+ """Get the current gpu usage."""
+ result = subprocess.check_output(
+ ["nvidia-smi", "--query-gpu=memory.free", "--format=csv,nounits,noheader"],
+ encoding="utf-8",
+ )
+ gpu_memory = [int(x) for x in result.strip().split("\n")]
+ gpu_memory_map = dict(zip(range(len(gpu_memory)), gpu_memory, strict=False))
+
+ return gpu_memory_map
+
+
+def select_device():
+ if torch.cuda.is_available():
+ return torch.device("cuda:0")
+ else:
+ return torch.device("cpu")
+
+
+def raise_warning_if_called_directly():
+ current_frame = inspect.currentframe()
+ caller_frame = inspect.getouterframes(current_frame, 2)
+ caller_name = caller_frame[1].filename
+
+ if "pose_estimation_" not in caller_name:
+ warnings.warn(
+ f"{caller_name} is intended for internal use only and should not be called directly.",
+ UserWarning,
+ stacklevel=2,
+ )
+
+
+def update_config(config: dict, max_individuals: int, device: str):
+ """Loads the model configuration file for a model, detector and SuperAnimal.
+
+ Args:
+ config: The default model configuration file.
+ max_individuals: The maximum number of detections to make in an image
+ device: The device to use to train/run inference on the model
+
+ Returns:
+ The model configuration for a SuperAnimal-pretrained model.
+ """
+ config = config_utils.replace_default_values(
+ config,
+ num_bodyparts=len(config["metadata"]["bodyparts"]),
+ num_individuals=max_individuals,
+ backbone_output_channels=config["model"]["backbone_output_channels"],
+ )
+ config["metadata"]["individuals"] = [f"animal{i}" for i in range(max_individuals)]
+
+ config["device"] = device
+ if config.get("detector", None) is not None:
+ config["detector"]["device"] = device
+
+ return config
diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py
new file mode 100644
index 0000000000..25f07ebbf1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/__init__.py
@@ -0,0 +1,14 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from deeplabcut.pose_estimation_pytorch.post_processing.match_predictions_to_gt import (
+ oks_match_prediction_to_gt,
+ rmse_match_prediction_to_gt,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/identity.py b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py
new file mode 100644
index 0000000000..f56b888b6b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/identity.py
@@ -0,0 +1,45 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Functions to assign identity to predictions from an identity head."""
+
+from __future__ import annotations
+
+import numpy as np
+from scipy.optimize import linear_sum_assignment
+
+
+def assign_identity(predictions: np.ndarray, identity_scores: np.ndarray) -> np.ndarray:
+ """
+ Args:
+ predictions: Pose predictions for an image, with shape (num_individuals,
+ num_bodyparts, 3)
+ identity_scores: Identity predictions for keypoints in an image, of shape
+ (num_individuals, num_bodyparts, num_individuals).
+
+ Returns:
+ The ordering to use to match predictions to identities.
+ """
+ if not len(predictions) == len(identity_scores):
+ raise ValueError(
+ "There are not the same number of predictions as identity scores"
+ f" ({len(predictions)} != {len(identity_scores)}"
+ )
+
+ # average of ID scores, weighted by keypoint confidence
+ pose_conf = predictions[:, :, 2:3]
+ cost_matrix = np.mean(pose_conf * identity_scores, axis=1)
+
+ row_ind, col_ind = linear_sum_assignment(cost_matrix, maximize=True)
+ new_order = np.zeros_like(row_ind)
+ for old_pos, new_pos in zip(row_ind, col_ind, strict=True):
+ new_order[new_pos] = old_pos
+
+ return new_order
diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py
new file mode 100644
index 0000000000..1c49d97558
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py
@@ -0,0 +1,184 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import numpy as np
+from scipy.optimize import linear_sum_assignment
+
+from deeplabcut.core.inferenceutils import (
+ calc_object_keypoint_similarity,
+)
+
+
+def rmse_match_prediction_to_gt(pred_kpts: np.ndarray, gt_kpts: np.ndarray) -> np.ndarray:
+ """Hungarian algorithm predicted individuals to ground truth ones, using root mean
+ squared error (rmse). The function provides a way to match predicted individuals to
+ ground truth individuals based on the rmse distance between their corresponding
+ keypoints. This algorithm is used to find the optimal matching, taking into account
+ the potential missing animal.
+
+ Raises:
+ ValueError: if `gt_kpts.shape != pred_kpts.shape`
+
+ Args:
+ pred_kpts: shape (num_predictions, num_keypoints, 3), ground truth keypoints for
+ an image, where the 3 values are (x,y,score) for each keypoint
+ gt_kpts: shape (num_individuals, num_keypoints, 3), ground truth keypoints for
+ an image, where the 3 values are (x,y,visibility) for each keypoint
+
+ Returns:
+ col_ind: array of the individuals indices for prediction
+ """
+ num_pred, num_keypoints, _ = pred_kpts.shape
+ num_idv, num_keypoints_gt, _ = gt_kpts.shape
+ if num_keypoints + 1 == num_keypoints_gt:
+ gt_kpts = gt_kpts[:, :-1, :].copy()
+ elif num_keypoints == num_keypoints_gt:
+ gt_kpts = gt_kpts.copy()
+ else:
+ raise ValueError("Shape mismatch between ground truth and predictions")
+
+ valid_gt = np.any(gt_kpts[..., 2] > 0, axis=1)
+ valid_gt_indices = np.nonzero(valid_gt)[0]
+ if len(valid_gt_indices) == 0:
+ return np.arange(num_idv)
+
+ valid_pred = np.any(pred_kpts[..., 2] > 0, axis=1)
+ valid_pred_indices = np.nonzero(valid_pred)[0]
+ if len(valid_pred_indices) == 0:
+ return np.arange(num_idv)
+
+ distance_matrix = np.full((len(valid_gt_indices), len(valid_pred_indices)), np.nan)
+ for i, gt_idx in enumerate(valid_gt_indices):
+ gt_idv = gt_kpts[gt_idx]
+ mask = gt_idv[:, 2] > 0
+ for j, pred_idx in enumerate(valid_pred_indices):
+ pred_idv = pred_kpts[pred_idx]
+ d = (gt_idv[mask, :2] - pred_idv[mask, :2]) ** 2
+ if np.any(~np.isnan(d)):
+ distance_matrix[i, j] = np.nanmean(d)
+
+ if np.all(np.isnan(distance_matrix)):
+ return np.arange(num_idv)
+
+ # np.inf and np.nan in linear_sum_assignment raises error; so when a prediction
+ # cannot be assigned to a ground truth (e.g. with PAFs, where predicted bodyparts
+ # can be NaN) set the distance to a distance greater than the maximum distance
+ max_dist = np.nanmax(distance_matrix)
+ distance_matrix = np.nan_to_num(distance_matrix, nan=100 * max_dist)
+ _, col_ind = linear_sum_assignment(distance_matrix) # len == len(valid_gt_indices)
+
+ gt_idx_to_pred_idx = {
+ valid_gt_indices[valid_gt_index]: valid_pred_indices[valid_pred_index]
+ for valid_gt_index, valid_pred_index in enumerate(col_ind)
+ }
+ matched_pred = {valid_pred_indices[i] for i in col_ind}
+ unmatched_pred = [i for i in range(num_idv) if i not in matched_pred]
+ next_unmatched = 0
+ col_ind = []
+ for gt_index in range(num_idv):
+ if gt_index in gt_idx_to_pred_idx:
+ col_ind.append(gt_idx_to_pred_idx[gt_index])
+ else:
+ col_ind.append(unmatched_pred[next_unmatched])
+ next_unmatched += 1
+
+ return np.array(col_ind)
+
+
+def oks_match_prediction_to_gt(pred_kpts: np.array, gt_kpts: np.array, individual_names: list) -> np.array:
+ """Summary:
+ Hungarian algorithm predicted individuals to ground truth ones, using object keypoint similarity (oks).
+ Oks measures the accuracy of predicted keypoints compared to ground truth keypoints.
+ More information about oks can be found in cocodataset (https://cocodataset.org/#keypoints-eval).
+
+ Args:
+ pred_kpts: Predicted keypoints for each animal. The shape of the array is (num_animals, num_keypoints, 3):
+ num_animals: Number of animals.
+ num_keypoints: Number of keypoints.
+ 3: (x, y, score) coordinates of each keypoint.
+ gt_kpts: Ground truth keypoints for each animal. The shape of the array is (num_animals, num_keypoints(+1 if
+ with center), 2):
+ num_animals: Number of animals.
+ num_keypoints: Number of keypoints.
+ individual_names: names of individuals
+
+ Returns:
+ col_ind: Array of the individual indexes for prediction.
+
+ Examples:
+ input:
+ pred_kpts = np.array(...)
+ gt_kpts = np.array(...)
+ individual_names = [...]
+ output:
+ col_ind = np.array([...])
+ """
+
+ num_animals, num_keypoints, _ = pred_kpts.shape
+ if num_keypoints + 1 == gt_kpts.shape[1]:
+ gt_kpts_without_ctr = gt_kpts[:, :-1, :].copy()
+ elif num_keypoints == gt_kpts.shape[1]:
+ gt_kpts_without_ctr = gt_kpts.copy()
+ else:
+ raise ValueError("Shape mismatch between ground truth and predictions")
+
+ # Computation of the number of annotated animals in the ground truth
+ num_animals_gt = num_animals
+ for animal_index in range(num_animals):
+ if (gt_kpts_without_ctr[animal_index] < 0).all():
+ num_animals_gt -= 1
+
+ oks_matrix = np.zeros((num_animals_gt, num_animals))
+ gt_kpts_without_ctr[gt_kpts_without_ctr < 0] = np.nan # non visible keypoints should be nan to use calc_oks
+ idx_gt = -1
+ for g in range(num_animals):
+ if np.isnan(gt_kpts_without_ctr[g]).all():
+ continue
+ else:
+ idx_gt += 1
+ for p in range(num_animals):
+ oks_matrix[idx_gt, p] = calc_object_keypoint_similarity(
+ pred_kpts[p, :, :2],
+ gt_kpts_without_ctr[g],
+ 0.1,
+ margin=0,
+ symmetric_kpts=None, # TODO take into account symmetric keypoints
+ )
+
+ row_ind, col_ind = linear_sum_assignment(oks_matrix, maximize=True)
+ # if animals are missing in the frame, the predictions corresponding to nothing are not shuffled
+ col_ind = extend_col_ind(col_ind, num_animals)
+
+ return col_ind
+
+
+def extend_col_ind(col_ind: np.array, num_animals: int) -> np.array:
+ """Summary:
+ Extends the column indices of a 1D array, col_ind, by adding any missing column indices from 0 to num_animals-1.
+
+ Args:
+ col_ind: 1D array of column indices
+ num_animals: total number of animals
+
+ Returns:
+ extended_array: extended 1D array of column indices
+
+ Examples:
+ input:
+ col_ind =
+ num_animals = 5
+ output:
+ extended_array =
+ """
+ existing_cols = set(col_ind) # Convert the array to a set for faster lookup
+ missing_cols = [num for num in range(num_animals) if num not in existing_cols]
+ extended_array = np.concatenate((col_ind, missing_cols)).astype(int)
+ return extended_array
diff --git a/deeplabcut/pose_estimation_pytorch/post_processing/nms.py b/deeplabcut/pose_estimation_pytorch/post_processing/nms.py
new file mode 100644
index 0000000000..f8a1dad015
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/nms.py
@@ -0,0 +1,93 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Methods for non-maximum suppression of detected poses."""
+
+import numpy as np
+
+from deeplabcut.core.inferenceutils import calc_object_keypoint_similarity
+
+
+def nms_oks(
+ predictions: np.ndarray,
+ oks_threshold: float,
+ oks_sigmas: float | np.ndarray = 0.1,
+ oks_margin: float = 1.0,
+ score_threshold: float | None = None,
+ order: np.ndarray | None = None,
+) -> np.ndarray:
+ """Implementation of NMS using OKS.
+
+ Args:
+ predictions: The predicted poses, of shape (num_predictions, num_keypoints, 3).
+ oks_threshold: The threshold for NMS. Keeps predictions for which the OKS score
+ is below this threshold.
+ oks_sigmas: The sigmas to use to compute OKS scores.
+ oks_margin: The margin to add around keypoints when computing area.
+ score_threshold: If not None, computes NMS using only keypoints for which the
+ score is above this threshold.
+ order: If predictions should be sorted by another means than score, the order
+ to use in NMS.
+
+ Returns:
+ An array of length num_predictions indicating which keypoints should be kept.
+ """
+ if len(predictions) == 0:
+ return np.zeros(0, dtype=bool)
+ elif len(predictions) == 1:
+ return np.ones(1, dtype=bool)
+
+ predictions = predictions.copy()
+
+ # mask keypoints with score below the threshold
+ if score_threshold is None:
+ score_threshold = 0.0
+ predictions[predictions[:, :, 2] < score_threshold] = np.nan
+
+ # get visibility masks for the keypoints and individuals
+ kpt_vis = np.all(~np.isnan(predictions), axis=-1)
+ idv_vis = np.sum(kpt_vis, axis=-1) > 1 # need at least 2 keypoints to compute OKS
+
+ # if no keypoints match the visibility criteria, mask all
+ if np.sum(idv_vis) == 0:
+ return np.zeros(len(predictions), dtype=bool)
+
+ # mask keypoints that aren't visible
+ predictions[~kpt_vis] = np.nan
+
+ if order is None:
+ # compute scores for each individual
+ scores = np.zeros(len(predictions))
+ scores[idv_vis] = np.nanmean(predictions[idv_vis, :, 2], axis=-1)
+
+ # only compute OKS for non-zero score poses
+ order = scores.argsort()[::-1]
+ order = order[scores[order] > 0]
+
+ # NMS suppression
+ keep = np.zeros(len(predictions), dtype=bool)
+ while len(order) > 0:
+ i = order[0]
+ order = order[1:]
+ keep[i] = True
+
+ oks_scores = [
+ calc_object_keypoint_similarity(
+ predictions[i],
+ predictions[j],
+ sigma=oks_sigmas,
+ margin=oks_margin,
+ )
+ for j in order
+ ]
+ to_keep = [s < oks_threshold and not np.isnan(s) for s in oks_scores]
+ order = [idx for idx, kept in zip(order, to_keep, strict=False) if kept]
+
+ return keep
diff --git a/deeplabcut/pose_estimation_pytorch/registry.py b/deeplabcut/pose_estimation_pytorch/registry.py
new file mode 100644
index 0000000000..8cae38140b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/registry.py
@@ -0,0 +1,335 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import inspect
+from functools import partial
+from typing import Any
+
+
+def build_from_cfg(cfg: dict, registry: "Registry", default_args: dict | None = None) -> Any:
+ """Builds a module from the configuration dictionary when it represents a class
+ configuration, or call a function from the configuration dictionary when it
+ represents a function configuration.
+
+ Args:
+ cfg: Configuration dictionary. It should at least contain the key "type".
+ registry: The registry to search the type from.
+ default_args: Default initialization arguments.
+ Defaults to None.
+
+ Returns:
+ Any: The constructed object.
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+ >>> class Model:
+ >>> def __init__(self, param):
+ >>> self.param = param
+ >>> cfg = {"type": "Model", "param": 10}
+ >>> registry = Registry("models")
+ >>> registry.register_module(Model)
+ >>> obj = build_from_cfg(cfg, registry)
+ >>> assert isinstance(obj, Model)
+ >>> assert obj.param == 10
+ """
+
+ args = cfg.copy()
+
+ if default_args is not None:
+ for name, value in default_args.items():
+ args.setdefault(name, value)
+
+ obj_type = args.pop("type")
+ if isinstance(obj_type, str):
+ obj_cls = registry.get(obj_type)
+ if obj_cls is None:
+ raise KeyError(f"{obj_type} is not in the {registry.name} registry")
+ elif inspect.isclass(obj_type) or inspect.isfunction(obj_type):
+ obj_cls = obj_type
+ else:
+ raise TypeError(f"type must be a str or valid type, but got {type(obj_type)}")
+ try:
+ sig = inspect.signature(obj_cls.__init__ if inspect.isclass(obj_cls) else obj_cls)
+ accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
+ valid_params = {
+ p
+ for p, param in sig.parameters.items()
+ if param.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) and p != "self"
+ }
+ filtered_args = {k: v for k, v in args.items() if accepts_kwargs or k in valid_params}
+ return obj_cls(**filtered_args)
+ except Exception as e:
+ # Normal TypeError does not print class name.
+ raise type(e)(f"{obj_cls.__name__}: {e}") from None
+
+
+class Registry:
+ """A registry to map strings to classes or functions. Registered objects could be
+ built from the registry. Meanwhile, registered functions could be called from the
+ registry.
+
+ Args:
+ name: Registry name.
+ build_func: Builds function to construct an instance from
+ the Registry. If neither ``parent`` nor
+ ``build_func`` is specified, the ``build_from_cfg``
+ function is used. If ``parent`` is specified and
+ ``build_func`` is not given, ``build_func`` will be
+ inherited from ``parent``. Default: None.
+ parent: Parent registry. The class registered in
+ children's registry could be built from the parent.
+ Default: None.
+ scope: The scope of the registry. It is the key to search
+ for children's registry. If not specified, scope will be the
+ name of the package where the class is defined, e.g. mmdet, mmcls, mmseg.
+ Default: None.
+
+ Attributes:
+ name: Registry name.
+ module_dict: The dictionary containing registered modules.
+ children: The dictionary containing children registries.
+ scope: The scope of the registry.
+ """
+
+ def __init__(self, name, build_func=None, parent=None, scope=None):
+ self._name = name
+ self._module_dict = dict()
+ self._children = dict()
+ self._scope = "."
+
+ if build_func is None:
+ if parent is not None:
+ self.build_func = parent.build_func
+ else:
+ self.build_func = build_from_cfg
+ else:
+ self.build_func = build_func
+ if parent is not None:
+ assert isinstance(parent, Registry)
+ parent._add_children(self)
+ self.parent = parent
+ else:
+ self.parent = None
+
+ def __len__(self):
+ return len(self._module_dict)
+
+ def __contains__(self, key):
+ return self.get(key) is not None
+
+ def __repr__(self):
+ format_str = self.__class__.__name__ + f"(name={self._name}, items={self._module_dict})"
+ return format_str
+
+ @staticmethod
+ def split_scope_key(key):
+ """Split scope and key.
+
+ The first scope will be split from key.
+ Examples:
+ >>> Registry.split_scope_key('mmdet.ResNet')
+ 'mmdet', 'ResNet'
+ >>> Registry.split_scope_key('ResNet')
+ None, 'ResNet'
+ Return:
+ tuple[str | None, str]: The former element is the first scope of
+ the key, which can be ``None``. The latter is the remaining key.
+ """
+ split_index = key.find(".")
+ if split_index != -1:
+ return key[:split_index], key[split_index + 1 :]
+ else:
+ return None, key
+
+ @property
+ def name(self):
+ return self._name
+
+ @property
+ def scope(self):
+ return self._scope
+
+ @property
+ def module_dict(self):
+ return self._module_dict
+
+ @property
+ def children(self):
+ return self._children
+
+ def get(self, key):
+ """Get the registry record.
+
+ Args:
+ key: The class name in string format.
+
+ Returns:
+ class: The corresponding class.
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry
+ >>> registry = Registry("models")
+ >>> class Model:
+ >>> pass
+ >>> registry.register_module(Model, "Model")
+ >>> assert registry.get("Model") == Model
+ """
+ scope, real_key = self.split_scope_key(key)
+ if scope is None or scope == self._scope:
+ # get from self
+ if real_key in self._module_dict:
+ return self._module_dict[real_key]
+ else:
+ # get from self._children
+ if scope in self._children:
+ return self._children[scope].get(real_key)
+ else:
+ # goto root
+ parent = self.parent
+ while parent.parent is not None:
+ parent = parent.parent
+ return parent.get(key)
+
+ def build(self, *args, **kwargs):
+ """Builds an instance from the registry.
+
+ Args:
+ *args: Arguments passed to the build function.
+ **kwargs: Keyword arguments passed to the build function.
+
+ Returns:
+ Any: The constructed object.
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry, build_from_cfg
+ >>> class Model:
+ >>> def __init__(self, param):
+ >>> self.param = param
+ >>> cfg = {"type": "Model", "param": 10}
+ >>> registry = Registry("models")
+ >>> registry.register_module(Model)
+ >>> obj = registry.build(cfg, param=20)
+ >>> assert isinstance(obj, Model)
+ >>> assert obj.param == 20
+ """
+ return self.build_func(*args, **kwargs, registry=self)
+
+ def _add_children(self, registry):
+ """Add children for a registry.
+
+ Args:
+ registry: The registry to be added as children based on its scope.
+
+ Returns:
+ None
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry
+ >>> models = Registry('models')
+ >>> mmdet_models = Registry('models', parent=models)
+ >>> class Model:
+ >>> pass
+ >>> mmdet_models.register_module(Model)
+ >>> obj = models.build(dict(type='mmdet.Model'))
+ >>> assert isinstance(obj, Model)
+ """
+ assert isinstance(registry, Registry)
+ assert registry.scope is not None
+ assert registry.scope not in self.children, f"scope {registry.scope} exists in {self.name} registry"
+ self.children[registry.scope] = registry
+
+ def _register_module(self, module, module_name=None, force=False):
+ """Register a module.
+
+ Args:
+ module: Module class or function to be registered.
+ module_name: The module name(s) to be registered.
+ If not specified, the class name will be used.
+ force: Whether to override an existing class with the same name.
+ Default: False.
+
+ Returns:
+ None
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry
+ >>> registry = Registry("models")
+ >>> class Model:
+ >>> pass
+ >>> registry._register_module(Model, "Model")
+ >>> assert registry.get("Model") == Model
+ """
+ if not inspect.isclass(module) and not inspect.isfunction(module):
+ raise TypeError(f"module must be a class or a function, but got {type(module)}")
+
+ if module_name is None:
+ module_name = module.__name__
+ if isinstance(module_name, str):
+ module_name = [module_name]
+ for name in module_name:
+ if not force and name in self._module_dict:
+ raise KeyError(f"{name} is already registered in {self.name}")
+ self._module_dict[name] = module
+
+ def deprecated_register_module(self, cls=None, force=False):
+ """Decorator to register a class in the registry.
+
+ Args:
+ cls: The class to be registered.
+ force: Whether to override an existing class with the same name.
+ Default: False.
+
+ Returns:
+ type: The input class.
+
+ Example:
+ >>> from deeplabcut.pose_estimation_pytorch.registry import Registry
+ >>> registry = Registry("models")
+ >>> @registry.deprecated_register_module()
+ >>> class Model:
+ >>> pass
+ >>> assert registry.get("Model") == Model
+ """
+ if cls is None:
+ return partial(self.deprecated_register_module, force=force)
+ self._register_module(cls, force=force)
+ return cls
+
+ def register_module(self, name=None, force=False, module=None):
+ """Register a module.
+
+ A record will be added to `self._module_dict`, whose key is the class
+ name or the specified name, and value is the class itself.
+ It can be used as a decorator or a normal function.
+ Args:
+ name: The module name to be registered. If not
+ specified, the class name will be used.
+ force: Whether to override an existing class with
+ the same name. Default: False.
+ module: Module class or function to be registered.
+ """
+ if not isinstance(force, bool):
+ raise TypeError(f"force must be a boolean, but got {type(force)}")
+ # NOTE: This is a walkaround to be compatible with the old api,
+ # while it may introduce unexpected bugs.
+ if isinstance(name, type):
+ return self.deprecated_register_module(name, force=force)
+
+ # use it as a normal method: x.register_module(module=SomeClass)
+ if module is not None:
+ self._register_module(module=module, module_name=name, force=force)
+ return module
+
+ # use it as a decorator: @x.register_module()
+ def _register(module):
+ self._register_module(module=module, module_name=name, force=force)
+ return module
+
+ return
diff --git a/deeplabcut/pose_estimation_pytorch/runners/__init__.py b/deeplabcut/pose_estimation_pytorch/runners/__init__.py
new file mode 100644
index 0000000000..fff68b2d50
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/__init__.py
@@ -0,0 +1,37 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from deeplabcut.pose_estimation_pytorch.runners.base import (
+ Runner,
+ attempt_snapshot_load,
+ fix_snapshot_metadata,
+ get_load_weights_only,
+ set_load_weights_only,
+)
+from deeplabcut.pose_estimation_pytorch.runners.ctd import CTDTrackingConfig
+from deeplabcut.pose_estimation_pytorch.runners.dynamic_cropping import (
+ DynamicCropper,
+ TopDownDynamicCropper,
+)
+from deeplabcut.pose_estimation_pytorch.runners.inference import (
+ DetectorInferenceRunner,
+ InferenceRunner,
+ PoseInferenceRunner,
+ build_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.runners.logger import LOGGER
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import TorchSnapshotManager
+from deeplabcut.pose_estimation_pytorch.runners.train import (
+ DetectorTrainingRunner,
+ PoseTrainingRunner,
+ TrainingRunner,
+ build_training_runner,
+)
diff --git a/deeplabcut/pose_estimation_pytorch/runners/base.py b/deeplabcut/pose_estimation_pytorch/runners/base.py
new file mode 100644
index 0000000000..f7f0f3da96
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/base.py
@@ -0,0 +1,226 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import logging
+import os
+import pickle
+from abc import ABC
+from pathlib import Path
+from typing import Generic, TypeVar
+
+import numpy as np
+import torch
+import torch.nn as nn
+
+ModelType = TypeVar("ModelType", bound=nn.Module)
+
+_load_weights_only: bool = os.getenv("TORCH_LOAD_WEIGHTS_ONLY", "true").lower() in (
+ "true",
+ "1",
+)
+
+
+def get_load_weights_only() -> bool:
+ """Gets the default value to use when loading snapshots with `torch.load(...)`.
+
+ Returns:
+ The default `weights_only` value when loading snapshots using `torch.load(...)`.
+ """
+ global _load_weights_only
+ return _load_weights_only
+
+
+def set_load_weights_only(value: bool) -> None:
+ """Sets the default value to use when loading snapshots with `torch.load(...)`.
+
+ Args:
+ value: The default `weights_only` value to use when loading snapshots using
+ `torch.load(...)`.
+ """
+ global _load_weights_only
+ _load_weights_only = value
+
+
+class Runner(ABC, Generic[ModelType]):
+ """Runner base class.
+
+ A runner takes a model and runs actions on it, such as training or inference
+ """
+
+ def __init__(
+ self,
+ model: ModelType,
+ device: str = "cpu",
+ gpus: list[int] | None = None,
+ snapshot_path: str | Path | None = None,
+ ):
+ """
+ Args:
+ model: the model to run
+ device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'})
+ gpus: the list of GPU indices to use for multi-GPU training
+ snapshot_path: the path of a snapshot from which to load model weights
+ """
+ if gpus is None:
+ gpus = []
+
+ if len(gpus) == 1:
+ if device != "cuda":
+ raise ValueError(
+ f"When specifying a GPU index to train on, the device must be set to 'cuda'. Found {device}"
+ )
+ device = f"cuda:{gpus[0]}"
+
+ self.model = model
+ self.device = device
+ self.snapshot_path = snapshot_path
+ self._gpus = gpus
+ self._data_parallel = len(gpus) > 1
+
+ @staticmethod
+ def load_snapshot(
+ snapshot_path: str | Path,
+ device: str,
+ model: ModelType,
+ weights_only: bool | None = None,
+ ) -> dict:
+ """Loads the state dict for a model from a file.
+
+ This method loads a file containing a DeepLabCut PyTorch model snapshot onto
+ a given device, and sets the model weights using the state_dict.
+
+ Args:
+ snapshot_path: The path containing the model weights to load
+ device: The device on which the model should be loaded
+ model: The model for which the weights are loaded
+ weights_only: Value for torch.load() `weights_only` parameter.
+ If False, the python pickle module is used implicitly, which is known to
+ be insecure. Only set to False if you're loading data that you trust
+ (e.g. snapshots that you created yourself). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+
+ Returns:
+ The content of the snapshot file.
+ """
+ snapshot = attempt_snapshot_load(snapshot_path, device, weights_only)
+ model.load_state_dict(snapshot["model"])
+ return snapshot
+
+
+def attempt_snapshot_load(
+ path: str | Path,
+ device: str,
+ weights_only: bool | None = None,
+) -> dict:
+ """Attempts to load a snapshot using `torch.load(...)`.
+
+ Args:
+ path: The path of the snapshot to try to load..
+ device: The device to use for the `map_location`.
+ weights_only: Value for torch.load() `weights_only` parameter.
+ If False, the python pickle module is used implicitly, which is known to be
+ insecure. Only set to False if you're loading data that you trust (e.g.
+ snapshots that you created yourself). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+
+ Returns:
+ The loaded snapshot.
+
+ Raises:
+ pickle.UnpicklingError: If `weights_only=True` but the snapshot failed to load
+ with `weights_only=True`.
+ """
+ try:
+ if weights_only is None:
+ weights_only = get_load_weights_only()
+
+ snapshot = torch.load(path, map_location=device, weights_only=weights_only)
+ except pickle.UnpicklingError as err:
+ logging.error(
+ f"\nFailed to load the snapshot: {path}.\n\n"
+ "If you trust the snapshot that you're trying to load, you can try\n"
+ "calling `Runner.load_snapshot` with `weights_only=False`. See the \n"
+ "error message below for more information and warnings.\n"
+ "You can set the `weights_only` parameter in the model configuration (\n"
+ "the content of the pytorch_config.yaml), as:\n\n```\n"
+ "runner:\n"
+ " load_weights_only: False\n```\n\n"
+ "If it's the detector snapshot that's failing to load, place the\n"
+ "`load_weights_only` key under the detector runner:\n\n```\n"
+ "detector:\n"
+ " runner:\n"
+ " load_weights_only: False\n```\n\n"
+ "You can also set the default `load_weights_only` that will be used when\n"
+ "the `load_weights_only` variable is not set in the `pytorch_config.yaml`\n"
+ "using `deeplabcut.pose_estimation_pytorch.set_load_weights_only(value)`:\n"
+ "\n```\n"
+ "from deeplabcut.pose_estimation_pytorch import set_load_weights_only\n"
+ "set_load_weights_only(True)\n"
+ "```\n\n"
+ "You can also set the value for `load_weights_only` with a \n"
+ "`TORCH_LOAD_WEIGHTS_ONLY` environment variable. If you call \n"
+ "`TORCH_LOAD_WEIGHTS_ONLY=False python -m deeplabcut`, it will launch the\n"
+ "DeepLabCut GUI with the default `load_weights_only` value to False.\n"
+ "If you set this value to `False`, make sure you only load snapshots that\n"
+ "you trust.\n\n"
+ )
+ raise err
+
+ return snapshot
+
+
+def fix_snapshot_metadata(path: str | Path) -> None:
+ """Replace numpy floats in snapshot metrics.
+
+ Only call this method with snapshots that you trust, as torch.load(...) is called
+ with `weights_only=False`. For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+
+ DeepLabCut PyTorch snapshots trained with older releases may have `numpy` floats in
+ the stored metrics. This method opens the snapshots (with `weights_only=False`),
+ replaces the numpy floats with python floats (allowing to load with
+ `weights_only=True`), and saves the new snapshot data.
+
+ Warning: This overwrites your existing snapshot. If you want to ensure that no data
+ is lost, copy your snapshot before calling `fix_snapshot_metadata`.
+
+ Args:
+ path: The path of the snapshot to fix.
+ """
+ snapshot = torch.load(path, map_location="cpu", weights_only=False)
+ metrics = snapshot.get("metadata", {}).get("metrics")
+ if metrics is not None:
+ snapshot["metadata"]["metrics"] = {k: float(v) for k, v in metrics.items()}
+
+ torch.save(snapshot, path)
+
+
+def _add_numpy_to_torch_safe_globals():
+ """Attempts tot add numpy classes allowing snapshots containing numpy floats in the
+ metrics to be loaded without needing to change the `weights_only` argument.
+
+ This fix only works for `numpy>=1.25.0`.
+ """
+ try:
+ from numpy.core.multiarray import scalar
+ from numpy.dtypes import Float64DType
+
+ torch.serialization.add_safe_globals([np.dtype, Float64DType, scalar])
+ except Exception:
+ pass
+
+
+_add_numpy_to_torch_safe_globals()
diff --git a/deeplabcut/pose_estimation_pytorch/runners/ctd.py b/deeplabcut/pose_estimation_pytorch/runners/ctd.py
new file mode 100644
index 0000000000..a8ae4d27e2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/ctd.py
@@ -0,0 +1,87 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Configuration for CTD tracking."""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class CTDTrackingConfig:
+ """Configuration for CTD tracking.
+
+ Args:
+ bu_on_lost_idv: When True, the BU model is run when there are fewer conditions
+ found than the expected number of individuals in the video.
+ bu_min_frequency: The minimum frequency at which the BU model is run to generate
+ conditions. If None, the BU model is only run to initialize the pose in the
+ first frame, and then is not run again. If a positive number N, the BU model
+ is run every N frames. The BU predictions are then combined with the CTD
+ predictions to continue the tracklets.
+ bu_max_frequency: The maximum frequency at which the BU model can be run. Must
+ be greater than `bu_min_frequency`. When there are fewer conditions than
+ individuals expected in the video and `bu_on_lost_idv` is True, the BU model
+ may be run on every frame. This can happen if individuals can disappear from
+ the video, and each frame may have a variable number of individuals. If
+ `bu_max_frequency` is set to N, then the BU model will be run at most every
+ N-th frame, which improves the inference speed of the model.
+ threshold_bu_add: The OKS threshold below which a BU pose must be (wrt. any
+ existing CTD pose) to be added to the poses.
+ threshold_ctd: The score threshold below which detected keypoints are NOT given
+ to the CTD model to predict pose for the next frame.
+ threshold_nms: The OKS threshold for non-maximum suppression to remove
+ duplicates poses when two CTD model predictions converge to a single animal.
+ """
+
+ bu_on_lost_idv: bool = True
+ bu_min_frequency: int | None = None
+ bu_max_frequency: int | None = 100
+ threshold_bu_add: float = 0.25
+ threshold_ctd: float = 0.01
+ threshold_nms: float = 0.9
+
+ @staticmethod
+ def build(config: dict, video_fps: float | None = None) -> "CTDTrackingConfig":
+ """Builds a CTD tracking configuration from a configuration dictionary.
+
+ Examples:
+ Building a CTDTrackingConfig from a basic dict:
+ >>> ctd_tracking = CTDTrackingConfig.build(
+ >>> dict(bu_on_lost_idv=True, threshold_nms=0.75)
+ >>> )
+
+ Building a CTDTrackingConfig from a basic dict:
+ >>> ctd_tracking = CTDTrackingConfig.build(
+ >>> dict(
+ >>> bu_on_lost_idv=True,
+ >>> bu_max_frequency=5, # When no FPS is given, this is in frames!
+ >>> threshold_nms=0.5,
+ >>> )
+ >>> )
+
+ Building a CTDTrackingConfig from a dict for a video with a given FPS:
+ >>> ctd_tracking = CTDTrackingConfig.build(
+ >>> dict(
+ >>> bu_on_lost_idv=True,
+ >>> bu_min_frequency=1, # When an FPS is given, this is in seconds!
+ >>> bu_max_frequency=5, # When an FPS is given, this is in seconds!
+ >>> threshold_ctd=0.1,
+ >>> threshold_nms=0.9
+ >>> ),
+ >>> video_fps=30.0,
+ >>> )
+ """
+ kwargs = {**config}
+ if video_fps is not None:
+ if "bu_min_frequency" in config:
+ kwargs["bu_min_frequency"] = int(config["bu_min_frequency"] * video_fps)
+ if "bu_max_frequency" in config:
+ kwargs["bu_max_frequency"] = int(config["bu_max_frequency"] * video_fps)
+ return CTDTrackingConfig(**kwargs)
diff --git a/deeplabcut/pose_estimation_pytorch/runners/dynamic_cropping.py b/deeplabcut/pose_estimation_pytorch/runners/dynamic_cropping.py
new file mode 100644
index 0000000000..3a5e09b733
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/dynamic_cropping.py
@@ -0,0 +1,535 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Modules to dynamically crop individuals out of videos to improve video analysis."""
+
+import math
+from dataclasses import dataclass, field
+from typing import Optional
+
+import torch
+import torchvision.transforms.functional as F
+
+
+@dataclass
+class DynamicCropper:
+ """If the state is true, then dynamic cropping will be performed. That means that if
+ an object is detected (i.e. any body part > detection threshold), then object
+ boundaries are computed according to the smallest/largest x position and
+ smallest/largest y position of all body parts. This window is expanded by the margin
+ and from then on only the posture within this crop is analyzed (until the object is
+ lost, i.e. < detection threshold). The current position is utilized for updating the
+ crop window for the next frame (this is why the margin is important and should be
+ set large enough given the movement of the animal).
+
+ Attributes:
+ threshold: float
+ The threshold score for bodyparts above which an individual is deemed to
+ have been detected.
+ margin: int
+ The margin used to expand an individuals bounding box before cropping it.
+
+ Examples:
+ >>> import deeplabcut.pose_estimation_pytorch.models as models
+ >>>
+ >>> model: models.PoseModel
+ >>> frames: torch.Tensor # shape (num_frames, 3, H, W)
+ >>>
+ >>> dynamic = DynamicCropper(threshold=0.6, margin=25)
+ >>> predictions = []
+ >>> for image in frames:
+ >>> image = dynamic.crop(image)
+ >>>
+ >>> outputs = model(image)
+ >>> preds = model.get_predictions(outputs)
+ >>> pose = preds["bodypart"]["poses"]
+ >>>
+ >>> dynamic.update(pose)
+ >>> predictions.append(pose)
+ >>>
+ """
+
+ threshold: float
+ margin: int
+ _crop: tuple[int, int, int, int] | None = field(default=None, repr=False)
+ _shape: tuple[int, int] | None = field(default=None, repr=False)
+
+ def crop(self, image: torch.Tensor) -> torch.Tensor:
+ """Crops an input image according to the dynamic cropping parameters.
+
+ Args:
+ image: The image to crop, of shape (1, C, H, W).
+
+ Returns:
+ The cropped image of shape (1, C, H', W'), where [H', W'] is the size of
+ the crop.
+
+ Raises:
+ RuntimeError: if there is not exactly one image in the batch to crop, or if
+ `crop` was previously called with an image of a different width or
+ height.
+ """
+ if len(image) != 1:
+ raise RuntimeError(f"DynamicCropper can only be used with batch size 1 (found image shape: {image.shape})")
+
+ if self._shape is None:
+ self._shape = image.shape[3], image.shape[2]
+
+ if image.shape[3] != self._shape[0] or image.shape[2] != self._shape[1]:
+ raise RuntimeError(
+ "All frames must have the same shape; The first frame had (W, H) "
+ f"{self._shape} but the current frame has shape {image.shape}."
+ )
+
+ if self._crop is None:
+ return image
+
+ x0, y0, x1, y1 = self._crop
+ return image[:, :, y0:y1, x0:x1]
+
+ def update(self, pose: torch.Tensor) -> torch.Tensor:
+ """Updates the dynamic crop according to the pose model output.
+
+ Uses the pose predicted by the model to update the dynamic crop parameters for
+ the next frame. Scales the pose predicted in the cropped image back to the
+ original image space and returns it.
+
+ This method modifies the pose tensor in-place; so pass a copy of the tensor if
+ you need to keep the original values.
+
+ Args:
+ pose: The pose that was predicted by the pose estimation model in the
+ cropped image coordinate space.
+
+ Returns:
+ The pose, with coordinates updated to the full image space.
+ """
+ if self._shape is None:
+ raise RuntimeError("You must call `crop` before calling `update`.")
+
+ # offset the pose to the original image space
+ offset_x, offset_y = 0, 0
+ if self._crop is not None:
+ offset_x, offset_y = self._crop[:2]
+ pose[..., 0] = pose[..., 0] + offset_x
+ pose[..., 1] = pose[..., 1] + offset_y
+
+ # check whether keypoints can be used for dynamic cropping
+ keypoints = pose[..., :3].reshape(-1, 3)
+ keypoints = keypoints[~torch.any(torch.isnan(keypoints), dim=1)]
+ if len(keypoints) == 0:
+ self.reset()
+ return pose
+
+ mask = keypoints[:, 2] >= self.threshold
+ if torch.all(~mask):
+ self.reset()
+ return pose
+
+ # set the crop coordinates
+ x0 = self._min_value(keypoints[:, 0], self._shape[0])
+ x1 = self._max_value(keypoints[:, 0], self._shape[0])
+ y0 = self._min_value(keypoints[:, 1], self._shape[1])
+ y1 = self._max_value(keypoints[:, 1], self._shape[1])
+ crop_w, crop_h = x1 - x0, y1 - y0
+ if crop_w == 0 or crop_h == 0:
+ self.reset()
+ else:
+ self._crop = x0, y0, x1, y1
+
+ return pose
+
+ def reset(self) -> None:
+ """Resets the DynamicCropper to not crop the next frame."""
+ self._crop = None
+
+ @staticmethod
+ def build(dynamic: bool, threshold: float, margin: int) -> Optional["DynamicCropper"]:
+ """Builds the DynamicCropper based on the given parameters.
+
+ Args:
+ dynamic: Whether dynamic cropping should be used
+ threshold: The threshold score for bodyparts above which an individual is
+ deemed to have been detected.
+ margin: The margin used to expand an individuals bounding box before
+ cropping it.
+
+ Returns:
+ None if dynamic is False
+ DynamicCropper to use if dynamic is True
+ """
+ if not dynamic:
+ return None
+
+ return DynamicCropper(threshold, margin)
+
+ def _min_value(self, coordinates: torch.Tensor, maximum: int) -> int:
+ """Returns: min(coordinates - margin), clipped to [0, maximum]"""
+ return self._clip(
+ int(math.floor(torch.min(coordinates).item() - self.margin)),
+ maximum,
+ )
+
+ def _max_value(self, coordinates: torch.Tensor, maximum: int) -> int:
+ """Returns: max(coordinates + margin), clipped to [0, maximum]"""
+ return self._clip(
+ int(math.ceil(torch.max(coordinates).item() + self.margin)),
+ maximum,
+ )
+
+ def _clip(self, value: int, maximum: int) -> int:
+ """Returns: The value clipped to [0, maximum]"""
+ return min(max(value, 0), maximum)
+
+
+class TopDownDynamicCropper(DynamicCropper):
+ """Dynamic cropping for top-down models used on single animal videos.
+
+ The `TopDownDynamicCropper` can be used instead of an object detector to analyze
+ videos **containing a single animal** with top-down models.
+
+ At frame 0, the full frame is split into (n, m) image patches, with a given overlap
+ between the patches. Patches are then
+ - Resized to the input size required by the model with a top-down crop.
+ - Stacked into a batch and given to the pose estimation model
+ - The output poses for each patch are post-processed: the patch containing the
+ highest average score prediction is selected as the patch containing the
+ individual, and the pose from that patch is selected as the predicted pose.
+
+ At frame n, one of two things can happen:
+ - If the individual was successfully detected at frame n - 1, a bounding box
+ is generated from the predicted pose and used as the bounding box for the
+ next frame.
+ - If the individual was not detected at frame n - 1, patches are cropped as in
+ frame 0 and the pose selected as in frame 0
+
+ An individual is considered to be successfully detected if:
+ - at least `min_hq_keypoints` keypoint have scores above the `threshold`
+
+ The bounding box is generated from the keypoints (either from all keypoints or only
+ the ones above the threshold) with a margin around the keypoints. If the bounding
+ box is smaller than a set minimum size, it is expanded to that size.
+
+ Args:
+ top_down_crop_size: The (width, height) of to resize crops to.
+ patch_counts: The number of patches along the (width, height) of the images when
+ no crop is found.
+ patch_overlap: The amount of overlapping pixels between adjacent patches.
+ min_bbox_size: The minimum (width, height) for a detected bounding box. If the
+ bounding box computed from the keypoints is smaller than this value, it
+ will be expanded to these values.
+ threshold: The threshold score for bodyparts above which an individual is
+ considered to be detected.
+ margin: The margin to add around keypoints when generating bounding boxes.
+ min_hq_keypoints: The minimum number of keypoints above the threshold required
+ for the individual to be considered detected and a bounding box to be
+ computed from the pose.
+ bbox_from_hq: If True, only keypoints above the score threshold will be used
+ to compute the bounding boxes.
+ store_crops: Useful for debugging. When True, all crops are stored in the
+ `crop_history` attribute.
+ **kwargs: Key-word arguments passed to the DynamicCropper base class.
+
+ Attributes:
+ min_bbox_size: tuple[int, int]. The minimum (width, height) for a detected
+ bounding box. If the bounding box computed from the keypoints is smaller
+ than this value, it will be expanded to these values.
+ min_hq_keypoints: int. The minimum number of keypoints above the threshold
+ required for the individual to be considered detected and a bounding box to
+ be computed from the pose.
+ bbox_from_hq: bool. If True, only keypoints above the score threshold will be
+ used to compute the bounding boxes.
+ store_crops: bool. Useful for debugging. When True, all crops are stored in the
+ `crop_history` attribute.
+ crop_history: list[list[tuple[int, int, int, int]]. Empty list if `store_crops`
+ is False. Every time `crop` is called, a list is appended to the
+ `crop_history` attribute. This list is empty if no crop was used for the
+ frame, otherwise a list containing a single (x, y, w, h) tuple is appended.
+ """
+
+ def __init__(
+ self,
+ top_down_crop_size: tuple[int, int],
+ patch_counts: tuple[int, int] = (3, 2),
+ patch_overlap: int = 50,
+ min_bbox_size: tuple[int, int] = (50, 50),
+ threshold: float = 0.6,
+ margin: int = 25,
+ min_hq_keypoints: int = 2,
+ bbox_from_hq: bool = False,
+ store_crops: bool = False,
+ **kwargs,
+ ) -> None:
+ super().__init__(threshold=threshold, margin=margin, **kwargs)
+ self.min_bbox_size = min_bbox_size
+ self.min_hq_keypoints = min_hq_keypoints
+ self.bbox_from_hq = bbox_from_hq
+
+ self._patch_counts = patch_counts
+ self._patch_overlap = patch_overlap
+ self._patches = []
+ self._patch_offsets = []
+ self._td_crop_size = top_down_crop_size
+ self._td_ratio = self._td_crop_size[0] / self._td_crop_size[1]
+
+ self.crop_history = []
+ self.store_crops = store_crops
+
+ def crop(self, image: torch.Tensor) -> torch.Tensor:
+ """Crops an input image according to the dynamic cropping parameters.
+
+ Args:
+ image: The image to crop, of shape (1, C, H, W).
+
+ Returns:
+ The cropped image of shape (B, C, H', W'), where [H', W'] is the size of
+ the crop.
+
+ Raises:
+ RuntimeError: if there is not exactly one image in the batch to crop, or if
+ `crop` was previously called with an image of a different W or H.
+ """
+ if len(image) != 1:
+ raise RuntimeError(f"DynamicCropper can only be used with batch size 1 (found image shape: {image.shape})")
+
+ if self._shape is None:
+ self._shape = image.shape[3], image.shape[2]
+ self._patches = self.generate_patches()
+
+ if image.shape[3] != self._shape[0] or image.shape[2] != self._shape[1]:
+ raise RuntimeError(
+ "All frames must have the same shape; The first frame had (W, H) "
+ f"{self._shape} but the current frame has shape {image.shape}."
+ )
+
+ if self._crop is None:
+ if self.store_crops:
+ self.crop_history.append([])
+ return self._crop_patches(image)
+
+ if self.store_crops:
+ self.crop_history.append([self._crop])
+
+ return self._crop_bounding_box(image, self._crop)
+
+ def update(self, pose: torch.Tensor) -> torch.Tensor:
+ """Updates the dynamic crop according to the pose model output.
+
+ Uses the pose predicted by the model to update the dynamic crop parameters for
+ the next frame. Scales the pose predicted in the cropped image back to the
+ original image space and returns it.
+
+ This method modifies the pose tensor in-place; so pass a copy of the tensor if
+ you need to keep the original values.
+
+ Args:
+ pose: The pose that was predicted by the pose estimation model in the
+ cropped image coordinate space.
+
+ Returns:
+ The pose, with coordinates updated to the full image space.
+ """
+ if self._shape is None:
+ raise RuntimeError("You must call `crop` before calling `update`.")
+
+ # check whether this was a patched crop
+ batch_size = pose.shape[0]
+ if batch_size > 1:
+ pose = self._extract_best_patch(pose)
+
+ if self._crop is None:
+ raise RuntimeError(
+ "The _crop should never be `None` when `update` is called. Ensure you "
+ "always alternate between `crop` and `update`."
+ )
+
+ # offset and rescale the pose to the original image space
+ out_w, out_h = self._td_crop_size
+ offset_x, offset_y, w, h = self._crop
+ scale_x, scale_y = w / out_w, h / out_h
+ pose[..., 0] = (pose[..., 0] * scale_x) + offset_x
+ pose[..., 1] = (pose[..., 1] * scale_y) + offset_y
+ pose[..., 0] = torch.clip(pose[..., 0], 0, self._shape[0])
+ pose[..., 1] = torch.clip(pose[..., 1], 0, self._shape[1])
+
+ # check whether keypoints can be used for dynamic cropping
+ keypoints = pose[..., :3].reshape(-1, 3)
+ keypoints = keypoints[~torch.any(torch.isnan(keypoints), dim=1)]
+ if len(keypoints) == 0:
+ self.reset()
+ return pose
+
+ mask = keypoints[:, 2] >= self.threshold
+ if torch.sum(mask) < self.min_hq_keypoints:
+ self.reset()
+ return pose
+
+ if self.bbox_from_hq:
+ keypoints = keypoints[mask]
+
+ # set the crop coordinates
+ x0 = self._min_value(keypoints[:, 0], self._shape[0])
+ x1 = self._max_value(keypoints[:, 0], self._shape[0])
+ y0 = self._min_value(keypoints[:, 1], self._shape[1])
+ y1 = self._max_value(keypoints[:, 1], self._shape[1])
+ crop_w, crop_h = x1 - x0, y1 - y0
+ if crop_w == 0 or crop_h == 0:
+ self.reset()
+ else:
+ self._crop = self._prepare_bounding_box(x0, y0, x1, y1)
+
+ return pose
+
+ def patch_counts(self) -> tuple[int, int]:
+ """Returns: the number of patches created for an image."""
+ return self._patch_counts
+
+ def num_patches(self) -> int:
+ """Returns: the total number of patches created for an image."""
+ return self._patch_counts[0] * self._patch_counts[1]
+
+ def _prepare_bounding_box(self, x1: int, y1: int, x2: int, y2: int) -> tuple[int, int, int, int]:
+ """Prepares the bounding box for cropping.
+
+ Adds a margin around the bounding box, then transforms it into the target aspect
+ ratio required for crops given as inputs to the model.
+
+ Args:
+ x1: The x coordinate for the top-left corner of the bounding box.
+ y1: The y coordinate for the top-left corner of the bounding box.
+ x2: The x coordinate for the bottom-right corner of the bounding box.
+ y2: The y coordinate for the bottom-right corner of the bounding box.
+
+ Returns:
+ The (x, y, w, h) coordinates for the prepared bounding box.
+ """
+ x1 -= self.margin
+ x2 += self.margin
+ y1 -= self.margin
+ y2 += self.margin
+ w, h = x2 - x1, y2 - y1
+ cx, cy = x1 + w / 2, y1 + h / 2
+
+ input_ratio = w / h
+ if input_ratio > self._td_ratio: # h/w < h0/w0 => h' = w * h0/w0
+ h = w / self._td_ratio
+ elif input_ratio < self._td_ratio: # w/h < w0/h0 => w' = h * w0/h0
+ w = h * self._td_ratio
+
+ x1, y1 = int(round(cx - (w / 2))), int(round(cy - (h / 2)))
+ w, h = max(int(w), self.min_bbox_size[0]), max(int(h), self.min_bbox_size[1])
+ return x1, y1, w, h
+
+ def _crop_bounding_box(
+ self,
+ image: torch.Tensor,
+ bbox: tuple[int, int, int, int],
+ ) -> torch.Tensor:
+ """Applies a top-down crop to an image given a bounding box.
+
+ Args:
+ image: The image to crop, of shape (1, C, H, W).
+ bbox: The bounding box to crop out of the image.
+
+ Returns:
+ The cropped and resized image.
+ """
+ x1, y1, w, h = bbox
+ out_w, out_h = self._td_crop_size
+ return F.resized_crop(image, y1, x1, h, w, [out_h, out_w])
+
+ def _crop_patches(self, image: torch.Tensor) -> torch.Tensor:
+ """Crops patches from the image.
+
+ Args:
+ image: The image to crop patches from, of shape (1, C, H, W).
+
+ Returns:
+ The patches, of shape (B, C, H', W'), where [H', W'] is the crop size.
+ """
+ patches = [self._crop_bounding_box(image, patch) for patch in self._patches]
+ return torch.cat(patches, dim=0)
+
+ def _extract_best_patch(self, pose: torch.Tensor) -> torch.Tensor:
+ """Extracts the best pose prediction from patches.
+
+ Args:
+ pose: The predicted pose, of shape (b, num_idv, num_kpt, 3). The number of
+ individuals must be 1.
+
+ Returns:
+ The selected pose, of shape [1, N, K, 3]
+ """
+ # check that only 1 prediction was made in each image
+ if pose.shape[1] != 1:
+ raise ValueError(
+ "The TopDownDynamicCropper can only be used with models predicting "
+ f"a single individual per image. Found {pose.shape[0]} "
+ f"predictions."
+ )
+
+ # compute the score for each individual
+ idv_scores = torch.mean(pose[:, 0, :, 2], dim=1)
+
+ # get the index of the best patch
+ best_patch = torch.argmax(idv_scores)
+
+ # set the crop to the one used for the best patch
+ self._crop = self._patches[best_patch]
+
+ return pose[best_patch : best_patch + 1]
+
+ def generate_patches(self) -> list[tuple[int, int, int, int]]:
+ """Generates patch coordinates for splitting an image.
+
+ Returns:
+ A list of patch coordinates as tuples (x0, y0, x1, y1).
+ """
+ patch_xs = self.split_array(self._shape[0], self._patch_counts[0], self._patch_overlap)
+ patch_ys = self.split_array(self._shape[1], self._patch_counts[1], self._patch_overlap)
+
+ patches = []
+ for y0, y1 in patch_ys:
+ for x0, x1 in patch_xs:
+ patches.append(self._prepare_bounding_box(x0, y0, x1, y1))
+
+ return patches
+
+ @staticmethod
+ def split_array(size: int, n: int, overlap: int) -> list[tuple[int, int]]:
+ """Splits an array into n segments of equal size, where the overlap between each
+ segment is at least a given value.
+
+ Args:
+ size: The size of the array.
+ n: The number of segments to split the array into.
+ overlap: The minimum overlap between each segment.
+
+ Returns:
+ (start_index, end_index) pairs for each segment. The end index is exclusive.
+ """
+ if n < 1:
+ raise ValueError(f"Array must be split into at least 1 segment. Found {n}.")
+
+ # FIXME - auto-correct the overlap to spread it out more evenly
+ padded_size = size + (n - 1) * overlap
+ segment_size = (padded_size // n) + (padded_size % n > 0)
+ segments = []
+ end = overlap
+ for _i in range(n):
+ start = end - overlap
+ end = start + segment_size
+ if end > size:
+ end = size
+ start = end - segment_size
+
+ segments.append((start, end))
+
+ return segments
diff --git a/deeplabcut/pose_estimation_pytorch/runners/inference.py b/deeplabcut/pose_estimation_pytorch/runners/inference.py
new file mode 100644
index 0000000000..367685bb3f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py
@@ -0,0 +1,1095 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import os
+import threading
+import warnings
+from abc import ABCMeta, abstractmethod
+from collections.abc import Iterable
+from contextlib import contextmanager
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+from queue import Empty, Full, Queue
+from typing import Any, Generic
+
+import numpy as np
+import torch
+import torch.nn as nn
+
+import deeplabcut.pose_estimation_pytorch.post_processing.nms as nms
+import deeplabcut.pose_estimation_pytorch.runners.ctd as ctd
+import deeplabcut.pose_estimation_pytorch.runners.shelving as shelving
+from deeplabcut.core.inferenceutils import calc_object_keypoint_similarity
+from deeplabcut.pose_estimation_pytorch.config.utils import update_config_by_dotpath
+from deeplabcut.pose_estimation_pytorch.data.postprocessor import Postprocessor
+from deeplabcut.pose_estimation_pytorch.data.preprocessor import LoadImage, Preprocessor
+from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector
+from deeplabcut.pose_estimation_pytorch.models.model import PoseModel
+from deeplabcut.pose_estimation_pytorch.runners.base import ModelType, Runner
+from deeplabcut.pose_estimation_pytorch.runners.dynamic_cropping import (
+ DynamicCropper,
+ TopDownDynamicCropper,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+# NOTE @deruyter92 2026-04-28: AMD GPUs with DirectML inference mode currently do not
+# support torch.inference_mode, which is stricter than torch.no_grad. The ENV
+# variable is used to conditionally use torch.no_grad instead. See PR #3295.
+_directml_no_grad: bool = os.getenv("DLC_DIRECTML_NO_GRAD", "false").lower() in (
+ "true",
+ "1",
+)
+
+
+def _inference_mode_decorator(fn):
+ """
+ Conditional decorator for inference mode, controlled by the DLC_DIRECTML_NO_GRAD ENV variable.
+ Uses @torch.no_grad if set to "true", otherwise defaults to @torch.inference_mode.
+ """
+ return torch.no_grad()(fn) if _directml_no_grad else torch.inference_mode()(fn)
+
+
+@contextmanager
+def _directml_runtime_error_hint():
+ """Context manager that augments runtime errors with a hint for DirectML-related issues."""
+ try:
+ yield
+ except RuntimeError as e:
+ if torch.is_inference_mode_enabled() and not _directml_no_grad:
+ raise RuntimeError(
+ f"{e}\n\n"
+ "If you are using an AMD GPU with DirectML, this error may be caused by "
+ "@torch.inference_mode being incompatible with the DirectML execution path. "
+ "Try setting the environment variable DLC_DIRECTML_NO_GRAD=true, "
+ "which will switch the inference context to @torch.no_grad."
+ ) from e
+ raise
+
+
+def _merge_defaults(cls, data: dict[str, Any]):
+ """
+ Utility: merge a partial dict with the defaults of a dataclass.
+ Unknown keys are ignored.
+ """
+ defaults = asdict(cls()) # defaults from calling the dataclass constructor
+ for k, v in data.items():
+ if k in defaults and isinstance(defaults[k], dict) and isinstance(v, dict):
+ # merge nested dicts recursively
+ defaults[k].update(v)
+ elif k in defaults:
+ defaults[k] = v
+ return defaults
+
+
+@dataclass
+class MultithreadingConfig:
+ """
+ Parameters for the multithreaded inference pipeline:
+ enabled: Whether to use async inference with pipeline parallelism
+ queue_length: Number of batches to prefetch in async mode
+ timeout: Timeout for queue operations in async mode
+ """
+
+ enabled: bool = True
+ queue_length: int = 4
+ timeout: float = 30.0
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> MultithreadingConfig:
+ return cls(**_merge_defaults(cls, data or {}))
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+@dataclass
+class CompileConfig:
+ """
+ Parameters for the torch.compile option:
+ enabled: Whether to use torch.compile on the model during InferenceRunner initialization
+ backed: torch.compile backend to use
+ """
+
+ enabled: bool = False
+ backend: str = "inductor"
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> CompileConfig:
+ return cls(**_merge_defaults(cls, data or {}))
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+@dataclass
+class AutocastConfig:
+ """
+ Parameters for the torch.autocast option:
+ enabled: Whether to use torch.autocast when running inference
+ """
+
+ enabled: bool = False
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> AutocastConfig:
+ return cls(**_merge_defaults(cls, data or {}))
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+@dataclass
+class InferenceConfig:
+ """Top-level inference configuration that mirrors the `inference` block in
+ pytorch_config.yaml."""
+
+ multithreading: MultithreadingConfig = field(default_factory=MultithreadingConfig)
+ compile: CompileConfig = field(default_factory=CompileConfig)
+ autocast: AutocastConfig = field(default_factory=AutocastConfig)
+ conditions: dict | None = None
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any] | None) -> InferenceConfig:
+ """Build an InferenceConfig from a dict, supporting:
+
+ - nested dictionaries
+ - dot-notation keys (e.g., {"compile.enabled": True})
+ Raises KeyError if a key does not exist.
+ """
+ instance = cls()
+ data = data or {}
+
+ # Convert instance to dict for easy updates
+ cfg_dict = instance.to_dict()
+
+ # Use utility to apply dot-notation updates
+ updated_dict = update_config_by_dotpath(cfg_dict, data, copy_original=True)
+
+ # Validate keys against the dataclass structure
+ def validate_keys(obj, dct, path=""):
+ for k, v in dct.items():
+ if k == "conditions":
+ if not (v is None or isinstance(v, dict)):
+ raise TypeError(f"'conditions' must be a dict or None, got {type(v)}")
+ continue
+ if not hasattr(obj, k):
+ raise KeyError(f"Invalid key path: {path + k}")
+ sub_obj = getattr(obj, k)
+ if isinstance(v, dict):
+ validate_keys(sub_obj, v, path=f"{path + k}.")
+
+ validate_keys(instance, updated_dict)
+
+ # Re-build nested dataclasses
+ instance.multithreading = MultithreadingConfig.from_dict(updated_dict["multithreading"])
+ instance.compile = CompileConfig.from_dict(updated_dict["compile"])
+ instance.autocast = AutocastConfig.from_dict(updated_dict["autocast"])
+ instance.conditions = updated_dict.get("conditions", None)
+
+ return instance
+
+ def to_dict(self) -> dict:
+ d = {
+ "multithreading": self.multithreading.to_dict(),
+ "compile": self.compile.to_dict(),
+ "autocast": self.autocast.to_dict(),
+ }
+ if self.conditions is not None:
+ d["conditions"] = self.conditions
+ return d
+
+
+class InferenceRunner(Runner, Generic[ModelType], metaclass=ABCMeta):
+ """Base class for inference runners.
+
+ A runner takes a model and runs actions on it, such as training or inference
+ """
+
+ def __init__(
+ self,
+ model: ModelType,
+ batch_size: int = 1,
+ device: str = "cpu",
+ snapshot_path: str | Path | None = None,
+ preprocessor: Preprocessor | None = None,
+ postprocessor: Postprocessor | None = None,
+ load_weights_only: bool | None = None,
+ inference_cfg: InferenceConfig | dict | None = None,
+ ):
+ """
+ Args:
+ model: The model to run actions on
+ device: The device to use (e.g. {'cpu', 'cuda:0', 'mps'})
+ snapshot_path: If defined, the path of a snapshot from which to load
+ pretrained weights
+ preprocessor: The preprocessor to use on images before inference
+ postprocessor: The postprocessor to use on images after inference
+ load_weights_only: Value for the torch.load() `weights_only` parameter.
+ If False, the python pickle module is used implicitly, which is known to
+ be insecure. Only set to False if you're loading data that you trust
+ (e.g. snapshots that you created). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+ inference_cfg: Configuration for the inference runner
+ """
+ super().__init__(model=model, device=device, snapshot_path=snapshot_path)
+ if not isinstance(batch_size, int) or batch_size <= 0:
+ raise ValueError(f"batch_size must be a positive integer; is {batch_size}")
+
+ self.batch_size = batch_size
+ self.preprocessor = preprocessor
+ self.postprocessor = postprocessor
+
+ if isinstance(inference_cfg, InferenceConfig):
+ self.inference_cfg = inference_cfg
+ elif isinstance(inference_cfg, dict):
+ self.inference_cfg = InferenceConfig.from_dict(inference_cfg)
+ elif inference_cfg is None:
+ self.inference_cfg = InferenceConfig()
+
+ if self.snapshot_path is not None and self.snapshot_path != "":
+ self.load_snapshot(
+ self.snapshot_path,
+ self.device,
+ self.model,
+ weights_only=load_weights_only,
+ )
+
+ self.model.to(self.device)
+ self.model.eval()
+
+ if self.inference_cfg.compile.enabled:
+ try:
+ self.model = torch.compile(self.model, backend=self.inference_cfg.compile.backend)
+ except Exception as e:
+ warnings.warn(
+ f"torch.compile failed with backend='{self.inference_cfg.compile.backend}', "
+ f"falling back to eager mode. Error: {e}",
+ stacklevel=2,
+ )
+
+ self._batch_list: list[torch.Tensor] = []
+ self._model_kwargs: dict[str, np.ndarray | torch.Tensor] = {}
+
+ self._contexts: list[dict] = []
+ self._image_batch_sizes: list[int] = []
+ self._predictions: list = []
+
+ # Async-specific attributes
+ if self.inference_cfg.multithreading.enabled:
+ self._input_queue = Queue(maxsize=self.inference_cfg.multithreading.queue_length)
+ self._preprocessing_thread = None
+ self._stop_event = threading.Event()
+ self._exception = None
+
+ @abstractmethod
+ def predict(self, inputs: torch.Tensor, **kwargs) -> list[dict[str, dict[str, np.ndarray]]]:
+ """Makes predictions from a model input and output.
+
+ Args:
+ the inputs to the model, of shape (batch_size, ...)
+
+ Returns:
+ the predictions for each of the 'batch_size' inputs
+ """
+
+ @_inference_mode_decorator
+ def inference(
+ self,
+ images: (Iterable[str | Path | np.ndarray] | Iterable[tuple[str | Path | np.ndarray, dict[str, Any]]]),
+ shelf_writer: shelving.ShelfWriter | None = None,
+ ) -> list[dict[str, np.ndarray]]:
+ """Run model inference on the given dataset.
+
+ TODO: Add an option to also return head outputs (such as heatmaps)? Can be
+ super useful for debugging
+
+ Args:
+ images: the images to run inference on, optionally with context
+ shelf_writer: by default, data are saved in a list and returned at the end
+ of inference. Passing a shelf manager writes data to disk on-the-fly
+ using a "shelf" (a pickle-based, persistent, database-like object by
+ default, resulting in constant memory footprint). The returned list is
+ then empty.
+
+ Returns:
+ a dict containing head predictions for each image
+ [
+ {
+ "bodypart": {"poses": np.array},
+ "unique_bodypart": {"poses": np.array},
+ }
+ ]
+ """
+ if self.inference_cfg.multithreading.enabled:
+ return self._async_inference(images, shelf_writer)
+ else:
+ return self._sequential_inference(images, shelf_writer)
+
+ def _sequential_inference(
+ self,
+ images: (Iterable[str | Path | np.ndarray] | Iterable[tuple[str | Path | np.ndarray, dict[str, Any]]]),
+ shelf_writer: shelving.ShelfWriter | None = None,
+ ) -> list[dict[str, np.ndarray]]:
+ """Original sequential inference implementation."""
+ results = []
+ for data in images:
+ self._prepare_inputs(data)
+ self._process_full_batches()
+ results += self._extract_results(shelf_writer)
+
+ # Process the last batch even if not full
+ if self._inputs_waiting_for_processing():
+ self._process_batch()
+ results += self._extract_results(shelf_writer)
+
+ return results
+
+ def _async_inference(
+ self,
+ images: (Iterable[str | Path | np.ndarray] | Iterable[tuple[str | Path | np.ndarray, dict[str, Any]]]),
+ shelf_writer: shelving.ShelfWriter | None = None,
+ ) -> list[dict[str, np.ndarray]]:
+ """Async inference with pipeline parallelism."""
+ # Reset state
+ self._stop_event.clear()
+ self._exception = None
+ self._batch_list = []
+ self._model_kwargs = {}
+ self._contexts = []
+ self._image_batch_sizes = []
+ self._predictions = []
+
+ # Start preprocessing thread
+ self._preprocessing_thread = threading.Thread(target=self._preprocessing_worker, args=(images,))
+ self._preprocessing_thread.start()
+
+ results = []
+
+ try:
+ while True:
+ # Get next batch from queue
+ item = self._safe_get()
+
+ # None means either producer finished or stop_event triggered
+ if item is None:
+ break
+
+ batch, model_kwargs = item
+
+ # Run model inference
+ with _directml_runtime_error_hint():
+ predictions = self.predict(batch, **model_kwargs)
+ self._predictions.extend(predictions)
+
+ # Extract and return results
+ batch_results = self._extract_results(shelf_writer)
+ results.extend(batch_results)
+
+ # propagate any exception from the producer immediately
+ if self._exception is not None:
+ raise self._exception
+
+ except BaseException as e: # catches KeyboardInterrupt, SystemExit, etc.
+ # tell producer to quit
+ self._stop_event.set()
+ raise e
+ finally:
+ # Wait for preprocessing thread to finish
+ if self._preprocessing_thread is not None:
+ self._preprocessing_thread.join(timeout=self.inference_cfg.multithreading.timeout)
+
+ # Check for exceptions in preprocessing thread
+ if self._exception is not None:
+ raise self._exception
+
+ return results
+
+ def _prepare_inputs(
+ self,
+ data: str | Path | np.ndarray | tuple[str | Path | np.ndarray, dict],
+ ) -> None:
+ """Prepares inputs for an image and adds them to the data ready to be
+ processed."""
+ if isinstance(data, (str, Path, np.ndarray)):
+ inputs, context = data, {}
+ else:
+ inputs, context = data
+
+ if self.preprocessor is not None:
+ inputs, context = self.preprocessor(inputs, context)
+ else:
+ inputs = torch.as_tensor(inputs)
+
+ # add new model_kwargs from the inputs
+ model_kwargs = context.pop("model_kwargs", {})
+ for k, v in model_kwargs.items():
+ curr_v = self._model_kwargs.get(k)
+ if curr_v is None or len(curr_v) == 0:
+ curr_v = v
+ elif len(v) == 0:
+ continue
+ elif isinstance(curr_v, np.ndarray):
+ curr_v = np.concatenate([curr_v, v], axis=0)
+ elif isinstance(curr_v, torch.Tensor):
+ curr_v = torch.cat([curr_v, v], dim=0)
+ else:
+ raise ValueError(f"model_kwargs {k} must be a numpy array or torch tensor - found '{type(v)}'.")
+ self._model_kwargs[k] = curr_v
+
+ self._contexts.append(context)
+ self._image_batch_sizes.append(len(inputs))
+
+ # skip when there are no inputs for an image
+ if len(inputs) == 0:
+ return
+
+ # extend the list with individual image tensors (slice along first dim)
+ self._batch_list.extend(list(inputs))
+
+ def _process_full_batches(self) -> None:
+ """Processes prepared inputs in batches of the desired batch size."""
+ while len(self._batch_list) >= self.batch_size:
+ self._process_batch()
+
+ def _extract_results(self, shelf_writer: shelving.ShelfWriter) -> list:
+ """Obtains results that were obtained from processing a batch."""
+ results = []
+ while len(self._image_batch_sizes) > 0 and len(self._predictions) >= self._image_batch_sizes[0]:
+ num_predictions = self._image_batch_sizes[0]
+ image_predictions = self._predictions[:num_predictions]
+ context = self._contexts[0]
+ if self.postprocessor is not None:
+ # TODO: Should we return context?
+ # TODO: typing update - the post-processor can remove a dict level
+ image_predictions, _ = self.postprocessor(image_predictions, context)
+
+ if shelf_writer is not None:
+ shelf_writer.add_prediction(
+ bodyparts=image_predictions["bodyparts"],
+ unique_bodyparts=image_predictions.get("unique_bodyparts"),
+ identity_scores=image_predictions.get("identity_scores"),
+ features=image_predictions.get("features"),
+ )
+ else:
+ results.append(image_predictions)
+
+ self._contexts = self._contexts[1:]
+ self._image_batch_sizes = self._image_batch_sizes[1:]
+ self._predictions = self._predictions[num_predictions:]
+
+ return results
+
+ def _process_batch(self) -> None:
+ """Processes a batch.
+
+ There must be inputs waiting to be processed before this is called, otherwise
+ this method will raise an error.
+ """
+ batch = torch.stack(self._batch_list[: self.batch_size], dim=0)
+ model_kwargs = {mk: v[: self.batch_size] for mk, v in self._model_kwargs.items()}
+
+ with _directml_runtime_error_hint():
+ self._predictions += self.predict(batch, **model_kwargs)
+
+ # remove processed inputs
+ if len(self._batch_list) <= self.batch_size:
+ self._batch_list = []
+ self._model_kwargs = {}
+ else:
+ self._batch_list = self._batch_list[self.batch_size :]
+ self._model_kwargs = {mk: v[self.batch_size :] for mk, v in self._model_kwargs.items()}
+
+ def _inputs_waiting_for_processing(self) -> bool:
+ """Returns: Whether there are inputs which have not yet been processed"""
+ return len(self._batch_list) > 0
+
+ def _safe_put(self, item: Any) -> bool:
+ """Put item in the queue, retrying until successful or stop_event is set."""
+ while not self._stop_event.is_set():
+ try:
+ self._input_queue.put(item, timeout=1.0)
+ return True
+ except Full:
+ continue
+ return False
+
+ def _safe_get(self) -> Any:
+ """Get the next item from the queue safely, retrying until successful or
+ stop_event is set.
+
+ Returns:
+ The item from the queue, or None if the producer is dead or stop_signal is raised and queue empty.
+ """
+ while True:
+ try:
+ item = self._input_queue.get(timeout=1.0)
+ return item
+ except Empty:
+ # check if producer is still running
+ if (
+ self._stop_event.is_set()
+ or self._preprocessing_thread is None
+ or not self._preprocessing_thread.is_alive()
+ ):
+ return None
+ continue
+
+ def _preprocessing_worker(self, images: Iterable) -> None:
+ """Background worker that prepares inputs and puts them in the input queue."""
+ try:
+ for data in images:
+ if self._stop_event.is_set():
+ break
+
+ # Prepare inputs using the parent class method
+ self._prepare_inputs(data)
+
+ # Process full batches and put them in the queue
+ while len(self._batch_list) >= self.batch_size:
+ batch = torch.stack(self._batch_list[: self.batch_size], dim=0)
+ model_kwargs = {mk: v[: self.batch_size] for mk, v in self._model_kwargs.items()}
+
+ self._safe_put((batch, model_kwargs))
+
+ # Remove processed inputs from batch
+ if len(self._batch_list) <= self.batch_size:
+ self._batch_list, self._model_kwargs = [], {}
+ else:
+ self._batch_list = self._batch_list[self.batch_size :]
+ self._model_kwargs = {mk: v[self.batch_size :] for mk, v in self._model_kwargs.items()}
+
+ # Process any remaining inputs
+ if len(self._batch_list) > 0:
+ batch = torch.stack(self._batch_list, dim=0)
+ self._safe_put((batch, self._model_kwargs))
+
+ except BaseException as e: # catches KeyboardInterrupt, SystemExit, etc.
+ self._exception = e
+ self._stop_event.set()
+ finally:
+ # Signal that preprocessing is done
+ self._safe_put(None)
+
+ def __del__(self):
+ """Cleanup method to ensure threads are stopped."""
+ if hasattr(self, "_stop_event"):
+ self._stop_event.set()
+ if hasattr(self, "_preprocessing_thread") and self._preprocessing_thread is not None:
+ self._preprocessing_thread.join(timeout=1.0)
+
+
+class PoseInferenceRunner(InferenceRunner[PoseModel]):
+ """Runner for pose estimation inference."""
+
+ def __init__(
+ self,
+ model: PoseModel,
+ dynamic: DynamicCropper | None = None,
+ **kwargs,
+ ):
+ super().__init__(model, **kwargs)
+ self.dynamic = dynamic
+ if dynamic is not None and self.batch_size != 1:
+ raise ValueError("Dynamic cropping can only be used with batch size 1. Please set your batch size to 1.")
+
+ def predict(self, inputs: torch.Tensor, **kwargs) -> list[dict[str, dict[str, np.ndarray]]]:
+ """Makes predictions from a model input and output.
+
+ Args:
+ the inputs to the model, of shape (batch_size, ...)
+
+ Returns:
+ predictions for each of the 'batch_size' inputs, made by each head, e.g.
+ [
+ {
+ "bodypart": {"poses": np.ndarray},
+ "unique_bodypart": {"poses": np.ndarray},
+ }
+ ]
+ """
+ batch_size = len(inputs)
+ if self.dynamic is not None:
+ # dynamic cropping can use patches
+ inputs = self.dynamic.crop(inputs)
+ if self.inference_cfg.autocast.enabled:
+ with torch.autocast(device_type=str(self.device)):
+ outputs = self.model(inputs.to(self.device), **kwargs)
+ raw_predictions = self.model.get_predictions(outputs)
+ else:
+ outputs = self.model(inputs.to(self.device), **kwargs)
+ raw_predictions = self.model.get_predictions(outputs)
+
+ if self.dynamic is not None:
+ raw_predictions["bodypart"]["poses"] = self.dynamic.update(raw_predictions["bodypart"]["poses"])
+
+ predictions = [
+ {
+ head: {pred_name: pred[b].cpu().numpy() for pred_name, pred in head_outputs.items()}
+ for head, head_outputs in raw_predictions.items()
+ }
+ for b in range(batch_size)
+ ]
+ return predictions
+
+
+class CTDInferenceRunner(PoseInferenceRunner):
+ """Runner for pose estimation inference.
+
+ Args:
+ model: The CTD model to run inference with.
+ bu_runner: A runner for the BU model to run inference with. If no BU runner is
+ given, conditions must be given in the context for the data. Otherwise an
+ error will be raised during inference.
+ tracking: Whether to track using the CTD model. If
+ """
+
+ def __init__(
+ self,
+ model: PoseModel,
+ bu_runner: PoseInferenceRunner | None = None,
+ ctd_tracking: bool | ctd.CTDTrackingConfig = False,
+ **kwargs,
+ ):
+ super().__init__(model, **kwargs)
+ self.bu_runner = bu_runner
+ if bu_runner is not None:
+ self.bu_runner.model.eval()
+
+ self.tracking = None
+ if isinstance(ctd_tracking, ctd.CTDTrackingConfig):
+ self.tracking = ctd_tracking
+ elif ctd_tracking: # generate default config
+ self.tracking = ctd.CTDTrackingConfig()
+
+ if self.tracking and self.batch_size != 1:
+ print("CTD tracking can only be used with batch size 1. Updating it.")
+ self.batch_size = 1
+
+ self._image_loader = LoadImage()
+
+ # Stored poses and IDX -> ID map for CTD tracking
+ self._bu_age = -1
+ self._missing_idvs = False
+ self._prev_pose = None
+ self._idx_to_id = None
+ self._ctd_track_ages = None # the age of each CTD tracklet
+
+ @_inference_mode_decorator
+ def inference(
+ self,
+ images: (Iterable[str | Path | np.ndarray] | Iterable[tuple[str | Path | np.ndarray, dict[str, Any]]]),
+ shelf_writer: shelving.ShelfWriter | None = None,
+ ) -> list[dict[str, np.ndarray]]:
+ """Run CTD model inference on the given dataset.
+
+ Args:
+ images: the images to run inference on, optionally with context
+ shelf_writer: by default, data are saved in a list and returned at the end
+ of inference. Passing a shelf manager writes data to disk on-the-fly
+ using a "shelf" (a pickle-based, persistent, database-like object by
+ default, resulting in constant memory footprint). The returned list is
+ then empty.
+
+ Returns:
+ a dict containing head predictions for each image
+ [
+ {
+ "bodypart": {"poses": np.array},
+ "unique_bodypart": {"poses": np.array},
+ }
+ ]
+ """
+ if self.tracking:
+ return self._ctd_tracking_inference(images, shelf_writer)
+
+ results = []
+ for data in images:
+ data = self.add_conditions(data)
+ self._prepare_inputs(data)
+ self._process_full_batches()
+ results += self._extract_results(shelf_writer)
+
+ # Process the last batch even if not full
+ if self._inputs_waiting_for_processing():
+ self._process_batch()
+ results += self._extract_results(shelf_writer)
+
+ return results
+
+ def predict(self, inputs: torch.Tensor, **kwargs) -> list[dict[str, dict[str, np.ndarray]]]:
+ """Makes predictions from a model input and output.
+
+ Args:
+ the inputs to the model, of shape (batch_size, ...)
+
+ Returns:
+ predictions for each of the 'batch_size' inputs, made by each head, e.g.
+ [
+ {
+ "bodypart": {"poses": np.ndarray},
+ "unique_bodypart": {"poses": np.ndarray},
+ }
+ ]
+ """
+ cond_kpts = kwargs.get("cond_kpts", None)
+ if cond_kpts is not None and cond_kpts.shape[0] == 0:
+ # No conditions, so just return an empty prediction list
+ return []
+
+ # Normal prediction path
+ if self.inference_cfg.autocast.enabled:
+ with torch.autocast(device_type=str(self.device)):
+ outputs = self.model(inputs.to(self.device), **kwargs)
+ raw_predictions = self.model.get_predictions(outputs)
+ else:
+ outputs = self.model(inputs.to(self.device), **kwargs)
+ raw_predictions = self.model.get_predictions(outputs)
+
+ predictions = [
+ {
+ head: {pred_name: pred[b].cpu().numpy() for pred_name, pred in head_outputs.items()}
+ for head, head_outputs in raw_predictions.items()
+ }
+ for b in range(len(inputs))
+ ]
+
+ return predictions
+
+ def add_conditions(
+ self,
+ data: str | Path | np.ndarray | tuple[str | Path | np.ndarray, dict],
+ ) -> tuple[np.ndarray, dict]:
+ if isinstance(data, (str, Path, np.ndarray)):
+ inputs, context = data, {}
+ else:
+ inputs, context = data
+
+ # Load the image once - then given as a numpy array to CTD
+ image, _ = self._image_loader(inputs, context)
+
+ # If the conditional keypoints are in the context, return the context
+ if "cond_kpts" in context:
+ return image, context
+
+ # Run the pre-processor
+ if self.bu_runner.preprocessor is not None:
+ inputs, context = self.bu_runner.preprocessor(image, context)
+ else:
+ inputs = torch.as_tensor(image)
+
+ # Get and post-process the predictions
+ with _directml_runtime_error_hint():
+ predictions = self.bu_runner.predict(inputs)
+ if self.bu_runner.postprocessor is not None:
+ predictions, context = self.bu_runner.postprocessor(predictions, context)
+
+ # Extract the conditions
+ conds = predictions["bodyparts"][..., :3]
+ pred_mask = ~np.all(np.any(conds <= 0 | np.isnan(conds), axis=2), axis=1)
+ if np.sum(pred_mask) > 0:
+ conds = conds[pred_mask]
+ else:
+ conds = np.zeros((0, conds.shape[1], 3))
+
+ return image, {"cond_kpts": conds}
+
+ def _ctd_tracking_inference(
+ self,
+ images: (Iterable[str | Path | np.ndarray] | Iterable[tuple[str | Path | np.ndarray, dict[str, Any]]]),
+ shelf_writer: shelving.ShelfWriter | None = None,
+ ) -> list[dict[str, np.ndarray]]:
+ results = []
+ for data in images:
+ inputs, context = self._prepare_ctd_inputs(data)
+ model_kwargs = context.pop("model_kwargs", {})
+ with _directml_runtime_error_hint():
+ predictions = self.predict(inputs, **model_kwargs)
+ if self.postprocessor is not None:
+ # Pop the "cond_kpts" from the context so there's no re-scoring
+ # This is required when tracking with CTD, otherwise scores go to 0
+ if self._prev_pose is not None:
+ context.pop("cond_kpts")
+
+ predictions, _ = self.postprocessor(predictions, context)
+
+ # Set the predictions as context for the next frame
+ self._ctd_tracking_postprocess(predictions, context["image_size"])
+
+ if shelf_writer is not None:
+ shelf_writer.add_prediction(
+ bodyparts=predictions["bodyparts"],
+ unique_bodyparts=predictions.get("unique_bodyparts"),
+ identity_scores=predictions.get("identity_scores"),
+ features=predictions.get("features"),
+ )
+ else:
+ results.append(predictions)
+
+ return results
+
+ def _prepare_ctd_inputs(self, data) -> tuple[torch.Tensor, dict[str, Any]]:
+ # If there's no valid poses, use the BU model to get conditions
+ self._bu_age += 1
+ if (
+ self._prev_pose is None
+ or (self._missing_idvs and self.tracking.bu_on_lost_idv and self._bu_age >= self.tracking.bu_max_frequency)
+ or (self.tracking.bu_min_frequency is not None and self._bu_age >= self.tracking.bu_min_frequency)
+ ):
+ self._bu_age = 0
+ inputs, context = self.add_conditions(data)
+
+ if self._prev_pose is not None:
+ context["cond_kpts"] = self._merge_conditions(context["cond_kpts"])
+
+ else:
+ if isinstance(data, (str, Path, np.ndarray)):
+ inputs, context = data, {}
+ else:
+ inputs, context = data
+
+ context["cond_kpts"] = self._prev_pose
+
+ if self.preprocessor is None:
+ return torch.as_tensor(inputs), context
+
+ inputs, context = self.preprocessor(inputs, context)
+ return inputs, context
+
+ def _ctd_tracking_postprocess(
+ self,
+ predictions: dict[str, np.ndarray],
+ image_size: tuple[int, int],
+ ) -> None:
+ """Post-processes predictions.
+
+ In-place changes to the predictions dict.
+ """
+ # reorder the previous poses so the indices match the track IDs
+ if self._idx_to_id is not None:
+ predictions["bodyparts"] = predictions["bodyparts"][self._idx_to_id]
+
+ # mask all keypoints below the CTD tracking threshold
+ prev_pose = predictions["bodyparts"][..., :3].copy()
+ prev_pose[prev_pose[..., 2] <= self.tracking.threshold_ctd] = np.nan
+
+ # mask all keypoints outside the image
+ w, h = image_size
+ prev_pose[prev_pose[..., 0] < 0] = np.nan
+ prev_pose[prev_pose[..., 1] < 0] = np.nan
+ prev_pose[prev_pose[..., 0] >= w] = np.nan
+ prev_pose[prev_pose[..., 1] >= h] = np.nan
+
+ # apply NMS on the conditions, keeping older tracks
+ order = None
+ if self._ctd_track_ages is not None:
+ ordering = self._ctd_track_ages.copy()
+
+ # sort by track age, then score
+ vis = np.sum(np.all(~np.isnan(prev_pose), axis=-1), axis=-1) > 1
+ scores = np.nanmean(prev_pose[vis, :, 2], axis=-1)
+ ordering[vis] += scores
+
+ # only keep non-zero scores
+ order = ordering.argsort()[::-1]
+ order = order[ordering[order] > 0]
+
+ nms_mask = nms.nms_oks(
+ prev_pose,
+ oks_threshold=self.tracking.threshold_nms,
+ oks_sigmas=0.1,
+ oks_margin=1.0,
+ score_threshold=self.tracking.threshold_ctd,
+ order=order,
+ )
+
+ # Set the previous pose and ID ordering
+ if np.any(nms_mask):
+ self._prev_pose = prev_pose[nms_mask]
+
+ # get the IDs of the kept poses
+ found_idx_to_id = np.where(nms_mask)[0]
+ missing_ids = np.where(~nms_mask)[0]
+ self._idx_to_id = np.concatenate([found_idx_to_id, missing_ids])
+
+ # add 1 to the age of kept tracks
+ if self._ctd_track_ages is None:
+ self._ctd_track_ages = np.zeros(len(self._idx_to_id))
+ self._ctd_track_ages[nms_mask] += 1
+ self._ctd_track_ages[~nms_mask] = 0
+
+ # check if there are any missing individuals
+ self._missing_idvs = len(self._prev_pose) != len(self._idx_to_id)
+ else:
+ self._prev_pose = None
+ self._idx_to_id = None
+ self._idx_ages = None
+
+ def _merge_conditions(self, bu_cond: np.ndarray) -> np.ndarray:
+ """Merges conditions made by a BU model with existing conditions from CTD
+ tracking."""
+ # prepare the BU conditions for matching
+ bu_cond = bu_cond.copy()[:, :, :3]
+ # mask low-quality keypoints
+ bu_cond[bu_cond[..., 2] < self.tracking.threshold_ctd] = np.nan
+
+ # remove non-visible individuals
+ kpt_vis = np.all(~np.isnan(bu_cond), axis=-1)
+ idv_vis = np.sum(kpt_vis, axis=-1) > 1 # need at least 2 kpts for OKS
+
+ # if no valid BU predictions are left, return the CTD conditions
+ if np.sum(idv_vis) == 0:
+ return self._prev_pose
+
+ # match BU conditions to CTD poses from the highest score to the lowest
+ bu_cond = bu_cond[idv_vis]
+ new_conditions = []
+ for bu_pose in bu_cond:
+ best_oks = 0
+ for ctd_pose in self._prev_pose:
+ best_oks = max(
+ best_oks,
+ calc_object_keypoint_similarity(bu_pose, ctd_pose, sigma=0.1),
+ )
+
+ if best_oks < self.tracking.threshold_bu_add:
+ new_conditions.append((best_oks, bu_pose))
+
+ # add the conditions with the lowest OKS score
+ new_conditions = [c[1] for c in sorted(new_conditions, key=lambda x: x[0])]
+
+ # if there are no new conditions,
+ if len(new_conditions) == 0:
+ return self._prev_pose
+
+ new_conditions = np.stack(new_conditions, axis=0)
+ cond_pose = np.concatenate([self._prev_pose, new_conditions], axis=0)
+ return cond_pose[: len(self._idx_to_id)]
+
+
+class DetectorInferenceRunner(InferenceRunner[BaseDetector]):
+ """Runner for object detection inference."""
+
+ def __init__(self, model: BaseDetector, **kwargs):
+ """
+ Args:
+ model: The detector to use for inference.
+ **kwargs: Inference runner kwargs.
+ """
+ super().__init__(model, **kwargs)
+
+ def predict(self, inputs: torch.Tensor, **kwargs) -> list[dict[str, dict[str, np.ndarray]]]:
+ """Makes predictions from a model input and output.
+
+ Args:
+ the inputs to the model, of shape (batch_size, ...)
+
+ Returns:
+ predictions for each of the 'batch_size' inputs, made by each head, e.g.
+ [
+ {
+ "bodypart": {"poses": np.ndarray},
+ "unique_bodypart": "poses": np.ndarray},
+ }
+ ]
+ """
+ if self.inference_cfg.autocast.enabled:
+ with torch.autocast(device_type=str(self.device)):
+ _, raw_predictions = self.model(inputs.to(self.device))
+ else:
+ _, raw_predictions = self.model(inputs.to(self.device))
+ predictions = [
+ {
+ "detection": {
+ "bboxes": item["boxes"].cpu().numpy().reshape(-1, 4),
+ "scores": item["scores"].cpu().numpy().reshape(-1),
+ }
+ }
+ for item in raw_predictions
+ ]
+ return predictions
+
+
+def build_inference_runner(
+ task: Task,
+ model: nn.Module,
+ device: str,
+ snapshot_path: str | Path | None = None,
+ batch_size: int = 1,
+ preprocessor: Preprocessor | None = None,
+ postprocessor: Postprocessor | None = None,
+ dynamic: DynamicCropper | None = None,
+ load_weights_only: bool | None = None,
+ inference_cfg: InferenceConfig | dict | None = None,
+ **kwargs,
+) -> InferenceRunner:
+ """Build a runner object according to a pytorch configuration file.
+
+ Args:
+ task: the inference task to run
+ model: the model to run
+ device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'})
+ snapshot_path: the snapshot from which to load the weights
+ batch_size: the batch size to use to run inference
+ preprocessor: the preprocessor to use on images before inference
+ postprocessor: the postprocessor to use on images after inference
+ dynamic: The DynamicCropper used for video inference, or None if dynamic
+ cropping should not be used. Only for bottom-up pose estimation models.
+ Should only be used when creating inference runners for video pose
+ estimation with batch size 1.
+ load_weights_only: Value for the torch.load() `weights_only` parameter.
+ If False, the python pickle module is used implicitly, which is known to
+ be insecure. Only set to False if you're loading data that you trust (e.g.
+ snapshots that you created). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+ inference_cfg: Configuration for the inference runner
+ **kwargs: Other arguments for the InferenceRunner.
+
+ Returns:
+ The inference runner.
+ """
+ kwargs = dict(
+ model=model,
+ device=device,
+ snapshot_path=snapshot_path,
+ batch_size=batch_size,
+ preprocessor=preprocessor,
+ postprocessor=postprocessor,
+ load_weights_only=load_weights_only,
+ inference_cfg=inference_cfg,
+ **kwargs,
+ )
+
+ if task == Task.DETECT:
+ if dynamic is not None:
+ raise ValueError(
+ "The DynamicCropper can only be used for pose estimation; not object "
+ "detection. Please turn off dynamic cropping."
+ )
+ return DetectorInferenceRunner(**kwargs)
+
+ if task != Task.BOTTOM_UP:
+ if dynamic is not None and not isinstance(dynamic, TopDownDynamicCropper):
+ print(
+ "Turning off dynamic cropping. It should only be used for bottom-up "
+ f"pose estimation models, but you are using a {task} model. To use "
+ f"dynamic cropping with {task}, use a TopDownDynamicCropper."
+ )
+ dynamic = None
+
+ if task == Task.COND_TOP_DOWN:
+ return CTDInferenceRunner(**kwargs)
+
+ return PoseInferenceRunner(dynamic=dynamic, **kwargs)
diff --git a/deeplabcut/pose_estimation_pytorch/runners/logger.py b/deeplabcut/pose_estimation_pytorch/runners/logger.py
new file mode 100644
index 0000000000..53608fcb23
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py
@@ -0,0 +1,507 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import csv
+import logging
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+import torchvision.transforms as transforms
+import torchvision.transforms.functional as F
+import yaml
+from torch.utils.data import DataLoader
+from torchvision.utils import draw_bounding_boxes, draw_keypoints
+
+try:
+ import wandb
+
+ has_wandb = True
+except ImportError:
+ has_wandb = False
+
+import deeplabcut.pose_estimation_pytorch.registry as deeplabcut_pose_estimation_pytorch_registry
+from deeplabcut.pose_estimation_pytorch.models.model import PoseModel
+
+LOGGER = deeplabcut_pose_estimation_pytorch_registry.Registry(
+ "loggers", build_func=deeplabcut_pose_estimation_pytorch_registry.build_from_cfg
+)
+
+
+def setup_file_logging(filepath: Path) -> None:
+ """Sets up logging to a file.
+
+ Args:
+ filepath: the path where logs should be saved
+ """
+ logging.basicConfig(
+ filename=filepath,
+ filemode="a",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ level=logging.INFO,
+ format="%(asctime)-15s %(message)s",
+ force=True,
+ )
+ console_logger = logging.StreamHandler()
+ console_logger.setLevel(logging.INFO)
+ root = logging.getLogger()
+ root.addHandler(console_logger)
+
+
+def destroy_file_logging() -> None:
+ """Resets the logging module to log everything to the console."""
+ root = logging.getLogger()
+ handlers = [h for h in root.handlers]
+ for handler in handlers:
+ root.removeHandler(handler)
+
+
+class BaseLogger(ABC):
+ """Base class for logging training runs."""
+
+ @abstractmethod
+ def log_config(self, config: dict = None) -> None:
+ """Logs the configuration data for a training run.
+
+ Args:
+ config: the training configuration used for the run
+ """
+
+ @abstractmethod
+ def log(self, metrics: dict[str, Any], step: int | None = None) -> None:
+ """Logs data from a training run.
+
+ Args:
+ metrics: the metrics to log
+ step: The global step in processing. Defaults to None.
+ """
+
+ @abstractmethod
+ def save(self) -> None:
+ """Saves the current training logs."""
+
+
+class ImageLoggerMixin(ABC):
+ """Mixin for loggers that can log images.
+
+ Before starting training, you should call `select_images_to_log`, which will
+ select a train and a test image for which inputs/outputs will always be logged.
+ Then logger.log_images should be called at every step - the logger will check if
+ anything needs to be uploaded, and take care of it.
+
+ Example:
+ project_name = "example"
+ run_name = "run-1"
+ logger = WandbLogger(project_name, run_name)
+ logger.select_images_to_log(train_loader, test_loader)
+
+ for i in range(epochs):
+ for batch_inputs in train_loader:
+ batch_labels = batch_data["annotations"]
+ batch_inputs = batch_data["image"]
+ batch_outputs = model(batch_inputs)
+ batch_targets = model.get_target(batch_outputs, batch_labels)
+ loss = criterion(batch_targets, batch_outputs)
+ loss.backwards()
+ optim.step()
+
+ logger.log_images(batch_inputs, batch_outputs, batch_targets)
+
+ for batch_inputs in train_loader:
+ ...
+ logger.log_images(batch_inputs, batch_outputs, batch_targets)
+ """
+
+ def __init__(self, image_log_interval: int | None = None, *args, **kwargs):
+ """"""
+ super().__init__(*args, **kwargs)
+ self.image_log_interval = image_log_interval
+ self._logged = {}
+ self._denormalize = transforms.Compose(
+ [
+ transforms.Normalize(mean=[0, 0, 0], std=[1 / 0.229, 1 / 0.224, 1 / 0.225]),
+ transforms.Normalize(mean=[-0.485, -0.456, -0.406], std=[1, 1, 1]),
+ ]
+ )
+ self._softmax = torch.nn.Softmax2d()
+
+ @abstractmethod
+ def log_images(
+ self,
+ inputs: dict[str, Any],
+ outputs: dict[str, torch.Tensor],
+ targets: dict[str, dict[str, torch.Tensor]],
+ step: int,
+ ) -> None:
+ """Log images for a batch.
+
+ Args:
+ inputs: the inputs for the model, containing at least an "image" key
+ outputs: the outputs of each model head
+ targets: the targets for each model head
+ step: the current step
+ """
+ pass
+
+ def select_images_to_log(self, train: DataLoader, valid: DataLoader) -> None:
+ """Selects the train and test images to log.
+
+ Args:
+ train: the training dataloader
+ valid: the inference dataloader
+ """
+
+ def _caption(image_path: str) -> str:
+ p = Path(image_path)
+ return f"{p.parent.name}.{p.stem}"
+
+ train_image = train.dataset[0]["path"]
+ test_image = valid.dataset[0]["path"]
+ self._logged = {
+ train_image: {"name": "train-0", "caption": _caption(train_image)},
+ test_image: {"name": "test-0", "caption": _caption(test_image)},
+ }
+
+ def _prepare_image(
+ self,
+ image: torch.Tensor,
+ denormalize: bool = False,
+ keypoints: torch.Tensor | None = None,
+ bboxes: torch.Tensor | None = None,
+ ) -> np.ndarray:
+ """
+ Args:
+ image: the image to log, of shape (C, H, W), of any data type
+ denormalize: whether to remove ImageNet channel normalization
+ keypoints: size (num_instances, K, 2) the K keypoints location
+ bboxes: size (N, 4) containing bboxes in (xmin, ymin, xmax, ymax)
+
+ Returns:
+ an uint8 array with keypoints and bounding boxes drawn
+ """
+ if denormalize:
+ image = self._denormalize(image.unsqueeze(0)).squeeze()
+
+ image = F.convert_image_dtype(image.detach().cpu(), dtype=torch.uint8)
+ if keypoints is not None and len(keypoints) > 0:
+ assert len(keypoints.shape) == 3
+ # Use visibility and force torchvision >= 0.18
+ # pytorch.org/vision/0.18/generated/torchvision.utils.draw_keypoints.html
+ # pytorch.org/vision/0.17/generated/torchvision.utils.draw_keypoints.html
+ keypoints[torch.any(torch.isnan(keypoints), dim=-1)] = -1
+ image = draw_keypoints(image, keypoints=keypoints[..., :2], colors="red", radius=5)
+
+ if bboxes is not None and len(bboxes) > 0:
+ assert len(bboxes.shape) == 2
+ image = draw_bounding_boxes(image, boxes=bboxes[:, :4], width=1)
+
+ return image.permute(1, 2, 0).numpy()
+
+ def _heatmap_softmax(self, heatmaps: torch.Tensor) -> torch.Tensor:
+ """Applies a softmax to the heatmap channels."""
+ return self._softmax(heatmaps.detach().cpu())
+
+ def _prepare_images(
+ self,
+ inputs: dict[str, Any],
+ outputs: dict[str, dict[str, torch.Tensor]],
+ targets: dict[str, dict[str, dict[str, torch.Tensor]]],
+ ) -> dict[str, np.ndarray]:
+ """Prepares images for logging."""
+ image_logs = {}
+ paths = inputs["path"]
+ images_to_log = [(i, p) for i, p in enumerate(paths) if p in self._logged]
+ for idx, path in images_to_log:
+ base = self._logged[path]["name"]
+ keypoints = inputs.get("annotations", {}).get("keypoints")
+ if keypoints is not None:
+ keypoints = keypoints[idx]
+ image_logs[f"{base}.input"] = self._prepare_image(
+ inputs["image"][idx],
+ keypoints=keypoints,
+ denormalize=True,
+ )
+
+ for head, head_outputs in outputs.items():
+ if "heatmap" in head_outputs:
+ head_heatmaps = self._heatmap_softmax(head_outputs["heatmap"][idx])
+ head_targets = targets[head]["heatmap"]["target"][idx]
+ for j, (h, t) in enumerate(zip(head_heatmaps, head_targets, strict=False)):
+ h = self._prepare_image(h.unsqueeze(0))
+ t = self._prepare_image(t.unsqueeze(0))
+ image_logs[f"{base}.heatmap.{j}"] = np.concatenate([h, t])
+
+ return image_logs
+
+
+@LOGGER.register_module
+class WandbLogger(ImageLoggerMixin, BaseLogger):
+ """Wandb logger to track experiments and log data.
+
+ Refer to: https://docs.wandb.ai/guides for more information on wandb.
+
+ Attributes:
+ run (wandb.Run): The wandb run object associated with the current experiment.
+ """
+
+ def __init__(
+ self,
+ project_name: str = "deeplabcut",
+ run_name: str = "tmp",
+ image_log_interval: int | None = None,
+ model: PoseModel = None,
+ train_folder: str = None,
+ **wandb_kwargs,
+ ) -> None:
+ """Initialize the WandbLogger class.
+
+ Args:
+ project_name: The name of the wandb project. Defaults to "deeplabcut".
+ run_name: The name of the wandb run. Defaults to "tmp".
+ image_log_interval: How often train/test images are logged in epochs (if
+ None, train/test inputs are never logged).
+ model: The model to log. Defaults to None.
+ train_folder: path to the train folder (used to store the W&B run identifiers)
+ wandb_kwargs: extra arguments to pass to ``wb.init``
+
+ Example:
+ logger = WandbLogger(project_name="mice", run_name="exp1", model=my_model)
+ """
+ super().__init__(image_log_interval=image_log_interval)
+
+ if not has_wandb:
+ raise ValueError(
+ "Cannot use ``WandbLogger`` as wandb is not installed. Please run"
+ "``pip install wandb`` if you want to log to wandb"
+ )
+
+ if wandb.run is not None:
+ wandb.finish()
+
+ self.run = wandb.init(
+ project=project_name,
+ name=run_name,
+ **wandb_kwargs,
+ )
+ if model is None:
+ raise ValueError("Specify the model to track!")
+ self.run.watch(model)
+ if train_folder is None:
+ raise ValueError("Specify the train folder!")
+ self.train_folder = Path(train_folder)
+ self._save_wandb_info()
+
+ def _save_wandb_info(self):
+ wandb_info = {
+ "entity": self.run.entity,
+ "project": self.run.project,
+ "run_id": self.run.id,
+ }
+
+ output_path = self.train_folder / "wandb_info.yaml"
+ with open(output_path, "w") as f:
+ yaml.safe_dump(wandb_info, f)
+
+ logging.info(f"WandB run info saved to {output_path}")
+
+ def log(self, metrics: dict[str, Any], step: int | None = None) -> None:
+ """Logs metrics from runs.
+
+ Args:
+ metrics: the metrics to log
+ step: The global step in processing. Defaults to None.
+
+ Example:
+ logger = WandbLogger()
+ logger.log({"loss": 0.123}, step=100)
+ """
+ self.run.log(metrics, step=step)
+
+ def log_images(
+ self,
+ inputs: dict[str, Any],
+ outputs: dict[str, dict[str, torch.Tensor]],
+ targets: dict[str, dict[str, dict[str, torch.Tensor]]],
+ step: int,
+ ) -> None:
+ """Log images for a batch.
+
+ Args:
+ inputs: the inputs for the model, containing at least an "image" key
+ outputs: the outputs of each model head
+ targets: the targets for each model head
+ step: the current step
+ """
+ if self.image_log_interval is None or step % self.image_log_interval != 0:
+ return
+
+ images = self._prepare_images(inputs, outputs, targets)
+ if len(images) > 0:
+ self.run.log(
+ {name: wandb.Image(image) for name, image in images.items()},
+ step=step,
+ )
+
+ def save(self):
+ """Syncs all files to wandb with the policy specified.
+
+ Notes:
+ self.run: A run is a unit of computation logged by wandb.
+ self.run.run.dir: The directory where files associated with the run are saved.
+
+ Example:
+ logger = WandbLogger()
+ # Training and logging
+ logger.save()
+ """
+ self.run.save(self.run.dir)
+
+ def log_config(self, config: dict = None) -> None:
+ """Updates the current run with the given config dict.
+
+ Notes:
+ self.run: A run is a unit of computation logged by wandb.
+ self.run.config: Config object associated with this run.
+
+ Args:
+ config: Experiment config file.
+
+ Example:
+ logger = WandbLogger()
+ config = {"learning_rate": 0.001, "batch_size": 32}
+ logger.log_config(config)
+ """
+ self.run.config.update(config)
+
+
+@LOGGER.register_module
+class CSVLogger(BaseLogger):
+ """Logger saving stats and metrics to a CSV file."""
+
+ def __init__(self, train_folder: str, log_filename: str) -> None:
+ """Initialize the CSVLogger class.
+
+ Args:
+ train_folder: The path of the folder containing training files.
+ log_filename: The name of the file in which to store training stats
+ """
+ super().__init__()
+ train_folder = Path(train_folder)
+ self.train_folder = train_folder
+ self.log_filename = log_filename
+ self.log_file = train_folder / log_filename
+
+ self._steps: list[int] = []
+ self._metric_store: list[dict] = []
+ self._logged_metrics: set[str] = set()
+
+ # Load existing data if the file exists (e.g., when resuming from snapshot)
+ if self.log_file.exists():
+ self._load_existing_data()
+
+ def log(self, metrics: dict[str, Any], step: int | None = None) -> None:
+ """Logs metrics from runs.
+
+ Args:
+ metrics: the metrics to log
+ step: The global step in processing. Defaults to None.
+ """
+ if step is None:
+ if len(self._steps) == 0:
+ step = 0
+ else:
+ step = self._steps[-1] + 1
+
+ self._logged_metrics = self._logged_metrics.union(metrics.keys())
+ if len(self._steps) > 0 and step == self._steps[-1]:
+ self._metric_store[-1].update(metrics)
+ else:
+ self._steps.append(step)
+ self._metric_store.append(metrics)
+
+ self.save()
+
+ def save(self):
+ """Saves the metrics to the file system."""
+ logs = self._prepare_logs()
+ with open(self.log_file, "w", newline="") as f:
+ writer = csv.writer(f)
+ writer.writerows(logs)
+
+ def log_config(self, config: dict = None) -> None:
+ """Does not do anything as the config should already be saved.
+
+ Args:
+ config: Experiment config file.
+ """
+ pass
+
+ def _load_existing_data(self) -> None:
+ """Loads existing CSV data if the log file exists."""
+ logging.info(f"Loading existing CSV data from {self.log_file}")
+ try:
+ with open(self.log_file, newline="") as f:
+ reader = csv.DictReader(f)
+
+ # Update logged metrics from header
+ if "step" not in reader.fieldnames:
+ raise ValueError("Invalid CSV format: missing 'step' column")
+
+ metric_names = [m for m in reader.fieldnames if m != "step"]
+ self._logged_metrics.update(metric_names)
+
+ # Load data rows
+ steps = []
+ metric_store = []
+ for row in reader:
+ try:
+ step = int(row["step"])
+ except (ValueError, KeyError):
+ logging.warning(f"Invalid step value in row: {row}")
+ continue
+
+ # Convert metric values: empty strings -> None, numeric strings -> float
+ step_metrics = {}
+ for metric in metric_names:
+ value = row.get(metric, "").strip()
+ if not value:
+ step_metrics[metric] = None
+ else:
+ try:
+ step_metrics[metric] = float(value)
+ except ValueError:
+ step_metrics[metric] = value
+
+ steps.append(step)
+ metric_store.append(step_metrics)
+
+ except Exception as e:
+ logging.warning(f"Failed to load existing CSV data from {self.log_file}: {e}. Starting with empty log.")
+ return
+ self._steps.extend(steps)
+ self._metric_store.extend(metric_store)
+
+ def _prepare_logs(self) -> list[list]:
+ """Prepares the data to log as a list of strings."""
+ if len(self._metric_store) == 0:
+ return []
+
+ metrics = list(sorted(self._logged_metrics))
+ logs = [["step"] + metrics]
+ for step, step_metrics in zip(self._steps, self._metric_store, strict=False):
+ # Convert None values to empty strings for proper CSV formatting
+ row = [step] + ["" if step_metrics.get(m) is None else step_metrics.get(m) for m in metrics]
+ logs.append(row)
+
+ return logs
diff --git a/deeplabcut/pose_estimation_pytorch/runners/schedulers.py b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py
new file mode 100644
index 0000000000..029b42bd92
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.py
@@ -0,0 +1,130 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from typing import Any
+
+import torch
+from torch.optim.lr_scheduler import _LRScheduler
+
+
+class LRListScheduler(_LRScheduler):
+ """You can achieve increased performance and faster training by using a learning
+ rate that changes during training.
+
+ A scheduler makes the learning rate adaptive. Given a list of learning rates and
+ milestones modifies the learning rate accordingly during training.
+ """
+
+ def __init__(self, optimizer, milestones, lr_list, last_epoch=-1) -> None:
+ """
+ Args:
+ optimizer: optimizer used for learning.
+ milestones: number of epochs.
+ lr_list: learning rate list.
+ last_epoch: where to start the scheduler. (-1: start from beginning)
+
+ Examples:
+ input:
+ last_epoch = -1
+ verbose = False
+ milestones = [10, 30, 40]
+ lr_list = [[0.00001],[0.000005],[0.000001]]
+ """
+ self.milestones = milestones
+ self.lr_list = lr_list
+ super().__init__(optimizer, last_epoch)
+
+ def get_lr(self):
+ """Summary:
+ Given a milestones, get the corresponding learning rate.
+
+ Returns:
+ lr: learning rate value
+
+ Examples:
+ input: LRListScheduler object
+ output: learning rate (lr) = [0.001]
+ """
+ if self.last_epoch not in self.milestones:
+ return [group["lr"] for group in self.optimizer.param_groups]
+ return [lr for lr in self.lr_list[self.milestones.index(self.last_epoch)]]
+
+
+def build_scheduler(
+ scheduler_cfg: dict | None, optimizer: torch.optim.Optimizer
+) -> torch.optim.lr_scheduler.LRScheduler | None:
+ """Builds a scheduler from a configuration, if defined.
+
+ Args:
+ scheduler_cfg: the configuration of the scheduler to build
+ optimizer: the optimizer the scheduler will be built for
+
+ Returns:
+ None if scheduler_cfg is None, otherwise the scheduler
+ """
+ if scheduler_cfg is None:
+ return None
+
+ if scheduler_cfg["type"] == "LRListScheduler":
+ scheduler = LRListScheduler
+ else:
+ scheduler = getattr(torch.optim.lr_scheduler, scheduler_cfg["type"])
+
+ parsed_params = {}
+ for param_name, param in scheduler_cfg["params"].items():
+ if isinstance(param, list):
+ param = [_parse_scheduler_param(p, optimizer) for p in param]
+ else:
+ param = _parse_scheduler_param(param, optimizer)
+
+ parsed_params[param_name] = param
+
+ return scheduler(optimizer=optimizer, **parsed_params)
+
+
+def _parse_scheduler_param(param: Any, optimizer: torch.optim.Optimizer) -> Any:
+ """Parses parameters so they're built as schedulers if they're configured as one."""
+ if isinstance(param, dict) and "type" in param:
+ param = build_scheduler(param, optimizer)
+
+ return param
+
+
+def load_scheduler_state(
+ scheduler: torch.optim.lr_scheduler.LRScheduler,
+ state_dict: dict,
+) -> None:
+ """
+ Args:
+ scheduler: The scheduler for which to load the state dict.
+ state_dict: The state dict to load
+
+ Raises:
+ ValueError: if the state dict fails to load.
+ """
+ try:
+ scheduler.load_state_dict(state_dict)
+ except Exception as err:
+ raise ValueError("Failed to load state dict") from err
+
+ param_groups = scheduler.optimizer.param_groups
+ resume_lrs = scheduler.get_last_lr()
+
+ if len(param_groups) != len(resume_lrs):
+ raise ValueError(
+ f"Number of optimizer parameter groups ({len(param_groups)}) did not match "
+ f"number of learning rates to resume from ({len(scheduler.get_last_lr())})."
+ )
+
+ # Update the learning rate for the optimizer based on the scheduler
+ for group, resume_lr in zip(param_groups, resume_lrs, strict=False):
+ group["lr"] = resume_lr
diff --git a/deeplabcut/pose_estimation_pytorch/runners/shelving.py b/deeplabcut/pose_estimation_pytorch/runners/shelving.py
new file mode 100644
index 0000000000..3d1459bbe7
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/shelving.py
@@ -0,0 +1,218 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Modules used to read/write shelve data during video analysis in DeepLabCut 3.0."""
+
+import pickle
+import shelve
+from pathlib import Path
+
+import numpy as np
+
+
+class ShelfManager:
+ """Class to manage shelf data."""
+
+ def __init__(self, filepath: str | Path, flag: str = "r") -> None:
+ self.filepath = Path(filepath)
+ self.flag = flag
+
+ self._db: shelve.Shelf | None = None
+ self._open: bool = False
+
+ def open(self) -> None:
+ """Opens the shelf."""
+ self._db = shelve.open(
+ str(self.filepath),
+ flag=self.flag,
+ protocol=pickle.DEFAULT_PROTOCOL,
+ )
+ self._open = True
+
+ def close(self) -> None:
+ """Closes the shelf."""
+ if not self._open:
+ return
+
+ try:
+ self._db.close()
+ except AttributeError:
+ pass
+
+ self._open = False
+
+ def keys(self) -> list[str]:
+ if not self._open:
+ raise ValueError("You must call open() before reading keys!")
+
+ return [k for k in self._db]
+
+
+class ShelfReader(ShelfManager):
+ """Reads data from a shelf."""
+
+ def __getitem__(self, item: str) -> dict:
+ """Reads an item from the shelf.
+
+ Args:
+ item: The key of the item to read.
+
+ Returns:
+ The item.
+ """
+ if not self._open:
+ raise ValueError("You must call open() before reading data!")
+
+ return self._db[item]
+
+
+class ShelfWriter(ShelfManager):
+ """Writes data to a shelf on-the-fly during video analysis.
+
+ Args:
+ pose_cfg: The test pose config for the model.
+ filepath: The path where the data should be saved.
+ num_frames: The number of frames in the video. Used to set the number of
+ leading 0s in the keys of the dictionary. Default is 5 if the number of
+ frames is not given.
+
+ Attributes:
+ filepath: The path to the shelf.
+ """
+
+ def __init__(self, pose_cfg: dict, filepath: str | Path, num_frames: int | None = None):
+ super().__init__(filepath, flag="c")
+ self._pose_cfg = pose_cfg
+ self._num_frames = num_frames
+ self._frame_index = 0
+
+ self._str_width = 5
+ if num_frames is not None:
+ self._str_width = int(np.ceil(np.log10(num_frames)))
+
+ def add_prediction(
+ self,
+ bodyparts: np.ndarray,
+ unique_bodyparts: np.ndarray | None = None,
+ identity_scores: np.ndarray | None = None,
+ **kwargs,
+ ) -> None:
+ """Adds the prediction for a frame to the shelf.
+
+ Args:
+ bodyparts: The predicted bodyparts.
+ unique_bodyparts: The predicted unique bodyparts, if there are any.
+ identity_scores: The predicted identities, if there are any.
+ """
+ if not self._open:
+ raise ValueError("You must call open() before adding data!")
+
+ key = "frame" + str(self._frame_index).zfill(self._str_width)
+
+ # convert bodyparts to shape (num_bpts, num_assemblies, 3)
+ bodyparts = bodyparts.transpose((1, 0, 2))
+ coordinates = [bpt[:, :2] for bpt in bodyparts]
+ scores = [bpt[:, 2:3] for bpt in bodyparts]
+
+ # full pickle has bodyparts and unique bodyparts in same array
+ if unique_bodyparts is not None:
+ unique_bpts = unique_bodyparts.transpose((1, 0, 2))
+ coordinates += [bpt[:, :2] for bpt in unique_bpts]
+ scores += [bpt[:, 2:] for bpt in unique_bpts]
+
+ output = dict(coordinates=(coordinates,), confidence=scores, costs=None)
+
+ if identity_scores is not None:
+ # Reshape id scores from (num_assemblies, num_bpts, num_individuals)
+ # to the original DLC full pickle format: (num_bpts, num_assem, num_ind)
+ id_scores = identity_scores.transpose((1, 0, 2))
+ output["identity"] = [bpt_id_scores for bpt_id_scores in id_scores]
+
+ if unique_bodyparts is not None:
+ # needed for create_video_with_all_detections to display unique bpts
+ num_unique = unique_bodyparts.shape[1]
+ num_assem, num_ind = id_scores.shape[1:]
+ output["identity"] += [-1 * np.ones((num_assem, num_ind)) for i in range(num_unique)]
+
+ self._db[key] = output
+ self._frame_index += 1
+
+ def close(self) -> None:
+ """Closes the shelf and writes the updated metadata."""
+ if self._open and self._frame_index > 0:
+ # Write updated metadata to shelf (top-level indexing required for shelve)
+ metadata = self._db["metadata"]
+ metadata["nframes"] = self._frame_index
+ self._db["metadata"] = metadata
+
+ super().close()
+
+ def open(self) -> None:
+ """Opens the shelf."""
+ super().open()
+ self._frame_index = 0
+
+ all_joints = self._pose_cfg["all_joints"]
+ paf_graph = self._pose_cfg.get("partaffinityfield_graph", [])
+
+ self._db["metadata"] = {
+ "nms radius": self._pose_cfg.get("nmsradius"),
+ "minimal confidence": self._pose_cfg.get("minconfidence"),
+ "sigma": self._pose_cfg.get("sigma", 1),
+ "PAFgraph": paf_graph,
+ "PAFinds": self._pose_cfg.get("paf_best", np.arange(len(paf_graph))),
+ "all_joints": [[i] for i in range(len(all_joints))],
+ "all_joints_names": [self._pose_cfg["all_joints_names"][i] for i in range(len(all_joints))],
+ "nframes": self._num_frames,
+ "key_str_width": self._str_width,
+ }
+
+
+class FeatureShelfWriter(ShelfWriter):
+ """Writes bodypart features to a shelf on-the-fly for ReID model training.
+
+ Args:
+ pose_cfg: The test pose config for the model.
+ filepath: The path where the data should be saved.
+ num_frames: The number of frames in the video. Used to set the number of
+ leading 0s in the keys of the dictionary. Default is 5 if the number of
+ frames is not given.
+
+ Attributes:
+ filepath: The path to the shelf.
+ """
+
+ def __init__(self, pose_cfg: dict, filepath: str | Path, num_frames: int | None = None):
+ super().__init__(pose_cfg, filepath, num_frames)
+
+ def add_prediction(
+ self,
+ bodyparts: np.ndarray,
+ features: np.ndarray | None = None,
+ **kwargs,
+ ) -> None:
+ """Adds the prediction for a frame to the shelf.
+
+ Args:
+ bodyparts: The predicted bodyparts.
+ features: The features for the bodyparts.
+ """
+ if not self._open:
+ raise ValueError("You must call open() before adding data!")
+
+ key = "frame" + str(self._frame_index).zfill(self._str_width)
+
+ # bodyparts to shape (num_assemblies, num_bpts, xy)
+ coordinates = bodyparts[:, :, :2]
+ if features is None:
+ raise ValueError("Backbone features must be given to the FeatureShelfWriter")
+
+ self._db[key] = dict(coordinates=coordinates, features=features)
+ self._frame_index += 1
diff --git a/deeplabcut/pose_estimation_pytorch/runners/snapshots.py b/deeplabcut/pose_estimation_pytorch/runners/snapshots.py
new file mode 100755
index 0000000000..7498de0a09
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/snapshots.py
@@ -0,0 +1,178 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Code to handle storing models."""
+
+from __future__ import annotations
+
+import warnings
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.data.snapshots import Snapshot, list_snapshots
+
+
+@dataclass
+class TorchSnapshotManager:
+ """Class handling model checkpoint I/O.
+
+ Attributes:
+ snapshot_prefix: The prefix to use when saving snapshots.
+ model_folder: The path to the directory where model snapshots should be stored.
+ key_metric: If defined, the metric is used to save the best model. Otherwise no
+ best model is used.
+ key_metric_asc: Whether the key metric is ascending (larger values are better).
+ max_snapshots: The maximum number of snapshots to store for the training run.
+ This does not include the best model (e.g., setting max_snapshots=5 will
+ mean that the 5 latest models will be kept, plus the best model)
+ save_epochs: The number of epochs between each model save
+ save_optimizer_state: Whether to store the optimizer state. This makes snapshots
+ much heavier, but allows to resume training as if it was never stopped.
+
+ Examples:
+ # Storing snapshots while training
+ model: nn.Module
+ loader = DLCLoader(...)
+ snapshot_manager = TorchSnapshotManager(
+ "snapshot",
+ loader.model_folder,
+ key_metric="test.mAP",
+ )
+ ...
+ for epoch in range(num_epochs):
+ train_epoch(model, data)
+ snapshot_manager.update({
+ "metadata": {
+ "metrics": {"mAP": ...}
+ },
+ "model": model.state_dict(),
+ "optimizer": optimizer.state_dict()
+ })
+ """
+
+ snapshot_prefix: str
+ model_folder: Path
+ key_metric: str | None = None
+ key_metric_asc: bool = True
+ max_snapshots: int = 5
+ save_epochs: int = 25
+ save_optimizer_state: bool = False
+ _best_model_epochs: int = -1
+ _best_metric: float | None = None
+ _key: str = field(init=False)
+
+ def __post_init__(self):
+ assert self.max_snapshots > 0, "max_snapshots must be a positive integer"
+ self._key = f"metrics/{self.key_metric}"
+
+ def update(self, epoch: int, state_dict: dict, last: bool = False) -> None:
+ """Saves the model state dict if the epoch is one that requires a save.
+
+ Args:
+ epoch: the number of epochs the model was trained for
+ state_dict: the state dict to store
+ last: whether this is the last epoch in the training run, which forces a
+ model save no matter the epoch number
+
+ Returns:
+ the path to the saved snapshot if one
+ """
+ metrics = state_dict["metadata"]["metrics"]
+ if (
+ self._key in metrics
+ and not np.isnan(metrics[self._key])
+ and (
+ self._best_metric is None
+ or (self.key_metric_asc and self._best_metric < metrics[self._key])
+ or (not self.key_metric_asc and self._best_metric > metrics[self._key])
+ )
+ ):
+ current_best = self.best()
+ self._best_metric = metrics[self._key]
+
+ # Save the new best model
+ save_path = self.snapshot_path(epoch, best=True)
+ parsed_state_dict = {k: v for k, v in state_dict.items() if self.save_optimizer_state or k != "optimizer"}
+ torch.save(parsed_state_dict, save_path)
+
+ # Handle previous best model
+ if current_best is not None:
+ if current_best.epochs % self.save_epochs == 0:
+ new_name = self.snapshot_path(epoch=current_best.epochs)
+ current_best.path.rename(new_name)
+ else:
+ current_best.path.unlink(missing_ok=False)
+ elif last or epoch % self.save_epochs == 0:
+ # Save regular snapshot if needed
+ save_path = self.snapshot_path(epoch=epoch)
+ parsed_state_dict = {k: v for k, v in state_dict.items() if self.save_optimizer_state or k != "optimizer"}
+ torch.save(parsed_state_dict, save_path)
+
+ # Clean up old snapshots if needed
+ existing_snapshots = [s for s in self.snapshots() if not s.best]
+ if len(existing_snapshots) >= self.max_snapshots:
+ num_to_delete = len(existing_snapshots) - self.max_snapshots
+ to_delete = existing_snapshots[:num_to_delete]
+ for snapshot in to_delete:
+ snapshot.path.unlink(missing_ok=False)
+
+ def best(self) -> Snapshot | None:
+ """Returns: the path to the best snapshot, if it exists"""
+ snapshots = self.snapshots()
+ best_snapshots = [s for s in snapshots if s.best]
+ if len(best_snapshots) == 0:
+ return None
+
+ if len(best_snapshots) > 1:
+ warnings.warn(
+ f"TorchSnapshotManager.best(): found multiple best snapshots ("
+ f"{best_snapshots}), returning the last one.",
+ stacklevel=2,
+ )
+
+ best_snapshot = best_snapshots[-1]
+ return best_snapshot
+
+ def last(self) -> Snapshot | None:
+ """Returns: path to the last snapshot that was saved, if any snapshot exists"""
+ snapshots = self.snapshots(best_in_last=False)
+ if len(snapshots) == 0:
+ return None
+ return snapshots[-1]
+
+ def snapshots(self, best_in_last: bool = True) -> list[Snapshot]:
+ """
+ Args:
+ best_in_last: Whether to place the snapshot with the best performance in the
+ last position in the list, even if it wasn't the last epoch.
+
+ Returns:
+ The snapshots for a training run, sorted by the number of epochs they were
+ trained for. If ``best_in_last=True`` and a best snapshot exists, it will be
+ the last one in the list.
+ """
+ return list_snapshots(self.model_folder, self.snapshot_prefix, best_in_last=best_in_last)
+
+ def snapshot_path(self, epoch: int, best: bool = False) -> Path:
+ """
+ Args:
+ epoch: the number of epochs for which a snapshot was trained
+ best: whether this is the best performing model for the training run
+
+ Returns:
+ the path where the model should be stored
+ """
+ uid = f"{epoch:03}"
+ if best:
+ uid = f"best-{uid}"
+ return self.model_folder / f"{self.snapshot_prefix}-{uid}.pt"
diff --git a/deeplabcut/pose_estimation_pytorch/runners/train.py b/deeplabcut/pose_estimation_pytorch/runners/train.py
new file mode 100644
index 0000000000..6e87751d53
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/train.py
@@ -0,0 +1,751 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import logging
+from abc import ABCMeta, abstractmethod
+from collections import defaultdict
+from pathlib import Path
+from typing import Any, Generic
+
+import numpy as np
+import torch
+import torch.nn as nn
+from torch.nn.parallel import DataParallel
+from torch.utils.data import DataLoader
+
+import deeplabcut.core.metrics as metrics
+import deeplabcut.pose_estimation_pytorch.runners.schedulers as schedulers
+from deeplabcut.pose_estimation_pytorch.models.detectors import BaseDetector
+from deeplabcut.pose_estimation_pytorch.models.model import PoseModel
+from deeplabcut.pose_estimation_pytorch.runners.base import (
+ ModelType,
+ Runner,
+ attempt_snapshot_load,
+)
+from deeplabcut.pose_estimation_pytorch.runners.logger import (
+ BaseLogger,
+ CSVLogger,
+ ImageLoggerMixin,
+)
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import TorchSnapshotManager
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+class TrainingRunner(Runner, Generic[ModelType], metaclass=ABCMeta):
+ """Base TrainingRunner class.
+
+ A TrainingRunner is used to fit models to datasets. Subclasses must implement the
+ ``step(self, batch, mode)`` method, which performs a single training or validation
+ step on a batch of data. The step is different depending on the model type (e.g.
+ a pose model step vs. an object detector step).
+
+ Args:
+ model: The model to fit.
+ optimizer: The optimizer to use to fit the model.
+ snapshot_manager: Manages how snapshots are saved to disk during training.
+ device: The device on which to run training (e.g. 'cpu', 'cuda', 'cuda:0').
+ gpus: Used to specify the GPU indices for multi-GPU training (e.g. [0, 1, 2, 3]
+ to train on 4 GPUs). When a GPUs list is given, the device must be 'cuda'.
+ eval_interval: The interval at which the model will be evaluated while training
+ (e.g. `eval_interva=5` means the model will be evaluated every 5 epochs).
+ snapshot_path: If continuing to train a model, the path to the snapshot to
+ resume training from.
+ scheduler: The learning rate scheduler (or it's configuration), if one should be
+ used.
+ load_scheduler_state_dict: When resuming training (snapshot_path is not None),
+ attempts to load the scheduler state dict from the snapshot. If you've
+ modified your scheduler, set this to False or the old scheduler parameters
+ might be used.
+ logger: Logger to monitor training (e.g. a WandBLogger).
+ log_filename: Name of the file in which to store training stats.
+ load_weights_only: Value for the torch.load() `weights_only` parameter if
+ `snapshot_path` is not None.
+ If False, the python pickle module is used implicitly, which is known to
+ be insecure. Only set to False if you're loading data that you trust
+ (e.g. snapshots that you created yourself). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+ """
+
+ def __init__(
+ self,
+ model: ModelType,
+ optimizer: dict | torch.optim.Optimizer,
+ snapshot_manager: TorchSnapshotManager,
+ device: str = "cpu",
+ gpus: list[int] | None = None,
+ eval_interval: int = 1,
+ snapshot_path: str | Path | None = None,
+ scheduler: dict | torch.optim.lr_scheduler.LRScheduler | None = None,
+ load_scheduler_state_dict: bool = True,
+ logger: BaseLogger | None = None,
+ log_filename: str = "learning_stats.csv",
+ load_weights_only: bool | None = None,
+ ):
+ super().__init__(model=model, device=device, gpus=gpus, snapshot_path=snapshot_path)
+ if isinstance(optimizer, dict):
+ optimizer = build_optimizer(model, optimizer)
+ if isinstance(scheduler, dict):
+ scheduler = schedulers.build_scheduler(scheduler, optimizer)
+
+ self.eval_interval = eval_interval
+ self.optimizer = optimizer
+ self.scheduler = scheduler
+ self.snapshot_manager = snapshot_manager
+ self.history: dict[str, list] = dict(train_loss=[], eval_loss=[])
+ self.csv_logger = CSVLogger(
+ train_folder=snapshot_manager.model_folder,
+ log_filename=log_filename,
+ )
+ self.logger = logger
+ self.starting_epoch = 0
+ self.current_epoch = 0
+
+ # some models cannot compute a validation loss (e.g. detectors)
+ self._print_valid_loss = True
+
+ if self.snapshot_path:
+ snapshot = self.load_snapshot(
+ self.snapshot_path,
+ self.device,
+ self.model,
+ weights_only=load_weights_only,
+ )
+ self.starting_epoch = snapshot.get("metadata", {}).get("epoch", 0)
+
+ if "optimizer" in snapshot:
+ self.optimizer.load_state_dict(snapshot["optimizer"])
+
+ self._load_scheduler_state_dict(load_scheduler_state_dict, snapshot)
+
+ self._metadata = dict(epoch=self.starting_epoch, metrics=dict(), losses=dict())
+ self._epoch_ground_truth = {}
+ self._epoch_predictions = {}
+
+ def state_dict(self) -> dict:
+ """Returns: the state dict for the runner"""
+ model = self.model
+ if self._data_parallel:
+ model = self.model.module
+
+ state_dict_ = dict(
+ metadata=self._metadata,
+ model=model.state_dict(),
+ optimizer=self.optimizer.state_dict(),
+ )
+ if self.scheduler is not None:
+ state_dict_["scheduler"] = self.scheduler.state_dict()
+
+ return state_dict_
+
+ @abstractmethod
+ def step(self, batch: dict[str, Any], mode: str = "train") -> dict[str, torch.Tensor]:
+ """Perform a single epoch gradient update or validation step.
+
+ Args:
+ batch: the batch data on which to run a step
+ mode: "train" or "eval". Defaults to "train".
+
+ Raises:
+ ValueError: if mode is not in {"train", "eval"}
+
+ Returns:
+ A dictionary containing the different losses for the step
+ """
+
+ @abstractmethod
+ def _compute_epoch_metrics(self) -> dict[str, float]:
+ """Computes the metrics using the data accumulated during an epoch.
+
+ Returns:
+ A dictionary containing the different losses for the step
+ """
+ raise NotImplementedError
+
+ def _gpu_usage_str(self) -> str:
+ if not torch.cuda.is_available():
+ return ""
+ used = torch.cuda.memory_reserved() / 1024**2
+ total = torch.cuda.get_device_properties(0).total_memory / 1024**2
+ return f", GPU: {used:.1f}/{total:.1f} MiB"
+
+ def fit(
+ self,
+ train_loader: DataLoader,
+ valid_loader: DataLoader,
+ epochs: int,
+ display_iters: int,
+ ) -> None:
+ """Train model for the specified number of steps.
+
+ Args:
+ train_loader: Data loader, which is an iterator over train instances.
+ Each batch contains image tensor and heat maps tensor input samples.
+ valid_loader: Data loader used for validation of the model.
+ epochs: The number of training epochs.
+ display_iters: The number of iterations between each loss print
+
+ Example:
+ runner = Runner(model, optimizer, cfg, device='cuda')
+ runner.fit(train_loader, valid_loader, "example/models" epochs=50)
+ """
+ if self._data_parallel:
+ self.model = DataParallel(self.model, device_ids=self._gpus).cuda()
+ else:
+ self.model.to(self.device)
+
+ if isinstance(self.logger, ImageLoggerMixin):
+ self.logger.select_images_to_log(train_loader, valid_loader)
+
+ # continuing to train a model: either total epochs or extra epochs
+ if self.starting_epoch > 0:
+ epochs = self.starting_epoch + epochs
+
+ for e in range(self.starting_epoch + 1, epochs + 1):
+ self.current_epoch = e
+ self._metadata["epoch"] = e
+ train_loss = self._epoch(train_loader, mode="train", display_iters=display_iters)
+ if self.scheduler:
+ self.scheduler.step()
+
+ lr = self.optimizer.param_groups[0]["lr"]
+ msg = f"Epoch {e}/{epochs} (lr={lr}), train loss {float(train_loss):.5f}"
+ if e % self.eval_interval == 0:
+ with torch.no_grad():
+ logging.info(f"Training for epoch {e} done, starting evaluation")
+ valid_loss = self._epoch(valid_loader, mode="eval", display_iters=display_iters)
+ if self._print_valid_loss:
+ msg += f", valid loss {float(valid_loss):.5f}"
+ msg += self._gpu_usage_str()
+
+ self.snapshot_manager.update(e, self.state_dict(), last=(e == epochs))
+ logging.info(msg)
+
+ epoch_metrics = self._metadata.get("metrics")
+ if e % self.eval_interval == 0 and epoch_metrics is not None and len(epoch_metrics) > 0:
+ logging.info("Model performance:")
+ line_length = max([len(name) for name in epoch_metrics.keys()]) + 2
+ for name, score in epoch_metrics.items():
+ logging.info(f" {(name + ':').ljust(line_length)}{score:6.2f}")
+
+ def _epoch(
+ self,
+ loader: torch.utils.data.DataLoader,
+ mode: str = "train",
+ display_iters: int = 500,
+ ) -> float:
+ """Facilitates training over an epoch. Returns the loss over the batches.
+
+ Args:
+ loader: Data loader, which is an iterator over instances.
+ Each batch contains image tensor and heat maps tensor input samples.
+ mode: str identifier to instruct the Runner whether to train or evaluate.
+ Possible values are: "train" or "eval".
+ display_iters: the number of iterations between each loss print
+
+ Raises:
+ ValueError: When the given mode is invalid
+
+ Returns:
+ epoch_loss: Average of the loss over the batches.
+ """
+ if mode == "train":
+ self.model.train()
+ elif mode == "eval" or mode == "inference":
+ self.model.eval()
+ else:
+ raise ValueError(f"Runner mode must be train or eval, found mode={mode}.")
+
+ epoch_loss = []
+ loss_metrics = defaultdict(list)
+ for i, batch in enumerate(loader):
+ losses_dict = self.step(batch, mode)
+ if "total_loss" in losses_dict:
+ epoch_loss.append(losses_dict["total_loss"])
+ if (i + 1) % display_iters == 0 and mode != "eval":
+ logging.info(
+ f"Number of iterations: {i + 1}, "
+ f"loss: {losses_dict['total_loss']:.5f}, "
+ f"lr: {self.optimizer.param_groups[0]['lr']}"
+ )
+
+ for key in losses_dict.keys():
+ loss_metrics[key].append(losses_dict[key])
+
+ perf_metrics = None
+ if mode == "eval":
+ perf_metrics = self._compute_epoch_metrics()
+ self._metadata["metrics"] = perf_metrics
+ self._epoch_predictions = {}
+ self._epoch_ground_truth = {}
+
+ if len(epoch_loss) > 0:
+ epoch_loss = np.mean(epoch_loss).item()
+ else:
+ epoch_loss = 0
+ self.history[f"{mode}_loss"].append(epoch_loss)
+
+ metrics_to_log = {}
+ if perf_metrics:
+ for name, score in perf_metrics.items():
+ if not isinstance(score, (int, float)):
+ score = 0.0
+ metrics_to_log[name] = score
+
+ for key in loss_metrics:
+ name = f"{mode}.{key}"
+ val = float("nan")
+ if np.sum(~np.isnan(loss_metrics[key])) > 0:
+ val = np.nanmean(loss_metrics[key]).item()
+ self._metadata["losses"][name] = val
+ metrics_to_log[f"losses/{name}"] = val
+
+ self.csv_logger.log(metrics_to_log, step=self.current_epoch)
+ if self.logger:
+ self.logger.log(metrics_to_log, step=self.current_epoch)
+
+ return epoch_loss
+
+ def _load_scheduler_state_dict(self, load_state_dict: bool, snapshot: dict) -> None:
+ if self.scheduler is None:
+ return
+
+ loaded_state_dict = False
+ if load_state_dict and "scheduler" in snapshot:
+ try:
+ schedulers.load_scheduler_state(self.scheduler, snapshot["scheduler"])
+ loaded_state_dict = True
+ except ValueError as err:
+ logging.warning(
+ "Failed to load the scheduler state_dict. The scheduler will "
+ "restart at epoch 0. This is expected if the scheduler "
+ "configuration was edited since the original snapshot was "
+ f"trained. Error: {err}"
+ )
+
+ if not loaded_state_dict and self.starting_epoch > 0:
+ logging.info(f"Setting the scheduler starting epoch to {self.starting_epoch}")
+ self.scheduler.last_epoch = self.starting_epoch
+
+
+class PoseTrainingRunner(TrainingRunner[PoseModel]):
+ """Runner to train pose estimation models."""
+
+ def __init__(
+ self,
+ model: PoseModel,
+ optimizer: torch.optim.Optimizer,
+ load_head_weights: bool = True,
+ **kwargs,
+ ):
+ """
+ Args:
+ model: The neural network for solving pose estimation task.
+ optimizer: A PyTorch optimizer for updating model parameters.
+ load_head_weights: When `snapshot_path` is not None, whether to load the
+ head weights from the saved snapshot or just the backbone weights.
+ **kwargs: TrainingRunner kwargs
+ """
+ self._load_head_weights = load_head_weights
+ super().__init__(model, optimizer, **kwargs)
+
+ def load_snapshot(
+ self,
+ snapshot_path: str | Path,
+ device: str,
+ model: PoseModel,
+ weights_only: bool | None = None,
+ ) -> dict:
+ """Loads the state dict for a model from a file.
+
+ This method loads a file containing a DeepLabCut PyTorch model snapshot onto
+ a given device, and sets the model weights using the state_dict.
+
+ Args:
+ snapshot_path: the path containing the model weights to load
+ device: the device on which the model should be loaded
+ model: the model for which the weights are loaded
+ weights_only: Value for torch.load() `weights_only` parameter.
+ If False, the python pickle module is used implicitly, which is known to
+ be insecure. Only set to False if you're loading data that you trust
+ (e.g. snapshots that you created yourself). For more information, see:
+ https://pytorch.org/docs/stable/generated/torch.load.html
+ If None, the default value is used:
+ `deeplabcut.pose_estimation_pytorch.get_load_weights_only()`
+
+ Returns:
+ The content of the snapshot file.
+ """
+ snapshot = attempt_snapshot_load(snapshot_path, device, weights_only)
+ if self._load_head_weights:
+ model.load_state_dict(snapshot["model"])
+ else:
+ backbone_prefix = "backbone."
+ backbone_weights = {
+ k[len(backbone_prefix) :]: v for k, v in snapshot["model"].items() if k.startswith(backbone_prefix)
+ }
+ model.backbone.load_state_dict(backbone_weights)
+
+ return snapshot
+
+ def step(self, batch: dict[str, Any], mode: str = "train") -> dict[str, torch.Tensor]:
+ """Perform a single epoch gradient update or validation step.
+
+ Args:
+ batch: Tuple of input image(s) and target(s) for train or valid single step.
+ mode: `train` or `eval`. Defaults to "train".
+
+ Raises:
+ ValueError: "Runner must be in train or eval mode, but {mode} was found."
+
+ Returns:
+ dict: {
+ "total_loss": aggregate_loss,
+ "aux_loss_1": loss_value,
+ ...,
+ }
+ """
+ if mode not in ["train", "eval"]:
+ raise ValueError(f"BottomUpSolver must be in train or eval mode, but {mode} was found.")
+
+ if mode == "train":
+ self.optimizer.zero_grad()
+
+ inputs = batch["image"]
+ inputs = inputs.to(self.device).float()
+ if "cond_keypoints" in batch["context"]:
+ cond_kpts = batch["context"]["cond_keypoints"]
+ outputs = self.model(inputs, cond_kpts=cond_kpts)
+ else:
+ outputs = self.model(inputs)
+
+ if self._data_parallel:
+ underlying_model = self.model.module
+ else:
+ underlying_model = self.model
+
+ target = underlying_model.get_target(outputs, batch["annotations"])
+ losses_dict = underlying_model.get_loss(outputs, target)
+ if mode == "train":
+ losses_dict["total_loss"].backward()
+ self.optimizer.step()
+
+ if isinstance(self.logger, ImageLoggerMixin):
+ self.logger.log_images(batch, outputs, target, step=self.current_epoch)
+
+ if mode == "eval":
+ predictions = {
+ name: {k: v.detach().cpu().numpy() for k, v in pred.items()}
+ for name, pred in underlying_model.get_predictions(outputs).items()
+ }
+
+ ground_truth = batch["annotations"]["keypoints"]
+ if batch["annotations"]["with_center_keypoints"][0]:
+ ground_truth = ground_truth[..., :-1, :]
+
+ self._update_epoch_predictions(
+ name="bodyparts",
+ gt_keypoints=ground_truth,
+ pred_keypoints=predictions["bodypart"]["poses"],
+ offsets=batch["offsets"],
+ scales=batch["scales"],
+ )
+ if "unique_bodypart" in predictions:
+ self._update_epoch_predictions(
+ name="unique_bodyparts",
+ gt_keypoints=batch["annotations"]["keypoints_unique"],
+ pred_keypoints=predictions["unique_bodypart"]["poses"],
+ offsets=batch["offsets"],
+ scales=batch["scales"],
+ )
+
+ return {k: v.detach().cpu().numpy() for k, v in losses_dict.items()}
+
+ def _compute_epoch_metrics(self) -> dict[str, float]:
+ """Computes the metrics using the data accumulated during an epoch
+ Returns:
+ A dictionary containing the different losses for the step
+ """
+ scores = metrics.compute_metrics(
+ ground_truth=self._epoch_ground_truth["bodyparts"],
+ predictions=self._epoch_predictions["bodyparts"],
+ single_animal=False,
+ unique_bodypart_gt=self._epoch_ground_truth.get("unique_bodyparts"),
+ unique_bodypart_poses=self._epoch_predictions.get("unique_bodyparts"),
+ pcutoff=0.6,
+ compute_detection_rmse=False,
+ )
+ return {f"metrics/test.{metric}": value for metric, value in scores.items()}
+
+ def _update_epoch_predictions(
+ self,
+ name: str,
+ gt_keypoints: torch.Tensor,
+ pred_keypoints: torch.Tensor,
+ scales: torch.Tensor,
+ offsets: torch.Tensor,
+ ) -> None:
+ """Updates the stored predictions with a new batch."""
+ epoch_gt_metric = self._epoch_ground_truth.get(name, {})
+ epoch_metric = self._epoch_predictions.get(name, {})
+ assert len(gt_keypoints) == len(pred_keypoints)
+ assert len(offsets) == len(scales)
+ scales = scales.detach().cpu().numpy()
+ offsets = offsets.detach().cpu().numpy()
+
+ for gt, pred, scale, offset in zip(
+ gt_keypoints,
+ pred_keypoints,
+ scales,
+ offsets,
+ strict=False,
+ ):
+ ground_truth = gt.detach().cpu().numpy()
+ pred = pred.copy()
+
+ # rescale to the full image for TD or CTD
+ ground_truth[..., :2] = (ground_truth[..., :2] * scale) + offset
+ pred[..., :2] = (pred[..., :2] * scale) + offset
+
+ # we don't care about image paths here - use a default index
+ index = len(epoch_metric) + 1
+ epoch_gt_metric[f"sample{index:09}"] = ground_truth
+ epoch_metric[f"sample{index:09}"] = pred
+
+ self._epoch_ground_truth[name] = epoch_gt_metric
+ self._epoch_predictions[name] = epoch_metric
+
+
+class DetectorTrainingRunner(TrainingRunner[BaseDetector]):
+ """Runner to train object detection models."""
+
+ def __init__(self, model: BaseDetector, optimizer: torch.optim.Optimizer, **kwargs):
+ """
+ Args:
+ model: The detector model to train.
+ optimizer: The optimizer to use to train the model.
+ **kwargs: TrainingRunner kwargs
+ """
+ log_filename = "learning_stats_detector.csv"
+ if "log_filename" in kwargs:
+ log_filename = kwargs.pop("log_filename")
+
+ super().__init__(model, optimizer, log_filename=log_filename, **kwargs)
+ self._pycoco_warning_displayed = False
+ self._print_valid_loss = False
+
+ def step(self, batch: dict[str, Any], mode: str = "train") -> dict[str, torch.Tensor]:
+ """Perform a single epoch gradient update or validation step.
+
+ Args:
+ batch: Tuple of input image(s) and target(s) for train or valid single step.
+ mode: `train` or `eval`. Defaults to "train".
+
+ Raises:
+ ValueError: "Runner must be in train or eval mode, but {mode} was found."
+
+ Returns:
+ dict: {
+ 'total_loss': torch.Tensor,
+ 'aux_loss_1': torch.Tensor,
+ ...,
+ }
+ """
+ if mode not in ["train", "eval"]:
+ raise ValueError(f"DetectorSolver must be in train or eval mode, but {mode} was found.")
+
+ if mode == "train":
+ self.optimizer.zero_grad()
+ self.model.train()
+ else:
+ self.model.eval()
+
+ images = batch["image"]
+ images = images.to(self.device)
+
+ if self._data_parallel:
+ underlying_model = self.model.module
+ else:
+ underlying_model = self.model
+
+ target = underlying_model.get_target(batch["annotations"])
+ for item in target: # target is a list here
+ for key in item:
+ if item[key] is not None:
+ item[key] = item[key].to(self.device)
+
+ losses, predictions = self.model(images, target)
+
+ # losses only returned during training, not evaluation
+ if mode == "train":
+ losses["total_loss"] = sum(loss_part for loss_part in losses.values())
+ losses["total_loss"].backward()
+ self.optimizer.step()
+ losses = {k: v.detach().cpu().numpy() for k, v in losses.items()}
+
+ elif mode == "eval":
+ losses["total_loss"] = float("nan")
+ self._update_epoch_predictions(
+ paths=batch["path"],
+ sizes=batch["original_size"],
+ bboxes=batch["annotations"]["boxes"],
+ predictions=predictions,
+ offsets=batch["offsets"],
+ scales=batch["scales"],
+ )
+
+ return losses
+
+ def _compute_epoch_metrics(self) -> dict[str, float]:
+ """Returns: bounding box metrics, if"""
+ try:
+ return {
+ f"metrics/test.{k}": v
+ for k, v in metrics.compute_bbox_metrics(self._epoch_ground_truth, self._epoch_predictions).items()
+ }
+ except ModuleNotFoundError:
+ if not self._pycoco_warning_displayed:
+ logging.info(
+ "\nNote:\n"
+ "Cannot compute bounding box metrics as ``pycocotools`` is not "
+ "installed. If you want bounding box mAP metrics when training "
+ "detectors for top-down models, please run ``pip install "
+ "pycocotools``.\n"
+ )
+ self._pycoco_warning_displayed = True
+
+ return {}
+
+ def _update_epoch_predictions(
+ self,
+ paths: torch.Tensor,
+ sizes: torch.Tensor,
+ bboxes: torch.Tensor,
+ predictions: list[dict[str, torch.Tensor]],
+ scales: torch.Tensor,
+ offsets: torch.Tensor,
+ ) -> None:
+ """Updates the stored predictions with a new batch."""
+ for img_path, img_size, img_bboxes, img_pred, scale, offset in zip(
+ paths, sizes, bboxes, predictions, scales, offsets, strict=False
+ ):
+ scale_x, scale_y = scale
+ scale_factors = np.array([scale_x, scale_y, scale_x, scale_y])
+ offset = np.array(offset)
+
+ # remove bboxes that are not visible
+ img_bbox_mask = (img_bboxes[:, 2] > 0.0) & (img_bboxes[:, 3] > 0.0)
+ img_bboxes = img_bboxes[img_bbox_mask]
+
+ # rescale ground truth bounding boxes
+ gt_rescaled = img_bboxes.cpu().numpy() * scale_factors
+ gt_rescaled[..., :2] = gt_rescaled[..., :2] + offset
+
+ # convert to COCO format (xywh) before rescaling
+ pred_rescaled = img_pred["boxes"].detach().cpu().numpy()
+ pred_rescaled[:, 2] -= pred_rescaled[:, 0]
+ pred_rescaled[:, 3] -= pred_rescaled[:, 1]
+ pred_rescaled[..., :4] = pred_rescaled[..., :4] * scale_factors
+ pred_rescaled[..., :2] = pred_rescaled[..., :2] + offset
+
+ self._epoch_ground_truth[img_path] = {
+ "bboxes": gt_rescaled,
+ "width": img_size[1],
+ "height": img_size[0],
+ }
+ self._epoch_predictions[img_path] = {
+ "bboxes": pred_rescaled,
+ "scores": img_pred["scores"].detach().cpu().numpy(),
+ }
+
+
+def build_training_runner(
+ runner_config: dict,
+ model_folder: Path,
+ task: Task,
+ model: nn.Module,
+ device: str,
+ gpus: list[int] | None = None,
+ snapshot_path: str | Path | None = None,
+ load_head_weights: bool = True,
+ logger: BaseLogger | None = None,
+) -> TrainingRunner:
+ """Build a runner object according to a pytorch configuration file.
+
+ Args:
+ runner_config: the configuration for the runner
+ model_folder: the folder where models should be saved
+ task: the task the runner will perform
+ model: the model to run
+ device: the device to use (e.g. {'cpu', 'cuda:0', 'mps'})
+ gpus: the list of GPU indices to use for multi-GPU training
+ snapshot_path: the snapshot from which to load the weights
+ load_head_weights: When `snapshot_path` is not None and a pose model is being
+ trained, whether to load the head weights from the saved snapshot.
+ logger: the logger to use, if any
+
+ Returns:
+ the runner that was built
+ """
+ optimizer = build_optimizer(model, runner_config["optimizer"])
+ scheduler = schedulers.build_scheduler(runner_config.get("scheduler"), optimizer)
+
+ # if no custom snapshot prefix is defined, use the default one
+ snapshot_prefix = runner_config.get("snapshot_prefix")
+ if snapshot_prefix is None or len(snapshot_prefix) == 0:
+ snapshot_prefix = task.snapshot_prefix
+
+ kwargs = dict(
+ model=model,
+ optimizer=optimizer,
+ snapshot_manager=TorchSnapshotManager(
+ snapshot_prefix=snapshot_prefix,
+ model_folder=model_folder,
+ key_metric=runner_config.get("key_metric"),
+ key_metric_asc=runner_config.get("key_metric_asc"),
+ max_snapshots=runner_config["snapshots"]["max_snapshots"],
+ save_epochs=runner_config["snapshots"]["save_epochs"],
+ save_optimizer_state=runner_config["snapshots"]["save_optimizer_state"],
+ ),
+ device=device,
+ gpus=gpus,
+ eval_interval=runner_config.get("eval_interval"),
+ snapshot_path=snapshot_path,
+ scheduler=scheduler,
+ load_scheduler_state_dict=runner_config.get("load_scheduler_state_dict", True),
+ logger=logger,
+ load_weights_only=runner_config.get("load_weights_only", None),
+ )
+ if task == Task.DETECT:
+ return DetectorTrainingRunner(**kwargs)
+
+ kwargs["load_head_weights"] = load_head_weights
+ return PoseTrainingRunner(**kwargs)
+
+
+def build_optimizer(
+ model: nn.Module,
+ optimizer_config: dict,
+) -> torch.optim.Optimizer:
+ """Builds an optimizer from a configuration.
+
+ Args:
+ model: The model to optimize.
+ optimizer_config: The configuration for the optimizer.
+
+ Returns:
+ The optimizer for the model built according to the given configuration.
+ """
+ optim_cls = getattr(torch.optim, optimizer_config["type"])
+ optimizer = optim_cls(params=model.parameters(), **optimizer_config["params"])
+ return optimizer
diff --git a/deeplabcut/pose_estimation_pytorch/task.py b/deeplabcut/pose_estimation_pytorch/task.py
new file mode 100644
index 0000000000..37785df34c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/task.py
@@ -0,0 +1,43 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Types of tasks that can be run by DeepLabCut pose estimation models."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+
+
+@dataclass(frozen=True)
+class TaskDataMixin:
+ aliases: tuple[str]
+ snapshot_prefix: str
+
+
+class Task(TaskDataMixin, Enum):
+ """A task to solve."""
+
+ BOTTOM_UP = ("BU", "BottomUp"), "snapshot"
+ DETECT = ("DT", "Detect"), "snapshot-detector"
+ TOP_DOWN = ("TD", "TopDown"), "snapshot"
+ COND_TOP_DOWN = ("CTD", "CondTopDown", "ConditionalTopDown"), "snapshot"
+
+ @classmethod
+ def _missing_(cls, value):
+ if isinstance(value, str):
+ value = value.upper()
+ for member in cls:
+ if value in member.aliases:
+ return member
+ return None
+
+ def __repr__(self) -> str:
+ return f"Task.{self.name}"
diff --git a/deeplabcut/pose_estimation_pytorch/utils.py b/deeplabcut/pose_estimation_pytorch/utils.py
new file mode 100644
index 0000000000..8b39d8f0f4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/utils.py
@@ -0,0 +1,67 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import os
+import random
+
+import numpy as np
+import torch
+
+
+def create_folder(path_to_folder):
+ """Creates all folders contained in the path.
+
+ Args:
+ path_to_folder: Path to the folder that should be created
+ """
+ if not os.path.exists(path_to_folder):
+ os.makedirs(path_to_folder)
+
+
+def fix_seeds(seed: int) -> None:
+ """Fixes the random seed for python, numpy and pytorch.
+
+ Args:
+ seed: the seed to set
+ """
+ random.seed(seed)
+ torch.manual_seed(seed)
+ np.random.seed(seed)
+ torch.backends.cudnn.deterministic = True
+ torch.backends.cudnn.benchmark = False
+
+
+def resolve_device(model_config: dict) -> str:
+ """Determines which device should be used from the model config.
+
+ When the device is set to 'auto':
+ If an Nvidia GPU is available, selects the device as cuda:0.
+ Selects 'mps' if available (on macOS) and the net type is compatible.
+ Otherwise, returns 'cpu'.
+ Otherwise, simply returns the selected device
+
+ Args:
+ model_config: the configuration for the pose model
+
+ Returns:
+ the device on which training should be run
+ """
+ device = model_config["device"]
+ supports_mps = "resnet" in model_config.get("net_type", "resnet")
+
+ if device == "auto":
+ if torch.cuda.is_available():
+ return "cuda"
+ elif supports_mps and torch.backends.mps.is_available():
+ return "mps"
+ return "cpu"
+ return device
diff --git a/deeplabcut/pose_estimation_tensorflow/LICENSE b/deeplabcut/pose_estimation_tensorflow/LICENSE
index 341c30bda4..65c5ca88a6 100644
--- a/deeplabcut/pose_estimation_tensorflow/LICENSE
+++ b/deeplabcut/pose_estimation_tensorflow/LICENSE
@@ -163,4 +163,3 @@ whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
-
diff --git a/deeplabcut/pose_estimation_tensorflow/__init__.py b/deeplabcut/pose_estimation_tensorflow/__init__.py
index 38586cb91f..31282b7a4f 100644
--- a/deeplabcut/pose_estimation_tensorflow/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/__init__.py
@@ -12,13 +12,19 @@
# Licensed under GNU Lesser General Public License v3.0
#
+# Suppress tensorflow warning messages
+import tensorflow as tf
+
+from . import _tf_legacy
+
+tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.ERROR)
from deeplabcut.pose_estimation_tensorflow.config import *
-from deeplabcut.pose_estimation_tensorflow.datasets import *
-from deeplabcut.pose_estimation_tensorflow.default_config import *
from deeplabcut.pose_estimation_tensorflow.core.evaluate import *
-from deeplabcut.pose_estimation_tensorflow.core.train import *
from deeplabcut.pose_estimation_tensorflow.core.test import *
+from deeplabcut.pose_estimation_tensorflow.core.train import *
+from deeplabcut.pose_estimation_tensorflow.datasets import *
+from deeplabcut.pose_estimation_tensorflow.default_config import *
from deeplabcut.pose_estimation_tensorflow.export import export_model
from deeplabcut.pose_estimation_tensorflow.models import *
from deeplabcut.pose_estimation_tensorflow.nnets import *
@@ -26,6 +32,3 @@
from deeplabcut.pose_estimation_tensorflow.training import *
from deeplabcut.pose_estimation_tensorflow.util import *
from deeplabcut.pose_estimation_tensorflow.visualizemaps import *
-from deeplabcut.pose_estimation_tensorflow.predict_supermodel import (
- video_inference_superanimal,
-)
diff --git a/deeplabcut/pose_estimation_tensorflow/_tf_legacy.py b/deeplabcut/pose_estimation_tensorflow/_tf_legacy.py
new file mode 100644
index 0000000000..8ed9128fa7
--- /dev/null
+++ b/deeplabcut/pose_estimation_tensorflow/_tf_legacy.py
@@ -0,0 +1,13 @@
+import os
+import sys
+
+os.environ.setdefault("TF_USE_LEGACY_KERAS", "1")
+os.environ.setdefault("WRAPT_DISABLE_EXTENSIONS", "1")
+
+try:
+ import tf_keras.src.legacy_tf_layers as legacy_tf_layers
+
+ sys.modules["tf_keras.legacy_tf_layers"] = legacy_tf_layers
+except ImportError:
+ # Older tf-keras didn’t use src/, so nothing to do
+ pass
diff --git a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
index 307bf4adf1..d3ba8c89bf 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
@@ -16,6 +16,7 @@
import functools
import os
import re
+
import tensorflow as tf
import deeplabcut.pose_estimation_tensorflow.backbones.efficientnet_model as efficientnet_model
@@ -41,7 +42,7 @@ def efficientnet_params(model_name):
return params_dict[model_name]
-class BlockDecoder(object):
+class BlockDecoder:
"""Block Decoder for readability."""
def _decode_block_string(self, block_string):
@@ -73,22 +74,23 @@ def _decode_block_string(self, block_string):
def _encode_block_string(self, block):
"""Encodes a block to a string."""
args = [
- "r%d" % block.num_repeat,
- "k%d" % block.kernel_size,
- "s%d%d" % (block.strides[0], block.strides[1]),
- "e%s" % block.expand_ratio,
- "i%d" % block.input_filters,
- "o%d" % block.output_filters,
- "c%d" % block.conv_type,
+ f"r{block.num_repeat}",
+ f"k{block.kernel_size}",
+ f"s{block.strides[0]}{block.strides[1]}",
+ f"e{block.expand_ratio}",
+ f"i{block.input_filters}",
+ f"o{block.output_filters}",
+ f"c{block.conv_type}",
]
if block.se_ratio > 0 and block.se_ratio <= 1:
- args.append("se%s" % block.se_ratio)
+ args.append(f"se{block.se_ratio}")
if block.id_skip is False:
args.append("noskip")
return "_".join(args)
def decode(self, string_list):
"""Decodes a list of string notations to specify blocks inside the network.
+
Args:
string_list: a list of strings, each string is a notation of block.
Returns:
@@ -102,6 +104,7 @@ def decode(self, string_list):
def encode(self, blocks_args):
"""Encodes a list of Blocks to a list of strings.
+
Args:
blocks_args: A list of namedtuples to represent blocks arguments.
Returns:
@@ -115,6 +118,7 @@ def encode(self, blocks_args):
def swish(features, use_native=True):
"""Computes the Swish activation function.
+
The tf.nn.swish operation uses a custom gradient to reduce memory usage.
Since saving custom gradients in SavedModel is currently not supported, and
one would not be able to use an exported TF-Hub module for fine-tuning, we
@@ -183,14 +187,10 @@ def efficientnet(
def get_model_params(model_name, override_params):
"""Get the block args and global params for a given model."""
if model_name.startswith("efficientnet"):
- width_coefficient, depth_coefficient, _, dropout_rate = efficientnet_params(
- model_name
- )
- blocks_args, global_params = efficientnet(
- width_coefficient, depth_coefficient, dropout_rate
- )
+ width_coefficient, depth_coefficient, _, dropout_rate = efficientnet_params(model_name)
+ blocks_args, global_params = efficientnet(width_coefficient, depth_coefficient, dropout_rate)
else:
- raise NotImplementedError("model name is not pre-defined: %s" % model_name)
+ raise NotImplementedError(f"model name is not pre-defined: {model_name}")
if override_params:
# ValueError will be raised here if override_params has fields not included
@@ -212,6 +212,7 @@ def build_model(
features_only=False,
):
"""A helper function to creates a model and returns predicted logits.
+
Args:
images: input images tensor.
model_name: string, the predefined model name.
@@ -242,10 +243,10 @@ def build_model(
if not tf.io.gfile.exists(model_dir):
tf.io.gfile.makedirs(model_dir)
with tf.io.gfile.GFile(param_file, "w") as f:
- tf.compat.v1.logging.info("writing to %s" % param_file)
- f.write("model_name= %s\n\n" % model_name)
- f.write("global_params= %s\n\n" % str(global_params))
- f.write("blocks_args= %s\n\n" % str(blocks_args))
+ tf.compat.v1.logging.info(f"writing to {param_file}")
+ f.write(f"model_name= {model_name}\n\n")
+ f.write(f"global_params= {str(global_params)}\n\n")
+ f.write(f"blocks_args= {str(blocks_args)}\n\n")
with tf.compat.v1.variable_scope(model_name):
model = efficientnet_model.Model(blocks_args, global_params)
@@ -254,10 +255,9 @@ def build_model(
return outputs, model.endpoints
-def build_model_base(
- images, model_name, use_batch_norm=False, drop_out=False, override_params=None
-):
+def build_model_base(images, model_name, use_batch_norm=False, drop_out=False, override_params=None):
"""A helper function to create a base model and return global_pool.
+
Args:
images: input images tensor.
model_name: string, the predefined model name.
@@ -276,9 +276,7 @@ def build_model_base(
with tf.compat.v1.variable_scope(model_name):
model = efficientnet_model.Model(blocks_args, global_params)
- features = model(
- images, use_batch_norm=use_batch_norm, drop_out=drop_out, features_only=True
- )
+ features = model(images, use_batch_norm=use_batch_norm, drop_out=drop_out, features_only=True)
features = tf.identity(features, "features")
return features, model.endpoints
diff --git a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py
index 5a063d2c17..76d03becde 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py
@@ -14,12 +14,15 @@
# limitations under the License.
#
"""Contains definitions for EfficientNet model.
+
[1] Mingxing Tan, Quoc V. Le
EfficientNet: Rethinking Model Scaling for Convolutional Neural Networks.
ICML'19, https://arxiv.org/abs/1905.11946
"""
+
import collections
import math
+
import numpy as np
import tensorflow as tf
@@ -66,6 +69,7 @@
def conv_kernel_initializer(shape, dtype=None, partition_info=None):
"""Initialization for convolutional kernels.
+
The main difference with tf.variance_scaling_initializer is that
tf.variance_scaling_initializer uses a truncated normal with an uncorrected
standard deviation, whereas here we use a normal distribution. Similarly,
@@ -86,6 +90,7 @@ def conv_kernel_initializer(shape, dtype=None, partition_info=None):
def dense_kernel_initializer(shape, dtype=None, partition_info=None):
"""Initialization for dense kernels.
+
This initialization is equal to
tf.variance_scaling_initializer(scale=1.0/3.0, mode='fan_out',
distribution='uniform').
@@ -117,9 +122,7 @@ def round_filters(filters, global_params):
# Make sure that round down does not go down by more than 10%.
if new_filters < 0.9 * filters:
new_filters += divisor
- tf.compat.v1.logging.info(
- "round_filter input={} output={}".format(orig_f, new_filters)
- )
+ tf.compat.v1.logging.info(f"round_filter input={orig_f} output={new_filters}")
return int(new_filters)
@@ -139,11 +142,12 @@ class MBConvBlock(tf.keras.layers.Layer):
def __init__(self, block_args, global_params):
"""Initializes a MBConv block.
+
Args:
block_args: BlockArgs, arguments to create a Block.
global_params: GlobalParams, a set of global parameters.
"""
- super(MBConvBlock, self).__init__()
+ super().__init__()
self._block_args = block_args
self._batch_norm_momentum = global_params.batch_norm_momentum
self._batch_norm_epsilon = global_params.batch_norm_epsilon
@@ -158,12 +162,10 @@ def __init__(self, block_args, global_params):
self._relu_fn = global_params.relu_fn or tf.nn.swish
self._has_se = (
- global_params.use_se
- and self._block_args.se_ratio is not None
- and 0 < self._block_args.se_ratio <= 1
+ global_params.use_se and self._block_args.se_ratio is not None and 0 < self._block_args.se_ratio <= 1
)
- self.endpoints = None
+ self.endpoints = {}
# Builds the block accordings to arguments.
self._build()
@@ -208,9 +210,7 @@ def _build(self):
)
if self._has_se:
- num_reduced_filters = max(
- 1, int(self._block_args.input_filters * self._block_args.se_ratio)
- )
+ num_reduced_filters = max(1, int(self._block_args.input_filters * self._block_args.se_ratio))
# Squeeze and Excitation layer.
self._se_reduce = tf.compat.v1.layers.Conv2D(
num_reduced_filters,
@@ -250,24 +250,20 @@ def _build(self):
def _call_se(self, input_tensor):
"""Call Squeeze and Excitation layer.
+
Args:
input_tensor: Tensor, a single input tensor for Squeeze/Excitation layer.
Returns:
A output tensor, which should have the same shape as input.
"""
- se_tensor = tf.reduce_mean(
- input_tensor=input_tensor, axis=self._spatial_dims, keepdims=True
- )
+ se_tensor = tf.reduce_mean(input_tensor=input_tensor, axis=self._spatial_dims, keepdims=True)
se_tensor = self._se_expand(self._relu_fn(self._se_reduce(se_tensor)))
- tf.compat.v1.logging.info(
- "Built Squeeze and Excitation with tensor shape: %s" % (se_tensor.shape)
- )
+ tf.compat.v1.logging.info(f"Built Squeeze and Excitation with tensor shape: {se_tensor.shape}")
return tf.sigmoid(se_tensor) * input_tensor
- def call(
- self, inputs, use_batch_norm=False, drop_out=False, drop_connect_rate=None
- ):
+ def call(self, inputs, use_batch_norm=False, drop_out=False, drop_connect_rate=None):
"""Implementation of call().
+
Args:
inputs: the inputs tensor.
training: boolean, whether the model is constructed for training.
@@ -275,19 +271,15 @@ def call(
Returns:
A output tensor.
"""
- tf.compat.v1.logging.info(
- "Block input: %s shape: %s" % (inputs.name, inputs.shape)
- )
+ tf.compat.v1.logging.info(f"Block input: {inputs.name} shape: {inputs.shape}")
if self._block_args.expand_ratio != 1:
- x = self._relu_fn(
- self._bn0(self._expand_conv(inputs), training=use_batch_norm)
- )
+ x = self._relu_fn(self._bn0(self._expand_conv(inputs), training=use_batch_norm))
else:
x = inputs
- tf.compat.v1.logging.info("Expand: %s shape: %s" % (x.name, x.shape))
+ tf.compat.v1.logging.info(f"Expand: {x.name} shape: {x.shape}")
x = self._relu_fn(self._bn1(self._depthwise_conv(x), training=use_batch_norm))
- tf.compat.v1.logging.info("DWConv: %s shape: %s" % (x.name, x.shape))
+ tf.compat.v1.logging.info(f"DWConv: {x.name} shape: {x.shape}")
if self._has_se:
with tf.compat.v1.variable_scope("se"):
@@ -305,7 +297,7 @@ def call(
if drop_connect_rate:
x = utils.drop_connect(x, drop_out, drop_connect_rate)
x = tf.add(x, inputs)
- tf.compat.v1.logging.info("Project: %s shape: %s" % (x.name, x.shape))
+ tf.compat.v1.logging.info(f"Project: {x.name} shape: {x.shape}")
return x
@@ -347,10 +339,9 @@ def _build(self):
epsilon=self._batch_norm_epsilon,
)
- def call(
- self, inputs, use_batch_norm=False, drop_out=False, drop_connect_rate=None
- ):
+ def call(self, inputs, use_batch_norm=False, drop_out=False, drop_connect_rate=None):
"""Implementation of call().
+
Args:
inputs: the inputs tensor.
training: boolean, whether the model is constructed for training.
@@ -358,16 +349,12 @@ def call(
Returns:
A output tensor.
"""
- tf.compat.v1.logging.info(
- "Block input: %s shape: %s" % (inputs.name, inputs.shape)
- )
+ tf.compat.v1.logging.info(f"Block input: {inputs.name} shape: {inputs.shape}")
if self._block_args.expand_ratio != 1:
- x = self._relu_fn(
- self._bn0(self._expand_conv(inputs), training=use_batch_norm)
- )
+ x = self._relu_fn(self._bn0(self._expand_conv(inputs), training=use_batch_norm))
else:
x = inputs
- tf.compat.v1.logging.info("Expand: %s shape: %s" % (x.name, x.shape))
+ tf.compat.v1.logging.info(f"Expand: {x.name} shape: {x.shape}")
self.endpoints = {"expansion_output": x}
@@ -381,24 +368,26 @@ def call(
if drop_connect_rate:
x = utils.drop_connect(x, drop_out, drop_connect_rate)
x = tf.add(x, inputs)
- tf.compat.v1.logging.info("Project: %s shape: %s" % (x.name, x.shape))
+ tf.compat.v1.logging.info(f"Project: {x.name} shape: {x.shape}")
return x
class Model(tf.keras.Model):
"""A class implements tf.keras.Model for MNAS-like model.
+
Reference: https://arxiv.org/abs/1807.11626
"""
def __init__(self, blocks_args=None, global_params=None):
"""Initializes an `Model` instance.
+
Args:
blocks_args: A list of BlockArgs to construct block modules.
global_params: GlobalParams, a set of global parameters.
Raises:
ValueError: when blocks_args is not specified as a list.
"""
- super(Model, self).__init__()
+ super().__init__()
if not isinstance(blocks_args, list):
raise ValueError("blocks_args should be a list.")
self._global_params = global_params
@@ -406,7 +395,7 @@ def __init__(self, blocks_args=None, global_params=None):
self._relu_fn = global_params.relu_fn or tf.nn.swish
self._batch_norm = global_params.batch_norm
- self.endpoints = None
+ self.endpoints = {}
self._build()
@@ -422,12 +411,8 @@ def _build(self):
assert block_args.num_repeat > 0
# Update block input and output filters based on depth multiplier.
block_args = block_args._replace(
- input_filters=round_filters(
- block_args.input_filters, self._global_params
- ),
- output_filters=round_filters(
- block_args.output_filters, self._global_params
- ),
+ input_filters=round_filters(block_args.input_filters, self._global_params),
+ output_filters=round_filters(block_args.output_filters, self._global_params),
num_repeat=round_repeats(block_args.num_repeat, self._global_params),
)
@@ -436,9 +421,7 @@ def _build(self):
self._blocks.append(conv_block(block_args, self._global_params))
if block_args.num_repeat > 1:
# pylint: disable=protected-access
- block_args = block_args._replace(
- input_filters=block_args.output_filters, strides=[1, 1]
- )
+ block_args = block_args._replace(input_filters=block_args.output_filters, strides=[1, 1])
# pylint: enable=protected-access
for _ in range(block_args.num_repeat - 1):
self._blocks.append(conv_block(block_args, self._global_params))
@@ -460,9 +443,7 @@ def _build(self):
data_format=self._global_params.data_format,
use_bias=False,
)
- self._bn0 = self._batch_norm(
- axis=channel_axis, momentum=batch_norm_momentum, epsilon=batch_norm_epsilon
- )
+ self._bn0 = self._batch_norm(axis=channel_axis, momentum=batch_norm_momentum, epsilon=batch_norm_epsilon)
# Head part.
self._conv_head = tf.compat.v1.layers.Conv2D(
@@ -473,13 +454,9 @@ def _build(self):
padding="same",
use_bias=False,
)
- self._bn1 = self._batch_norm(
- axis=channel_axis, momentum=batch_norm_momentum, epsilon=batch_norm_epsilon
- )
+ self._bn1 = self._batch_norm(axis=channel_axis, momentum=batch_norm_momentum, epsilon=batch_norm_epsilon)
- self._avg_pooling = tf.keras.layers.GlobalAveragePooling2D(
- data_format=self._global_params.data_format
- )
+ self._avg_pooling = tf.keras.layers.GlobalAveragePooling2D(data_format=self._global_params.data_format)
if self._global_params.num_classes:
self._fc = tf.compat.v1.layers.Dense(
self._global_params.num_classes,
@@ -495,6 +472,7 @@ def _build(self):
def call(self, inputs, use_batch_norm=False, drop_out=False, features_only=None):
"""Implementation of call().
+
Args:
inputs: input tensors.
training: boolean, whether the model is constructed for training.
@@ -506,53 +484,44 @@ def call(self, inputs, use_batch_norm=False, drop_out=False, features_only=None)
self.endpoints = {}
# Calls Stem layers
with tf.compat.v1.variable_scope("stem"):
- outputs = self._relu_fn(
- self._bn0(self._conv_stem(inputs), training=use_batch_norm)
- )
- tf.compat.v1.logging.info(
- "Built stem layers with output shape: %s" % outputs.shape
- )
+ outputs = self._relu_fn(self._bn0(self._conv_stem(inputs), training=use_batch_norm))
+ tf.compat.v1.logging.info(f"Built stem layers with output shape: {outputs.shape}")
self.endpoints["stem"] = outputs
# Calls blocks.
reduction_idx = 0
for idx, block in enumerate(self._blocks):
is_reduction = False
- if (idx == len(self._blocks) - 1) or self._blocks[
- idx + 1
- ].block_args().strides[0] > 1:
+ if (idx == len(self._blocks) - 1) or self._blocks[idx + 1].block_args().strides[0] > 1:
is_reduction = True
reduction_idx += 1
- with tf.compat.v1.variable_scope("blocks_%s" % idx):
+ with tf.compat.v1.variable_scope(f"blocks_{idx}"):
drop_rate = self._global_params.drop_connect_rate
if drop_rate:
drop_rate *= float(idx) / len(self._blocks)
- tf.compat.v1.logging.info(
- "block_%s drop_connect_rate: %s" % (idx, drop_rate)
- )
+ tf.compat.v1.logging.info(f"block_{idx} drop_connect_rate: {drop_rate}")
outputs = block.call(
outputs,
use_batch_norm=use_batch_norm,
drop_out=drop_out,
drop_connect_rate=drop_rate,
)
- self.endpoints["block_%s" % idx] = outputs
+ self.endpoints[f"block_{idx}"] = outputs
if is_reduction:
- self.endpoints["reduction_%s" % reduction_idx] = outputs
- if block.endpoints:
- for k, v in block.endpoints.items():
- self.endpoints["block_%s/%s" % (idx, k)] = v
- if is_reduction:
- self.endpoints["reduction_%s/%s" % (reduction_idx, k)] = v
+ self.endpoints[f"reduction_{reduction_idx}"] = outputs
+
+ for k, v in block.endpoints.items():
+ self.endpoints[f"block_{idx}/{k}"] = v
+ if is_reduction:
+ self.endpoints[f"reduction_{reduction_idx}/{k}"] = v
+
self.endpoints["features"] = outputs
if not features_only:
# Calls final layers and returns logits.
with tf.compat.v1.variable_scope("head"):
- outputs = self._relu_fn(
- self._bn1(self._conv_head(outputs), training=use_batch_norm)
- )
+ outputs = self._relu_fn(self._bn1(self._conv_head(outputs), training=use_batch_norm))
outputs = self._avg_pooling(outputs)
if self._dropout:
outputs = self._dropout(outputs, training=drop_out)
diff --git a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
index b4fe2f5e8f..4dd91a096d 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
@@ -57,15 +57,11 @@ def _set_arg_scope_defaults(defaults):
@slim.add_arg_scope
-def depth_multiplier(
- output_params, multiplier, divisible_by=8, min_depth=8, **unused_kwargs
-):
+def depth_multiplier(output_params, multiplier, divisible_by=8, min_depth=8, **unused_kwargs):
if "num_outputs" not in output_params:
return
d = output_params["num_outputs"]
- output_params["num_outputs"] = _make_divisible(
- d * multiplier, divisible_by, min_depth
- )
+ output_params["num_outputs"] = _make_divisible(d * multiplier, divisible_by, min_depth)
_Op = collections.namedtuple("Op", ["op", "params", "multiplier_func"])
@@ -76,7 +72,7 @@ def op(opfunc, multiplier_func=depth_multiplier, **params):
return _Op(opfunc, params=params, multiplier_func=multiplier)
-class NoOpScope(object):
+class NoOpScope:
"""No-op context manager."""
def __enter__(self):
@@ -188,10 +184,11 @@ def mobilenet_base( # pylint: disable=invalid-name
# c) set all defaults
# d) set all extra overrides.
# pylint: disable=g-backslash-continuation
- with _scope_all(scope, default_scope="Mobilenet"), safe_arg_scope(
- [slim.batch_norm], is_training=is_training
- ), _set_arg_scope_defaults(conv_defs_defaults), _set_arg_scope_defaults(
- conv_defs_overrides
+ with (
+ _scope_all(scope, default_scope="Mobilenet"),
+ safe_arg_scope([slim.batch_norm], is_training=is_training),
+ _set_arg_scope_defaults(conv_defs_defaults),
+ _set_arg_scope_defaults(conv_defs_overrides),
):
# The current_stride variable keeps track of the output stride of the
# activations, i.e., the running product of convolution strides up to the
@@ -242,11 +239,11 @@ def mobilenet_base( # pylint: disable=invalid-name
else:
params["use_explicit_padding"] = True
- end_point = "layer_%d" % (i + 1)
+ end_point = f"layer_{i + 1}"
try:
net = opdef.op(net, **params)
except Exception:
- print("Failed to create op %i: %r params: %r" % (i, opdef, params))
+ print(f"Failed to create op {i}: {opdef} params: {params}")
raise
end_points[end_point] = net
scope = os.path.dirname(net.name)
@@ -266,9 +263,10 @@ def mobilenet_base( # pylint: disable=invalid-name
@contextlib.contextmanager
def _scope_all(scope, default_scope=None):
- with tf.compat.v1.variable_scope(
- scope, default_name=default_scope
- ) as s, tf.compat.v1.name_scope(s.original_name_scope):
+ with (
+ tf.compat.v1.variable_scope(scope, default_name=default_scope) as s,
+ tf.compat.v1.name_scope(s.original_name_scope),
+ ):
yield s
@@ -280,7 +278,7 @@ def mobilenet(
reuse=None,
scope="Mobilenet",
base_only=False,
- **mobilenet_args
+ **mobilenet_args,
):
"""Mobilenet model for classification, supports both V1 and V2.
@@ -324,7 +322,7 @@ def mobilenet(
is_training = mobilenet_args.get("is_training", False)
input_shape = inputs.get_shape().as_list()
if len(input_shape) != 4:
- raise ValueError("Expected rank 4 input, was: %d" % len(input_shape))
+ raise ValueError(f"Expected rank 4 input, was: {len(input_shape)}")
with tf.compat.v1.variable_scope(scope, "Mobilenet", reuse=reuse) as scope:
inputs = tf.identity(inputs, "input")
@@ -385,9 +383,7 @@ def global_pool(input_tensor, pool_op=tf.nn.avg_pool2d):
)
else:
kernel_size = [1, shape[1], shape[2], 1]
- output = pool_op(
- input_tensor, ksize=kernel_size, strides=[1, 1, 1, 1], padding="VALID"
- )
+ output = pool_op(input_tensor, ksize=kernel_size, strides=[1, 1, 1, 1], padding="VALID")
# Recover output shape, for unknown shape.
output.set_shape([None, 1, 1, None])
return output
@@ -433,20 +429,19 @@ def training_scope(
weight_intitializer = tf.compat.v1.truncated_normal_initializer(stddev=stddev)
# Set weight_decay for weights in Conv and FC layers.
- with slim.arg_scope(
- [slim.conv2d, slim.fully_connected, slim.separable_conv2d],
- weights_initializer=weight_intitializer,
- normalizer_fn=slim.batch_norm,
- ), slim.arg_scope(
- [mobilenet_base, mobilenet], is_training=is_training
- ), safe_arg_scope(
- [slim.batch_norm], **batch_norm_params
- ), safe_arg_scope(
- [slim.dropout], is_training=is_training, keep_prob=dropout_keep_prob
- ), slim.arg_scope(
- [slim.conv2d],
- weights_regularizer=tf.keras.regularizers.l2(0.5 * (weight_decay)),
- ), slim.arg_scope(
- [slim.separable_conv2d], weights_regularizer=None
- ) as s:
+ with (
+ slim.arg_scope(
+ [slim.conv2d, slim.fully_connected, slim.separable_conv2d],
+ weights_initializer=weight_intitializer,
+ normalizer_fn=slim.batch_norm,
+ ),
+ slim.arg_scope([mobilenet_base, mobilenet], is_training=is_training),
+ safe_arg_scope([slim.batch_norm], **batch_norm_params),
+ safe_arg_scope([slim.dropout], is_training=is_training, keep_prob=dropout_keep_prob),
+ slim.arg_scope(
+ [slim.conv2d],
+ weights_regularizer=tf.keras.regularizers.l2(0.5 * (weight_decay)),
+ ),
+ slim.arg_scope([slim.separable_conv2d], weights_regularizer=None) as s,
+ ):
return s
diff --git a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
index f716ed6daa..59e27744e9 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
@@ -29,8 +29,8 @@
import tensorflow as tf
import tf_slim as slim
-from deeplabcut.pose_estimation_tensorflow.nnets import conv_blocks as ops
from deeplabcut.pose_estimation_tensorflow.backbones import mobilenet as lib
+from deeplabcut.pose_estimation_tensorflow.nnets import conv_blocks as ops
op = lib.op
@@ -98,7 +98,7 @@ def mobilenet(
min_depth=None,
divisible_by=None,
activation_fn=None,
- **kwargs
+ **kwargs,
):
"""Creates mobilenet V2 network.
@@ -139,10 +139,7 @@ def mobilenet(
if conv_defs is None:
conv_defs = V2_DEF
if "multiplier" in kwargs:
- raise ValueError(
- "mobilenetv2 doesn't support generic "
- 'multiplier parameter use "depth_multiplier" instead.'
- )
+ raise ValueError('mobilenetv2 doesn\'t support generic multiplier parameter use "depth_multiplier" instead.')
if finegrain_classification_mode:
conv_defs = copy.deepcopy(conv_defs)
if depth_multiplier < 1:
@@ -150,9 +147,7 @@ def mobilenet(
if activation_fn:
conv_defs = copy.deepcopy(conv_defs)
defaults = conv_defs["defaults"]
- conv_defaults = defaults[
- (slim.conv2d, slim.fully_connected, slim.separable_conv2d)
- ]
+ conv_defaults = defaults[(slim.conv2d, slim.fully_connected, slim.separable_conv2d)]
conv_defaults["activation_fn"] = activation_fn
depth_args = {}
@@ -170,7 +165,7 @@ def mobilenet(
conv_defs=conv_defs,
scope=scope,
multiplier=depth_multiplier,
- **kwargs
+ **kwargs,
)
@@ -187,20 +182,14 @@ def wrapped_partial(func, *args, **kwargs):
# 'finegrain_classification_mode' is set to True, which means the embedding
# layer will not be shrunk when given a depth-multiplier < 1.0.
mobilenet_v2_140 = wrapped_partial(mobilenet, depth_multiplier=1.4)
-mobilenet_v2_050 = wrapped_partial(
- mobilenet, depth_multiplier=0.50, finegrain_classification_mode=True
-)
-mobilenet_v2_035 = wrapped_partial(
- mobilenet, depth_multiplier=0.35, finegrain_classification_mode=True
-)
+mobilenet_v2_050 = wrapped_partial(mobilenet, depth_multiplier=0.50, finegrain_classification_mode=True)
+mobilenet_v2_035 = wrapped_partial(mobilenet, depth_multiplier=0.35, finegrain_classification_mode=True)
@slim.add_arg_scope
def mobilenet_base(input_tensor, depth_multiplier=1.0, **kwargs):
"""Creates base of the mobilenet (no pooling and no logits) ."""
- return mobilenet(
- input_tensor, depth_multiplier=depth_multiplier, base_only=True, **kwargs
- )
+ return mobilenet(input_tensor, depth_multiplier=depth_multiplier, base_only=True, **kwargs)
def training_scope(**kwargs):
diff --git a/deeplabcut/pose_estimation_tensorflow/config.py b/deeplabcut/pose_estimation_tensorflow/config.py
index d36bf01a01..e44474eab1 100644
--- a/deeplabcut/pose_estimation_tensorflow/config.py
+++ b/deeplabcut/pose_estimation_tensorflow/config.py
@@ -19,10 +19,8 @@
def _merge_a_into_b(a, b):
- """
- Merge config dictionary a into config dictionary b, clobbering the
- options in b whenever they are also specified in a.
- """
+ """Merge config dictionary a into config dictionary b, clobbering the options in b
+ whenever they are also specified in a."""
for k, v in a.items():
# a must specify keys that are in b
# if k not in b:
@@ -35,18 +33,16 @@ def _merge_a_into_b(a, b):
else:
try:
_merge_a_into_b(a[k], b[k])
- except:
- print("Error under config key: {}".format(k))
+ except Exception:
+ print(f"Error under config key: {k}")
raise
else:
b[k] = v
def cfg_from_file(filename):
- """
- Load a config from file filename and merge it into the default options.
- """
- with open(filename, "r") as f:
+ """Load a config from file filename and merge it into the default options."""
+ with open(filename) as f:
yaml_cfg = yaml.load(f, Loader=yaml.SafeLoader)
# Update the snapshot path to the corresponding path!
@@ -56,6 +52,7 @@ def cfg_from_file(filename):
# reloading defaults, as they can bleed over from a previous run otherwise
import importlib
+
from . import default_config
importlib.reload(default_config)
diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
index c7214df642..74fa483bc9 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
@@ -13,7 +13,7 @@
import argparse
import os
from pathlib import Path
-from typing import List
+
import numpy as np
import pandas as pd
from tqdm import tqdm
@@ -22,33 +22,26 @@
def pairwisedistances(DataCombined, scorer1, scorer2, pcutoff=-1, bodyparts=None):
- """Calculates the pairwise Euclidean distance metric over body parts vs. images"""
+ """Calculates the pairwise Euclidean distance metric over body parts vs.
+
+ images
+ """
mask = DataCombined[scorer2].xs("likelihood", level=1, axis=1) >= pcutoff
if bodyparts is None:
Pointwisesquareddistance = (DataCombined[scorer1] - DataCombined[scorer2]) ** 2
RMSE = np.sqrt(
- Pointwisesquareddistance.xs("x", level=1, axis=1)
- + Pointwisesquareddistance.xs("y", level=1, axis=1)
+ Pointwisesquareddistance.xs("x", level=1, axis=1) + Pointwisesquareddistance.xs("y", level=1, axis=1)
) # Euclidean distance (proportional to RMSE)
return RMSE, RMSE[mask]
else:
- Pointwisesquareddistance = (
- DataCombined[scorer1][bodyparts] - DataCombined[scorer2][bodyparts]
- ) ** 2
+ Pointwisesquareddistance = (DataCombined[scorer1][bodyparts] - DataCombined[scorer2][bodyparts]) ** 2
RMSE = np.sqrt(
- Pointwisesquareddistance.xs("x", level=1, axis=1)
- + Pointwisesquareddistance.xs("y", level=1, axis=1)
+ Pointwisesquareddistance.xs("x", level=1, axis=1) + Pointwisesquareddistance.xs("y", level=1, axis=1)
) # Euclidean distance (proportional to RMSE)
return RMSE, RMSE[mask]
-def distance(v, w):
- return np.sqrt(np.sum((v - w) ** 2))
-
-
-def calculatepafdistancebounds(
- config, shuffle=0, trainingsetindex=0, modelprefix="", numdigits=0, onlytrain=False
-):
+def calculatepafdistancebounds(config, shuffle=0, trainingsetindex=0, modelprefix="", numdigits=0, onlytrain=False):
"""
Returns distances along paf edges in train/test data
@@ -60,15 +53,17 @@ def calculatepafdistancebounds(
integers specifying shuffle index of the training dataset. The default is 0.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml). This
variable can also be set to "all".
numdigits: number of digits to round for distances.
"""
import os
- from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
+
from deeplabcut.pose_estimation_tensorflow.config import load_config
+ from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
# Read file path for pose_config file. >> pass it on
cfg = auxiliaryfunctions.read_config(config)
@@ -85,11 +80,7 @@ def calculatepafdistancebounds(
trainFraction = cfg["TrainingFraction"][trainingsetindex]
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
# Load meta data & annotations
@@ -116,15 +107,10 @@ def calculatepafdistancebounds(
# get the graph!
partaffinityfield_graph = test_pose_cfg["partaffinityfield_graph"]
- jointnames = [
- test_pose_cfg["all_joints_names"][i]
- for i in range(len(test_pose_cfg["all_joints"]))
- ]
- path_inferencebounds_config = (
- Path(modelfolder) / "test" / "inferencebounds.yaml"
- )
+ jointnames = [test_pose_cfg["all_joints_names"][i] for i in range(len(test_pose_cfg["all_joints"]))]
+ path_inferencebounds_config = Path(modelfolder) / "test" / "inferencebounds.yaml"
inferenceboundscfg = {}
- for pi, edge in enumerate(partaffinityfield_graph):
+ for _pi, edge in enumerate(partaffinityfield_graph):
j1, j2 = jointnames[edge[0]], jointnames[edge[1]]
ds_within = []
ds_across = []
@@ -157,12 +143,8 @@ def calculatepafdistancebounds(
edgeencoding = str(edge[0]) + "_" + str(edge[1])
inferenceboundscfg[edgeencoding] = {}
if len(ds_within) > 0:
- inferenceboundscfg[edgeencoding]["intra_max"] = str(
- round(np.nanmax(ds_within), numdigits)
- )
- inferenceboundscfg[edgeencoding]["intra_min"] = str(
- round(np.nanmin(ds_within), numdigits)
- )
+ inferenceboundscfg[edgeencoding]["intra_max"] = str(round(np.nanmax(ds_within), numdigits))
+ inferenceboundscfg[edgeencoding]["intra_min"] = str(round(np.nanmin(ds_within), numdigits))
else:
inferenceboundscfg[edgeencoding]["intra_max"] = str(
1e5
@@ -171,31 +153,23 @@ def calculatepafdistancebounds(
# NOTE: the inter-animal distances are currently not used, but are interesting to compare to intra_*
if len(ds_across) > 0:
- inferenceboundscfg[edgeencoding]["inter_max"] = str(
- round(np.nanmax(ds_across), numdigits)
- )
- inferenceboundscfg[edgeencoding]["inter_min"] = str(
- round(np.nanmin(ds_across), numdigits)
- )
+ inferenceboundscfg[edgeencoding]["inter_max"] = str(round(np.nanmax(ds_across), numdigits))
+ inferenceboundscfg[edgeencoding]["inter_min"] = str(round(np.nanmin(ds_across), numdigits))
else:
inferenceboundscfg[edgeencoding]["inter_max"] = str(
1e5
) # large number (larger than image diameters in typical experiments)
inferenceboundscfg[edgeencoding]["inter_min"] = str(0)
- auxiliaryfunctions.write_plainconfig(
- str(path_inferencebounds_config), dict(inferenceboundscfg)
- )
+ auxiliaryfunctions.write_plainconfig(str(path_inferencebounds_config), dict(inferenceboundscfg))
return inferenceboundscfg
else:
print("You might as well bring owls to Athens.")
return {}
-def Plotting(
- cfg, comparisonbodyparts, DLCscorer, trainIndices, DataCombined, foldername
-):
- """Function used for plotting GT and predictions"""
+def Plotting(cfg, comparisonbodyparts, DLCscorer, trainIndices, DataCombined, foldername):
+ """Function used for plotting GT and predictions."""
from deeplabcut.utils import visualization
colors = visualization.get_cmap(len(comparisonbodyparts), name=cfg["colormap"])
@@ -229,12 +203,15 @@ def return_evaluate_network_data(
modelprefix="",
returnjustfns=True,
):
- """
- Returns the results for (previously evaluated) network. deeplabcut.evaluate_network(..)
- Returns list of (per model): [trainingsiterations,trainfraction,shuffle,trainerror,testerror,pcutoff,trainerrorpcutoff,testerrorpcutoff,Snapshots[snapindex],scale,net_type]
+ """Returns the results for (previously evaluated) network.
+ deeplabcut.evaluate_network(..) Returns list of (per model): [trainingsiterations,tr
+ ainfraction,shuffle,trainerror,testerror,pcutoff,trainerrorpcutoff,testerrorpcutoff,
+ Snapshots[snapindex],scale,net_type]
If fulldata=True, also returns (the complete annotation and prediction array)
- Returns list of: (DataMachine, Data, data, trainIndices, testIndices, trainFraction, DLCscorer,comparisonbodyparts, cfg, Snapshots[snapindex])
+ Returns list of:
+ (DataMachine, Data, data, trainIndices, testIndices, trainFraction,
+ DLCscorer,comparisonbodyparts, cfg, Snapshots[snapindex])
----------
config : string
Full path of the config.yaml file as a string.
@@ -243,18 +220,22 @@ def return_evaluate_network_data(
integers specifying shuffle index of the training dataset. The default is 0.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This
- variable can also be set to "all".
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ This variable can also be set to "all".
comparisonbodyparts: list of bodyparts, Default is "all".
The average error will be computed for those body parts only (Has to be a subset of the body parts).
rescale: bool, default False
- Evaluate the model at the 'global_scale' variable (as set in the test/pose_config.yaml file for a particular project). I.e. every
- image will be resized according to that scale and prediction will be compared to the resized ground truth. The error will be reported
- in pixels at rescaled to the *original* size. I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
- on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!. The evaluation images are also shown for the
- original size!
+ Evaluate the model at the 'global_scale' variable
+ (as set in the test/pose_config.yaml file for a particular project).
+ I.e. every image will be resized according to that scale and
+ prediction will be compared to the resized ground truth. The error will be reported
+ in pixels at rescaled to the *original* size.
+ I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
+ on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!.
+ The evaluation images are also shown for the original size!
Examples
--------
@@ -276,25 +257,22 @@ def return_evaluate_network_data(
# Loading human annotatated data
trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
- # Data=pd.read_hdf(os.path.join(cfg["project_path"],str(trainingsetfolder),'CollectedData_' + cfg["scorer"] + '.h5'),'df_with_missing')
+ # Data=pd.read_hdf(
+ # os.path.join(
+ # cfg["project_path"],
+ # str(trainingsetfolder
+ # ),'CollectedData_' + cfg["scorer"] + '.h5'),'df_with_missing'
+ # )
# Get list of body parts to evaluate network for
- comparisonbodyparts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts
- )
- )
+ comparisonbodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
##################################################
# Load data...
##################################################
trainFraction = cfg["TrainingFraction"][trainingsetindex]
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_train_config, path_test_config, _ = return_train_network_path(
config=config,
@@ -305,11 +283,10 @@ def return_evaluate_network_data(
try:
test_pose_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
train_pose_cfg = load_config(str(path_train_config))
# Load meta data
@@ -318,7 +295,7 @@ def return_evaluate_network_data(
)
########################### RESCALING (to global scale)
- if rescale == True:
+ if rescale:
scale = test_pose_cfg["global_scale"]
print("Rescaling Data to ", scale)
Data = (
@@ -343,54 +320,29 @@ def return_evaluate_network_data(
evaluationfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
- # Check which snapshots are available and sort them by # iterations
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(str(modelfolder), "train"))
- if "index" in fn
- ]
+
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
)
- if len(Snapshots) == 0:
- print(
- "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so."
- % (shuffle, trainFraction)
- )
- snapindices = []
- else:
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
- if Snapindex is None:
- Snapindex = cfg["snapshotindex"]
-
- if Snapindex == -1:
- snapindices = [-1]
- elif Snapindex == "all":
- snapindices = range(len(Snapshots))
- elif Snapindex < len(Snapshots):
- snapindices = [Snapindex]
- else:
- print(
- "Invalid choice, only -1 (last), any integer up to last, or all (as string)!"
- )
+ if Snapindex is None:
+ Snapindex = cfg["snapshotindex"]
+
+ snapshot_names = get_snapshots_by_index(
+ idx=Snapindex,
+ available_snapshots=Snapshots,
+ )
DATA = []
results = []
resultsfns = []
- for snapindex in snapindices:
+ for snapshot_name in snapshot_names:
test_pose_cfg["init_weights"] = os.path.join(
- str(modelfolder), "train", Snapshots[snapindex]
+ str(modelfolder), "train", snapshot_name
) # setting weights to corresponding snapshot.
- trainingsiterations = (test_pose_cfg["init_weights"].split(os.sep)[-1]).split(
- "-"
- )[
+ trainingsiterations = (test_pose_cfg["init_weights"].split(os.sep)[-1]).split("-")[
-1
] # read how many training siterations that corresponds to.
@@ -410,10 +362,10 @@ def return_evaluate_network_data(
notanalyzed,
resultsfilename,
DLCscorer,
- ) = auxiliaryfunctions.check_if_not_evaluated(
- str(evaluationfolder), DLCscorer, DLCscorerlegacy, Snapshots[snapindex]
- )
- # resultsfilename=os.path.join(str(evaluationfolder),DLCscorer + '-' + str(Snapshots[snapindex])+ '.h5') # + '-' + str(snapshot)+ ' #'-' + Snapshots[snapindex]+ '.h5')
+ ) = auxiliaryfunctions.check_if_not_evaluated(str(evaluationfolder), DLCscorer, DLCscorerlegacy, snapshot_name)
+ # resultsfilename=os.path.join(str(evaluationfolder),DLCscorer + '-' +
+ # str(Snapshots[snapindex])+ '.h5') # + '-' + str(snapshot)+ ' #'-' +
+ # Snapshots[snapindex]+ '.h5')
print(resultsfilename)
resultsfns.append(resultsfilename)
if not returnjustfns:
@@ -430,13 +382,9 @@ def return_evaluate_network_data(
testerror = np.nanmean(RMSE.iloc[testIndices].values.flatten())
trainerror = np.nanmean(RMSE.iloc[trainIndices].values.flatten())
- testerrorpcutoff = np.nanmean(
- RMSEpcutoff.iloc[testIndices].values.flatten()
- )
- trainerrorpcutoff = np.nanmean(
- RMSEpcutoff.iloc[trainIndices].values.flatten()
- )
- if show_errors == True:
+ testerrorpcutoff = np.nanmean(RMSEpcutoff.iloc[testIndices].values.flatten())
+ trainerrorpcutoff = np.nanmean(RMSEpcutoff.iloc[trainIndices].values.flatten())
+ if show_errors:
print(
"Results for",
trainingsiterations,
@@ -458,7 +406,7 @@ def return_evaluate_network_data(
np.round(testerrorpcutoff, 2),
"pixels",
)
- print("Snapshot", Snapshots[snapindex])
+ print("Snapshot", snapshot_name)
r = [
trainingsiterations,
@@ -469,14 +417,14 @@ def return_evaluate_network_data(
cfg["pcutoff"],
np.round(trainerrorpcutoff, 2),
np.round(testerrorpcutoff, 2),
- Snapshots[snapindex],
+ snapshot_name,
scale,
test_pose_cfg["net_type"],
]
results.append(r)
else:
print("Model not trained/evaluated!")
- if fulldata == True:
+ if fulldata:
DATA.append(
[
DataMachine,
@@ -489,7 +437,7 @@ def return_evaluate_network_data(
comparisonbodyparts,
cfg,
evaluationfolder,
- Snapshots[snapindex],
+ snapshot_name,
]
)
@@ -497,7 +445,7 @@ def return_evaluate_network_data(
if returnjustfns:
return resultsfns
else:
- if fulldata == True:
+ if fulldata:
return DATA, results
else:
return results
@@ -506,10 +454,10 @@ def return_evaluate_network_data(
def keypoint_error(
df_error: pd.DataFrame,
df_error_p_cutoff: pd.DataFrame,
- train_indices: List[int],
- test_indices: List[int],
+ train_indices: list[int],
+ test_indices: list[int],
) -> pd.DataFrame:
- """Computes the RMSE error for each bodypart
+ """Computes the RMSE error for each bodypart.
The error dataframes can be in single animal format (non-hierarchical columns, one
column for each bodypart) or multi-animal format (hierarchical columns with 3
@@ -554,7 +502,7 @@ def keypoint_error(
def evaluate_network(
config,
- Shuffles=[1],
+ Shuffles=None,
trainingsetindex=0,
plotting=False,
show_errors=True,
@@ -563,6 +511,7 @@ def evaluate_network(
rescale=False,
modelprefix="",
per_keypoint_evaluation: bool = False,
+ snapshots_to_evaluate: list[str] = None,
):
"""Evaluates the network.
@@ -618,7 +567,10 @@ def evaluate_network(
per_keypoint_evaluation: bool, default=False
Compute the train and test RMSE for each keypoint, and save the results to
- a {model_name}-keypoint-results.csv in the evalution-results folder
+ a {model_name}-keypoint-results.csv in the evaluation-results folder
+
+ snapshots_to_evaluate: List[str], optional, default=None
+ List of snapshot names to evaluate (e.g. ["snapshot-50000", "snapshot-75000", ...])
Returns
-------
@@ -650,6 +602,8 @@ def evaluate_network(
Note: This defaults to standard plotting for single-animal projects.
"""
+ if Shuffles is None:
+ Shuffles = [1]
if plotting not in (True, False, "bodypart", "individual"):
raise ValueError(f"Unknown value for `plotting`={plotting}")
@@ -673,22 +627,22 @@ def evaluate_network(
gputouse=gputouse,
modelprefix=modelprefix,
per_keypoint_evaluation=per_keypoint_evaluation,
+ snapshots_to_evaluate=snapshots_to_evaluate,
)
else:
- from deeplabcut.utils.auxfun_videos import imread, imresize
- from deeplabcut.pose_estimation_tensorflow.core import predict
+ import tensorflow as tf
+
from deeplabcut.pose_estimation_tensorflow.config import load_config
+ from deeplabcut.pose_estimation_tensorflow.core import predict
from deeplabcut.pose_estimation_tensorflow.datasets.utils import data_to_input
from deeplabcut.utils import auxiliaryfunctions, conversioncode
- import tensorflow as tf
+ from deeplabcut.utils.auxfun_videos import imread, imresize
# If a string was passed in, auto-convert to True for backward compatibility
plotting = bool(plotting)
if "TF_CUDNN_USE_AUTOTUNE" in os.environ:
- del os.environ[
- "TF_CUDNN_USE_AUTOTUNE"
- ] # was potentially set during training
+ del os.environ["TF_CUDNN_USE_AUTOTUNE"] # was potentially set during training
tf.compat.v1.reset_default_graph()
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" #
@@ -724,15 +678,11 @@ def evaluate_network(
)
# Get list of body parts to evaluate network for
- comparisonbodyparts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts
- )
+ comparisonbodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
+ cfg, comparisonbodyparts
)
# Make folder for evaluation
- auxiliaryfunctions.attempt_to_make_folder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(str(cfg["project_path"] + "/evaluation-results/"))
for shuffle in Shuffles:
for trainFraction in TrainingFractions:
##################################################
@@ -744,7 +694,8 @@ def evaluate_network(
)
modelfolder = Path(cfg["project_path"]) / modelfolder_rel_path
- # TODO: Unlike using create_training_dataset() If create_training_model_comparison() is used there won't
+ # TODO: Unlike using create_training_dataset()
+ # If create_training_model_comparison() is used there won't
# necessarily be training fractions for every shuffle which will raise the FileNotFoundError..
# Not sure if this should throw an exception or just be a warning...
if not modelfolder.exists():
@@ -777,46 +728,23 @@ def evaluate_network(
# Create folder structure to store results.
evaluationfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
- )
- auxiliaryfunctions.attempt_to_make_folder(
- evaluationfolder, recursive=True
+ str(auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
+ auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True)
- # Check which snapshots are available and sort them by # iterations
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(str(modelfolder), "train"))
- if "index" in fn
- ]
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
)
- try: # check if any where found?
- Snapshots[0]
- except IndexError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so."
- % (shuffle, trainFraction)
- )
- increasing_indices = np.argsort(
- [int(m.split("-")[1]) for m in Snapshots]
- )
- Snapshots = Snapshots[increasing_indices]
-
- if cfg["snapshotindex"] == -1:
- snapindices = [-1]
- elif cfg["snapshotindex"] == "all":
- snapindices = range(len(Snapshots))
- elif cfg["snapshotindex"] < len(Snapshots):
- snapindices = [cfg["snapshotindex"]]
+ if snapshots_to_evaluate is not None:
+ snapshot_names = get_available_requested_snapshots(
+ requested_snapshots=snapshots_to_evaluate,
+ available_snapshots=Snapshots,
+ )
else:
- raise ValueError(
- "Invalid choice, only -1 (last), any integer up to last, or all (as string)!"
+ snapshot_names = get_snapshots_by_index(
+ idx=cfg["snapshotindex"],
+ available_snapshots=Snapshots,
)
final_result = []
@@ -841,29 +769,25 @@ def evaluate_network(
##################################################
# Compute predictions over images
##################################################
- for snapindex in snapindices:
+ for snapshot_name in snapshot_names:
test_pose_cfg["init_weights"] = os.path.join(
- str(modelfolder), "train", Snapshots[snapindex]
+ str(modelfolder), "train", snapshot_name
) # setting weights to corresponding snapshot.
- trainingsiterations = (
- test_pose_cfg["init_weights"].split(os.sep)[-1]
- ).split("-")[
- -1
- ] # read how many training siterations that corresponds to.
+ training_iterations = int(snapshot_name.split("-")[-1])
# Name for deeplabcut net (based on its parameters)
DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
trainFraction,
- trainingsiterations,
+ trainingsiterations=training_iterations,
modelprefix=modelprefix,
)
print(
"Running ",
DLCscorer,
" with # of training iterations:",
- trainingsiterations,
+ training_iterations,
)
(
notanalyzed,
@@ -873,17 +797,13 @@ def evaluate_network(
str(evaluationfolder),
DLCscorer,
DLCscorerlegacy,
- Snapshots[snapindex],
+ snapshot_name,
)
if notanalyzed:
# Specifying state of model (snapshot / training state)
- sess, inputs, outputs = predict.setup_pose_prediction(
- test_pose_cfg
- )
+ sess, inputs, outputs = predict.setup_pose_prediction(test_pose_cfg)
Numimages = len(Data.index)
- PredicteData = np.zeros(
- (Numimages, 3 * len(test_pose_cfg["all_joints_names"]))
- )
+ PredicteData = np.zeros((Numimages, 3 * len(test_pose_cfg["all_joints_names"])))
print("Running evaluation ...")
for imageindex, imagename in tqdm(enumerate(Data.index)):
image = imread(
@@ -895,20 +815,12 @@ def evaluate_network(
image_batch = data_to_input(image)
# Compute prediction with the CNN
- outputs_np = sess.run(
- outputs, feed_dict={inputs: image_batch}
- )
- scmap, locref = predict.extract_cnn_output(
- outputs_np, test_pose_cfg
- )
+ outputs_np = sess.run(outputs, feed_dict={inputs: image_batch})
+ scmap, locref = predict.extract_cnn_output(outputs_np, test_pose_cfg)
# Extract maximum scoring location from the heatmap, assume 1 person
- pose = predict.argmax_pose_predict(
- scmap, locref, test_pose_cfg["stride"]
- )
- PredicteData[
- imageindex, :
- ] = (
+ pose = predict.argmax_pose_predict(scmap, locref, test_pose_cfg["stride"])
+ PredicteData[imageindex, :] = (
pose.flatten()
) # NOTE: thereby cfg_test['all_joints_names'] should be same order as bodyparts!
@@ -924,18 +836,14 @@ def evaluate_network(
)
# Saving results
- DataMachine = pd.DataFrame(
- PredicteData, columns=index, index=Data.index
- )
- DataMachine.to_hdf(resultsfilename, "df_with_missing")
+ DataMachine = pd.DataFrame(PredicteData, columns=index, index=Data.index)
+ DataMachine.to_hdf(resultsfilename, key="df_with_missing")
print(
"Analysis is done and the results are stored (see evaluation-results) for snapshot: ",
- Snapshots[snapindex],
+ snapshot_name,
)
- DataCombined = pd.concat(
- [Data.T, DataMachine.T], axis=0, sort=False
- ).T
+ DataCombined = pd.concat([Data.T, DataMachine.T], axis=0, sort=False).T
RMSE, RMSEpcutoff = pairwisedistances(
DataCombined,
@@ -945,17 +853,11 @@ def evaluate_network(
comparisonbodyparts,
)
testerror = np.nanmean(RMSE.iloc[testIndices].values.flatten())
- trainerror = np.nanmean(
- RMSE.iloc[trainIndices].values.flatten()
- )
- testerrorpcutoff = np.nanmean(
- RMSEpcutoff.iloc[testIndices].values.flatten()
- )
- trainerrorpcutoff = np.nanmean(
- RMSEpcutoff.iloc[trainIndices].values.flatten()
- )
+ trainerror = np.nanmean(RMSE.iloc[trainIndices].values.flatten())
+ testerrorpcutoff = np.nanmean(RMSEpcutoff.iloc[testIndices].values.flatten())
+ trainerrorpcutoff = np.nanmean(RMSEpcutoff.iloc[trainIndices].values.flatten())
results = [
- trainingsiterations,
+ training_iterations,
int(100 * trainFraction),
shuffle,
np.round(trainerror, 2),
@@ -967,18 +869,14 @@ def evaluate_network(
final_result.append(results)
if per_keypoint_evaluation:
- df_keypoint_error = keypoint_error(
- RMSE, RMSEpcutoff, trainIndices, testIndices
- )
+ df_keypoint_error = keypoint_error(RMSE, RMSEpcutoff, trainIndices, testIndices)
kpt_filename = DLCscorer + "-keypoint-results.csv"
- df_keypoint_error.to_csv(
- Path(evaluationfolder) / kpt_filename
- )
+ df_keypoint_error.to_csv(Path(evaluationfolder) / kpt_filename)
if show_errors:
print(
"Results for",
- trainingsiterations,
+ training_iterations,
" training iterations:",
int(100 * trainFraction),
shuffle,
@@ -999,21 +897,19 @@ def evaluate_network(
)
if scale != 1:
print(
- "The predictions have been calculated for rescaled images (and rescaled ground truth). Scale:",
- scale,
+ "The predictions have been calculated for"
+ f" rescaled images (and rescaled ground truth). Scale: {scale}"
)
print(
- "Thereby, the errors are given by the average distances between the labels by DLC and the scorer."
+ "Thereby, the errors are given by the average distances "
+ "between the labels by DLC and the scorer."
)
if plotting:
print("Plotting...")
foldername = os.path.join(
str(evaluationfolder),
- "LabeledImages_"
- + DLCscorer
- + "_"
- + Snapshots[snapindex],
+ "LabeledImages_" + DLCscorer + "_" + snapshot_name,
)
auxiliaryfunctions.attempt_to_make_folder(foldername)
Plotting(
@@ -1031,19 +927,16 @@ def evaluate_network(
DataMachine = pd.read_hdf(resultsfilename)
conversioncode.guarantee_multiindex_rows(DataMachine)
if plotting:
- DataCombined = pd.concat(
- [Data.T, DataMachine.T], axis=0, sort=False
- ).T
+ DataCombined = pd.concat([Data.T, DataMachine.T], axis=0, sort=False).T
foldername = os.path.join(
str(evaluationfolder),
- "LabeledImages_"
- + DLCscorer
- + "_"
- + Snapshots[snapindex],
+ "LabeledImages_" + DLCscorer + "_" + snapshot_name,
)
if not os.path.exists(foldername):
print(
- "Plotting...(attention scale might be inconsistent in comparison to when data was analyzed; i.e. if you used rescale)"
+ "Plotting..."
+ "(warning, scale might be inconsistent in comparison "
+ "to when data was analyzed; i.e. if you used rescale)"
)
auxiliaryfunctions.attempt_to_make_folder(foldername)
Plotting(
@@ -1055,9 +948,7 @@ def evaluate_network(
foldername,
)
else:
- print(
- "Plots already exist for this snapshot... Skipping to the next one."
- )
+ print("Plots already exist for this snapshot... Skipping to the next one.")
if len(final_result) > 0: # Only append if results were calculated
make_results_file(final_result, evaluationfolder, DLCscorer)
@@ -1065,10 +956,13 @@ def evaluate_network(
"The network is evaluated and the results are stored in the subdirectory 'evaluation_results'."
)
print(
- "Please check the results, then choose the best model (snapshot) for prediction. You can update the config.yaml file with the appropriate index for the 'snapshotindex'.\nUse the function 'analyze_video' to make predictions on new videos."
+ "Please check the results, then choose the best model (snapshot) for prediction. "
+ "You can update the config.yaml file with the appropriate index for the 'snapshotindex'.\n"
+ "Use the function 'analyze_video' to make predictions on new videos."
)
print(
- "Otherwise, consider adding more labeled-data and retraining the network (see DeepLabCut workflow Fig 2, Nath 2019)"
+ "Otherwise, consider adding more labeled-data and retraining the network "
+ "(see DeepLabCut workflow Fig 2, Nath 2019)"
)
# returning to initial folder
@@ -1076,10 +970,10 @@ def evaluate_network(
def make_results_file(final_result, evaluationfolder, DLCscorer):
- """
- Makes result file in csv format and saves under evaluation_results directory.
- If the file exists (typically, when the network has already been evaluated),
- newer results are appended to it.
+ """Makes result file in csv format and saves under evaluation_results directory.
+
+ If the file exists (typically, when the network has already been evaluated), newer
+ results are appended to it.
"""
col_names = [
"Training iterations:",
@@ -1102,9 +996,7 @@ def make_results_file(final_result, evaluationfolder, DLCscorer):
## Also storing one "large" table with results:
# note: evaluationfolder.parents[0] to get common folder above all shuffle evaluations.
df = pd.DataFrame(final_result, columns=col_names)
- output_path = os.path.join(
- str(Path(evaluationfolder).parents[0]), "CombinedEvaluation-results.csv"
- )
+ output_path = os.path.join(str(Path(evaluationfolder).parents[0]), "CombinedEvaluation-results.csv")
if os.path.exists(output_path):
temp = pd.read_csv(output_path, index_col=0)
df = pd.concat((temp, df)).reset_index(drop=True)
@@ -1112,6 +1004,50 @@ def make_results_file(final_result, evaluationfolder, DLCscorer):
df.to_csv(output_path)
+def get_available_requested_snapshots(
+ requested_snapshots: list[str],
+ available_snapshots: list[str],
+) -> list[str]:
+ """Intersects the requested snapshot names with the available snapshots.
+
+ Returns: snapshot names
+ """
+ snapshot_names = []
+ missing_snapshots = []
+ for snap in requested_snapshots:
+ if snap in available_snapshots:
+ snapshot_names.append(snap)
+ else:
+ missing_snapshots.append(snap)
+
+ if len(snapshot_names) == 0:
+ raise ValueError(f"None of the requested snapshots were found: \n{missing_snapshots}")
+ elif len(missing_snapshots) > 0:
+ print(f"The following requested snapshots were not found and will be skipped:\n{missing_snapshots}")
+
+ return snapshot_names
+
+
+def get_snapshots_by_index(
+ idx: int | str,
+ available_snapshots: list[str],
+) -> list[str]:
+ """Assume available_snapshots is ordered in ascending order.
+
+ Returns snapshot names.
+ """
+ if isinstance(idx, int) and -len(available_snapshots) <= idx < len(available_snapshots):
+ return [available_snapshots[idx]]
+ elif idx == "all":
+ return available_snapshots
+
+ raise IndexError(
+ f"Invalid index: {idx}. The index should be an int less than the number of "
+ f"available snapshots, negative indexing is supported. The keyword 'all' "
+ f"is also a valid option."
+ )
+
+
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("config")
diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py
index 8d6c4de19d..42b4a7b63e 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py
@@ -9,22 +9,25 @@
# Licensed under GNU Lesser General Public License v3.0
#
-import imgaug.augmenters as iaa
import os
import pickle
from pathlib import Path
+
+import imgaug.augmenters as iaa
import numpy as np
import pandas as pd
-from scipy.spatial import cKDTree
from tqdm import tqdm
+from deeplabcut.core import crossvalutils
+from deeplabcut.core.crossvalutils import find_closest_neighbors
+from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.core.evaluate import (
- make_results_file,
+ get_available_requested_snapshots,
+ get_snapshots_by_index,
keypoint_error,
+ make_results_file,
)
from deeplabcut.pose_estimation_tensorflow.training import return_train_network_path
-from deeplabcut.pose_estimation_tensorflow.config import load_config
-from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils
from deeplabcut.utils import visualization
@@ -50,32 +53,14 @@ def _compute_stats(df):
).stack(level=1)
-def _find_closest_neighbors(xy_true, xy_pred, k=5):
- n_preds = xy_pred.shape[0]
- tree = cKDTree(xy_pred)
- dist, inds = tree.query(xy_true, k=k)
- idx = np.argsort(dist[:, 0])
- neighbors = np.full(len(xy_true), -1, dtype=int)
- picked = set()
- for i, ind in enumerate(inds[idx]):
- for j in ind:
- if j not in picked:
- picked.add(j)
- neighbors[idx[i]] = j
- break
- if len(picked) == n_preds:
- break
- return neighbors
-
-
def _calc_prediction_error(data):
_ = data.pop("metadata", None)
dists = []
- for n, dict_ in enumerate(tqdm(data.values())):
+ for _n, dict_ in enumerate(tqdm(data.values())):
gt = np.concatenate(dict_["groundtruth"][1])
xy = np.concatenate(dict_["prediction"]["coordinates"][0])
p = np.concatenate(dict_["prediction"]["confidence"])
- neighbors = _find_closest_neighbors(gt, xy)
+ neighbors = find_closest_neighbors(gt, xy)
found = neighbors != -1
gt2 = gt[found]
xy2 = xy[neighbors[found]]
@@ -103,7 +88,7 @@ def _calc_train_test_error(data, metadata, pcutoff=0.3):
def evaluate_multianimal_full(
config,
- Shuffles=[1],
+ Shuffles=None,
trainingsetindex=0,
plotting=False,
show_errors=True,
@@ -111,20 +96,25 @@ def evaluate_multianimal_full(
gputouse=None,
modelprefix="",
per_keypoint_evaluation: bool = False,
+ snapshots_to_evaluate: list[str] = None,
):
+ import tensorflow as tf
+
from deeplabcut.pose_estimation_tensorflow.core import (
predict,
+ )
+ from deeplabcut.pose_estimation_tensorflow.core import (
predict_multianimal as predictma,
)
from deeplabcut.utils import (
- auxiliaryfunctions,
auxfun_multianimal,
auxfun_videos,
+ auxiliaryfunctions,
conversioncode,
)
- import tensorflow as tf
-
+ if Shuffles is None:
+ Shuffles = [1]
if "TF_CUDNN_USE_AUTOTUNE" in os.environ:
del os.environ["TF_CUDNN_USE_AUTOTUNE"] # was potentially set during training
@@ -159,19 +149,11 @@ def evaluate_multianimal_full(
conversioncode.guarantee_multiindex_rows(Data)
# Get list of body parts to evaluate network for
- comparisonbodyparts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts
- )
- )
- all_bpts = np.asarray(
- len(cfg["individuals"]) * cfg["multianimalbodyparts"] + cfg["uniquebodyparts"]
- )
+ comparisonbodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
+ all_bpts = np.asarray(len(cfg["individuals"]) * cfg["multianimalbodyparts"] + cfg["uniquebodyparts"])
colors = visualization.get_cmap(len(comparisonbodyparts), name=cfg["colormap"])
# Make folder for evaluation
- auxiliaryfunctions.attempt_to_make_folder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(str(cfg["project_path"] + "/evaluation-results/"))
for shuffle in Shuffles:
for trainFraction in TrainingFractions:
##################################################
@@ -226,423 +208,190 @@ def evaluate_multianimal_full(
# Create folder structure to store results.
evaluationfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True)
- # Check which snapshots are available and sort them by # iterations
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(str(modelfolder), "train"))
- if "index" in fn
- ]
- )
- if len(Snapshots) == 0:
- print(
- "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so."
- % (shuffle, trainFraction)
+ try:
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
)
- else:
- increasing_indices = np.argsort(
- [int(m.split("-")[1]) for m in Snapshots]
+ except FileNotFoundError as e:
+ print(e)
+ continue
+
+ if snapshots_to_evaluate is not None:
+ snapshot_names = get_available_requested_snapshots(
+ requested_snapshots=snapshots_to_evaluate,
+ available_snapshots=Snapshots,
)
- Snapshots = Snapshots[increasing_indices]
-
- if cfg["snapshotindex"] == -1:
- snapindices = [-1]
- elif cfg["snapshotindex"] == "all":
- snapindices = range(len(Snapshots))
- elif cfg["snapshotindex"] < len(Snapshots):
- snapindices = [cfg["snapshotindex"]]
- else:
- print(
- "Invalid choice, only -1 (last), any integer up to last, or all (as string)!"
- )
-
- final_result = []
- ##################################################
- # Compute predictions over images
- ##################################################
- for snapindex in snapindices:
- test_pose_cfg["init_weights"] = os.path.join(
- str(modelfolder), "train", Snapshots[snapindex]
- ) # setting weights to corresponding snapshot.
- trainingsiterations = (
- test_pose_cfg["init_weights"].split(os.sep)[-1]
- ).split("-")[
- -1
- ] # read how many training siterations that corresponds to.
-
- # name for deeplabcut net (based on its parameters)
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
- cfg,
- shuffle,
- trainFraction,
- trainingsiterations,
- modelprefix=modelprefix,
+ else:
+ try:
+ snapshot_names = get_snapshots_by_index(
+ idx=cfg["snapshotindex"],
+ available_snapshots=Snapshots,
)
+ except IndexError as err:
print(
- "Running ",
- DLCscorer,
- " with # of trainingiterations:",
- trainingsiterations,
+ f"Failed to get snapshot_names for trainFraction={trainFraction} and shuffle={shuffle}. Error:"
)
- (
- notanalyzed,
- resultsfilename,
- DLCscorer,
- ) = auxiliaryfunctions.check_if_not_evaluated(
+ print(err)
+ snapshot_names = []
+
+ final_result = []
+ ##################################################
+ # Compute predictions over images
+ ##################################################
+ for snapshot_name in snapshot_names:
+ test_pose_cfg["init_weights"] = os.path.join(
+ str(modelfolder), "train", snapshot_name
+ ) # setting weights to corresponding snapshot.
+ training_iterations = int(snapshot_name.split("-")[-1])
+
+ # name for deeplabcut net (based on its parameters)
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
+ cfg,
+ shuffle,
+ trainFraction,
+ trainingsiterations=training_iterations,
+ modelprefix=modelprefix,
+ )
+ print(
+ "Running ",
+ DLCscorer,
+ " with # of trainingiterations:",
+ training_iterations,
+ )
+ (
+ notanalyzed,
+ resultsfilename,
+ DLCscorer,
+ ) = auxiliaryfunctions.check_if_not_evaluated(
+ str(evaluationfolder),
+ DLCscorer,
+ DLCscorerlegacy,
+ snapshot_name,
+ )
+
+ data_path = resultsfilename.split(".h5")[0] + "_full.pickle"
+
+ if plotting:
+ foldername = os.path.join(
str(evaluationfolder),
- DLCscorer,
- DLCscorerlegacy,
- Snapshots[snapindex],
+ "LabeledImages_" + DLCscorer + "_" + snapshot_name,
)
+ auxiliaryfunctions.attempt_to_make_folder(foldername)
+ if plotting == "bodypart":
+ fig, ax = visualization.create_minimal_figure()
- data_path = resultsfilename.split(".h5")[0] + "_full.pickle"
+ if os.path.isfile(data_path):
+ print("Model already evaluated.", resultsfilename)
+ else:
+ (
+ sess,
+ inputs,
+ outputs,
+ ) = predict.setup_pose_prediction(test_pose_cfg)
+
+ PredicteData = {}
+ predicted_poses = np.full((len(Data), len(all_bpts), 2), np.nan)
+ dist = np.full((len(Data), len(all_bpts)), np.nan)
+ conf = np.full_like(dist, np.nan)
+ print("Network Evaluation underway...")
+ for imageindex, imagename in tqdm(enumerate(Data.index)):
+ image_path = os.path.join(cfg["project_path"], *imagename)
+ frame = auxfun_videos.imread(image_path, mode="skimage")
+
+ GT = Data.iloc[imageindex]
+ if not GT.any():
+ continue
+
+ # Pass the image and the keypoints through the resizer;
+ # this has no effect if no augmenters were added to it.
+ keypoints = [GT.to_numpy().reshape((-1, 2)).astype(float)]
+ frame_, keypoints = pipeline(images=[frame], keypoints=keypoints)
+ frame = frame_[0]
+ GT[:] = keypoints[0].flatten()
+
+ df = GT.unstack("coords").reindex(joints, level="bodyparts")
+
+ # FIXME Is having an empty array vs nan really that necessary?!
+ groundtruthidentity = list(df.index.get_level_values("individuals").to_numpy().reshape((-1, 1)))
+ groundtruthcoordinates = list(df.values[:, np.newaxis])
+ for i, coords in enumerate(groundtruthcoordinates):
+ if np.isnan(coords).any():
+ groundtruthcoordinates[i] = np.empty((0, 2), dtype=float)
+ groundtruthidentity[i] = np.array([], dtype=str)
+
+ # Form 2D array of shape (n_rows, 4) where the last dim is
+ # (sample_index, peak_y, peak_x, bpt_index) to slice the PAFs.
+ temp = df.reset_index(level="bodyparts").dropna()
+ with pd.option_context("future.no_silent_downcasting", True):
+ temp["bodyparts"] = (
+ temp["bodyparts"]
+ .replace(
+ dict(zip(joints, range(len(joints)), strict=False)),
+ )
+ .infer_objects(copy=False)
+ )
- if plotting:
- foldername = os.path.join(
- str(evaluationfolder),
- "LabeledImages_" + DLCscorer + "_" + Snapshots[snapindex],
- )
- auxiliaryfunctions.attempt_to_make_folder(foldername)
- if plotting == "bodypart":
- fig, ax = visualization.create_minimal_figure()
+ temp["sample"] = 0
+ peaks_gt = temp.loc[:, ["sample", "y", "x", "bodyparts"]].to_numpy()
+ peaks_gt[:, 1:3] = (peaks_gt[:, 1:3] - stride // 2) / stride
- if os.path.isfile(data_path):
- print("Model already evaluated.", resultsfilename)
- else:
- (
+ pred = predictma.predict_batched_peaks_and_costs(
+ test_pose_cfg,
+ np.expand_dims(frame, axis=0),
sess,
inputs,
outputs,
- ) = predict.setup_pose_prediction(test_pose_cfg)
-
- PredicteData = {}
- dist = np.full((len(Data), len(all_bpts)), np.nan)
- conf = np.full_like(dist, np.nan)
- print("Network Evaluation underway...")
- for imageindex, imagename in tqdm(enumerate(Data.index)):
- image_path = os.path.join(cfg["project_path"], *imagename)
- frame = auxfun_videos.imread(image_path, mode="skimage")
-
- GT = Data.iloc[imageindex]
- if not GT.any():
- continue
-
- # Pass the image and the keypoints through the resizer;
- # this has no effect if no augmenters were added to it.
- keypoints = [GT.to_numpy().reshape((-1, 2)).astype(float)]
- frame_, keypoints = pipeline(
- images=[frame], keypoints=keypoints
- )
- frame = frame_[0]
- GT[:] = keypoints[0].flatten()
-
- df = GT.unstack("coords").reindex(joints, level="bodyparts")
-
- # FIXME Is having an empty array vs nan really that necessary?!
- groundtruthidentity = list(
- df.index.get_level_values("individuals")
- .to_numpy()
- .reshape((-1, 1))
- )
- groundtruthcoordinates = list(df.values[:, np.newaxis])
- for i, coords in enumerate(groundtruthcoordinates):
- if np.isnan(coords).any():
- groundtruthcoordinates[i] = np.empty(
- (0, 2), dtype=float
- )
- groundtruthidentity[i] = np.array([], dtype=str)
-
- # Form 2D array of shape (n_rows, 4) where the last dimension
- # is (sample_index, peak_y, peak_x, bpt_index) to slice the PAFs.
- temp = df.reset_index(level="bodyparts").dropna()
- temp["bodyparts"].replace(
- dict(zip(joints, range(len(joints)))),
- inplace=True,
- )
- temp["sample"] = 0
- peaks_gt = temp.loc[
- :, ["sample", "y", "x", "bodyparts"]
- ].to_numpy()
- peaks_gt[:, 1:3] = (peaks_gt[:, 1:3] - stride // 2) / stride
-
- pred = predictma.predict_batched_peaks_and_costs(
- test_pose_cfg,
- np.expand_dims(frame, axis=0),
- sess,
- inputs,
- outputs,
- peaks_gt.astype(int),
- )
-
- if not pred:
- continue
- else:
- pred = pred[0]
-
- PredicteData[imagename] = {}
- PredicteData[imagename]["index"] = imageindex
- PredicteData[imagename]["prediction"] = pred
- PredicteData[imagename]["groundtruth"] = [
- groundtruthidentity,
- groundtruthcoordinates,
- GT,
- ]
-
- coords_pred = pred["coordinates"][0]
- probs_pred = pred["confidence"]
- for bpt, xy_gt in df.groupby(level="bodyparts"):
- inds_gt = np.flatnonzero(
- np.all(~np.isnan(xy_gt), axis=1)
- )
- n_joint = joints.index(bpt)
- xy = coords_pred[n_joint]
- if inds_gt.size and xy.size:
- # Pick the predictions closest to ground truth,
- # rather than the ones the model has most confident in
- xy_gt_values = xy_gt.iloc[inds_gt].values
- neighbors = _find_closest_neighbors(
- xy_gt_values, xy, k=3
- )
- found = neighbors != -1
- min_dists = np.linalg.norm(
- xy_gt_values[found] - xy[neighbors[found]],
- axis=1,
- )
- inds = np.flatnonzero(all_bpts == bpt)
- sl = imageindex, inds[inds_gt[found]]
- dist[sl] = min_dists
- conf[sl] = probs_pred[n_joint][
- neighbors[found]
- ].squeeze()
-
- if plotting == "bodypart":
- temp_xy = GT.unstack("bodyparts")[joints].values
- gt = temp_xy.reshape(
- (-1, 2, temp_xy.shape[1])
- ).T.swapaxes(1, 2)
- h, w, _ = np.shape(frame)
- fig.set_size_inches(w / 100, h / 100)
- ax.set_xlim(0, w)
- ax.set_ylim(0, h)
- ax.invert_yaxis()
- ax = visualization.make_multianimal_labeled_image(
- frame,
- gt,
- coords_pred,
- probs_pred,
- colors,
- cfg["dotsize"],
- cfg["alphavalue"],
- cfg["pcutoff"],
- ax=ax,
- )
- visualization.save_labeled_frame(
- fig,
- image_path,
- foldername,
- imageindex in trainIndices,
- )
- visualization.erase_artists(ax)
-
- sess.close() # closes the current tf session
-
- # Compute all distance statistics
- df_dist = pd.DataFrame(dist, columns=df.index)
- df_conf = pd.DataFrame(conf, columns=df.index)
- df_joint = pd.concat(
- [df_dist, df_conf],
- keys=["rmse", "conf"],
- names=["metrics"],
- axis=1,
- )
- df_joint = df_joint.reorder_levels(
- list(np.roll(df_joint.columns.names, -1)), axis=1
+ peaks_gt.astype(int),
)
- df_joint.sort_index(
- axis=1,
- level=["individuals", "bodyparts"],
- ascending=[True, True],
- inplace=True,
- )
- write_path = os.path.join(
- evaluationfolder, f"dist_{trainingsiterations}.csv"
- )
- df_joint.to_csv(write_path)
- # Calculate overall prediction error
- error = df_joint.xs("rmse", level="metrics", axis=1)
- mask = (
- df_joint.xs("conf", level="metrics", axis=1)
- >= cfg["pcutoff"]
- )
- error_masked = error[mask]
- error_train = np.nanmean(error.iloc[trainIndices])
- error_train_cut = np.nanmean(error_masked.iloc[trainIndices])
- error_test = np.nanmean(error.iloc[testIndices])
- error_test_cut = np.nanmean(error_masked.iloc[testIndices])
- results = [
- trainingsiterations,
- int(100 * trainFraction),
- shuffle,
- np.round(error_train, 2),
- np.round(error_test, 2),
- cfg["pcutoff"],
- np.round(error_train_cut, 2),
- np.round(error_test_cut, 2),
+ if not pred:
+ continue
+ else:
+ pred = pred[0]
+
+ PredicteData[imagename] = {}
+ PredicteData[imagename]["index"] = imageindex
+ PredicteData[imagename]["prediction"] = pred
+ PredicteData[imagename]["groundtruth"] = [
+ groundtruthidentity,
+ groundtruthcoordinates,
+ GT,
]
- final_result.append(results)
-
- if per_keypoint_evaluation:
- df_keypoint_error = keypoint_error(
- error,
- error[mask],
- trainIndices,
- testIndices,
- )
- kpt_filename = DLCscorer + "-keypoint-results.csv"
- df_keypoint_error.to_csv(
- Path(evaluationfolder) / kpt_filename
- )
- if show_errors:
- string = (
- "Results for {} training iterations, training fraction of {}, and shuffle {}:\n"
- "Train error: {} pixels. Test error: {} pixels.\n"
- "With pcutoff of {}:\n"
- "Train error: {} pixels. Test error: {} pixels."
- )
- print(string.format(*results))
-
- print("##########################################")
- print(
- "Average Euclidean distance to GT per individual (in pixels; test-only)"
- )
- print(
- error_masked.iloc[testIndices]
- .groupby("individuals", axis=1)
- .mean()
- .mean()
- .to_string()
- )
- print(
- "Average Euclidean distance to GT per bodypart (in pixels; test-only)"
- )
- print(
- error_masked.iloc[testIndices]
- .groupby("bodyparts", axis=1)
- .mean()
- .mean()
- .to_string()
- )
-
- PredicteData["metadata"] = {
- "nms radius": test_pose_cfg["nmsradius"],
- "minimal confidence": test_pose_cfg["minconfidence"],
- "sigma": test_pose_cfg.get("sigma", 1),
- "PAFgraph": test_pose_cfg["partaffinityfield_graph"],
- "PAFinds": np.arange(
- len(test_pose_cfg["partaffinityfield_graph"])
- ),
- "all_joints": [
- [i] for i in range(len(test_pose_cfg["all_joints"]))
- ],
- "all_joints_names": [
- test_pose_cfg["all_joints_names"][i]
- for i in range(len(test_pose_cfg["all_joints"]))
- ],
- "stride": test_pose_cfg.get("stride", 8),
- }
- print(
- "Done and results stored for snapshot: ",
- Snapshots[snapindex],
- )
-
- dictionary = {
- "Scorer": DLCscorer,
- "DLC-model-config file": test_pose_cfg,
- "trainIndices": trainIndices,
- "testIndices": testIndices,
- "trainFraction": trainFraction,
- }
- metadata = {"data": dictionary}
- _ = auxfun_multianimal.SaveFullMultiAnimalData(
- PredicteData, metadata, resultsfilename
- )
-
- tf.compat.v1.reset_default_graph()
-
- n_multibpts = len(cfg["multianimalbodyparts"])
- if n_multibpts == 1:
- continue
-
- # Skip data-driven skeleton selection unless
- # the model was trained on the full graph.
- max_n_edges = n_multibpts * (n_multibpts - 1) // 2
- n_edges = len(test_pose_cfg["partaffinityfield_graph"])
- if n_edges == max_n_edges:
- print("Selecting best skeleton...")
- n_graphs = 10
- paf_inds = None
- else:
- n_graphs = 1
- paf_inds = [list(range(n_edges))]
- (
- results,
- paf_scores,
- best_assemblies,
- ) = crossvalutils.cross_validate_paf_graphs(
- config,
- str(path_test_config).replace("pose_", "inference_"),
- data_path,
- data_path.replace("_full.", "_meta."),
- n_graphs=n_graphs,
- paf_inds=paf_inds,
- oks_sigma=test_pose_cfg.get("oks_sigma", 0.1),
- margin=test_pose_cfg.get("bbox_margin", 0),
- symmetric_kpts=test_pose_cfg.get("symmetric_kpts"),
- )
- if plotting == "individual":
- assemblies, assemblies_unique, image_paths = best_assemblies
- fig, ax = visualization.create_minimal_figure()
- n_animals = len(cfg["individuals"])
- if cfg["uniquebodyparts"]:
- n_animals += 1
- colors = visualization.get_cmap(n_animals, name=cfg["colormap"])
- for k, v in tqdm(assemblies.items()):
- imname = image_paths[k]
- image_path = os.path.join(cfg["project_path"], *imname)
- frame = auxfun_videos.imread(image_path, mode="skimage")
+ coords_pred = pred["coordinates"][0]
+ probs_pred = pred["confidence"]
+ for bpt, xy_gt in df.groupby(level="bodyparts"):
+ inds_gt = np.flatnonzero(np.all(~np.isnan(xy_gt), axis=1))
+ n_joint = joints.index(bpt)
+ xy = coords_pred[n_joint]
+ if inds_gt.size and xy.size:
+ # Pick the predictions closest to ground truth,
+ # rather than the ones the model has most confident in
+ xy_gt_values = xy_gt.iloc[inds_gt].values
+ neighbors = find_closest_neighbors(xy_gt_values, xy, k=3)
+ found = neighbors != -1
+ min_dists = np.linalg.norm(
+ xy_gt_values[found] - xy[neighbors[found]],
+ axis=1,
+ )
+ inds = np.flatnonzero(all_bpts == bpt)
+ sl = imageindex, inds[inds_gt[found]]
+ dist[sl] = min_dists
+ predicted_poses[sl] = xy[neighbors[found]]
+ conf[sl] = probs_pred[n_joint][neighbors[found]].squeeze()
+ if plotting == "bodypart":
+ temp_xy = GT.unstack("bodyparts")[joints].values
+ gt = temp_xy.reshape((-1, 2, temp_xy.shape[1])).T.swapaxes(1, 2)
h, w, _ = np.shape(frame)
fig.set_size_inches(w / 100, h / 100)
ax.set_xlim(0, w)
ax.set_ylim(0, h)
ax.invert_yaxis()
-
- gt = [
- s.to_numpy().reshape((-1, 2))
- for _, s in Data.loc[imname].groupby("individuals")
- ]
- coords_pred = []
- coords_pred += [ass.xy for ass in v]
- probs_pred = []
- probs_pred += [ass.data[:, 2:3] for ass in v]
- if assemblies_unique is not None:
- unique = assemblies_unique.get(k, None)
- if unique is not None:
- coords_pred.append(unique[:, :2])
- probs_pred.append(unique[:, 2:3])
- while len(coords_pred) < len(gt):
- coords_pred.append(np.full((1, 2), np.nan))
- probs_pred.append(np.full((1, 2), np.nan))
ax = visualization.make_multianimal_labeled_image(
frame,
gt,
@@ -658,27 +407,216 @@ def evaluate_multianimal_full(
fig,
image_path,
foldername,
- k in trainIndices,
+ imageindex in trainIndices,
)
visualization.erase_artists(ax)
- df = results[1].copy()
- df.loc(axis=0)[("mAP_train", "mean")] = [
- d[0]["mAP"] for d in results[2]
- ]
- df.loc(axis=0)[("mAR_train", "mean")] = [
- d[0]["mAR"] for d in results[2]
- ]
- df.loc(axis=0)[("mAP_test", "mean")] = [
- d[1]["mAP"] for d in results[2]
- ]
- df.loc(axis=0)[("mAR_test", "mean")] = [
- d[1]["mAR"] for d in results[2]
+ sess.close() # closes the current tf session
+
+ # Save predicted poses
+ coordinates = ["x", "y", "conf"]
+ # Create the new MultiIndex by repeating the existing index and adding the new level
+ poses_multi_index = pd.MultiIndex.from_tuples(
+ [
+ (scorer, individual, bodypart, coordinate)
+ for scorer, individual, bodypart in df.index
+ for coordinate in coordinates
+ ],
+ names=df.index.names + ["coordinates"],
+ )
+
+ predicted_poses = np.concatenate((predicted_poses, np.expand_dims(conf, axis=-1)), axis=-1)
+ predicted_poses = predicted_poses.reshape(predicted_poses.shape[0], -1)
+ df_predicted_poses = pd.DataFrame(predicted_poses, columns=poses_multi_index)
+ write_poses_path = os.path.join(evaluationfolder, f"predicted_poses_{training_iterations}.h5")
+ df_predicted_poses.to_hdf(write_poses_path, key="df_with_missing")
+
+ # Compute all distance statistics
+ df_dist = pd.DataFrame(dist, columns=df.index)
+ df_conf = pd.DataFrame(conf, columns=df.index)
+ df_joint = pd.concat(
+ [df_dist, df_conf],
+ keys=["rmse", "conf"],
+ names=["metrics"],
+ axis=1,
+ )
+ df_joint = df_joint.reorder_levels(list(np.roll(df_joint.columns.names, -1)), axis=1)
+ df_joint.sort_index(
+ axis=1,
+ level=["individuals", "bodyparts"],
+ ascending=[True, True],
+ inplace=True,
+ )
+ write_path = os.path.join(evaluationfolder, f"dist_{training_iterations}.csv")
+ df_joint.to_csv(write_path)
+
+ # Calculate overall prediction error
+ error = df_joint.xs("rmse", level="metrics", axis=1)
+ mask = df_joint.xs("conf", level="metrics", axis=1) >= cfg["pcutoff"]
+ error_masked = error[mask]
+ error_train = np.nanmean(error.iloc[trainIndices])
+ error_train_cut = np.nanmean(error_masked.iloc[trainIndices])
+ error_test = np.nanmean(error.iloc[testIndices])
+ error_test_cut = np.nanmean(error_masked.iloc[testIndices])
+ results = [
+ training_iterations,
+ int(100 * trainFraction),
+ shuffle,
+ np.round(error_train, 2),
+ np.round(error_test, 2),
+ cfg["pcutoff"],
+ np.round(error_train_cut, 2),
+ np.round(error_test_cut, 2),
]
- with open(data_path.replace("_full.", "_map."), "wb") as file:
- pickle.dump((df, paf_scores), file)
+ final_result.append(results)
+
+ if per_keypoint_evaluation:
+ df_keypoint_error = keypoint_error(
+ error,
+ error[mask],
+ trainIndices,
+ testIndices,
+ )
+ kpt_filename = DLCscorer + "-keypoint-results.csv"
+ df_keypoint_error.to_csv(Path(evaluationfolder) / kpt_filename)
+
+ if show_errors:
+ string = (
+ "Results for {} training iterations, training fraction of {}, and shuffle {}:\n"
+ "Train error: {} pixels. Test error: {} pixels.\n"
+ "With pcutoff of {}:\n"
+ "Train error: {} pixels. Test error: {} pixels."
+ )
+ print(string.format(*results))
+
+ print("##########################################")
+ print("Average Euclidean distance to GT per individual (in pixels; test-only)")
+ print(error_masked.iloc[testIndices].groupby("individuals", axis=1).mean().mean().to_string())
+ print("Average Euclidean distance to GT per bodypart (in pixels; test-only)")
+ print(error_masked.iloc[testIndices].groupby("bodyparts", axis=1).mean().mean().to_string())
+
+ PredicteData["metadata"] = {
+ "nms radius": test_pose_cfg["nmsradius"],
+ "minimal confidence": test_pose_cfg["minconfidence"],
+ "sigma": test_pose_cfg.get("sigma", 1),
+ "PAFgraph": test_pose_cfg["partaffinityfield_graph"],
+ "PAFinds": np.arange(len(test_pose_cfg["partaffinityfield_graph"])),
+ "all_joints": [[i] for i in range(len(test_pose_cfg["all_joints"]))],
+ "all_joints_names": [
+ test_pose_cfg["all_joints_names"][i] for i in range(len(test_pose_cfg["all_joints"]))
+ ],
+ "stride": test_pose_cfg.get("stride", 8),
+ }
+ print(
+ "Done and results stored for snapshot: ",
+ snapshot_name,
+ )
+
+ dictionary = {
+ "Scorer": DLCscorer,
+ "DLC-model-config file": test_pose_cfg,
+ "trainIndices": trainIndices,
+ "testIndices": testIndices,
+ "trainFraction": trainFraction,
+ }
+ metadata = {"data": dictionary}
+ _ = auxfun_multianimal.SaveFullMultiAnimalData(PredicteData, metadata, resultsfilename)
+
+ tf.compat.v1.reset_default_graph()
+
+ n_multibpts = len(cfg["multianimalbodyparts"])
+ if n_multibpts == 1:
+ continue
+
+ # Skip data-driven skeleton selection unless
+ # the model was trained on the full graph.
+ max_n_edges = n_multibpts * (n_multibpts - 1) // 2
+ n_edges = len(test_pose_cfg["partaffinityfield_graph"])
+ if n_edges == max_n_edges:
+ print("Selecting best skeleton...")
+ n_graphs = 10
+ paf_inds = None
+ else:
+ n_graphs = 1
+ paf_inds = [list(range(n_edges))]
+ (
+ results,
+ paf_scores,
+ best_assemblies,
+ ) = crossvalutils.cross_validate_paf_graphs(
+ config,
+ str(path_test_config).replace("pose_", "inference_"),
+ data_path,
+ data_path.replace("_full.", "_meta."),
+ n_graphs=n_graphs,
+ paf_inds=paf_inds,
+ oks_sigma=test_pose_cfg.get("oks_sigma", 0.1),
+ margin=test_pose_cfg.get("bbox_margin", 0),
+ symmetric_kpts=test_pose_cfg.get("symmetric_kpts"),
+ )
+ if plotting == "individual":
+ assemblies, assemblies_unique, image_paths = best_assemblies
+ fig, ax = visualization.create_minimal_figure()
+ n_animals = len(cfg["individuals"])
+ if cfg["uniquebodyparts"]:
+ n_animals += 1
+ colors = visualization.get_cmap(n_animals, name=cfg["colormap"])
+ for k, v in tqdm(assemblies.items()):
+ imname = image_paths[k]
+ image_path = os.path.join(cfg["project_path"], *imname)
+ frame = auxfun_videos.imread(image_path, mode="skimage")
+
+ h, w, _ = np.shape(frame)
+ fig.set_size_inches(w / 100, h / 100)
+ ax.set_xlim(0, w)
+ ax.set_ylim(0, h)
+ ax.invert_yaxis()
+
+ gt = [s.to_numpy().reshape((-1, 2)) for _, s in Data.loc[imname].groupby("individuals")]
+ coords_pred = []
+ coords_pred += [ass.xy for ass in v]
+ probs_pred = []
+ probs_pred += [ass.data[:, 2:3] for ass in v]
+ if assemblies_unique is not None:
+ unique = assemblies_unique.get(k, None)
+ if unique is not None:
+ coords_pred.append(unique[:, :2])
+ probs_pred.append(unique[:, 2:3])
+ while len(coords_pred) < len(gt):
+ coords_pred.append(np.full((1, 2), np.nan))
+ probs_pred.append(np.full((1, 2), np.nan))
+ ax = visualization.make_multianimal_labeled_image(
+ frame,
+ gt,
+ coords_pred,
+ probs_pred,
+ colors,
+ cfg["dotsize"],
+ cfg["alphavalue"],
+ cfg["pcutoff"],
+ ax=ax,
+ )
+ visualization.save_labeled_frame(
+ fig,
+ image_path,
+ foldername,
+ k in trainIndices,
+ )
+ visualization.erase_artists(ax)
- if len(final_result) > 0: # Only append if results were calculated
- make_results_file(final_result, evaluationfolder, DLCscorer)
+ df = results[1].copy()
+ df.loc(axis=0)[("mAP_train", "mean")] = [d[0]["mAP"] for d in results[2]]
+ df.loc(axis=0)[("mAR_train", "mean")] = [d[0]["mAR"] for d in results[2]]
+ df.loc(axis=0)[("mAP_test", "mean")] = [d[1]["mAP"] for d in results[2]]
+ df.loc(axis=0)[("mAR_test", "mean")] = [d[1]["mAR"] for d in results[2]]
+ with open(data_path.replace("_full.", "_map."), "wb") as file:
+ pickle.dump((df, paf_scores), file)
+
+ if len(final_result) > 0: # Only append if results were calculated
+ make_results_file(final_result, evaluationfolder, DLCscorer)
os.chdir(str(start_path))
+
+
+# backwards compatibility
+_find_closest_neighbors = find_closest_neighbors
diff --git a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/unravel_index.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/unravel_index.py
index f72dd5e289..8983bf89ae 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/unravel_index.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/unravel_index.py
@@ -9,14 +9,14 @@
# Licensed under GNU Lesser General Public License v3.0
#
import numpy as np
+from openvino.tools.mo.front.common.partial_infer.utils import int64_array
from openvino.tools.mo.front.common.replacement import FrontReplacementOp
from openvino.tools.mo.graph.graph import Graph, Node
-from openvino.tools.mo.ops.const import Const
-from openvino.tools.mo.ops.strided_slice import StridedSlice
-from openvino.tools.mo.front.common.partial_infer.utils import int64_array
-from openvino.tools.mo.ops.elementwise import FloorMod, Div
from openvino.tools.mo.ops.Cast import Cast
+from openvino.tools.mo.ops.const import Const
+from openvino.tools.mo.ops.elementwise import Div, FloorMod
from openvino.tools.mo.ops.pack import PackOp
+from openvino.tools.mo.ops.strided_slice import StridedSlice
class UnravelIndex(FrontReplacementOp):
@@ -43,18 +43,10 @@ def replace_op(self, graph: Graph, node: Node):
rows = Div(graph, dict(name=node.name + "/rows")).create_node([inp0, dim1])
- inp0 = Cast(
- graph, dict(name=inp0.name + "/fp32", dst_type=np.float32)
- ).create_node([inp0])
- dim1 = Cast(
- graph, dict(name=dim1.name + "/fp32", dst_type=np.float32)
- ).create_node([dim1])
+ inp0 = Cast(graph, dict(name=inp0.name + "/fp32", dst_type=np.float32)).create_node([inp0])
+ dim1 = Cast(graph, dict(name=dim1.name + "/fp32", dst_type=np.float32)).create_node([dim1])
cols = FloorMod(graph, dict(name=node.name + "/cols")).create_node([inp0, dim1])
- cols = Cast(
- graph, dict(name=cols.name + "/i64", dst_type=np.int64)
- ).create_node([cols])
+ cols = Cast(graph, dict(name=cols.name + "/i64", dst_type=np.int64)).create_node([cols])
- concat = PackOp(graph, dict(name=node.name + "/merged", axis=0)).create_node(
- [rows, cols]
- )
+ concat = PackOp(graph, dict(name=node.name + "/merged", axis=0)).create_node([rows, cols])
return [concat.id]
diff --git a/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py
index 9feb5e1042..c4c32ecc7a 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py
@@ -11,12 +11,12 @@
import os
import subprocess
+import cv2
import numpy as np
from tqdm import tqdm
-import cv2
try:
- from openvino.runtime import Core, AsyncInferQueue
+ from openvino.runtime import AsyncInferQueue, Core
is_openvino_available = True
except ImportError:
@@ -69,9 +69,7 @@ def _init_model(self, inp_h, inp_w):
},
)
if "GPU" in self.device:
- self.core.set_property(
- "GPU", {"GPU_THROUGHPUT_STREAMS": "GPU_THROUGHPUT_AUTO"}
- )
+ self.core.set_property("GPU", {"GPU_THROUGHPUT_STREAMS": "GPU_THROUGHPUT_AUTO"})
compiled_model = self.core.compile_model(self.net, self.device)
num_requests = compiled_model.get_property("OPTIMAL_NUMBER_OF_INFER_REQUESTS")
@@ -85,13 +83,11 @@ def run(self, out_name, feed_dict):
self._init_model(inp.shape[1], inp.shape[2])
batch_size = inp.shape[0]
- batch_output = np.zeros(
- [batch_size] + self.net.outputs[out_name].shape, dtype=np.float32
- )
+ batch_output = np.zeros([batch_size] + self.net.outputs[out_name].shape, dtype=np.float32)
def completion_callback(request, inp_id):
output = next(iter(request.results.values()))
- batch_output[out_id] = output
+ batch_output[inp_id] = output
self.infer_queue.set_callback(completion_callback)
@@ -104,10 +100,12 @@ def completion_callback(request, inp_id):
def GetPoseF_OV(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize):
- """Prediction of pose"""
+ """Prediction of pose."""
PredictedData = np.zeros((nframes, 3 * len(dlc_cfg["all_joints_names"])))
ny, nx = int(cap.get(4)), int(cap.get(3))
if cfg["cropping"]:
+ from ...predict_videos import checkcropping
+
ny, nx = checkcropping(cfg, cap)
sess._init_model(ny, nx)
@@ -137,9 +135,7 @@ def completion_callback(request, inp_id):
if cfg["cropping"]:
frame = frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- sess.infer_queue.start_async(
- {sess.input_name: np.expand_dims(frame, axis=0)}, counter
- )
+ sess.infer_queue.start_async({sess.input_name: np.expand_dims(frame, axis=0)}, counter)
counter += 1
diff --git a/deeplabcut/pose_estimation_tensorflow/core/predict.py b/deeplabcut/pose_estimation_tensorflow/core/predict.py
index 252ef0aa31..9ff985d66e 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/predict.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/predict.py
@@ -14,15 +14,15 @@
import numpy as np
import tensorflow as tf
+
from deeplabcut.pose_estimation_tensorflow.nnets.factory import PoseNetFactory
+
from .openvino.session import OpenVINOSession
def setup_pose_prediction(cfg, allow_growth=False, collect_extra=False):
tf.compat.v1.reset_default_graph()
- inputs = tf.compat.v1.placeholder(
- tf.float32, shape=[cfg["batch_size"], None, None, 3]
- )
+ inputs = tf.compat.v1.placeholder(tf.float32, shape=[cfg["batch_size"], None, None, 3])
net_heads = PoseNetFactory.create(cfg).test(inputs)
extra_dict = {}
outputs = [net_heads["part_prob"]]
@@ -59,7 +59,7 @@ def setup_pose_prediction(cfg, allow_growth=False, collect_extra=False):
def extract_cnn_output(outputs_np, cfg):
- """extract locref + scmap from network"""
+ """Extract locref + scmap from network."""
scmap = outputs_np[0]
scmap = np.squeeze(scmap)
locref = None
@@ -78,9 +78,7 @@ def argmax_pose_predict(scmap, offmat, stride):
num_joints = scmap.shape[2]
pose = []
for joint_idx in range(num_joints):
- maxloc = np.unravel_index(
- np.argmax(scmap[:, :, joint_idx]), scmap[:, :, joint_idx].shape
- )
+ maxloc = np.unravel_index(np.argmax(scmap[:, :, joint_idx]), scmap[:, :, joint_idx].shape)
offset = np.array(offmat[maxloc][joint_idx])[::-1]
pos_f8 = np.array(maxloc).astype("float") * stride + 0.5 * stride + offset
pose.append(np.hstack((pos_f8[::-1], [scmap[maxloc][joint_idx]])))
@@ -112,7 +110,7 @@ def multi_pose_predict(scmap, locref, stride, num_outputs):
def getpose(image, cfg, sess, inputs, outputs, outall=False):
- """Extract pose"""
+ """Extract pose."""
im = np.expand_dims(image, axis=0).astype(float)
outputs_np = sess.run(outputs, feed_dict={inputs: im})
scmap, locref = extract_cnn_output(outputs_np, cfg)
@@ -162,7 +160,9 @@ def get_top_values(scmap, n_top=5):
def getposeNP(image, cfg, sess, inputs, outputs, outall=False):
"""Adapted from DeeperCut, performs numpy-based faster inference on batches.
- Introduced in https://www.biorxiv.org/content/10.1101/457242v1"""
+
+ Introduced in https://www.biorxiv.org/content/10.1101/457242v1
+ """
num_outputs = cfg.get("num_outputs", 1)
outputs_np = sess.run(outputs, feed_dict={inputs: image})
@@ -190,9 +190,7 @@ def getposeNP(image, cfg, sess, inputs, outputs, outall=False):
Ys = Y.swapaxes(0, 2).swapaxes(0, 1)
Ps = P.swapaxes(0, 2).swapaxes(0, 1)
- pose = np.empty(
- (cfg["batch_size"], num_outputs * cfg["num_joints"] * 3), dtype=X.dtype
- )
+ pose = np.empty((cfg["batch_size"], num_outputs * cfg["num_joints"] * 3), dtype=X.dtype)
pose[:, 0::3] = Xs.reshape(batchsize, -1)
pose[:, 1::3] = Ys.reshape(batchsize, -1)
pose[:, 2::3] = Ps.reshape(batchsize, -1)
@@ -206,9 +204,7 @@ def getposeNP(image, cfg, sess, inputs, outputs, outall=False):
### Code for TF inference on GPU
def setup_GPUpose_prediction(cfg, allow_growth=False):
tf.compat.v1.reset_default_graph()
- inputs = tf.compat.v1.placeholder(
- tf.float32, shape=[cfg["batch_size"], None, None, 3]
- )
+ inputs = tf.compat.v1.placeholder(tf.float32, shape=[cfg["batch_size"], None, None, 3])
net_heads = PoseNetFactory.create(cfg).inference(inputs)
outputs = [net_heads["pose"]]
diff --git a/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py
index c212c9380e..95c426cfa2 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py
@@ -12,12 +12,12 @@
import numpy as np
import tensorflow as tf
-from skimage.feature import peak_local_max
from scipy.ndimage import measurements
+from skimage.feature import peak_local_max
def extract_cnn_output(outputs_np, cfg):
- """extract locref, scmap and partaffinityfield from network"""
+ """Extract locref, scmap and partaffinityfield from network."""
scmap = outputs_np[0]
scmap = np.squeeze(scmap)
if cfg["location_refinement"]:
@@ -87,7 +87,7 @@ def compute_edge_costs(
idx = np.arange(peaks.shape[0])
idx_per_bpt = {j: idx[bpt_inds == j].tolist() for j in range(n_bodyparts)}
edges = []
- for k, (s, t) in zip(paf_inds, graph):
+ for k, (s, t) in zip(paf_inds, graph, strict=False):
inds_s = idx_per_bpt[s]
inds_t = idx_per_bpt[t]
if not (inds_s and inds_t):
@@ -259,9 +259,7 @@ def predict_batched_peaks_and_costs(
def find_local_maxima(scmap, radius, threshold):
- peak_idx = peak_local_max(
- scmap, min_distance=radius, threshold_abs=threshold, exclude_border=False
- )
+ peak_idx = peak_local_max(scmap, min_distance=radius, threshold_abs=threshold, exclude_border=False)
grid = np.zeros_like(scmap, dtype=bool)
grid[tuple(peak_idx.T)] = True
labels = measurements.label(grid)[0]
diff --git a/deeplabcut/pose_estimation_tensorflow/core/test.py b/deeplabcut/pose_estimation_tensorflow/core/test.py
index 06bed0af9e..27bb23a35c 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/test.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/test.py
@@ -21,14 +21,15 @@
import scipy.ndimage
from deeplabcut.pose_estimation_tensorflow.config import load_config
-from deeplabcut.pose_estimation_tensorflow.datasets.factory import PoseDatasetFactory
from deeplabcut.pose_estimation_tensorflow.datasets import Batch
+from deeplabcut.pose_estimation_tensorflow.datasets.factory import PoseDatasetFactory
+from deeplabcut.pose_estimation_tensorflow.util import visualize
+
from .predict import (
- setup_pose_prediction,
- extract_cnn_output,
argmax_pose_predict,
+ extract_cnn_output,
+ setup_pose_prediction,
)
-from deeplabcut.pose_estimation_tensorflow.util import visualize
def test_net(visualise, cache_scoremaps):
@@ -50,7 +51,7 @@ def test_net(visualise, cache_scoremaps):
predictions = np.zeros((num_images,), dtype=np.object)
for k in range(num_images):
- print("processing image {}/{}".format(k, num_images - 1))
+ print(f"processing image {k}/{num_images - 1}")
batch = dataset.next_batch()
@@ -77,9 +78,7 @@ def test_net(visualise, cache_scoremaps):
out_fn = os.path.join(out_dir, raw_name + "_locreg" + ".mat")
if cfg["location_refinement"]:
- scipy.io.savemat(
- out_fn, mdict={"locreg_pred": locref.astype("float32")}
- )
+ scipy.io.savemat(out_fn, mdict={"locreg_pred": locref.astype("float32")})
scipy.io.savemat("predictions.mat", mdict={"joints": predictions})
diff --git a/deeplabcut/pose_estimation_tensorflow/core/train.py b/deeplabcut/pose_estimation_tensorflow/core/train.py
index 487df6b462..0f4e94a06b 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/train.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/train.py
@@ -20,8 +20,6 @@
from pathlib import Path
import tensorflow as tf
-
-tf.compat.v1.disable_eager_execution()
import tf_slim as slim
from deeplabcut.pose_estimation_tensorflow.config import load_config
@@ -33,8 +31,10 @@
from deeplabcut.pose_estimation_tensorflow.util.logging import setup_logging
from deeplabcut.utils import auxfun_models
+tf.compat.v1.disable_eager_execution()
+
-class LearningRate(object):
+class LearningRate:
def __init__(self, cfg):
self.steps = cfg["multi_step"]
self.current_step = 0
@@ -60,10 +60,7 @@ def get_batch_spec(cfg):
def setup_preloading(batch_spec):
- placeholders = {
- name: tf.compat.v1.placeholder(tf.float32, shape=spec)
- for (name, spec) in batch_spec.items()
- }
+ placeholders = {name: tf.compat.v1.placeholder(tf.float32, shape=spec) for (name, spec) in batch_spec.items()}
names = placeholders.keys()
placeholders_list = list(placeholders.values())
@@ -101,16 +98,12 @@ def get_optimizer(loss_op, cfg):
if "efficientnet" in cfg["net_type"]:
print("Switching to cosine decay schedule with adam!")
cfg["optimizer"] = "adam"
- learning_rate = tf.compat.v1.train.cosine_decay(
- cfg["lr_init"], tstep, cfg["decay_steps"], alpha=cfg["alpha_r"]
- )
+ learning_rate = tf.compat.v1.train.cosine_decay(cfg["lr_init"], tstep, cfg["decay_steps"], alpha=cfg["alpha_r"])
else:
learning_rate = tf.compat.v1.placeholder(tf.float32, shape=[])
if cfg["optimizer"] == "sgd":
- optimizer = tf.compat.v1.train.MomentumOptimizer(
- learning_rate=learning_rate, momentum=0.9
- )
+ optimizer = tf.compat.v1.train.MomentumOptimizer(learning_rate=learning_rate, momentum=0.9)
elif cfg["optimizer"] == "adam":
optimizer = tf.compat.v1.train.AdamOptimizer(learning_rate)
else:
@@ -124,22 +117,16 @@ def get_optimizer_with_freeze(loss_op, cfg):
learning_rate = tf.compat.v1.placeholder(tf.float32, shape=[])
if cfg["optimizer"] == "sgd":
- optimizer = tf.compat.v1.train.MomentumOptimizer(
- learning_rate=learning_rate, momentum=0.9
- )
+ optimizer = tf.compat.v1.train.MomentumOptimizer(learning_rate=learning_rate, momentum=0.9)
elif cfg["optimizer"] == "adam":
optimizer = tf.compat.v1.train.AdamOptimizer(learning_rate)
else:
raise ValueError("unknown optimizer {}".format(cfg["optimizer"]))
train_unfrozen_op = slim.learning.create_train_op(loss_op, optimizer)
- variables_unfrozen = tf.compat.v1.get_collection(
- tf.compat.v1.GraphKeys.TRAINABLE_VARIABLES, "pose"
- )
+ variables_unfrozen = tf.compat.v1.get_collection(tf.compat.v1.GraphKeys.TRAINABLE_VARIABLES, "pose")
- train_frozen_op = slim.learning.create_train_op(
- loss_op, optimizer, variables_to_train=variables_unfrozen
- )
+ train_frozen_op = slim.learning.create_train_op(loss_op, optimizer, variables_to_train=variables_unfrozen)
return learning_rate, train_unfrozen_op, train_frozen_op
@@ -154,16 +141,15 @@ def train(
allow_growth=True,
):
start_path = os.getcwd()
- os.chdir(
- str(Path(config_yaml).parents[0])
- ) # switch to folder of config_yaml (for logging)
+ os.chdir(str(Path(config_yaml).parents[0])) # switch to folder of config_yaml (for logging)
setup_logging()
cfg = load_config(config_yaml)
net_type = cfg["net_type"]
if cfg["dataset_type"] in ("scalecrop", "tensorpack", "deterministic"):
print(
- "Switching batchsize to 1, as tensorpack/scalecrop/deterministic loaders do not support batches >1. Use imgaug/default loader."
+ "Switching batchsize to 1, as tensorpack/scalecrop/deterministic loaders "
+ "do not support batches >1. Use imgaug/default loader."
)
cfg["batch_size"] = 1 # in case this was edited for analysis.-
@@ -189,16 +175,11 @@ def train(
if "resnet" in net_type:
variables_to_restore = slim.get_variables_to_restore(include=["resnet_v1"])
elif "mobilenet" in net_type:
- variables_to_restore = slim.get_variables_to_restore(
- include=["MobilenetV2"]
- )
+ variables_to_restore = slim.get_variables_to_restore(include=["MobilenetV2"])
elif "efficientnet" in net_type:
- variables_to_restore = slim.get_variables_to_restore(
- include=["efficientnet"]
- )
+ variables_to_restore = slim.get_variables_to_restore(include=["efficientnet"])
variables_to_restore = {
- var.op.name.replace("efficientnet/", "")
- + "/ExponentialMovingAverage": var
+ var.op.name.replace("efficientnet/", "") + "/ExponentialMovingAverage": var
for var in variables_to_restore
}
else:
@@ -225,7 +206,7 @@ def train(
info = build_info.build_info
if not info["is_cuda_build"]: # Apple Silicon is not built with CUDA
- warnings.warn("Switching to Adam, as SGD crashes on Apple Silicon.")
+ warnings.warn("Switching to Adam, as SGD crashes on Apple Silicon.", stacklevel=2)
cfg["optimizer"] = "adam"
cfg["lr_init"] = 5e-4
cfg["multi_step"] = [[1e-4, 7500], [5e-5, 12000], [1e-5, 200000]]
@@ -284,21 +265,15 @@ def train(
current_lr = lr_gen.get_lr(it - start_iter)
lr_dict = {learning_rate: current_lr}
- [_, loss_val, summary] = sess.run(
- [train_op, total_loss, merged_summaries], feed_dict=lr_dict
- )
+ [_, loss_val, summary] = sess.run([train_op, total_loss, merged_summaries], feed_dict=lr_dict)
cum_loss += loss_val
train_writer.add_summary(summary, it)
if it % display_iters == 0 and it > start_iter:
average_loss = cum_loss / display_iters
cum_loss = 0.0
- logging.info(
- "iteration: {} loss: {} lr: {}".format(
- it, "{0:.4f}".format(average_loss), current_lr
- )
- )
- lrf.write("{}, {:.5f}, {}\n".format(it, average_loss, current_lr))
+ logging.info("iteration: {} loss: {} lr: {}".format(it, f"{average_loss:.4f}", current_lr))
+ lrf.write(f"{it}, {average_loss:.5f}, {current_lr}\n")
lrf.flush()
# Save snapshot
diff --git a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
index 79f96c539a..88f7e9bc35 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
@@ -18,16 +18,16 @@
import tf_slim as slim
from deeplabcut.pose_estimation_tensorflow.config import load_config
-from deeplabcut.pose_estimation_tensorflow.datasets import PoseDatasetFactory
-from deeplabcut.pose_estimation_tensorflow.nnets import PoseNetFactory
-from deeplabcut.pose_estimation_tensorflow.nnets.utils import get_batch_spec
-from deeplabcut.pose_estimation_tensorflow.util.logging import setup_logging
from deeplabcut.pose_estimation_tensorflow.core.train import (
+ LearningRate,
+ get_optimizer,
setup_preloading,
start_preloading,
- get_optimizer,
- LearningRate,
)
+from deeplabcut.pose_estimation_tensorflow.datasets import PoseDatasetFactory
+from deeplabcut.pose_estimation_tensorflow.nnets import PoseNetFactory
+from deeplabcut.pose_estimation_tensorflow.nnets.utils import get_batch_spec
+from deeplabcut.pose_estimation_tensorflow.util.logging import setup_logging
from deeplabcut.utils import auxfun_models
@@ -53,15 +53,16 @@ def train(
start_path = os.getcwd()
if modelfolder == "":
- os.chdir(
- str(Path(config_yaml).parents[0])
- ) # switch to folder of config_yaml (for logging)
+ os.chdir(str(Path(config_yaml).parents[0])) # switch to folder of config_yaml (for logging)
else:
os.chdir(modelfolder)
setup_logging()
- cfg = load_config(config_yaml)
+ if isinstance(config_yaml, dict):
+ cfg = config_yaml
+ else:
+ cfg = load_config(config_yaml)
cfg["pseudo_threshold"] = pseudo_threshold
cfg["video_path"] = video_path
@@ -88,7 +89,9 @@ def train(
if (
cfg["partaffinityfield_predict"] and "multi-animal" in cfg["dataset_type"]
- ): # the PAF code currently just hijacks the pairwise net stuff (for the batch feeding via Batch.pairwise_targets: 5)
+ # the PAF code currently just hijacks the pairwise net stuff (for the batch feeding via Batch.pairwise_targets:
+ # 5)
+ ):
print("Activating limb prediction...")
cfg["pairwise_predict"] = True
@@ -133,16 +136,11 @@ def train(
if "resnet" in net_type:
variables_to_restore = slim.get_variables_to_restore(include=["resnet_v1"])
elif "mobilenet" in net_type:
- variables_to_restore = slim.get_variables_to_restore(
- include=["MobilenetV2"]
- )
+ variables_to_restore = slim.get_variables_to_restore(include=["MobilenetV2"])
elif "efficientnet" in net_type:
- variables_to_restore = slim.get_variables_to_restore(
- include=["efficientnet"]
- )
+ variables_to_restore = slim.get_variables_to_restore(include=["efficientnet"])
variables_to_restore = {
- var.op.name.replace("efficientnet/", "")
- + "/ExponentialMovingAverage": var
+ var.op.name.replace("efficientnet/", "") + "/ExponentialMovingAverage": var
for var in variables_to_restore
}
else:
@@ -192,8 +190,10 @@ def train(
cumloss, partloss, locrefloss, pwloss = 0.0, 0.0, 0.0, 0.0
lr_gen = LearningRate(cfg)
- stats_path = Path(config_yaml).with_name("learning_stats.csv")
- lrf = open(str(stats_path), "w")
+ lrf = None
+ if not isinstance(config_yaml, dict):
+ stats_path = Path(config_yaml).with_name("learning_stats.csv")
+ lrf = open(str(stats_path), "w")
print("Training parameters:")
print(cfg)
@@ -207,7 +207,8 @@ def train(
current_lr = lr_gen.get_lr(it - start_iter)
lr_dict = {learning_rate: current_lr}
- # [_, loss_val, summary] = sess.run([train_op, total_loss, merged_summaries],feed_dict={learning_rate: current_lr})
+ # [_, loss_val, summary] = sess.run([train_op, total_loss, merged_summaries],feed_dict={learning_rate:
+ # current_lr})
[_, alllosses, loss_val, summary] = sess.run(
[train_op, losses, total_loss, merged_summaries], feed_dict=lr_dict
)
@@ -224,34 +225,36 @@ def train(
logging.info(
"iteration: {} loss: {} scmap loss: {} locref loss: {} limb loss: {} lr: {}".format(
it,
- "{0:.4f}".format(cumloss / display_iters),
- "{0:.4f}".format(partloss / display_iters),
- "{0:.4f}".format(locrefloss / display_iters),
- "{0:.4f}".format(pwloss / display_iters),
+ f"{cumloss / display_iters:.4f}",
+ f"{partloss / display_iters:.4f}",
+ f"{locrefloss / display_iters:.4f}",
+ f"{pwloss / display_iters:.4f}",
current_lr,
)
)
- lrf.write(
- "iteration: {}, loss: {}, scmap loss: {}, locref loss: {}, limb loss: {}, lr: {}\n".format(
- it,
- "{0:.4f}".format(cumloss / display_iters),
- "{0:.4f}".format(partloss / display_iters),
- "{0:.4f}".format(locrefloss / display_iters),
- "{0:.4f}".format(pwloss / display_iters),
- current_lr,
+ if lrf:
+ lrf.write(
+ "iteration: {}, loss: {}, scmap loss: {}, locref loss: {}, limb loss: {}, lr: {}\n".format(
+ it,
+ f"{cumloss / display_iters:.4f}",
+ f"{partloss / display_iters:.4f}",
+ f"{locrefloss / display_iters:.4f}",
+ f"{pwloss / display_iters:.4f}",
+ current_lr,
+ )
)
- )
cumloss, partloss, locrefloss, pwloss = 0.0, 0.0, 0.0, 0.0
- lrf.flush()
+ if lrf:
+ lrf.flush()
# Save snapshot
if (it % save_iters == 0 and it != start_iter) or it == max_iter:
model_name = cfg["snapshot_prefix"]
saver.save(sess, model_name, global_step=it)
-
- lrf.close()
+ if lrf:
+ lrf.close()
sess.close()
coord.request_stop()
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py b/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
index c1a58a6b25..011f512d35 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
@@ -11,13 +11,12 @@
from .factory import PoseDatasetFactory
from .pose_deterministic import DeterministicPoseDataset
-from .pose_scalecrop import ScalecropPoseDataset
from .pose_imgaug import ImgaugPoseDataset
-from .pose_tensorpack import TensorpackPoseDataset
from .pose_multianimal_imgaug import MAImgaugPoseDataset
+from .pose_scalecrop import ScalecropPoseDataset
+from .pose_tensorpack import TensorpackPoseDataset
from .utils import Batch
-
__all__ = [
"PoseDatasetFactory",
"DeterministicPoseDataset",
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py b/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py
index f2923a6347..a10dcd7462 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py
@@ -8,18 +8,18 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+
import imgaug.augmenters as iaa
import numpy as np
from imgaug import KeypointsOnImage
from scipy.spatial.distance import pdist, squareform
-from typing import List, Union, Tuple
class KeypointFliplr(iaa.Fliplr):
def __init__(
self,
- keypoints: List[str],
- symmetric_pairs: List[Union[Tuple, List]],
+ keypoints: list[str],
+ symmetric_pairs: list[tuple | list],
p: float = 1.0,
):
super().__init__(p=p)
@@ -72,7 +72,7 @@ def __init__(
"density" (weighing preferentially dense regions of keypoints),
or "hybrid" (alternating randomly between "uniform" and "density").
"""
- super(KeypointAwareCropToFixedSize, self).__init__(
+ super().__init__(
width,
height,
name="kptscrop",
@@ -82,8 +82,7 @@ def __init__(
self.max_shift = max(0.0, min(max_shift, 0.4))
if crop_sampling not in ("uniform", "keypoints", "density", "hybrid"):
raise ValueError(
- f"Invalid sampling {crop_sampling}. Must be "
- f"either 'uniform', 'keypoints', 'density', or 'hybrid."
+ f"Invalid sampling {crop_sampling}. Must be either 'uniform', 'keypoints', 'density', or 'hybrid."
)
self.crop_sampling = crop_sampling
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/factory.py b/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
index 2415f9990e..62d71efe2f 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
@@ -23,7 +23,7 @@ class PoseDatasetFactory:
def register(cls, type_):
def wrapper(dataset):
if type_ in cls._datasets:
- warnings.warn("Overwriting existing dataset {}.")
+ warnings.warn(f"Overwriting existing dataset {type_}.", stacklevel=2)
cls._datasets[type_] = dataset
return dataset
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py
index 48797fab2b..b0a5606cd7 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py
@@ -11,6 +11,7 @@
import abc
+
import numpy as np
@@ -20,20 +21,16 @@ def __init__(self, cfg):
self.cfg = cfg
@abc.abstractmethod
- def load_dataset(self):
- ...
+ def load_dataset(self): ...
@abc.abstractmethod
- def next_batch(self):
- ...
+ def next_batch(self): ...
def sample_scale(self):
if self.cfg.get("deterministic", False):
np.random.seed(42)
scale = self.cfg["global_scale"]
if "scale_jitter_lo" in self.cfg and "scale_jitter_up" in self.cfg:
- scale_jitter = np.random.uniform(
- self.cfg["scale_jitter_lo"], self.cfg["scale_jitter_up"]
- )
+ scale_jitter = np.random.uniform(self.cfg["scale_jitter_lo"], self.cfg["scale_jitter_up"])
scale *= scale_jitter
return scale
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py
index 681150e919..8f67c10cba 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py
@@ -11,32 +11,33 @@
import logging
-import numpy as np
import os
+
+import numpy as np
import scipy.io as sio
+
from deeplabcut.utils.auxfun_videos import imread, imresize
from deeplabcut.utils.conversioncode import robust_split_path
+
from .factory import PoseDatasetFactory
from .pose_base import BasePoseDataset
from .utils import (
+ Batch,
DataItem,
- mirror_joints_map,
crop_image,
- Batch,
data_to_input,
+ mirror_joints_map,
)
@PoseDatasetFactory.register("deterministic")
class DeterministicPoseDataset(BasePoseDataset):
def __init__(self, cfg):
- super(DeterministicPoseDataset, self).__init__(cfg)
+ super().__init__(cfg)
self.data = self.load_dataset()
self.num_images = len(self.data)
if self.cfg["mirror"]:
- self.symmetric_joints = mirror_joints_map(
- cfg["all_joints"], cfg["num_joints"]
- )
+ self.symmetric_joints = mirror_joints_map(cfg["all_joints"], cfg["num_joints"])
self.curr_img = 0
self.scale = cfg["global_scale"]
self.locref_scale = 1.0 / cfg["locref_stdev"]
@@ -156,10 +157,7 @@ def is_valid_size(self, image_size, scale):
if "min_input_size" in self.cfg and "max_input_size" in self.cfg:
input_width = image_size[2] * scale
input_height = image_size[1] * scale
- if (
- input_height < self.cfg["min_input_size"]
- or input_width < self.cfg["min_input_size"]
- ):
+ if input_height < self.cfg["min_input_size"] or input_width < self.cfg["min_input_size"]:
return False
if input_height * input_width > self.cfg["max_input_size"] ** 2:
return False
@@ -178,9 +176,7 @@ def make_batch(self, data_item, scale, mirror):
if self.cfg["crop"]: # adapted cropping for DLC
if np.random.rand() < self.cfg["cropratio"]:
j = np.random.randint(np.shape(joints)[1])
- joints, image = crop_image(
- joints, image, joints[0, j, 1], joints[0, j, 2], self.cfg
- )
+ joints, image = crop_image(joints, image, joints[0, j, 1], joints[0, j, 2], self.cfg)
img = imresize(image, scale) if scale != 1 else image
scaled_img_size = np.array(img.shape[0:2])
@@ -194,10 +190,7 @@ def make_batch(self, data_item, scale, mirror):
stride = self.cfg["stride"]
if mirror:
joints = [
- self.mirror_joints(
- person_joints, self.symmetric_joints, image.shape[1]
- )
- for person_joints in joints
+ self.mirror_joints(person_joints, self.symmetric_joints, image.shape[1]) for person_joints in joints
]
sm_size = np.ceil(scaled_img_size / (stride * 2)).astype(int) * 2
scaled_joints = [person_joints[:, 1:3] * scale for person_joints in joints]
@@ -207,9 +200,7 @@ def make_batch(self, data_item, scale, mirror):
part_score_weights,
locref_targets,
locref_mask,
- ) = self.compute_target_part_scoremap(
- joint_id, scaled_joints, data_item, sm_size, scale
- )
+ ) = self.compute_target_part_scoremap(joint_id, scaled_joints, data_item, sm_size, scale)
batch.update(
{
@@ -228,7 +219,7 @@ def make_batch(self, data_item, scale, mirror):
def compute_target_part_scoremap(self, joint_id, coords, data_item, size, scale):
dist_thresh = self.cfg["pos_dist_thresh"] * scale
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
num_joints = self.cfg["num_joints"]
scmap = np.zeros(np.concatenate([size, np.array([num_joints])]))
locref_size = np.concatenate([size, np.array([num_joints * 2])])
@@ -260,7 +251,7 @@ def compute_target_part_scoremap(self, joint_id, coords, data_item, size, scale)
pt_x = i * self.stride + self.half_stride
dx = j_x - pt_x
dy = j_y - pt_y
- dist = dx ** 2 + dy ** 2
+ dist = dx**2 + dy**2
# print(la.norm(diff))
if dist <= dist_thresh_sq:
scmap[j, i, j_id] = 1
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py
index 11118e02d9..0d05c24afb 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py
@@ -8,9 +8,9 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Uses imgaug dataflow for flexible augmentation
-Largely written by Mert Yüksekgönül during the summer in the Bethge lab -- Thanks!
+"""Uses imgaug dataflow for flexible augmentation Largely written by Mert Yüksekgönül
+during the summer in the Bethge lab -- Thanks!
+
https://imgaug.readthedocs.io/en/latest/
"""
@@ -21,19 +21,21 @@
import imgaug.augmenters as iaa
import numpy as np
import scipy.io as sio
+
from deeplabcut.pose_estimation_tensorflow.datasets import augmentation
from deeplabcut.utils.auxfun_videos import imread
from deeplabcut.utils.conversioncode import robust_split_path
+
from .factory import PoseDatasetFactory
from .pose_base import BasePoseDataset
-from .utils import DataItem, Batch
+from .utils import Batch, DataItem
@PoseDatasetFactory.register("default")
@PoseDatasetFactory.register("imgaug")
class ImgaugPoseDataset(BasePoseDataset):
def __init__(self, cfg):
- super(ImgaugPoseDataset, self).__init__(cfg)
+ super().__init__(cfg)
self._n_kpts = len(cfg["all_joints_names"])
self.data = self.load_dataset()
self.batch_size = cfg.get("batch_size", 1)
@@ -54,7 +56,7 @@ def __init__(self, cfg):
cfg["rotation"] = cfg.get("rotation", True)
if cfg.get("rotation", True): # i.e. pm 10 degrees
opt = cfg.get("rotation", False)
- if type(opt) == int:
+ if isinstance(opt, int):
cfg["rotation"] = cfg.get("rotation", 25)
else:
cfg["rotation"] = 25
@@ -70,11 +72,9 @@ def __init__(self, cfg):
cfg["motion_blur"] = cfg.get("motion_blur", True)
if cfg["motion_blur"]:
- cfg["motion_blur_params"] = dict(
- cfg.get("motion_blur_params", {"k": 7, "angle": (-90, 90)})
- )
+ cfg["motion_blur_params"] = dict(cfg.get("motion_blur_params", {"k": 7, "angle": (-90, 90)}))
- print("Batch Size is %d" % self.batch_size)
+ print(f"Batch Size is {self.batch_size}")
def load_dataset(self):
cfg = self.cfg
@@ -141,20 +141,22 @@ def load_dataset(self):
return data
def build_augmentation_pipeline(self, height=None, width=None, apply_prob=0.5):
- sometimes = lambda aug: iaa.Sometimes(apply_prob, aug)
+ def sometimes(aug):
+ return iaa.Sometimes(apply_prob, aug)
+
pipeline = iaa.Sequential(random_order=False)
cfg = self.cfg
if cfg["mirror"]:
opt = cfg["mirror"] # fliplr
- if type(opt) == int:
+ if isinstance(opt, int):
pipeline.add(sometimes(iaa.Fliplr(opt)))
else:
pipeline.add(sometimes(iaa.Fliplr(0.5)))
if cfg.get("fliplr", False) and cfg.get("symmetric_pairs"):
opt = cfg.get("fliplr", False)
- if type(opt) == int:
+ if isinstance(opt, int):
p = opt
else:
p = 0.5
@@ -181,31 +183,17 @@ def build_augmentation_pipeline(self, height=None, width=None, apply_prob=0.5):
pipeline.add(sometimes(iaa.MotionBlur(**opts)))
if cfg["covering"]:
- pipeline.add(
- sometimes(iaa.CoarseDropout(0.02, size_percent=0.3, per_channel=0.5))
- )
+ pipeline.add(sometimes(iaa.CoarseDropout(0.02, size_percent=0.3, per_channel=0.5)))
if cfg["elastic_transform"]:
pipeline.add(sometimes(iaa.ElasticTransformation(sigma=5)))
if cfg.get("gaussian_noise", False):
opt = cfg.get("gaussian_noise", False)
- if type(opt) == int or type(opt) == float:
- pipeline.add(
- sometimes(
- iaa.AdditiveGaussianNoise(
- loc=0, scale=(0.0, opt), per_channel=0.5
- )
- )
- )
+ if isinstance(opt, (int, float)):
+ pipeline.add(sometimes(iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, opt), per_channel=0.5)))
else:
- pipeline.add(
- sometimes(
- iaa.AdditiveGaussianNoise(
- loc=0, scale=(0.0, 0.05 * 255), per_channel=0.5
- )
- )
- )
+ pipeline.add(sometimes(iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, 0.05 * 255), per_channel=0.5)))
if cfg.get("grayscale", False):
pipeline.add(sometimes(iaa.Grayscale(alpha=(0.5, 1.0))))
@@ -235,17 +223,11 @@ def get_aug_param(cfg_value):
if cfg_cnt["histeq"]:
opt = get_aug_param(cfg_cnt["histeq"])
- pipeline.add(
- iaa.Sometimes(
- cfg_cnt["histeqratio"], iaa.AllChannelsHistogramEqualization(**opt)
- )
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["histeqratio"], iaa.AllChannelsHistogramEqualization(**opt)))
if cfg_cnt["clahe"]:
opt = get_aug_param(cfg_cnt["clahe"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["claheratio"], iaa.AllChannelsCLAHE(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["claheratio"], iaa.AllChannelsCLAHE(**opt)))
if cfg_cnt["log"]:
opt = get_aug_param(cfg_cnt["log"])
@@ -253,15 +235,11 @@ def get_aug_param(cfg_value):
if cfg_cnt["linear"]:
opt = get_aug_param(cfg_cnt["linear"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["linearratio"], iaa.LinearContrast(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["linearratio"], iaa.LinearContrast(**opt)))
if cfg_cnt["sigmoid"]:
opt = get_aug_param(cfg_cnt["sigmoid"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["sigmoidratio"], iaa.SigmoidContrast(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["sigmoidratio"], iaa.SigmoidContrast(**opt)))
if cfg_cnt["gamma"]:
opt = get_aug_param(cfg_cnt["gamma"])
@@ -331,10 +309,8 @@ def get_batch(self):
data_items.append(data_item)
im_file = data_item.im_path
- logging.debug("image %s", im_file)
- image = imread(
- os.path.join(self.cfg["project_path"], im_file), mode="skimage"
- )
+ logging.debug(f"image {im_file}")
+ image = imread(os.path.join(self.cfg["project_path"], im_file), mode="skimage")
if self.has_gt:
joints = data_item.joints
@@ -370,18 +346,14 @@ def get_scmap_update(self, joint_ids, joints, data_items, sm_size, target_size):
part_score_weight,
locref_target,
locref_mask,
- ) = self.gaussian_scmap(
- joint_ids[i], [joints[i]], data_items[i], sm_size, scale
- )
+ ) = self.gaussian_scmap(joint_ids[i], [joints[i]], data_items[i], sm_size, scale)
else:
(
part_score_target,
part_score_weight,
locref_target,
locref_mask,
- ) = self.compute_target_part_scoremap_numpy(
- joint_ids[i], [joints[i]], data_items[i], sm_size, scale
- )
+ ) = self.compute_target_part_scoremap_numpy(joint_ids[i], [joints[i]], data_items[i], sm_size, scale)
part_score_targets.append(part_score_target)
part_score_weights.append(part_score_weight)
locref_targets.append(locref_target)
@@ -395,6 +367,7 @@ def get_scmap_update(self, joint_ids, joints, data_items, sm_size, target_size):
}
def next_batch(self):
+ cfg = self.cfg
while True:
(
batch_images,
@@ -406,18 +379,18 @@ def next_batch(self):
) = self.get_batch()
pipeline = self.build_augmentation_pipeline(
- height=target_size[0], width=target_size[1], apply_prob=0.5
+ height=target_size[0],
+ width=target_size[1],
+ apply_prob=cfg.get("apply_prob", 0.5),
)
- batch_images, batch_joints = pipeline(
- images=batch_images, keypoints=batch_joints
- )
+ batch_images, batch_joints = pipeline(images=batch_images, keypoints=batch_joints)
image_shape = np.array(batch_images).shape[1:3]
batch_joints_valid = []
joint_ids_valid = []
- for joints, ids in zip(batch_joints, joint_ids):
+ for joints, ids in zip(batch_joints, joint_ids, strict=False):
# invisible joints are represented by nans
mask = ~np.isnan(joints[:, 0])
joints = joints[mask, :]
@@ -439,7 +412,8 @@ def next_batch(self):
# import imageio
# for i in range(self.batch_size):
# joints = batch_joints[i]
- # kps = KeypointsOnImage([Keypoint(x=joint[0], y=joint[1]) for joint in joints], shape=batch_images[i].shape)
+ # kps = KeypointsOnImage([Keypoint(x=joint[0], y=joint[1]) for joint in joints],
+ # shape=batch_images[i].shape)
# im = kps.draw_on_image(batch_images[i])
# imageio.imwrite('some_location/augmented/'+str(i)+'.png', im)
@@ -487,7 +461,7 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
width = size[1]
height = size[0]
dist_thresh = float((width + height) / 6)
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
std = dist_thresh / 4
# Grid of coordinates
@@ -497,13 +471,13 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
for k, j_id in enumerate(joint_id[person_id]):
joint_pt = coords[person_id][k, :]
j_x = np.asarray(joint_pt[0]).item()
- j_x_sm = round((j_x - self.half_stride) / self.stride)
+ round((j_x - self.half_stride) / self.stride)
j_y = np.asarray(joint_pt[1]).item()
- j_y_sm = round((j_y - self.half_stride) / self.stride)
- map_j = grid.copy()
+ round((j_y - self.half_stride) / self.stride)
+ grid.copy()
# Distance between the joint point and each coordinate
dist = np.linalg.norm(grid - (j_y, j_x), axis=2) ** 2
- scmap_j = np.exp(-dist / (2 * (std ** 2)))
+ scmap_j = np.exp(-dist / (2 * (std**2)))
scmap[..., j_id] = scmap_j
locref_mask[dist <= dist_thresh_sq, j_id * 2 + 0] = 1
locref_mask[dist <= dist_thresh_sq, j_id * 2 + 1] = 1
@@ -524,11 +498,9 @@ def compute_scmap_weights(self, scmap_shape, joint_id, data_item):
weights = np.ones(scmap_shape)
return weights
- def compute_target_part_scoremap_numpy(
- self, joint_id, coords, data_item, size, scale
- ):
+ def compute_target_part_scoremap_numpy(self, joint_id, coords, data_item, size, scale):
dist_thresh = float(self.cfg["pos_dist_thresh"] * scale)
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
num_joints = self.cfg["num_joints"]
scmap = np.zeros(np.concatenate([size, np.array([num_joints])]))
@@ -555,7 +527,7 @@ def compute_target_part_scoremap_numpy(
y = grid.copy()[:, :, 0]
dx = j_x - x * self.stride - self.half_stride
dy = j_y - y * self.stride - self.half_stride
- dist = dx ** 2 + dy ** 2
+ dist = dx**2 + dy**2
mask1 = dist <= dist_thresh_sq
mask2 = (x >= min_x) & (x <= max_x)
mask3 = (y >= min_y) & (y <= max_y)
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py
index edbc6894fa..2e0bc03443 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py
@@ -13,40 +13,37 @@
import logging
import os
import pickle
+from math import sqrt
+from pathlib import Path
+
import imageio
import imgaug.augmenters as iaa
import numpy as np
import pandas as pd
from imgaug.augmentables import Keypoint, KeypointsOnImage
+
from deeplabcut.generate_training_dataset import read_image_shape_fast
from deeplabcut.pose_estimation_tensorflow.datasets import augmentation
from deeplabcut.pose_estimation_tensorflow.datasets.factory import PoseDatasetFactory
from deeplabcut.pose_estimation_tensorflow.datasets.pose_base import BasePoseDataset
-from deeplabcut.pose_estimation_tensorflow.datasets.utils import DataItem, Batch
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
-from deeplabcut.utils.auxfun_videos import imread
-from deeplabcut.utils.auxfun_videos import VideoReader
+from deeplabcut.pose_estimation_tensorflow.datasets.utils import Batch, DataItem
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import VideoReader, imread
from deeplabcut.utils.conversioncode import robust_split_path
-from pathlib import Path
-from math import sqrt
@PoseDatasetFactory.register("multi-animal-imgaug")
class MAImgaugPoseDataset(BasePoseDataset):
def __init__(self, cfg):
- super(MAImgaugPoseDataset, self).__init__(cfg)
+ super().__init__(cfg)
if cfg.get("pseudo_label", ""):
self._n_kpts = len(cfg["all_joints_names"])
self._n_animals = 1
else:
- self.main_cfg = auxiliaryfunctions.read_config(
- os.path.join(self.cfg["project_path"], "config.yaml")
- )
- animals, unique, multi = auxfun_multianimal.extractindividualsandbodyparts(
- self.main_cfg
- )
+ self.main_cfg = auxiliaryfunctions.read_config(os.path.join(self.cfg["project_path"], "config.yaml"))
+ animals, unique, multi = auxfun_multianimal.extractindividualsandbodyparts(self.main_cfg)
self._n_kpts = len(multi) + len(unique)
self._n_animals = len(animals)
@@ -61,7 +58,7 @@ def __init__(self, cfg):
self.data = self.load_dataset()
self.num_images = len(self.data)
self.batch_size = cfg["batch_size"]
- print("Batch Size is %d" % self.batch_size)
+ print("Batch size is %d", self.batch_size)
self._default_size = np.array(self.cfg.get("crop_size", (400, 400)))
self.pipeline = self.build_augmentation_pipeline(
apply_prob=cfg.get("apply_prob", 0.5),
@@ -112,14 +109,7 @@ def load_dataset(self):
item.im_size = sample["size"]
if "joints" in sample.keys():
Joints = sample["joints"]
- if (
- np.size(
- np.concatenate(
- [Joints[person_id][:, 1:3] for person_id in Joints.keys()]
- )
- )
- > 0
- ):
+ if np.size(np.concatenate([Joints[person_id][:, 1:3] for person_id in Joints.keys()])) > 0:
item.joints = Joints
else:
has_gt = False # no animal has joints!
@@ -131,20 +121,18 @@ def load_dataset(self):
self.has_gt = has_gt
return data
- def _load_pseudo_data_from_h5(
- self, cfg, threshold=0.5, mask_kpts_below_thresh=False
- ):
+ def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, mask_kpts_below_thresh=False):
gt_file = cfg["pseudo_label"]
assert os.path.exists(gt_file)
path_ = Path(gt_file)
print("Using gt file:", path_.name)
- num_kpts = len(cfg["all_joints_names"])
+ len(cfg["all_joints_names"])
df = pd.read_hdf(gt_file)
video_name = path_.name.split("DLC")[0]
video_root = str(path_.parents[0] / video_name)
itemlist = []
- for image_id, imagename in enumerate(df.index):
+ for _image_id, imagename in enumerate(df.index):
item = DataItem()
data = df.loc[imagename]
# 3 for likelihood
@@ -157,9 +145,7 @@ def _load_pseudo_data_from_h5(
if self.vid:
item.im_size = self.video_image_size
else:
- item.im_size = read_image_shape_fast(
- os.path.join(video_root, frame_name)
- )
+ item.im_size = read_image_shape_fast(os.path.join(video_root, frame_name))
item.joints = {}
@@ -192,7 +178,9 @@ def _load_pseudo_data_from_h5(
def build_augmentation_pipeline(self, apply_prob=0.5):
cfg = self.cfg
- sometimes = lambda aug: iaa.Sometimes(apply_prob, aug)
+ def sometimes(aug):
+ return iaa.Sometimes(apply_prob, aug)
+
pipeline = iaa.Sequential(random_order=False)
pre_resize = cfg.get("pre_resize")
@@ -222,7 +210,7 @@ def build_augmentation_pipeline(self, apply_prob=0.5):
if cfg.get("fliplr", False) and cfg.get("symmetric_pairs"):
opt = cfg.get("fliplr", False)
- if type(opt) == int:
+ if isinstance(opt, int):
p = opt
else:
p = 0.5
@@ -237,7 +225,7 @@ def build_augmentation_pipeline(self, apply_prob=0.5):
)
if cfg.get("rotation", False):
opt = cfg.get("rotation", False)
- if type(opt) == int:
+ if isinstance(opt, int):
pipeline.add(sometimes(iaa.Affine(rotate=(-opt, opt))))
else:
pipeline.add(sometimes(iaa.Affine(rotate=(-10, 10))))
@@ -245,35 +233,21 @@ def build_augmentation_pipeline(self, apply_prob=0.5):
pipeline.add(sometimes(iaa.AllChannelsHistogramEqualization()))
if cfg.get("motion_blur", False):
opts = cfg.get("motion_blur", False)
- if type(opts) == list:
+ if isinstance(opts, list):
opts = dict(opts)
pipeline.add(sometimes(iaa.MotionBlur(**opts)))
else:
pipeline.add(sometimes(iaa.MotionBlur(k=7, angle=(-90, 90))))
if cfg.get("covering", False):
- pipeline.add(
- sometimes(iaa.CoarseDropout((0, 0.02), size_percent=(0.01, 0.05)))
- ) # , per_channel=0.5)))
+ pipeline.add(sometimes(iaa.CoarseDropout((0, 0.02), size_percent=(0.01, 0.05)))) # , per_channel=0.5)))
if cfg.get("elastic_transform", False):
pipeline.add(sometimes(iaa.ElasticTransformation(sigma=5)))
if cfg.get("gaussian_noise", False):
opt = cfg.get("gaussian_noise", False)
- if type(opt) == int or type(opt) == float:
- pipeline.add(
- sometimes(
- iaa.AdditiveGaussianNoise(
- loc=0, scale=(0.0, opt), per_channel=0.5
- )
- )
- )
+ if isinstance(opt, int) or isinstance(opt, float):
+ pipeline.add(sometimes(iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, opt), per_channel=0.5)))
else:
- pipeline.add(
- sometimes(
- iaa.AdditiveGaussianNoise(
- loc=0, scale=(0.0, 0.05 * 255), per_channel=0.5
- )
- )
- )
+ pipeline.add(sometimes(iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, 0.05 * 255), per_channel=0.5)))
if cfg.get("grayscale", False):
pipeline.add(sometimes(iaa.Grayscale(alpha=(0.5, 1.0))))
@@ -303,17 +277,11 @@ def get_aug_param(cfg_value):
if cfg_cnt["histeq"]:
opt = get_aug_param(cfg_cnt["histeq"])
- pipeline.add(
- iaa.Sometimes(
- cfg_cnt["histeqratio"], iaa.AllChannelsHistogramEqualization(**opt)
- )
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["histeqratio"], iaa.AllChannelsHistogramEqualization(**opt)))
if cfg_cnt["clahe"]:
opt = get_aug_param(cfg_cnt["clahe"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["claheratio"], iaa.AllChannelsCLAHE(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["claheratio"], iaa.AllChannelsCLAHE(**opt)))
if cfg_cnt["log"]:
opt = get_aug_param(cfg_cnt["log"])
@@ -321,15 +289,11 @@ def get_aug_param(cfg_value):
if cfg_cnt["linear"]:
opt = get_aug_param(cfg_cnt["linear"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["linearratio"], iaa.LinearContrast(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["linearratio"], iaa.LinearContrast(**opt)))
if cfg_cnt["sigmoid"]:
opt = get_aug_param(cfg_cnt["sigmoid"])
- pipeline.add(
- iaa.Sometimes(cfg_cnt["sigmoidratio"], iaa.SigmoidContrast(**opt))
- )
+ pipeline.add(iaa.Sometimes(cfg_cnt["sigmoidratio"], iaa.SigmoidContrast(**opt)))
if cfg_cnt["gamma"]:
opt = get_aug_param(cfg_cnt["gamma"])
@@ -350,7 +314,7 @@ def get_aug_param(cfg_value):
return pipeline
def get_batch_from_video(self):
- num_images = len(self.vid)
+ len(self.vid)
batch_images = []
batch_joints = []
joint_ids = []
@@ -359,9 +323,7 @@ def get_batch_from_video(self):
if trim_ends is None:
trim_ends = 0
# because of the existence of threshold, sampling population is adjusted to len(self.data)
- img_idx = np.random.choice(
- len(self.data) - trim_ends * 2, size=self.batch_size, replace=True
- )
+ img_idx = np.random.choice(len(self.data) - trim_ends * 2, size=self.batch_size, replace=True)
for i in range(self.batch_size):
index = img_idx[i]
offset = trim_ends
@@ -383,10 +345,7 @@ def get_batch_from_video(self):
for n, x, y in joints.get(j, []):
kpts[j * self._n_kpts + int(n)] = x, y
- joint_id = [
- np.array(list(range(self._n_kpts)))
- for _ in range(self._n_animals)
- ]
+ joint_id = [np.array(list(range(self._n_kpts))) for _ in range(self._n_animals)]
joint_ids.append(joint_id)
batch_joints.append(kpts)
@@ -407,19 +366,14 @@ def get_batch(self):
im_file = data_item.im_path
logging.debug("image %s", im_file)
- image = imread(
- os.path.join(self.cfg["project_path"], im_file), mode="skimage"
- )
+ image = imread(os.path.join(self.cfg["project_path"], im_file), mode="skimage")
if self.has_gt:
joints = data_item.joints
kpts = np.full((self._n_kpts * self._n_animals, 2), np.nan)
for j in range(self._n_animals):
for n, x, y in joints.get(j, []):
kpts[j * self._n_kpts + int(n)] = x, y
- joint_id = [
- np.array(list(range(self._n_kpts)))
- for _ in range(self._n_animals)
- ]
+ joint_id = [np.array(list(range(self._n_kpts))) for _ in range(self._n_animals)]
joint_ids.append(joint_id)
batch_joints.append(kpts)
@@ -449,9 +403,7 @@ def get_targetmaps_update(
part_score_weight,
locref_target,
locref_mask,
- ) = self.gaussian_scmap(
- joint_ids[i], [joints[i]], data_items[i], sm_size, scale
- )
+ ) = self.gaussian_scmap(joint_ids[i], [joints[i]], data_items[i], sm_size, scale)
else:
(
part_score_target,
@@ -460,9 +412,7 @@ def get_targetmaps_update(
locref_mask,
partaffinityfield_target,
partaffinityfield_mask,
- ) = self.compute_target_part_scoremap_numpy(
- joint_ids[i], joints[i], data_items[i], sm_size, scale
- )
+ ) = self.compute_target_part_scoremap_numpy(joint_ids[i], joints[i], data_items[i], sm_size, scale)
part_score_targets.append(part_score_target)
part_score_weights.append(part_score_weight)
@@ -486,9 +436,9 @@ def calc_target_and_scoremap_sizes(self):
if not self.is_valid_size(target_size):
target_size = self.default_size
stride = self.cfg["stride"]
- sm_size = np.ceil(target_size / (stride * self.cfg.get("smfactor", 2))).astype(
- int
- ) * self.cfg.get("smfactor", 2)
+ sm_size = np.ceil(target_size / (stride * self.cfg.get("smfactor", 2))).astype(int) * self.cfg.get(
+ "smfactor", 2
+ )
if stride == 2:
sm_size = np.ceil(target_size / 16).astype(int)
sm_size *= 8
@@ -514,16 +464,14 @@ def next_batch(self, plotting=False):
target_size, sm_size = self.calc_target_and_scoremap_sizes()
scale = np.mean(target_size / self.default_size)
augmentation.update_crop_size(self.pipeline, *target_size)
- batch_images, batch_joints = self.pipeline(
- images=batch_images, keypoints=batch_joints
- )
+ batch_images, batch_joints = self.pipeline(images=batch_images, keypoints=batch_joints)
batch_images = np.asarray(batch_images)
image_shape = batch_images.shape[1:3]
# Discard keypoints whose coordinates lie outside the cropped image
batch_joints_valid = []
joint_ids_valid = []
- for joints, ids in zip(batch_joints, joint_ids):
+ for joints, ids in zip(batch_joints, joint_ids, strict=False):
# Invisible joints are represented by nans
visible = ~np.isnan(joints[:, 0])
inside = np.logical_and.reduce(
@@ -556,9 +504,7 @@ def next_batch(self, plotting=False):
shape=batch_images[i].shape,
)
im = kps.draw_on_image(batch_images[i])
- imageio.imwrite(
- os.path.join(self.cfg["project_path"], str(i) + ".png"), im
- )
+ imageio.imwrite(os.path.join(self.cfg["project_path"], str(i) + ".png"), im)
batch = {Batch.inputs: batch_images.astype(np.float64)}
if self.has_gt:
@@ -599,17 +545,13 @@ def compute_scmap_weights(self, scmap_shape, joint_id):
cfg = self.cfg
if cfg["weigh_only_present_joints"]:
weights = np.zeros(scmap_shape)
- for k, j_id in enumerate(
- np.concatenate(joint_id)
- ): # looping over all animals
+ for _k, j_id in enumerate(np.concatenate(joint_id)): # looping over all animals
weights[:, :, j_id] = 1.0
else:
weights = np.ones(scmap_shape)
return weights
- def compute_target_part_scoremap_numpy(
- self, joint_id, coords, data_item, size, scale
- ):
+ def compute_target_part_scoremap_numpy(self, joint_id, coords, data_item, size, scale):
stride = self.cfg["stride"]
half_stride = stride // 2
dist_thresh = float(self.cfg["pos_dist_thresh"] * scale)
@@ -621,7 +563,7 @@ def compute_target_part_scoremap_numpy(
locref_size = *size, num_joints * 2
locref_map = np.zeros(locref_size)
locref_scale = 1.0 / self.cfg["locref_stdev"]
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
partaffinityfield_shape = *size, self.cfg["num_limbs"] * 2
partaffinityfield_map = np.zeros(partaffinityfield_shape)
@@ -640,14 +582,12 @@ def compute_target_part_scoremap_numpy(
# Produce score maps and location refinement fields
coords_sm = np.round((coords - half_stride) / stride).astype(int)
mins = np.round(np.maximum(coords_sm - dist_thresh - 1, 0)).astype(int)
- maxs = np.round(
- np.minimum(coords_sm + dist_thresh + 1, [width - 1, height - 1])
- ).astype(int)
+ maxs = np.round(np.minimum(coords_sm + dist_thresh + 1, [width - 1, height - 1])).astype(int)
dx = coords[:, 0] - xx * stride - half_stride
dx_ = dx * locref_scale
dy = coords[:, 1] - yy * stride - half_stride
dy_ = dy * locref_scale
- dist = dx ** 2 + dy ** 2
+ dist = dx**2 + dy**2
mask1 = dist <= dist_thresh_sq
mask2 = (xx >= mins[:, 0]) & (xx <= maxs[:, 0])
mask3 = (yy >= mins[:, 1]) & (yy <= maxs[:, 1])
@@ -663,11 +603,7 @@ def compute_target_part_scoremap_numpy(
if num_idchannel > 0:
coordinateoffset = 0
# Find indices of individuals in joint_id
- idx = [
- (i, id_)
- for i, id_ in enumerate(data_item.joints)
- if id_ < num_idchannel
- ]
+ idx = [(i, id_) for i, id_ in enumerate(data_item.joints) if id_ < num_idchannel]
for i, person_id in idx:
joint_ids = joint_id[i]
n_joints = joint_ids.size
@@ -707,24 +643,15 @@ def compute_target_part_scoremap_numpy(
d2mid = j_y * Dx - j_x * Dy # orthogonal direction
distance_along = Dx * x + Dy * y
- distance_across = (
- ((y * Dx - x * Dy) - d2mid)
- * 1.0
- / self.cfg["pafwidth"]
- * scale
- )
+ distance_across = ((y * Dx - x * Dy) - d2mid) * 1.0 / self.cfg["pafwidth"] * scale
- mask1 = (distance_along >= d1lowerboundary) & (
- distance_along <= d1upperboundary
- )
+ mask1 = (distance_along >= d1lowerboundary) & (distance_along <= d1upperboundary)
distance_across_abs = np.abs(distance_across)
mask2 = distance_across_abs <= 1
mask = mask1 & mask2
temp = 1 - distance_across_abs[mask]
if self.cfg["weigh_only_present_joints"]:
- partaffinityfield_mask[
- mask, [l * 2 + 0, l * 2 + 1]
- ] = 1.0
+ partaffinityfield_mask[mask, [l * 2 + 0, l * 2 + 1]] = 1.0
partaffinityfield_map[mask, l * 2 + 0] = Dx * temp
partaffinityfield_map[mask, l * 2 + 1] = Dy * temp
@@ -754,11 +681,9 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
locref_map = np.zeros(locref_size)
locref_scale = 1.0 / self.cfg["locref_stdev"]
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
- partaffinityfield_shape = np.concatenate(
- [size, np.array([self.cfg["num_limbs"] * 2])]
- )
+ partaffinityfield_shape = np.concatenate([size, np.array([self.cfg["num_limbs"] * 2])])
partaffinityfield_map = np.zeros(partaffinityfield_shape)
if self.cfg["weigh_only_present_joints"]:
partaffinityfield_mask = np.zeros(partaffinityfield_shape)
@@ -774,19 +699,21 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
# Grid of coordinates
grid = np.mgrid[:height, :width].transpose((1, 2, 0))
grid = grid * stride + half_stride
+ # NOTE @C-Achard 2026--03-17: x and y were never assigned, added here
+ y, x = np.rollaxis(grid, 2)
# the animal id plays no role for scoremap + locref!
# so let's just loop over all bpts.
for k, j_id in enumerate(np.concatenate(joint_id)):
joint_pt = coords[0][k, :]
j_x = joint_pt[0].item()
- j_x_sm = round((j_x - half_stride) / stride)
+ round((j_x - half_stride) / stride)
j_y = joint_pt[1].item()
- j_y_sm = round((j_y - half_stride) / stride)
+ round((j_y - half_stride) / stride)
- map_j = grid.copy()
+ grid.copy()
# Distance between the joint point and each coordinate
dist = np.linalg.norm(grid - (j_y, j_x), axis=2) ** 2
- scmap_j = np.exp(-dist / (2 * (std ** 2)))
+ scmap_j = np.exp(-dist / (2 * (std**2)))
scmap[..., j_id] = scmap_j
locref_mask[dist <= dist_thresh_sq, j_id * 2 + 0] = 1
locref_mask[dist <= dist_thresh_sq, j_id * 2 + 1] = 1
@@ -831,24 +758,14 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
d1upperboundary = max(d1)
d2mid = j_y * Dx - j_x * Dy # orthogonal direction
- distance_along = Dx * (x * stride + half_stride) + Dy * (
- y * stride + half_stride
- )
+ distance_along = Dx * (x * stride + half_stride) + Dy * (y * stride + half_stride)
distance_across = (
- (
- (
- (y * stride + half_stride) * Dx
- - (x * stride + half_stride) * Dy
- )
- - d2mid
- )
+ (((y * stride + half_stride) * Dx - (x * stride + half_stride) * Dy) - d2mid)
* 1.0
/ self.cfg["pafwidth"]
* scale
)
- mask1 = (distance_along >= d1lowerboundary) & (
- distance_along <= d1upperboundary
- )
+ mask1 = (distance_along >= d1lowerboundary) & (distance_along <= d1upperboundary)
mask2 = np.abs(distance_across) <= 1
# mask3 = ((x >= 0) & (x <= width-1))
# mask4 = ((y >= 0) & (y <= height-1))
@@ -857,12 +774,8 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
partaffinityfield_mask[mask, l * 2 + 0] = 1.0
partaffinityfield_mask[mask, l * 2 + 1] = 1.0
- partaffinityfield_map[mask, l * 2 + 0] = (
- Dx * (1 - abs(distance_across))
- )[mask]
- partaffinityfield_map[mask, l * 2 + 1] = (
- Dy * (1 - abs(distance_across))
- )[mask]
+ partaffinityfield_map[mask, l * 2 + 0] = (Dx * (1 - abs(distance_across)))[mask]
+ partaffinityfield_map[mask, l * 2 + 1] = (Dy * (1 - abs(distance_across)))[mask]
coordinateoffset += len(joint_ids) # keeping track of the blocks
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py
index b17474a67b..ff921a35ef 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py
@@ -17,7 +17,7 @@
@PoseDatasetFactory.register("scalecrop")
class ScalecropPoseDataset(DeterministicPoseDataset):
def __init__(self, cfg):
- super(ScalecropPoseDataset, self).__init__(cfg)
+ super().__init__(cfg)
self.cfg["deterministic"] = False
self.max_input_sizesquare = cfg.get("max_input_size", 1500) ** 2
self.min_input_sizesquare = cfg.get("min_input_size", 64) ** 2
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py
index 5a1e591fd2..66a279d4e8 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py
@@ -19,32 +19,32 @@
https://github.com/tensorpack/tensorpack
"""
-
import multiprocessing
import os
import cv2
import numpy as np
import scipy.io as sio
-from deeplabcut.utils.conversioncode import robust_split_path
from numpy import array as arr
from tensorpack.dataflow.base import RNGDataFlow
from tensorpack.dataflow.common import MapData
from tensorpack.dataflow.imgaug import (
Brightness,
Contrast,
+ GaussianBlur,
+ GaussianNoise,
RandomResize,
Rotation,
Saturation,
- GaussianNoise,
- GaussianBlur,
)
from tensorpack.dataflow.imgaug.crop import RandomCropRandomShape
from tensorpack.dataflow.imgaug.meta import RandomApplyAug
from tensorpack.dataflow.imgaug.transform import CropTransform
-from tensorpack.dataflow.parallel import MultiProcessRunnerZMQ, MultiProcessRunner
+from tensorpack.dataflow.parallel import MultiProcessRunner, MultiProcessRunnerZMQ
from tensorpack.utils.utils import get_rng
+from deeplabcut.utils.conversioncode import robust_split_path
+
from .factory import PoseDatasetFactory
from .pose_base import BasePoseDataset
from .utils import Batch, data_to_input
@@ -166,7 +166,7 @@ def __init__(self, cfg):
# range [-rotate_max_deg_abs; rotate_max_deg_abs] to augment training data
if cfg.get("rotation", True): # i.e. pm 25 degrees
- if type(cfg.get("rotation", False)) == int:
+ if isinstance(cfg.get("rotation", False), int):
cfg["rotation"] = cfg.get("rotation", 25)
else:
cfg["rotation"] = 25
@@ -211,9 +211,7 @@ def __init__(self, cfg):
# Randomly applies gaussian blur to an image with a random window size
# within the range [0, 2 * blur_max_window_size + 1] to augment training data
cfg["blur_max_window_size"] = cfg.get("blur_max_window_size", 10)
- cfg["blurratio"] = cfg.get(
- "blurratio", 0.2
- ) # what is the fraction of training samples with blur augmentation?
+ cfg["blurratio"] = cfg.get("blurratio", 0.2) # what is the fraction of training samples with blur augmentation?
# Whether image is RGB or RBG. If None, contrast augmentation uses the mean per-channel.
cfg["is_rgb"] = cfg.get("is_rgb", True)
@@ -226,7 +224,8 @@ def __init__(self, cfg):
# Number of datapoints to prefetch at a time during training
cfg["num_prefetch"] = cfg.get("num_prefetch", 50)
- # Auto cropping is new (was not in Nature Neuroscience 2018 paper, but introduced in Nath et al. Nat. Protocols 2019)
+ # Auto cropping is new (was not in Nature Neuroscience 2018 paper, but introduced in Nath et al. Nat. Protocols
+ # 2019)
# and boosts performance by 2X, particularly on challenging datasets, like the cheetah in Nath et al.
# Parameters for augmentation with regard to cropping:
@@ -239,7 +238,7 @@ def __init__(self, cfg):
cfg["cropratio"] = cfg.get("cropratio", 0.4)
- super(TensorpackPoseDataset, self).__init__(cfg)
+ super().__init__(cfg)
self.scaling = RandomResize(
xrange=(
self.cfg["scale_jitter_lo"] * self.cfg["global_scale"],
@@ -261,9 +260,7 @@ def __init__(self, cfg):
rgb=self.cfg["is_rgb"],
clip=self.cfg["to_clip"],
)
- self.saturation = Saturation(
- self.cfg["saturation_max_dif"], rgb=self.cfg["is_rgb"]
- )
+ self.saturation = Saturation(self.cfg["saturation_max_dif"], rgb=self.cfg["is_rgb"])
self.gaussian_noise = GaussianNoise(sigma=self.cfg["noise_sigma"])
self.gaussian_blur = GaussianBlur(max_size=self.cfg["blur_max_window_size"])
self.augmentors = [
@@ -305,9 +302,7 @@ def augment(self, data):
aug_img = img
aug_coords = coords
size = [aug_img.shape[0], aug_img.shape[1]]
- aug_coords = [
- aug_coords.reshape(int(len(aug_coords[~np.isnan(aug_coords)]) / 2), 2)
- ]
+ aug_coords = [aug_coords.reshape(int(len(aug_coords[~np.isnan(aug_coords)]) / 2), 2)]
joint_id = data.joint_id
return [joint_id, aug_img, aug_coords, data, size, scale]
@@ -322,13 +317,9 @@ def get_dataflow(self, cfg):
if num_processes <= 1:
num_processes = 2 # recommended to use more than one process for training
if os.name == "nt":
- df2 = MultiProcessRunner(
- df, num_proc=num_processes, num_prefetch=self.cfg["num_prefetch"]
- )
+ df2 = MultiProcessRunner(df, num_proc=num_processes, num_prefetch=self.cfg["num_prefetch"])
else:
- df2 = MultiProcessRunnerZMQ(
- df, num_proc=num_processes, hwm=self.cfg["num_prefetch"]
- )
+ df2 = MultiProcessRunnerZMQ(df, num_proc=num_processes, hwm=self.cfg["num_prefetch"])
return df2
def compute_target_part_scoremap(self, components):
@@ -350,7 +341,7 @@ def compute_target_part_scoremap(self, components):
locref_map = np.zeros(locref_size)
locref_scale = 1.0 / self.cfg["locref_stdev"]
- dist_thresh_sq = dist_thresh ** 2
+ dist_thresh_sq = dist_thresh**2
width = size[1]
height = size[0]
@@ -375,7 +366,7 @@ def compute_target_part_scoremap(self, components):
pt_x = i * stride + half_stride
dx = j_x - pt_x
dy = j_y - pt_y
- dist = dx ** 2 + dy ** 2
+ dist = dx**2 + dy**2
# print(la.norm(diff))
if dist <= dist_thresh_sq:
scmap[j, i, j_id] = 1
@@ -418,10 +409,7 @@ def is_valid_size(self, image_size, scale):
if "min_input_size" in self.cfg and "max_input_size" in self.cfg:
input_width = image_size[2] * scale
input_height = image_size[1] * scale
- if (
- input_height < self.cfg["min_input_size"]
- or input_width < self.cfg["min_input_size"]
- ):
+ if input_height < self.cfg["min_input_size"] or input_width < self.cfg["min_input_size"]:
return False
if input_height * input_width > self.cfg["max_input_size"] ** 2:
return False
@@ -430,13 +418,12 @@ def is_valid_size(self, image_size, scale):
def make_batch(self, components):
data_item = DataItem.from_dict(components[0])
- mirror = components[2]
+ components[2]
part_score_targets = components[3]
part_score_weights = components[4]
locref_targets = components[5]
locref_mask = components[6]
- im_file = data_item.im_path
# logging.debug('image %s', im_file)
# print('image: {}'.format(im_file))
# logging.debug('mirror %r', mirror)
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/utils.py b/deeplabcut/pose_estimation_tensorflow/datasets/utils.py
index ded75db5be..454a876809 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/utils.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/utils.py
@@ -8,9 +8,10 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
from enum import Enum
+import numpy as np
+
class Batch(Enum):
inputs = 0
@@ -42,7 +43,9 @@ def mirror_joints_map(all_joints, num_joints):
def crop_image(joints, im, Xlabel, Ylabel, cfg):
"""Randomly cropping image around xlabel,ylabel taking into account size of image.
- Introduced in DLC 2.0 (Nature Protocols paper)"""
+
+ Introduced in DLC 2.0 (Nature Protocols paper)
+ """
widthforward = int(cfg["minsize"] + np.random.randint(cfg["rightwidth"]))
widthback = int(cfg["minsize"] + np.random.randint(cfg["leftwidth"]))
hup = int(cfg["minsize"] + np.random.randint(cfg["topheight"]))
diff --git a/deeplabcut/pose_estimation_tensorflow/export.py b/deeplabcut/pose_estimation_tensorflow/export.py
index ab9d2d96b2..7924b9af59 100644
--- a/deeplabcut/pose_estimation_tensorflow/export.py
+++ b/deeplabcut/pose_estimation_tensorflow/export.py
@@ -13,14 +13,14 @@
import os
import shutil
import tarfile
+from pathlib import Path
-import numpy as np
import ruamel.yaml
import tensorflow as tf
-from deeplabcut.utils import auxiliaryfunctions
from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.core import predict
+from deeplabcut.utils import auxiliaryfunctions
def create_deploy_config_template():
@@ -57,9 +57,7 @@ def create_deploy_config_template():
def write_deploy_config(configname, cfg):
- """
-
- CURRENTLY NOT IMPLEMENTED
+ """CURRENTLY NOT IMPLEMENTED.
Write structured config file.
"""
@@ -71,17 +69,15 @@ def write_deploy_config(configname, cfg):
cfg_file[key] = cfg[key]
# Adding default value for variable skeleton and skeleton_color for backward compatibility.
- if not "skeleton" in cfg.keys():
+ if "skeleton" not in cfg.keys():
cfg_file["skeleton"] = []
cfg_file["skeleton_color"] = "black"
ruamelFile.dump(cfg_file, cf)
def load_model(cfg, shuffle=1, trainingsetindex=0, TFGPUinference=True, modelprefix=""):
- """
-
- Loads a tensorflow session with a DLC model from the associated configuration
- Return a tensorflow session with DLC model given cfg and shuffle
+ """Loads a tensorflow session with a DLC model from the associated configuration
+ Return a tensorflow session with DLC model given cfg and shuffle.
Parameters:
-----------
@@ -113,65 +109,36 @@ def load_model(cfg, shuffle=1, trainingsetindex=0, TFGPUinference=True, modelpre
train_fraction = cfg["TrainingFraction"][trainingsetindex]
model_folder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- train_fraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(train_fraction, shuffle, cfg, modelprefix=modelprefix)),
)
- path_test_config = os.path.normpath(model_folder + "/test/pose_cfg.yaml")
+ os.path.normpath(model_folder + "/test/pose_cfg.yaml")
path_train_config = os.path.normpath(model_folder + "/train/pose_cfg.yaml")
try:
dlc_cfg = load_config(str(path_train_config))
# dlc_cfg_train = load_config(str(path_train_config))
- except FileNotFoundError:
- raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, train_fraction)
- )
-
- # Check which snapshots are available and sort them by # iterations
- try:
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(model_folder, "train"))
- if "index" in fn
- ]
- )
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before trying to export.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {train_fraction} does not exist."
+ ) from e
- if len(Snapshots) == 0:
- raise FileNotFoundError(
- "The train folder for iteration %s and shuffle %s exists, but no snapshots were found.\n Please train this model before trying to export.\n Use the function 'train_network' to train the network for iteration %s shuffle %s."
- % (cfg["iteration"], shuffle, cfg["iteration"], shuffle)
- )
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(model_folder) / "train",
+ )
if cfg["snapshotindex"] == "all":
- print(
- "Snapshotindex is set to 'all' in the config.yaml file. Changing snapshot index to -1!"
- )
+ print("Snapshotindex is set to 'all' in the config.yaml file. Changing snapshot index to -1!")
snapshotindex = -1
else:
snapshotindex = cfg["snapshotindex"]
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
-
####################################
### Load and setup CNN part detector
####################################
# Check if data already was generated:
- dlc_cfg["init_weights"] = os.path.join(
- model_folder, "train", Snapshots[snapshotindex]
- )
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
+ dlc_cfg["init_weights"] = os.path.join(model_folder, "train", Snapshots[snapshotindex])
+ (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
dlc_cfg["batch_size"] = None
@@ -213,9 +180,7 @@ def tf_to_pb(sess, checkpoint, output, output_dir=None):
If None, will export to the directory of the checkpoint file.
"""
- output_dir = (
- os.path.expanduser(output_dir) if output_dir else os.path.dirname(checkpoint)
- )
+ output_dir = os.path.expanduser(output_dir) if output_dir else os.path.dirname(checkpoint)
ckpt_base = os.path.basename(checkpoint)
# save graph to pbtxt file
@@ -245,9 +210,7 @@ def export_model(
wipepaths=False,
modelprefix="",
):
- """
-
- Export DeepLabCut models for the model zoo or for live inference.
+ """Export DeepLabCut models for the model zoo or for live inference.
Saves the pose configuration, snapshot files, and frozen TF graph of the model to
directory named exported-models within the project directory
@@ -298,22 +261,18 @@ def export_model(
try:
cfg = auxiliaryfunctions.read_config(cfg_path)
except FileNotFoundError:
- FileNotFoundError("The config.yaml file at %s does not exist." % cfg_path)
+ FileNotFoundError(f"The config.yaml file at {cfg_path} does not exist.")
cfg["project_path"] = os.path.dirname(os.path.realpath(cfg_path))
cfg["iteration"] = iteration if iteration is not None else cfg["iteration"]
cfg["batch_size"] = cfg["batch_size"] if cfg["batch_size"] > 1 else 2
- cfg["snapshotindex"] = (
- snapshotindex if snapshotindex is not None else cfg["snapshotindex"]
- )
+ cfg["snapshotindex"] = snapshotindex if snapshotindex is not None else cfg["snapshotindex"]
### load model
- sess, input, output, dlc_cfg = load_model(
- cfg, shuffle, trainingsetindex, TFGPUinference, modelprefix
- )
+ sess, input, output, dlc_cfg = load_model(cfg, shuffle, trainingsetindex, TFGPUinference, modelprefix)
ckpt = dlc_cfg["init_weights"]
- model_dir = os.path.dirname(ckpt)
+ os.path.dirname(ckpt)
### set up export directory
@@ -321,20 +280,12 @@ def export_model(
if not os.path.isdir(export_dir):
os.mkdir(export_dir)
- sub_dir_name = "DLC_%s_%s_iteration-%d_shuffle-%d" % (
- cfg["Task"],
- dlc_cfg["net_type"],
- cfg["iteration"],
- shuffle,
- )
+ sub_dir_name = f"DLC_{cfg['Task']}_{dlc_cfg['net_type']}_iteration-{cfg['iteration']}_shuffle-{shuffle}"
full_export_dir = os.path.normpath(export_dir + "/" + sub_dir_name)
if os.path.isdir(full_export_dir):
if not overwrite:
- raise FileExistsError(
- "Export directory %s already exists. Terminating export..."
- % full_export_dir
- )
+ raise FileExistsError(f"Export directory {full_export_dir} already exists. Terminating export...")
else:
os.mkdir(full_export_dir)
@@ -359,11 +310,8 @@ def export_model(
### copy checkpoint to export directory
ckpt_files = glob.glob(ckpt + "*")
- ckpt_dest = [
- os.path.normpath(full_export_dir + "/" + os.path.basename(ckf))
- for ckf in ckpt_files
- ]
- for ckf, ckd in zip(ckpt_files, ckpt_dest):
+ ckpt_dest = [os.path.normpath(full_export_dir + "/" + os.path.basename(ckf)) for ckf in ckpt_files]
+ for ckf, ckd in zip(ckpt_files, ckpt_dest, strict=False):
shutil.copy(ckf, ckd)
### create pbtxt and pb files for checkpoint in export directory
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
index 6b45344c4b..52c30a86f5 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
@@ -8,15 +8,8 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-"""
-
-from deeplabcut.pose_estimation_tensorflow.lib import *
+# imports for backwards compatibility
+import deeplabcut.core.crossvalutils
+import deeplabcut.core.inferenceutils
+import deeplabcut.core.trackingutils
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py b/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py
index df52558e6a..9a918bcaac 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py
@@ -8,451 +8,6 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+"""Backwards compatibility."""
-
-import os
-import pickle
-import shutil
-from collections import defaultdict
-from copy import deepcopy
-from tqdm import tqdm
-
-import networkx as nx
-import numpy as np
-import pandas as pd
-from scipy.spatial import cKDTree
-from sklearn.metrics.cluster import contingency_matrix
-
-from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import (
- Assembler,
- evaluate_assembly,
- _parse_ground_truth_data,
-)
-from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
-
-
-def _set_up_evaluation(data):
- params = dict()
- params["joint_names"] = data["metadata"]["all_joints_names"]
- params["num_joints"] = len(params["joint_names"])
- partaffinityfield_graph = data["metadata"]["PAFgraph"]
- params["paf"] = np.arange(len(partaffinityfield_graph))
- params["paf_graph"] = params["paf_links"] = [
- partaffinityfield_graph[l] for l in params["paf"]
- ]
- params["bpts"] = params["ibpts"] = range(params["num_joints"])
- params["imnames"] = [fn for fn in list(data) if fn != "metadata"]
- return params
-
-
-def _form_original_path(path):
- root, filename = os.path.split(path)
- base, ext = os.path.splitext(filename)
- return os.path.join(root, filename.split("c")[0] + ext)
-
-
-def _unsorted_unique(array):
- _, inds = np.unique(array, return_index=True)
- return np.asarray(array)[np.sort(inds)]
-
-
-def _find_closest_neighbors(query, ref, k=3):
- n_preds = ref.shape[0]
- tree = cKDTree(ref)
- dist, inds = tree.query(query, k=k)
- idx = np.argsort(dist[:, 0])
- neighbors = np.full(len(query), -1, dtype=int)
- picked = set()
- for i, ind in enumerate(inds[idx]):
- for j in ind:
- if j not in picked:
- picked.add(j)
- neighbors[idx[i]] = j
- break
- if len(picked) == n_preds:
- break
- return neighbors
-
-
-def _calc_separability(
- vals_left, vals_right, n_bins=101, metric="jeffries", max_sensitivity=False
-):
- if metric not in ("jeffries", "auc"):
- raise ValueError("`metric` should be either 'jeffries' or 'auc'.")
-
- bins = np.linspace(0, 1, n_bins)
- hist_left = np.histogram(vals_left, bins=bins)[0]
- hist_left = hist_left / hist_left.sum()
- hist_right = np.histogram(vals_right, bins=bins)[0]
- hist_right = hist_right / hist_right.sum()
- tpr = np.cumsum(hist_right)
- if metric == "jeffries":
- sep = np.sqrt(
- 2 * (1 - np.sum(np.sqrt(hist_left * hist_right)))
- ) # Jeffries-Matusita distance
- else:
- sep = np.trapz(np.cumsum(hist_left), tpr)
- if max_sensitivity:
- threshold = bins[max(1, np.argmax(tpr > 0))]
- else:
- threshold = bins[np.argmin(1 - np.cumsum(hist_left) + tpr)]
- return sep, threshold
-
-
-def _calc_within_between_pafs(
- data,
- metadata,
- per_edge=True,
- train_set_only=True,
-):
- data = deepcopy(data)
- train_inds = set(metadata["data"]["trainIndices"])
- graph = data["metadata"]["PAFgraph"]
- within_train = defaultdict(list)
- within_test = defaultdict(list)
- between_train = defaultdict(list)
- between_test = defaultdict(list)
- for i, (key, dict_) in enumerate(data.items()):
- if key == "metadata":
- continue
-
- is_train = i in train_inds
- if train_set_only and not is_train:
- continue
-
- df = dict_["groundtruth"][2]
- try:
- df.drop("single", level="individuals", inplace=True)
- except KeyError:
- pass
- bpts = df.index.get_level_values("bodyparts").unique().to_list()
- coords_gt = (
- df.unstack(["individuals", "coords"])
- .reindex(bpts, level="bodyparts")
- .to_numpy()
- .reshape((len(bpts), -1, 2))
- )
- if np.isnan(coords_gt).all():
- continue
-
- coords = dict_["prediction"]["coordinates"][0]
- # Get animal IDs and corresponding indices in the arrays of detections
- lookup = dict()
- for i, (coord, coord_gt) in enumerate(zip(coords, coords_gt)):
- inds = np.flatnonzero(np.all(~np.isnan(coord), axis=1))
- inds_gt = np.flatnonzero(np.all(~np.isnan(coord_gt), axis=1))
- if inds.size and inds_gt.size:
- neighbors = _find_closest_neighbors(coord_gt[inds_gt], coord[inds], k=3)
- found = neighbors != -1
- lookup[i] = dict(zip(inds_gt[found], inds[neighbors[found]]))
-
- costs = dict_["prediction"]["costs"]
- for k, v in costs.items():
- paf = v["m1"]
- mask_within = np.zeros(paf.shape, dtype=bool)
- s, t = graph[k]
- if s not in lookup or t not in lookup:
- continue
- lu_s = lookup[s]
- lu_t = lookup[t]
- common_id = set(lu_s).intersection(lu_t)
- for id_ in common_id:
- mask_within[lu_s[id_], lu_t[id_]] = True
- within_vals = paf[mask_within]
- between_vals = paf[~mask_within]
- if is_train:
- within_train[k].extend(within_vals)
- between_train[k].extend(between_vals)
- else:
- within_test[k].extend(within_vals)
- between_test[k].extend(between_vals)
- if not per_edge:
- within_train = np.concatenate([*within_train.values()])
- within_test = np.concatenate([*within_test.values()])
- between_train = np.concatenate([*between_train.values()])
- between_test = np.concatenate([*between_test.values()])
- return (within_train, within_test), (between_train, between_test)
-
-
-def _benchmark_paf_graphs(
- config,
- inference_cfg,
- data,
- paf_inds,
- greedy=False,
- add_discarded=True,
- identity_only=False,
- calibration_file="",
- oks_sigma=0.1,
- margin=0,
- symmetric_kpts=None,
- split_inds=None,
-):
- metadata = data.pop("metadata")
- multi_bpts_orig = auxfun_multianimal.extractindividualsandbodyparts(config)[2]
- multi_bpts = [j for j in metadata["all_joints_names"] if j in multi_bpts_orig]
- n_multi = len(multi_bpts)
- data_ = {"metadata": metadata}
- for k, v in data.items():
- data_[k] = v["prediction"]
- ass = Assembler(
- data_,
- max_n_individuals=inference_cfg["topktoretain"],
- n_multibodyparts=n_multi,
- greedy=greedy,
- pcutoff=inference_cfg.get("pcutoff", 0.1),
- min_affinity=inference_cfg.get("pafthreshold", 0.1),
- add_discarded=add_discarded,
- identity_only=identity_only,
- )
- if calibration_file:
- ass.calibrate(calibration_file)
-
- params = ass.metadata
- image_paths = params["imnames"]
- bodyparts = params["joint_names"]
- idx = (
- data[image_paths[0]]["groundtruth"][2]
- .unstack("coords")
- .reindex(bodyparts, level="bodyparts")
- .index
- )
- mask_multi = idx.get_level_values("individuals") != "single"
- if not mask_multi.all():
- idx = idx.drop("single", level="individuals")
- individuals = idx.get_level_values("individuals").unique()
- n_individuals = len(individuals)
- map_ = dict(zip(individuals, range(n_individuals)))
-
- # Form ground truth beforehand
- ground_truth = []
- for i, imname in enumerate(image_paths):
- temp = data[imname]["groundtruth"][2].reindex(multi_bpts, level="bodyparts")
- ground_truth.append(temp.to_numpy().reshape((-1, 2)))
- ground_truth = np.stack(ground_truth)
- temp = np.ones((*ground_truth.shape[:2], 3))
- temp[..., :2] = ground_truth
- temp = temp.reshape((temp.shape[0], n_individuals, -1, 3))
- ass_true_dict = _parse_ground_truth_data(temp)
- ids = np.vectorize(map_.get)(idx.get_level_values("individuals").to_numpy())
- ground_truth = np.insert(ground_truth, 2, ids, axis=2)
-
- # Assemble animals on the full set of detections
- paf_inds = sorted(paf_inds, key=len)
- n_graphs = len(paf_inds)
- all_scores = []
- all_metrics = []
- all_assemblies = []
- for j, paf in enumerate(paf_inds, start=1):
- print(f"Graph {j}|{n_graphs}")
- ass.paf_inds = paf
- ass.assemble()
- all_assemblies.append((ass.assemblies, ass.unique, ass.metadata["imnames"]))
- if split_inds is not None:
- oks = []
- for inds in split_inds:
- ass_gt = {k: v for k, v in ass_true_dict.items() if k in inds}
- oks.append(
- evaluate_assembly(
- ass.assemblies,
- ass_gt,
- oks_sigma,
- margin=margin,
- symmetric_kpts=symmetric_kpts,
- greedy_matching=inference_cfg.get("greedy_oks", False),
- )
- )
- else:
- oks = evaluate_assembly(
- ass.assemblies,
- ass_true_dict,
- oks_sigma,
- margin=margin,
- symmetric_kpts=symmetric_kpts,
- greedy_matching=inference_cfg.get("greedy_oks", False),
- )
- all_metrics.append(oks)
- scores = np.full((len(image_paths), 2), np.nan)
- for i, imname in enumerate(tqdm(image_paths)):
- gt = ground_truth[i]
- gt = gt[~np.isnan(gt).any(axis=1)]
- if len(np.unique(gt[:, 2])) < 2: # Only consider frames with 2+ animals
- continue
-
- # Count the number of unassembled bodyparts
- n_dets = len(gt)
- animals = ass.assemblies.get(i)
- if animals is None:
- if n_dets:
- scores[i, 0] = 1
- else:
- animals = [
- np.c_[animal.data, np.ones(animal.data.shape[0]) * n]
- for n, animal in enumerate(animals)
- ]
- hyp = np.concatenate(animals)
- hyp = hyp[~np.isnan(hyp).any(axis=1)]
- scores[i, 0] = max(0, (n_dets - hyp.shape[0]) / n_dets)
- neighbors = _find_closest_neighbors(gt[:, :2], hyp[:, :2])
- valid = neighbors != -1
- id_gt = gt[valid, 2]
- id_hyp = hyp[neighbors[valid], -1]
- mat = contingency_matrix(id_gt, id_hyp)
- purity = mat.max(axis=0).sum() / mat.sum()
- scores[i, 1] = purity
- all_scores.append((scores, paf))
-
- dfs = []
- for score, inds in all_scores:
- df = pd.DataFrame(score, columns=["miss", "purity"])
- df["ngraph"] = len(inds)
- dfs.append(df)
- big_df = pd.concat(dfs)
- group = big_df.groupby("ngraph")
- return (all_scores, group.agg(["mean", "std"]).T, all_metrics, all_assemblies)
-
-
-def _get_n_best_paf_graphs(
- data,
- metadata,
- full_graph,
- n_graphs=10,
- root=None,
- which="best",
- ignore_inds=None,
- metric="auc",
-):
- if which not in ("best", "worst"):
- raise ValueError('`which` must be either "best" or "worst"')
-
- (within_train, _), (between_train, _) = _calc_within_between_pafs(
- data,
- metadata,
- train_set_only=True,
- )
- # Handle unlabeled bodyparts...
- existing_edges = set(k for k, v in within_train.items() if v)
- if ignore_inds is not None:
- existing_edges = existing_edges.difference(ignore_inds)
- existing_edges = list(existing_edges)
-
- if not any(between_train.values()):
- # Only 1 animal, let us return the full graph indices only
- return ([existing_edges], dict(zip(existing_edges, [0] * len(existing_edges))))
-
- scores, _ = zip(
- *[
- _calc_separability(between_train[n], within_train[n], metric=metric)
- for n in existing_edges
- ]
- )
-
- # Find minimal skeleton
- G = nx.Graph()
- for edge, score in zip(existing_edges, scores):
- if np.isfinite(score):
- G.add_edge(*full_graph[edge], weight=score)
- if which == "best":
- order = np.asarray(existing_edges)[np.argsort(scores)[::-1]]
- if root is None:
- root = []
- for edge in nx.maximum_spanning_edges(G, data=False):
- root.append(full_graph.index(sorted(edge)))
- else:
- order = np.asarray(existing_edges)[np.argsort(scores)]
- if root is None:
- root = []
- for edge in nx.minimum_spanning_edges(G, data=False):
- root.append(full_graph.index(sorted(edge)))
-
- n_edges = len(existing_edges) - len(root)
- lengths = np.linspace(0, n_edges, min(n_graphs, n_edges + 1), dtype=int)[1:]
- order = order[np.isin(order, root, invert=True)]
- paf_inds = [root]
- for length in lengths:
- paf_inds.append(root + list(order[:length]))
- return paf_inds, dict(zip(existing_edges, scores))
-
-
-def cross_validate_paf_graphs(
- config,
- inference_config,
- full_data_file,
- metadata_file,
- output_name="",
- pcutoff=0.1,
- oks_sigma=0.1,
- margin=0,
- greedy=False,
- add_discarded=True,
- calibrate=False,
- overwrite_config=True,
- n_graphs=10,
- paf_inds=None,
- symmetric_kpts=None,
-):
- cfg = auxiliaryfunctions.read_config(config)
- inf_cfg = auxiliaryfunctions.read_plainconfig(inference_config)
- inf_cfg_temp = inf_cfg.copy()
- inf_cfg_temp["pcutoff"] = pcutoff
-
- with open(full_data_file, "rb") as file:
- data = pickle.load(file)
- with open(metadata_file, "rb") as file:
- metadata = pickle.load(file)
-
- params = _set_up_evaluation(data)
- to_ignore = auxfun_multianimal.filter_unwanted_paf_connections(
- cfg, params["paf_graph"]
- )
- best_graphs = _get_n_best_paf_graphs(
- data,
- metadata,
- params["paf_graph"],
- ignore_inds=to_ignore,
- n_graphs=n_graphs,
- )
- paf_scores = best_graphs[1]
- if paf_inds is None:
- paf_inds = best_graphs[0]
-
- if calibrate:
- trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
- calibration_file = os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
- else:
- calibration_file = ""
-
- results = _benchmark_paf_graphs(
- cfg,
- inf_cfg_temp,
- data,
- paf_inds,
- greedy,
- add_discarded,
- oks_sigma=oks_sigma,
- margin=margin,
- symmetric_kpts=symmetric_kpts,
- calibration_file=calibration_file,
- split_inds=[
- metadata["data"]["trainIndices"],
- metadata["data"]["testIndices"],
- ],
- )
- # Select optimal PAF graph
- df = results[1]
- size_opt = np.argmax((1 - df.loc["miss", "mean"]) * df.loc["purity", "mean"])
- pose_config = inference_config.replace("inference_cfg", "pose_cfg")
- if not overwrite_config:
- shutil.copy(pose_config, pose_config.replace(".yaml", "_old.yaml"))
- inds = list(paf_inds[size_opt])
- auxiliaryfunctions.edit_config(
- pose_config, {"paf_best": [int(ind) for ind in inds]}
- )
- if output_name:
- with open(output_name, "wb") as file:
- pickle.dump([results], file)
- return results[:3], paf_scores, results[3][size_opt]
+from deeplabcut.core.crossvalutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
index 8a7dc4a690..f7603bb6f3 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
@@ -8,1082 +8,6 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+"""Backwards compatibility."""
-import heapq
-import itertools
-import multiprocessing
-import networkx as nx
-import numpy as np
-import operator
-import pandas as pd
-import pickle
-import warnings
-from collections import defaultdict
-from dataclasses import dataclass
-from math import sqrt, erf
-from scipy.optimize import linear_sum_assignment
-from scipy.spatial import cKDTree
-from scipy.spatial.distance import pdist, cdist
-from scipy.special import softmax
-from scipy.stats import gaussian_kde, chi2
-from tqdm import tqdm
-from typing import Tuple
-
-
-def _conv_square_to_condensed_indices(ind_row, ind_col, n):
- if ind_row == ind_col:
- raise ValueError("There are no diagonal elements in condensed matrices.")
-
- if ind_row < ind_col:
- ind_row, ind_col = ind_col, ind_row
- return n * ind_col - ind_col * (ind_col + 1) // 2 + ind_row - 1 - ind_col
-
-
-Position = Tuple[float, float]
-
-
-@dataclass(frozen=True)
-class Joint:
- pos: Position
- confidence: float = 1.0
- label: int = None
- idx: int = None
- group: int = -1
-
-
-class Link:
- def __init__(self, j1, j2, affinity=1):
- self.j1 = j1
- self.j2 = j2
- self.affinity = affinity
- self._length = sqrt((j1.pos[0] - j2.pos[0]) ** 2 + (j1.pos[1] - j2.pos[1]) ** 2)
-
- def __repr__(self):
- return (
- f"Link {self.idx}, affinity={self.affinity:.2f}, length={self.length:.2f}"
- )
-
- @property
- def confidence(self):
- return self.j1.confidence * self.j2.confidence
-
- @property
- def idx(self):
- return self.j1.idx, self.j2.idx
-
- @property
- def length(self):
- return self._length
-
- @length.setter
- def length(self, length):
- self._length = length
-
- def to_vector(self):
- return [*self.j1.pos, *self.j2.pos]
-
-
-class Assembly:
- def __init__(self, size):
- self.data = np.full((size, 4), np.nan)
- self.confidence = 0 # 0 by default, overwritten otherwise with `add_joint`
- self._affinity = 0
- self._links = []
- self._visible = set()
- self._idx = set()
- self._dict = dict()
-
- def __len__(self):
- return len(self._visible)
-
- def __contains__(self, assembly):
- return bool(self._visible.intersection(assembly._visible))
-
- def __add__(self, other):
- if other in self:
- raise ValueError("Assemblies contain shared joints.")
-
- assembly = Assembly(self.data.shape[0])
- for link in self._links + other._links:
- assembly.add_link(link)
- return assembly
-
- @classmethod
- def from_array(cls, array):
- n_bpts, n_cols = array.shape
- ass = cls(size=n_bpts)
- ass.data[:, :n_cols] = array
- visible = np.flatnonzero(~np.isnan(array).any(axis=1))
- if n_cols < 3: # Only xy coordinates are being set
- ass.data[visible, 2] = 1 # Set detection confidence to 1
- ass._visible.update(visible)
- return ass
-
- @property
- def xy(self):
- return self.data[:, :2]
-
- @property
- def extent(self):
- bbox = np.empty(4)
- bbox[:2] = np.nanmin(self.xy, axis=0)
- bbox[2:] = np.nanmax(self.xy, axis=0)
- return bbox
-
- @property
- def area(self):
- x1, y1, x2, y2 = self.extent
- return (x2 - x1) * (y2 - y1)
-
- @property
- def confidence(self):
- return np.nanmean(self.data[:, 2])
-
- @confidence.setter
- def confidence(self, confidence):
- self.data[:, 2] = confidence
-
- @property
- def soft_identity(self):
- data = self.data[~np.isnan(self.data).any(axis=1)]
- unq, idx, cnt = np.unique(data[:, 3], return_inverse=True, return_counts=True)
- avg = np.bincount(idx, weights=data[:, 2]) / cnt
- soft = softmax(avg)
- return dict(zip(unq.astype(int), soft))
-
- @property
- def affinity(self):
- n_links = self.n_links
- if not n_links:
- return 0
- return self._affinity / n_links
-
- @property
- def n_links(self):
- return len(self._links)
-
- def intersection_with(self, other):
- x11, y11, x21, y21 = self.extent
- x12, y12, x22, y22 = other.extent
- x1 = max(x11, x12)
- y1 = max(y11, y12)
- x2 = min(x21, x22)
- y2 = min(y21, y22)
- if x2 < x1 or y2 < y1:
- return 0
- ll = np.array([x1, y1])
- ur = np.array([x2, y2])
- xy1 = self.xy[~np.isnan(self.xy).any(axis=1)]
- xy2 = other.xy[~np.isnan(other.xy).any(axis=1)]
- in1 = np.all((xy1 >= ll) & (xy1 <= ur), axis=1).sum()
- in2 = np.all((xy2 >= ll) & (xy2 <= ur), axis=1).sum()
- return min(in1 / len(self), in2 / len(other))
-
- def add_joint(self, joint):
- if joint.label in self._visible or joint.label is None:
- return False
- self.data[joint.label] = *joint.pos, joint.confidence, joint.group
- self._visible.add(joint.label)
- self._idx.add(joint.idx)
- return True
-
- def remove_joint(self, joint):
- if joint.label not in self._visible:
- return False
- self.data[joint.label] = np.nan
- self._visible.remove(joint.label)
- self._idx.remove(joint.idx)
- return True
-
- def add_link(self, link, store_dict=False):
- if store_dict:
- # Selective copy; deepcopy is >5x slower
- self._dict = {
- "data": self.data.copy(),
- "_affinity": self._affinity,
- "_links": self._links.copy(),
- "_visible": self._visible.copy(),
- "_idx": self._idx.copy(),
- }
- i1, i2 = link.idx
- if i1 in self._idx and i2 in self._idx:
- self._affinity += link.affinity
- self._links.append(link)
- return False
- if link.j1.label in self._visible and link.j2.label in self._visible:
- return False
- self.add_joint(link.j1)
- self.add_joint(link.j2)
- self._affinity += link.affinity
- self._links.append(link)
- return True
-
- def calc_pairwise_distances(self):
- return pdist(self.xy, metric="sqeuclidean")
-
-
-class Assembler:
- def __init__(
- self,
- data,
- *,
- max_n_individuals,
- n_multibodyparts,
- graph=None,
- paf_inds=None,
- greedy=False,
- pcutoff=0.1,
- min_affinity=0.05,
- min_n_links=2,
- max_overlap=0.8,
- identity_only=False,
- nan_policy="little",
- force_fusion=False,
- add_discarded=False,
- window_size=0,
- method="m1",
- ):
- self.data = data
- self.metadata = self.parse_metadata(self.data)
- self.max_n_individuals = max_n_individuals
- self.n_multibodyparts = n_multibodyparts
- self.n_uniquebodyparts = self.n_keypoints - n_multibodyparts
- self.greedy = greedy
- self.pcutoff = pcutoff
- self.min_affinity = min_affinity
- self.min_n_links = min_n_links
- self.max_overlap = max_overlap
- self._has_identity = "identity" in self[0]
- if identity_only and not self._has_identity:
- warnings.warn(
- "The network was not trained with identity; setting `identity_only` to False."
- )
- self.identity_only = identity_only & self._has_identity
- self.nan_policy = nan_policy
- self.force_fusion = force_fusion
- self.add_discarded = add_discarded
- self.window_size = window_size
- self.method = method
- self.graph = graph or self.metadata["paf_graph"]
- self.paf_inds = paf_inds or self.metadata["paf"]
- self._gamma = 0.01
- self._trees = dict()
- self.safe_edge = False
- self._kde = None
- self.assemblies = dict()
- self.unique = dict()
-
- def __getitem__(self, item):
- return self.data[self.metadata["imnames"][item]]
-
- @property
- def n_keypoints(self):
- return self.metadata["num_joints"]
-
- def calibrate(self, train_data_file):
- df = pd.read_hdf(train_data_file)
- try:
- df.drop("single", level="individuals", axis=1, inplace=True)
- except KeyError:
- pass
- n_bpts = len(df.columns.get_level_values("bodyparts").unique())
- if n_bpts == 1:
- warnings.warn("There is only one keypoint; skipping calibration...")
- return
-
- xy = df.to_numpy().reshape((-1, n_bpts, 2))
- frac_valid = np.mean(~np.isnan(xy), axis=(1, 2))
- # Only keeps skeletons that are more than 90% complete
- xy = xy[frac_valid >= 0.9]
- if not xy.size:
- warnings.warn("No complete poses were found. Skipping calibration...")
- return
-
- # TODO Normalize dists by longest length?
- # TODO Smarter imputation technique (Bayesian? Grassmann averages?)
- dists = np.vstack([pdist(data, "sqeuclidean") for data in xy])
- mu = np.nanmean(dists, axis=0)
- missing = np.isnan(dists)
- dists = np.where(missing, mu, dists)
- try:
- kde = gaussian_kde(dists.T)
- kde.mean = mu
- self._kde = kde
- self.safe_edge = True
- except np.linalg.LinAlgError:
- # Covariance matrix estimation fails due to numerical singularities
- warnings.warn(
- "The assembler could not be robustly calibrated. Continuing without it..."
- )
-
- def calc_assembly_mahalanobis_dist(
- self, assembly, return_proba=False, nan_policy="little"
- ):
- if self._kde is None:
- raise ValueError("Assembler should be calibrated first with training data.")
-
- dists = assembly.calc_pairwise_distances() - self._kde.mean
- mask = np.isnan(dists)
- # Distance is undefined if the assembly is empty
- if not len(assembly) or mask.all():
- if return_proba:
- return np.inf, 0
- return np.inf
-
- if nan_policy == "little":
- inds = np.flatnonzero(~mask)
- dists = dists[inds]
- inv_cov = self._kde.inv_cov[np.ix_(inds, inds)]
- # Correct distance to account for missing observations
- factor = self._kde.d / len(inds)
- else:
- # Alternatively, reduce contribution of missing values to the Mahalanobis
- # distance to zero by substituting the corresponding means.
- dists[mask] = 0
- mask.fill(False)
- inv_cov = self._kde.inv_cov
- factor = 1
- dot = dists @ inv_cov
- mahal = factor * sqrt(np.sum((dot * dists), axis=-1))
- if return_proba:
- proba = 1 - chi2.cdf(mahal, np.sum(~mask))
- return mahal, proba
- return mahal
-
- def calc_link_probability(self, link):
- if self._kde is None:
- raise ValueError("Assembler should be calibrated first with training data.")
-
- i = link.j1.label
- j = link.j2.label
- ind = _conv_square_to_condensed_indices(i, j, self.n_multibodyparts)
- mu = self._kde.mean[ind]
- sigma = self._kde.covariance[ind, ind]
- z = (link.length ** 2 - mu) / sigma
- return 2 * (1 - 0.5 * (1 + erf(abs(z) / sqrt(2))))
-
- @staticmethod
- def _flatten_detections(data_dict):
- ind = 0
- coordinates = data_dict["coordinates"][0]
- confidence = data_dict["confidence"]
- ids = data_dict.get("identity", None)
- if ids is None:
- ids = [np.ones(len(arr), dtype=int) * -1 for arr in confidence]
- else:
- ids = [arr.argmax(axis=1) for arr in ids]
- for i, (coords, conf, id_) in enumerate(zip(coordinates, confidence, ids)):
- if not np.any(coords):
- continue
- for xy, p, g in zip(coords, conf, id_):
- joint = Joint(tuple(xy), p.item(), i, ind, g)
- ind += 1
- yield joint
-
- def extract_best_links(self, joints_dict, costs, trees=None):
- links = []
- for ind in self.paf_inds:
- s, t = self.graph[ind]
- dets_s = joints_dict.get(s, None)
- dets_t = joints_dict.get(t, None)
- if dets_s is None or dets_t is None:
- continue
- if ind not in costs:
- continue
- lengths = costs[ind]["distance"]
- if np.isinf(lengths).all():
- continue
- aff = costs[ind][self.method].copy()
- aff[np.isnan(aff)] = 0
-
- if trees:
- vecs = np.vstack(
- [[*det_s.pos, *det_t.pos] for det_s in dets_s for det_t in dets_t]
- )
- dists = []
- for n, tree in enumerate(trees, start=1):
- d, _ = tree.query(vecs)
- dists.append(np.exp(-self._gamma * n * d))
- w = np.mean(dists, axis=0)
- aff *= w.reshape(aff.shape)
-
- if self.greedy:
- conf = np.asarray(
- [
- [det_s.confidence * det_t.confidence for det_t in dets_t]
- for det_s in dets_s
- ]
- )
- rows, cols = np.where(
- (conf >= self.pcutoff * self.pcutoff) & (aff >= self.min_affinity)
- )
- candidates = sorted(
- zip(rows, cols, aff[rows, cols], lengths[rows, cols]),
- key=lambda x: x[2],
- reverse=True,
- )
- i_seen = set()
- j_seen = set()
- for i, j, w, l in candidates:
- if i not in i_seen and j not in j_seen:
- i_seen.add(i)
- j_seen.add(j)
- links.append(Link(dets_s[i], dets_t[j], w))
- if len(i_seen) == self.max_n_individuals:
- break
- else: # Optimal keypoint pairing
- inds_s = sorted(
- range(len(dets_s)), key=lambda x: dets_s[x].confidence, reverse=True
- )[: self.max_n_individuals]
- inds_t = sorted(
- range(len(dets_t)), key=lambda x: dets_t[x].confidence, reverse=True
- )[: self.max_n_individuals]
- keep_s = [
- ind for ind in inds_s if dets_s[ind].confidence >= self.pcutoff
- ]
- keep_t = [
- ind for ind in inds_t if dets_t[ind].confidence >= self.pcutoff
- ]
- aff = aff[np.ix_(keep_s, keep_t)]
- rows, cols = linear_sum_assignment(aff, maximize=True)
- for row, col in zip(rows, cols):
- w = aff[row, col]
- if w >= self.min_affinity:
- links.append(Link(dets_s[keep_s[row]], dets_t[keep_t[col]], w))
- return links
-
- def _fill_assembly(self, assembly, lookup, assembled, safe_edge, nan_policy):
- stack = []
- visited = set()
- tabu = []
- counter = itertools.count()
-
- def push_to_stack(i):
- for j, link in lookup[i].items():
- if j in assembly._idx:
- continue
- if link.idx in visited:
- continue
- heapq.heappush(stack, (-link.affinity, next(counter), link))
- visited.add(link.idx)
-
- for idx in assembly._idx:
- push_to_stack(idx)
-
- while stack and len(assembly) < self.n_multibodyparts:
- _, _, best = heapq.heappop(stack)
- i, j = best.idx
- if i in assembly._idx:
- new_ind = j
- elif j in assembly._idx:
- new_ind = i
- else:
- continue
- if new_ind in assembled:
- continue
- if safe_edge:
- d_old = self.calc_assembly_mahalanobis_dist(
- assembly, nan_policy=nan_policy
- )
- success = assembly.add_link(best, store_dict=True)
- if not success:
- assembly._dict = dict()
- continue
- d = self.calc_assembly_mahalanobis_dist(assembly, nan_policy=nan_policy)
- if d < d_old:
- push_to_stack(new_ind)
- try:
- _, _, link = heapq.heappop(tabu)
- heapq.heappush(stack, (-link.affinity, next(counter), link))
- except IndexError:
- pass
- else:
- heapq.heappush(tabu, (d - d_old, next(counter), best))
- assembly.__dict__.update(assembly._dict)
- assembly._dict = dict()
- else:
- assembly.add_link(best)
- push_to_stack(new_ind)
-
- def build_assemblies(self, links):
- lookup = defaultdict(dict)
- for link in links:
- i, j = link.idx
- lookup[i][j] = link
- lookup[j][i] = link
-
- assemblies = []
- assembled = set()
-
- # Fill the subsets with unambiguous, complete individuals
- G = nx.Graph([link.idx for link in links])
- for chain in nx.connected_components(G):
- if len(chain) == self.n_multibodyparts:
- edges = [tuple(sorted(edge)) for edge in G.edges(chain)]
- assembly = Assembly(self.n_multibodyparts)
- for link in links:
- i, j = link.idx
- if (i, j) in edges:
- success = assembly.add_link(link)
- if success:
- lookup[i].pop(j)
- lookup[j].pop(i)
- assembled.update(assembly._idx)
- assemblies.append(assembly)
-
- if len(assemblies) == self.max_n_individuals:
- return assemblies, assembled
-
- for link in sorted(links, key=lambda x: x.affinity, reverse=True):
- if any(i in assembled for i in link.idx):
- continue
- assembly = Assembly(self.n_multibodyparts)
- assembly.add_link(link)
- self._fill_assembly(
- assembly, lookup, assembled, self.safe_edge, self.nan_policy
- )
- for link in assembly._links:
- i, j = link.idx
- lookup[i].pop(j)
- lookup[j].pop(i)
- assembled.update(assembly._idx)
- assemblies.append(assembly)
-
- # Fuse superfluous assemblies
- n_extra = len(assemblies) - self.max_n_individuals
- if n_extra > 0:
- if self.safe_edge:
- ds_old = [
- self.calc_assembly_mahalanobis_dist(assembly)
- for assembly in assemblies
- ]
- while len(assemblies) > self.max_n_individuals:
- ds = []
- for i, j in itertools.combinations(range(len(assemblies)), 2):
- if assemblies[j] not in assemblies[i]:
- temp = assemblies[i] + assemblies[j]
- d = self.calc_assembly_mahalanobis_dist(temp)
- delta = d - max(ds_old[i], ds_old[j])
- ds.append((i, j, delta, d, temp))
- if not ds:
- break
- min_ = sorted(ds, key=lambda x: x[2])
- i, j, delta, d, new = min_[0]
- if delta < 0 or len(min_) == 1:
- assemblies[i] = new
- assemblies.pop(j)
- ds_old[i] = d
- ds_old.pop(j)
- else:
- break
- elif self.force_fusion:
- assemblies = sorted(assemblies, key=len)
- for nrow in range(n_extra):
- assembly = assemblies[nrow]
- candidates = [a for a in assemblies[nrow:] if assembly not in a]
- if not candidates:
- continue
- if len(candidates) == 1:
- candidate = candidates[0]
- else:
- dists = []
- for cand in candidates:
- d = cdist(assembly.xy, cand.xy)
- dists.append(np.nanmin(d))
- candidate = candidates[np.argmin(dists)]
- ind = assemblies.index(candidate)
- assemblies[ind] += assembly
- else:
- store = dict()
- for assembly in assemblies:
- if len(assembly) != self.n_multibodyparts:
- for i in assembly._idx:
- store[i] = assembly
- used = [link for assembly in assemblies for link in assembly._links]
- unconnected = [link for link in links if link not in used]
- for link in unconnected:
- i, j = link.idx
- try:
- if store[j] not in store[i]:
- temp = store[i] + store[j]
- store[i].__dict__.update(temp.__dict__)
- assemblies.remove(store[j])
- for idx in store[j]._idx:
- store[idx] = store[i]
- except KeyError:
- pass
-
- # Second pass without edge safety
- for assembly in assemblies:
- if len(assembly) != self.n_multibodyparts:
- self._fill_assembly(assembly, lookup, assembled, False, "")
- assembled.update(assembly._idx)
-
- return assemblies, assembled
-
- def _assemble(self, data_dict, ind_frame):
- joints = list(self._flatten_detections(data_dict))
- if not joints:
- return None, None
-
- bag = defaultdict(list)
- for joint in joints:
- bag[joint.label].append(joint)
-
- assembled = set()
-
- if self.n_uniquebodyparts:
- unique = np.full((self.n_uniquebodyparts, 3), np.nan)
- for n, ind in enumerate(range(self.n_multibodyparts, self.n_keypoints)):
- dets = bag[ind]
- if not dets:
- continue
- if len(dets) > 1:
- det = max(dets, key=lambda x: x.confidence)
- else:
- det = dets[0]
- # Mark the unique body parts as assembled anyway so
- # they are not used later on to fill assemblies.
- assembled.update(d.idx for d in dets)
- if det.confidence <= self.pcutoff and not self.add_discarded:
- continue
- unique[n] = *det.pos, det.confidence
- if np.isnan(unique).all():
- unique = None
- else:
- unique = None
-
- if not any(i in bag for i in range(self.n_multibodyparts)):
- return None, unique
-
- if self.n_multibodyparts == 1:
- assemblies = []
- for joint in bag[0]:
- if joint.confidence >= self.pcutoff:
- ass = Assembly(self.n_multibodyparts)
- ass.add_joint(joint)
- assemblies.append(ass)
- return assemblies, unique
-
- if self.max_n_individuals == 1:
- get_attr = operator.attrgetter("confidence")
- ass = Assembly(self.n_multibodyparts)
- for ind in range(self.n_multibodyparts):
- joints = bag[ind]
- if not joints:
- continue
- ass.add_joint(max(joints, key=get_attr))
- return [ass], unique
-
- if self.identity_only:
- assemblies = []
- get_attr = operator.attrgetter("group")
- temp = sorted(
- (joint for joint in joints if np.isfinite(joint.confidence)),
- key=get_attr,
- )
- groups = itertools.groupby(temp, get_attr)
- for _, group in groups:
- ass = Assembly(self.n_multibodyparts)
- for joint in sorted(group, key=lambda x: x.confidence, reverse=True):
- if (
- joint.confidence >= self.pcutoff
- and joint.label < self.n_multibodyparts
- ):
- ass.add_joint(joint)
- if len(ass):
- assemblies.append(ass)
- assembled.update(ass._idx)
- else:
- trees = []
- for j in range(1, self.window_size + 1):
- tree = self._trees.get(ind_frame - j, None)
- if tree is not None:
- trees.append(tree)
-
- links = self.extract_best_links(bag, data_dict["costs"], trees)
- if self._kde:
- for link in links[::-1]:
- p = max(self.calc_link_probability(link), 0.001)
- link.affinity *= p
- if link.affinity < self.min_affinity:
- links.remove(link)
-
- if self.window_size >= 1 and links:
- # Store selected edges for subsequent frames
- vecs = np.vstack([link.to_vector() for link in links])
- self._trees[ind_frame] = cKDTree(vecs)
-
- assemblies, assembled_ = self.build_assemblies(links)
- assembled.update(assembled_)
-
- # Remove invalid assemblies
- discarded = set(
- joint
- for joint in joints
- if joint.idx not in assembled and np.isfinite(joint.confidence)
- )
- for assembly in assemblies[::-1]:
- if 0 < assembly.n_links < self.min_n_links or not len(assembly):
- for link in assembly._links:
- discarded.update((link.j1, link.j2))
- assemblies.remove(assembly)
- if 0 < self.max_overlap < 1: # Non-maximum pose suppression
- if self._kde is not None:
- scores = [
- -self.calc_assembly_mahalanobis_dist(ass) for ass in assemblies
- ]
- else:
- scores = [ass._affinity for ass in assemblies]
- lst = list(zip(scores, assemblies))
- assemblies = []
- while lst:
- temp = max(lst, key=lambda x: x[0])
- lst.remove(temp)
- assemblies.append(temp[1])
- for pair in lst[::-1]:
- if temp[1].intersection_with(pair[1]) >= self.max_overlap:
- lst.remove(pair)
- if len(assemblies) > self.max_n_individuals:
- assemblies = sorted(assemblies, key=len, reverse=True)
- for assembly in assemblies[self.max_n_individuals :]:
- for link in assembly._links:
- discarded.update((link.j1, link.j2))
- assemblies = assemblies[: self.max_n_individuals]
-
- if self.add_discarded and discarded:
- # Fill assemblies with unconnected body parts
- for joint in sorted(discarded, key=lambda x: x.confidence, reverse=True):
- if self.safe_edge:
- for assembly in assemblies:
- if joint.label in assembly._visible:
- continue
- d_old = self.calc_assembly_mahalanobis_dist(assembly)
- assembly.add_joint(joint)
- d = self.calc_assembly_mahalanobis_dist(assembly)
- if d < d_old:
- break
- assembly.remove_joint(joint)
- else:
- dists = []
- for i, assembly in enumerate(assemblies):
- if joint.label in assembly._visible:
- continue
- d = cdist(assembly.xy, np.atleast_2d(joint.pos))
- dists.append((i, np.nanmin(d)))
- if not dists:
- continue
- min_ = sorted(dists, key=lambda x: x[1])
- ind, _ = min_[0]
- assemblies[ind].add_joint(joint)
-
- return assemblies, unique
-
- def assemble(self, chunk_size=1, n_processes=None):
- self.assemblies = dict()
- self.unique = dict()
- # Spawning (rather than forking) multiple processes does not
- # work nicely with the GUI or interactive sessions.
- # In that case, we fall back to the serial assembly.
- if chunk_size == 0 or multiprocessing.get_start_method() == "spawn":
- for i, data_dict in enumerate(tqdm(self)):
- assemblies, unique = self._assemble(data_dict, i)
- if assemblies:
- self.assemblies[i] = assemblies
- if unique is not None:
- self.unique[i] = unique
- else:
- global wrapped # Hack to make the function pickable
-
- def wrapped(i):
- return i, self._assemble(self[i], i)
-
- n_frames = len(self.metadata["imnames"])
- with multiprocessing.Pool(n_processes) as p:
- with tqdm(total=n_frames) as pbar:
- for i, (assemblies, unique) in p.imap_unordered(
- wrapped, range(n_frames), chunksize=chunk_size
- ):
- if assemblies:
- self.assemblies[i] = assemblies
- if unique is not None:
- self.unique[i] = unique
- pbar.update()
-
- def from_pickle(self, pickle_path):
- with open(pickle_path, "rb") as file:
- data = pickle.load(file)
- self.unique = data.pop("single", {})
- self.assemblies = data
-
- @staticmethod
- def parse_metadata(data):
- params = dict()
- params["joint_names"] = data["metadata"]["all_joints_names"]
- params["num_joints"] = len(params["joint_names"])
- params["paf_graph"] = data["metadata"]["PAFgraph"]
- params["paf"] = data["metadata"].get(
- "PAFinds", np.arange(len(params["joint_names"]))
- )
- params["bpts"] = params["ibpts"] = range(params["num_joints"])
- params["imnames"] = [fn for fn in list(data) if fn != "metadata"]
- return params
-
- def to_h5(self, output_name):
- data = np.full(
- (
- len(self.metadata["imnames"]),
- self.max_n_individuals,
- self.n_multibodyparts,
- 4,
- ),
- fill_value=np.nan,
- )
- for ind, assemblies in self.assemblies.items():
- for n, assembly in enumerate(assemblies):
- data[ind, n] = assembly.data
- index = pd.MultiIndex.from_product(
- [
- ["scorer"],
- map(str, range(self.max_n_individuals)),
- map(str, range(self.n_multibodyparts)),
- ["x", "y", "likelihood"],
- ],
- names=["scorer", "individuals", "bodyparts", "coords"],
- )
- temp = data[..., :3].reshape((data.shape[0], -1))
- df = pd.DataFrame(temp, columns=index)
- df.to_hdf(output_name, key="ass")
-
- def to_pickle(self, output_name):
- data = dict()
- for ind, assemblies in self.assemblies.items():
- data[ind] = [ass.data for ass in assemblies]
- if self.unique:
- data["single"] = self.unique
- with open(output_name, "wb") as file:
- pickle.dump(data, file, pickle.HIGHEST_PROTOCOL)
-
-
-def calc_object_keypoint_similarity(
- xy_pred,
- xy_true,
- sigma,
- margin=0,
- symmetric_kpts=None,
-):
- visible_gt = ~np.isnan(xy_true).all(axis=1)
- if visible_gt.sum() < 2: # At least 2 points needed to calculate scale
- return np.nan
- true = xy_true[visible_gt]
- scale_squared = np.product(np.ptp(true, axis=0) + np.spacing(1) + margin * 2)
- if np.isclose(scale_squared, 0):
- return np.nan
- k_squared = (2 * sigma) ** 2
- denom = 2 * scale_squared * k_squared
- if symmetric_kpts is None:
- pred = xy_pred[visible_gt]
- pred[np.isnan(pred)] = np.inf
- dist_squared = np.sum((pred - true) ** 2, axis=1)
- oks = np.exp(-dist_squared / denom)
- return np.mean(oks)
- else:
- oks = []
- xy_preds = [xy_pred]
- combos = (
- pair
- for l in range(len(symmetric_kpts))
- for pair in itertools.combinations(symmetric_kpts, l + 1)
- )
- for pairs in combos:
- # Swap corresponding keypoints
- tmp = xy_pred.copy()
- for pair in pairs:
- tmp[pair, :] = tmp[pair[::-1], :]
- xy_preds.append(tmp)
- for xy_pred in xy_preds:
- pred = xy_pred[visible_gt]
- pred[np.isnan(pred)] = np.inf
- dist_squared = np.sum((pred - true) ** 2, axis=1)
- oks.append(np.mean(np.exp(-dist_squared / denom)))
- return max(oks)
-
-
-def match_assemblies(
- ass_pred, ass_true, sigma, margin=0, symmetric_kpts=None, greedy_matching=False
-):
- # Only consider assemblies of at least two keypoints
- ass_pred = [a for a in ass_pred if len(a) > 1]
- ass_true = [a for a in ass_true if len(a) > 1]
-
- matched = []
-
- # Greedy assembly matching like in pycocotools
- if greedy_matching:
- inds_true = list(range(len(ass_true)))
- inds_pred = np.argsort(
- [ins.affinity if ins.n_links else ins.confidence for ins in ass_pred]
- )[::-1]
- for ind_pred in inds_pred:
- xy_pred = ass_pred[ind_pred].xy
- oks = []
- for ind_true in inds_true:
- xy_true = ass_true[ind_true].xy
- oks.append(
- calc_object_keypoint_similarity(
- xy_pred,
- xy_true,
- sigma,
- margin,
- symmetric_kpts,
- )
- )
- if np.all(np.isnan(oks)):
- continue
- ind_best = np.nanargmax(oks)
- ind_true_best = inds_true.pop(ind_best)
- matched.append((ass_pred[ind_pred], ass_true[ind_true_best], oks[ind_best]))
- if not inds_true:
- break
-
- # Global rather than greedy assembly matching
- else:
- mat = np.zeros((len(ass_pred), len(ass_true)))
- for i, a_pred in enumerate(ass_pred):
- for j, a_true in enumerate(ass_true):
- oks = calc_object_keypoint_similarity(
- a_pred.xy,
- a_true.xy,
- sigma,
- margin,
- symmetric_kpts,
- )
- if ~np.isnan(oks):
- mat[i, j] = oks
- rows, cols = linear_sum_assignment(mat, maximize=True)
- inds_true = list(range(len(ass_true)))
- for row, col in zip(rows, cols):
- matched.append((ass_pred[row], ass_true[col], mat[row, col]))
- _ = inds_true.remove(col)
-
- unmatched = [ass_true[ind] for ind in inds_true]
- return matched, unmatched
-
-
-def parse_ground_truth_data_file(h5_file):
- df = pd.read_hdf(h5_file)
- try:
- df.drop("single", axis=1, level="individuals", inplace=True)
- except KeyError:
- pass
- # Cast columns of dtype 'object' to float to avoid TypeError
- # further down in _parse_ground_truth_data.
- cols = df.select_dtypes(include="object").columns
- if cols.to_list():
- df[cols] = df[cols].astype("float")
- n_individuals = len(df.columns.get_level_values("individuals").unique())
- n_bodyparts = len(df.columns.get_level_values("bodyparts").unique())
- data = df.to_numpy().reshape((df.shape[0], n_individuals, n_bodyparts, -1))
- return _parse_ground_truth_data(data)
-
-
-def _parse_ground_truth_data(data):
- gt = dict()
- for i, arr in enumerate(data):
- temp = []
- for row in arr:
- if np.isnan(row[:, :2]).all():
- continue
- ass = Assembly.from_array(row)
- temp.append(ass)
- if not temp:
- continue
- gt[i] = temp
- return gt
-
-
-def find_outlier_assemblies(dict_of_assemblies, criterion="area", qs=(5, 95)):
- if not hasattr(Assembly, criterion):
- raise ValueError(f"Invalid criterion {criterion}.")
-
- if len(qs) != 2:
- raise ValueError(
- "Two percentiles (for lower and upper bounds) should be given."
- )
-
- tuples = []
- for frame_ind, assemblies in dict_of_assemblies.items():
- for assembly in assemblies:
- tuples.append((frame_ind, getattr(assembly, criterion)))
- frame_inds, vals = zip(*tuples)
- vals = np.asarray(vals)
- lo, up = np.percentile(vals, qs, interpolation="nearest")
- inds = np.flatnonzero((vals < lo) | (vals > up)).tolist()
- return list(set(frame_inds[i] for i in inds))
-
-
-def evaluate_assembly(
- ass_pred_dict,
- ass_true_dict,
- oks_sigma=0.072,
- oks_thresholds=np.linspace(0.5, 0.95, 10),
- margin=0,
- symmetric_kpts=None,
- greedy_matching=False,
-):
- # sigma is taken as the median of all COCO keypoint standard deviations
- all_matched = []
- all_unmatched = []
- for ind, ass_true in tqdm(ass_true_dict.items()):
- ass_pred = ass_pred_dict.get(ind, [])
- matched, unmatched = match_assemblies(
- ass_pred,
- ass_true,
- oks_sigma,
- margin,
- symmetric_kpts,
- greedy_matching,
- )
- all_matched.extend(matched)
- all_unmatched.extend(unmatched)
- if not all_matched:
- return {
- "precisions": np.array([]),
- "recalls": np.array([]),
- "mAP": 0.0,
- "mAR": 0.0,
- }
-
- conf_pred = np.asarray([match[0].affinity for match in all_matched])
- idx = np.argsort(-conf_pred, kind="mergesort")
- # Sort matching score (OKS) in descending order of assembly affinity
- oks = np.asarray([match[2] for match in all_matched])[idx]
- ntot = len(all_matched) + len(all_unmatched)
- recall_thresholds = np.linspace(0, 1, 101)
- precisions = []
- recalls = []
- for th in oks_thresholds:
- tp = np.cumsum(oks >= th)
- fp = np.cumsum(oks < th)
- rc = tp / ntot
- pr = tp / (fp + tp + np.spacing(1))
- recall = rc[-1]
- # Guarantee precision decreases monotonically
- # See https://jonathan-hui.medium.com/map-mean-average-precision-for-object-detection-45c121a31173)
- for i in range(len(pr) - 1, 0, -1):
- if pr[i] > pr[i - 1]:
- pr[i - 1] = pr[i]
- inds_rc = np.searchsorted(rc, recall_thresholds)
- precision = np.zeros(inds_rc.shape)
- valid = inds_rc < len(pr)
- precision[valid] = pr[inds_rc[valid]]
- precisions.append(precision)
- recalls.append(recall)
- precisions = np.asarray(precisions)
- recalls = np.asarray(recalls)
- return {
- "precisions": precisions,
- "recalls": recalls,
- "mAP": precisions.mean(),
- "mAR": recalls.mean(),
- }
+from deeplabcut.core.inferenceutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
index 7cc88a92bd..8fdd526ab1 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
@@ -8,829 +8,6 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+"""Backwards compatibility."""
-import abc
-import math
-import numpy as np
-import warnings
-from collections import defaultdict
-from filterpy.common import kinematic_kf
-from filterpy.kalman import KalmanFilter
-from matplotlib import patches
-from numba import jit
-from numba.core.errors import NumbaPerformanceWarning
-from scipy.optimize import linear_sum_assignment
-from scipy.stats import mode
-from tqdm import tqdm
-
-
-warnings.simplefilter("ignore", category=NumbaPerformanceWarning)
-
-TRACK_METHODS = {
- "box": "_bx",
- "skeleton": "_sk",
- "ellipse": "_el",
- "transformer": "_tr",
-}
-
-
-def calc_iou(bbox1, bbox2):
- x1 = max(bbox1[0], bbox2[0])
- y1 = max(bbox1[1], bbox2[1])
- x2 = min(bbox1[2], bbox2[2])
- y2 = min(bbox1[3], bbox2[3])
- w = max(0, x2 - x1)
- h = max(0, y2 - y1)
- wh = w * h
- return wh / (
- (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1])
- + (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1])
- - wh
- )
-
-
-class BaseTracker:
- """Base class for a constant-velocity Kalman filter-based tracker."""
-
- n_trackers = 0
-
- def __init__(self, dim, dim_z):
- self.kf = kinematic_kf(
- dim,
- 1,
- dim_z=dim_z,
- order_by_dim=False,
- )
- self.id = self.__class__.n_trackers
- self.__class__.n_trackers += 1
- self.time_since_update = 0
- self.age = 0
- self.hits = 0
- self.hit_streak = 0
-
- def update(self, z):
- self.time_since_update = 0
- self.hits += 1
- self.hit_streak += 1
- self.kf.update(z)
-
- def predict(self):
- self.kf.predict()
- self.age += 1
- if self.time_since_update > 0:
- self.hit_streak = 0
- self.time_since_update += 1
- return self.state
-
- @property
- def state(self):
- return self.kf.x.squeeze()[: self.kf.dim_z]
-
- @state.setter
- def state(self, state):
- self.kf.x[: self.kf.dim_z] = state
-
-
-class Ellipse:
- def __init__(self, x, y, width, height, theta):
- self.x = x
- self.y = y
- self.width = width
- self.height = height
- self.theta = theta # in radians
- self._geometry = None
-
- @property
- def parameters(self):
- return self.x, self.y, self.width, self.height, self.theta
-
- @property
- def aspect_ratio(self):
- return max(self.width, self.height) / min(self.width, self.height)
-
- def calc_similarity_with(self, other_ellipse):
- max_dist = max(
- self.height, self.width, other_ellipse.height, other_ellipse.width
- )
- dist = math.sqrt(
- (self.x - other_ellipse.x) ** 2 + (self.y - other_ellipse.y) ** 2
- )
- cost1 = 1 - min(dist / max_dist, 1)
- cost2 = abs(math.cos(self.theta - other_ellipse.theta))
- return 0.8 * cost1 + 0.2 * cost2 * cost1
-
- def contains_points(self, xy, tol=0.1):
- ca = math.cos(self.theta)
- sa = math.sin(self.theta)
- x_demean = xy[:, 0] - self.x
- y_demean = xy[:, 1] - self.y
- return (
- ((ca * x_demean + sa * y_demean) ** 2 / (0.5 * self.width) ** 2)
- + ((sa * x_demean - ca * y_demean) ** 2 / (0.5 * self.height) ** 2)
- ) <= 1 + tol
-
- def draw(self, show_axes=True, ax=None, **kwargs):
- import matplotlib.pyplot as plt
- from matplotlib.lines import Line2D
- from matplotlib.transforms import Affine2D
-
- if ax is None:
- ax = plt.subplot(111, aspect="equal")
- el = patches.Ellipse(
- xy=(self.x, self.y),
- width=self.width,
- height=self.height,
- angle=np.rad2deg(self.theta),
- **kwargs,
- )
- ax.add_patch(el)
- if show_axes:
- major = Line2D([-self.width / 2, self.width / 2], [0, 0], lw=3, zorder=3)
- minor = Line2D([0, 0], [-self.height / 2, self.height / 2], lw=3, zorder=3)
- trans = (
- Affine2D().rotate(self.theta).translate(self.x, self.y) + ax.transData
- )
- major.set_transform(trans)
- minor.set_transform(trans)
- ax.add_artist(major)
- ax.add_artist(minor)
-
-
-class EllipseFitter:
- def __init__(self, sd=2):
- self.sd = sd
- self.x = None
- self.y = None
- self.params = None
- self._coeffs = None
-
- def fit(self, xy):
- self.x, self.y = xy[np.isfinite(xy).all(axis=1)].T
- if len(self.x) < 3:
- return None
- if self.sd:
- self.params = self._fit_error(self.x, self.y, self.sd)
- else:
- self._coeffs = self._fit(self.x, self.y)
- self.params = self.calc_parameters(self._coeffs)
- if not np.isnan(self.params).any():
- el = Ellipse(*self.params)
- # Regularize by forcing AR <= 5
- # max_ar = 5
- # if el.aspect_ratio >= max_ar:
- # if el.height > el.width:
- # el.width = el.height / max_ar
- # else:
- # el.height = el.width / max_ar
- # Orient the ellipse such that it encompasses most points
- # n_inside = el.contains_points(np.c_[self.x, self.y]).sum()
- # el.theta += 0.5 * np.pi
- # if el.contains_points(np.c_[self.x, self.y]).sum() < n_inside:
- # el.theta -= 0.5 * np.pi
- return el
- return None
-
- @staticmethod
- @jit(nopython=True)
- def _fit(x, y):
- """
- Least Squares ellipse fitting algorithm
- Fit an ellipse to a set of X- and Y-coordinates.
- See Halir and Flusser, 1998 for implementation details
-
- :param x: ndarray, 1D trajectory
- :param y: ndarray, 1D trajectory
- :return: 1D ndarray of 6 coefficients of the general quadratic curve:
- ax^2 + 2bxy + cy^2 + 2dx + 2fy + g = 0
- """
- D1 = np.vstack((x * x, x * y, y * y))
- D2 = np.vstack((x, y, np.ones_like(x)))
- S1 = D1 @ D1.T
- S2 = D1 @ D2.T
- S3 = D2 @ D2.T
- T = -np.linalg.inv(S3) @ S2.T
- temp = S1 + S2 @ T
- M = np.zeros_like(temp)
- M[0] = temp[2] * 0.5
- M[1] = -temp[1]
- M[2] = temp[0] * 0.5
- E, V = np.linalg.eig(M)
- cond = 4 * V[0] * V[2] - V[1] ** 2
- a1 = V[:, cond > 0][:, 0]
- a2 = T @ a1
- return np.hstack((a1, a2))
-
- @staticmethod
- @jit(nopython=True)
- def _fit_error(x, y, sd):
- """
- Fit a sd-sigma covariance error ellipse to the data.
-
- :param x: ndarray, 1D input of X coordinates
- :param y: ndarray, 1D input of Y coordinates
- :param sd: int, size of the error ellipse in 'standard deviation'
- :return: ellipse center, semi-axes length, angle to the X-axis
- """
- cov = np.cov(x, y)
- E, V = np.linalg.eigh(cov) # Returns the eigenvalues in ascending order
- # r2 = chi2.ppf(2 * norm.cdf(sd) - 1, 2)
- # height, width = np.sqrt(E * r2)
- height, width = 2 * sd * np.sqrt(E)
- a, b = V[:, 1]
- rotation = math.atan2(b, a) % np.pi
- return [np.mean(x), np.mean(y), width, height, rotation]
-
- @staticmethod
- @jit(nopython=True)
- def calc_parameters(coeffs):
- """
- Calculate ellipse center coordinates, semi-axes lengths, and
- the counterclockwise angle of rotation from the x-axis to the ellipse major axis.
- Visit http://mathworld.wolfram.com/Ellipse.html
- for how to estimate ellipse parameters.
-
- :param coeffs: list of fitting coefficients
- :return: center: 1D ndarray, semi-axes: 1D ndarray, angle: float
- """
- # The general quadratic curve has the form:
- # ax^2 + 2bxy + cy^2 + 2dx + 2fy + g = 0
- a, b, c, d, f, g = coeffs
- b *= 0.5
- d *= 0.5
- f *= 0.5
-
- # Ellipse center coordinates
- x0 = (c * d - b * f) / (b * b - a * c)
- y0 = (a * f - b * d) / (b * b - a * c)
-
- # Semi-axes lengths
- num = 2 * (a * f * f + c * d * d + g * b * b - 2 * b * d * f - a * c * g)
- den1 = (b * b - a * c) * (np.sqrt((a - c) ** 2 + 4 * b * b) - (a + c))
- den2 = (b * b - a * c) * (-np.sqrt((a - c) ** 2 + 4 * b * b) - (a + c))
- major = np.sqrt(num / den1)
- minor = np.sqrt(num / den2)
-
- # Angle to the horizontal
- if b == 0:
- if a < c:
- phi = 0
- else:
- phi = np.pi / 2
- else:
- if a < c:
- phi = np.arctan(2 * b / (a - c)) / 2
- else:
- phi = np.pi / 2 + np.arctan(2 * b / (a - c)) / 2
-
- return [x0, y0, 2 * major, 2 * minor, phi]
-
-
-class EllipseTracker(BaseTracker):
- def __init__(self, params):
- super().__init__(dim=5, dim_z=5)
- self.kf.R[2:, 2:] *= 10.0
- # High uncertainty to the unobservable initial velocities
- self.kf.P[5:, 5:] *= 1000.0
- self.kf.P *= 10.0
- self.kf.Q[5:, 5:] *= 0.01
- self.state = params
-
- @BaseTracker.state.setter
- def state(self, params):
- state = np.asarray(params).reshape((-1, 1))
- super(EllipseTracker, type(self)).state.fset(self, state)
-
-
-class SkeletonTracker(BaseTracker):
- def __init__(self, n_bodyparts):
- super().__init__(dim=n_bodyparts * 2, dim_z=n_bodyparts)
- self.kf.Q[self.kf.dim_z :, self.kf.dim_z :] *= 10
- self.kf.R[self.kf.dim_z :, self.kf.dim_z :] *= 0.01
- self.kf.P[self.kf.dim_z :, self.kf.dim_z :] *= 1000
-
- def update(self, pose):
- flat = pose.reshape((-1, 1))
- empty = np.isnan(flat).squeeze()
- if empty.any():
- H = self.kf.H.copy()
- H[empty] = 0
- flat[empty] = 0
- self.kf.update(flat, H=H)
- else:
- super().update(flat)
-
- @BaseTracker.state.setter
- def state(self, pose):
- curr_pose = pose.copy()
- empty = np.isnan(curr_pose).all(axis=1)
- if empty.any():
- fill = np.nanmean(pose, axis=0)
- curr_pose[empty] = fill
- super(SkeletonTracker, type(self)).state.fset(self, curr_pose.reshape((-1, 1)))
-
-
-class BoxTracker(BaseTracker):
- def __init__(self, bbox):
- super().__init__(dim=4, dim_z=4)
- self.kf = KalmanFilter(dim_x=7, dim_z=4)
- self.kf.F = np.array(
- [
- [1, 0, 0, 0, 1, 0, 0],
- [0, 1, 0, 0, 0, 1, 0],
- [0, 0, 1, 0, 0, 0, 1],
- [0, 0, 0, 1, 0, 0, 0],
- [0, 0, 0, 0, 1, 0, 0],
- [0, 0, 0, 0, 0, 1, 0],
- [0, 0, 0, 0, 0, 0, 1],
- ]
- )
- self.kf.H = np.array(
- [
- [1, 0, 0, 0, 0, 0, 0],
- [0, 1, 0, 0, 0, 0, 0],
- [0, 0, 1, 0, 0, 0, 0],
- [0, 0, 0, 1, 0, 0, 0],
- ]
- )
- self.kf.R[2:, 2:] *= 10.0
- # Give high uncertainty to the unobservable initial velocities
- self.kf.P[4:, 4:] *= 1000.0
- self.kf.P *= 10.0
- self.kf.Q[-1, -1] *= 0.01
- self.kf.Q[4:, 4:] *= 0.01
- self.state = bbox
-
- def update(self, bbox):
- super().update(self.convert_bbox_to_z(bbox))
-
- def predict(self):
- if (self.kf.x[6] + self.kf.x[2]) <= 0:
- self.kf.x[6] *= 0.0
- return super().predict()
-
- @property
- def state(self):
- return self.convert_x_to_bbox(self.kf.x)[0]
-
- @state.setter
- def state(self, bbox):
- state = self.convert_bbox_to_z(bbox)
- super(BoxTracker, type(self)).state.fset(self, state)
-
- @staticmethod
- def convert_x_to_bbox(x, score=None):
- """
- Takes a bounding box in the centre form [x,y,s,r] and returns it in the form
- [x1,y1,x2,y2] where x1,y1 is the top left and x2,y2 is the bottom right
- """
- w = np.sqrt(x[2] * x[3])
- h = x[2] / w
- if score is None:
- return np.array(
- [x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0]
- ).reshape((1, 4))
- else:
- return np.array(
- [x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0, score]
- ).reshape((1, 5))
-
- @staticmethod
- def convert_bbox_to_z(bbox):
- """
- Takes a bounding box in the form [x1,y1,x2,y2] and returns z in the form
- [x,y,s,r] where x,y is the centre of the box and s is the scale/area and r is
- the aspect ratio
- """
- w = bbox[2] - bbox[0]
- h = bbox[3] - bbox[1]
- x = bbox[0] + w / 2.0
- y = bbox[1] + h / 2.0
- s = w * h # scale is just area
- r = w / float(h)
- return np.array([x, y, s, r]).reshape((4, 1))
-
-
-class SORTBase(metaclass=abc.ABCMeta):
- def __init__(self):
- self.n_frames = 0
- self.trackers = []
-
- @abc.abstractmethod
- def track(self):
- pass
-
-
-class SORTEllipse(SORTBase):
- def __init__(self, max_age, min_hits, iou_threshold, sd=2):
- self.max_age = max_age
- self.min_hits = min_hits
- self.iou_threshold = iou_threshold
- self.fitter = EllipseFitter(sd)
- EllipseTracker.n_trackers = 0
- super().__init__()
-
- def track(self, poses, identities=None):
- self.n_frames += 1
-
- trackers = np.zeros((len(self.trackers), 6))
- for i in range(len(trackers)):
- trackers[i, :5] = self.trackers[i].predict()
- empty = np.isnan(trackers).any(axis=1)
- trackers = trackers[~empty]
- for ind in np.flatnonzero(empty)[::-1]:
- self.trackers.pop(ind)
-
- ellipses = []
- pred_ids = []
- for i, pose in enumerate(poses):
- el = self.fitter.fit(pose)
- if el is not None:
- ellipses.append(el)
- if identities is not None:
- pred_ids.append(mode(identities[i])[0][0])
- if not len(trackers):
- matches = np.empty((0, 2), dtype=int)
- unmatched_detections = np.arange(len(ellipses))
- unmatched_trackers = np.empty((0, 6), dtype=int)
- else:
- ellipses_trackers = [Ellipse(*t[:5]) for t in trackers]
- cost_matrix = np.zeros((len(ellipses), len(ellipses_trackers)))
- for i, el in enumerate(ellipses):
- for j, el_track in enumerate(ellipses_trackers):
- cost = el.calc_similarity_with(el_track)
- if identities is not None:
- match = 2 if pred_ids[i] == self.trackers[j].id_ else 1
- cost *= match
- cost_matrix[i, j] = cost
- row_indices, col_indices = linear_sum_assignment(cost_matrix, maximize=True)
- unmatched_detections = [
- i for i, _ in enumerate(ellipses) if i not in row_indices
- ]
- unmatched_trackers = [
- j for j, _ in enumerate(trackers) if j not in col_indices
- ]
- matches = []
- for row, col in zip(row_indices, col_indices):
- val = cost_matrix[row, col]
- # diff = val - cost_matrix
- # diff[row, col] += val
- # if (
- # val < self.iou_threshold
- # or np.any(diff[row] <= 0.2)
- # or np.any(diff[:, col] <= 0.2)
- # ):
- if val < self.iou_threshold:
- unmatched_detections.append(row)
- unmatched_trackers.append(col)
- else:
- matches.append([row, col])
- if not len(matches):
- matches = np.empty((0, 2), dtype=int)
- else:
- matches = np.stack(matches)
- unmatched_trackers = np.asarray(unmatched_trackers)
- unmatched_detections = np.asarray(unmatched_detections)
-
- animalindex = []
- for t, tracker in enumerate(self.trackers):
- if t not in unmatched_trackers:
- ind = matches[matches[:, 1] == t, 0][0]
- animalindex.append(ind)
- tracker.update(ellipses[ind].parameters)
- else:
- animalindex.append(-1)
-
- for i in unmatched_detections:
- trk = EllipseTracker(ellipses[i].parameters)
- if identities is not None:
- trk.id_ = mode(identities[i])[0][0]
- self.trackers.append(trk)
- animalindex.append(i)
-
- i = len(self.trackers)
- ret = []
- for trk in reversed(self.trackers):
- d = trk.state
- if (trk.time_since_update < 1) and (
- trk.hit_streak >= self.min_hits or self.n_frames <= self.min_hits
- ):
- ret.append(
- np.concatenate((d, [trk.id, int(animalindex[i - 1])])).reshape(
- 1, -1
- )
- ) # for DLC we also return the original animalid
- # +1 as MOT benchmark requires positive >> this is removed for DLC!
- i -= 1
- # remove dead tracklet
- if trk.time_since_update > self.max_age:
- self.trackers.pop(i)
-
- if len(ret) > 0:
- return np.concatenate(ret)
- return np.empty((0, 7))
-
-
-class SORTSkeleton(SORTBase):
- def __init__(self, n_bodyparts, max_age=20, min_hits=3, oks_threshold=0.5):
- self.n_bodyparts = n_bodyparts
- self.max_age = max_age
- self.min_hits = min_hits
- self.oks_threshold = oks_threshold
- SkeletonTracker.n_trackers = 0
- super().__init__()
-
- @staticmethod
- def weighted_hausdorff(x, y):
- # Modified from scipy source code:
- # - to restrict its use to 2D
- # - to get rid of shuffling (since arrays are only (nbodyparts * 3) element long)
- # TODO - factor in keypoint confidence (and weight by # of observations??)
- cmax = 0
- for i in range(x.shape[0]):
- no_break_occurred = True
- cmin = np.inf
- for j in range(y.shape[0]):
- d = (x[i, 0] - y[j, 0]) ** 2 + (x[i, 1] - y[j, 1]) ** 2
- if d < cmax:
- no_break_occurred = False
- break
- if d < cmin:
- cmin = d
- if cmin != np.inf and cmin > cmax and no_break_occurred:
- cmax = cmin
- return np.sqrt(cmax)
-
- @staticmethod
- def object_keypoint_similarity(x, y):
- mask = ~np.isnan(x * y).all(axis=1) # Intersection visible keypoints
- xx = x[mask]
- yy = y[mask]
- dist = np.linalg.norm(xx - yy, axis=1)
- scale = np.sqrt(
- np.product(np.ptp(yy, axis=0))
- ) # square root of bounding box area
- oks = np.exp(-0.5 * (dist / (0.05 * scale)) ** 2)
- return np.mean(oks)
-
- def calc_pairwise_hausdorff_dist(self, poses, poses_ref):
- mat = np.zeros((len(poses), len(poses_ref)))
- for i, pose in enumerate(poses):
- for j, pose_ref in enumerate(poses_ref):
- mat[i, j] = self.weighted_hausdorff(pose, pose_ref)
- return mat
-
- def calc_pairwise_oks(self, poses, poses_ref):
- mat = np.zeros((len(poses), len(poses_ref)))
- for i, pose in enumerate(poses):
- for j, pose_ref in enumerate(poses_ref):
- mat[i, j] = self.object_keypoint_similarity(pose, pose_ref)
- return mat
-
- def track(self, poses):
- self.n_frames += 1
-
- if not len(self.trackers):
- for pose in poses:
- tracker = SkeletonTracker(self.n_bodyparts)
- tracker.state = pose
- self.trackers.append(tracker)
-
- poses_ref = []
- for i, tracker in enumerate(self.trackers):
- pose_ref = tracker.predict()
- poses_ref.append(pose_ref.reshape((-1, 2)))
-
- # mat = self.calc_pairwise_oks(poses, poses_ref)
- mat = self.calc_pairwise_hausdorff_dist(poses, poses_ref)
- row_indices, col_indices = linear_sum_assignment(mat, maximize=False)
-
- unmatched_poses = [p for p, _ in enumerate(poses) if p not in row_indices]
- unmatched_trackers = [
- t for t, _ in enumerate(poses_ref) if t not in col_indices
- ]
- # Remove matched detections with low OKS
- # matches = []
- # for row, col in zip(row_indices, col_indices):
- # if mat[row, col] < self.oks_threshold:
- # unmatched_poses.append(row)
- # unmatched_trackers.append(col)
- # else:
- # matches.append([row, col])
- # if not len(matches):
- # matches = np.empty((0, 2), dtype=int)
- # else:
- # matches = np.stack(matches)
- matches = np.c_[row_indices, col_indices]
-
- animalindex = []
- for t, tracker in enumerate(self.trackers):
- if t not in unmatched_trackers:
- ind = matches[matches[:, 1] == t, 0][0]
- animalindex.append(ind)
- tracker.update(poses[ind])
- else:
- animalindex.append(-1)
-
- for i in unmatched_poses:
- tracker = SkeletonTracker(self.n_bodyparts)
- tracker.state = poses[i]
- self.trackers.append(tracker)
- animalindex.append(i)
-
- states = []
- i = len(self.trackers)
- for tracker in reversed(self.trackers):
- i -= 1
- if tracker.time_since_update > self.max_age:
- self.trackers.pop()
- continue
- state = tracker.predict()
- states.append(np.r_[state, [tracker.id, int(animalindex[i])]])
- if len(states) > 0:
- return np.stack(states)
- return np.empty((0, self.n_bodyparts * 2 + 2))
-
-
-class SORTBox(SORTBase):
- def __init__(self, max_age, min_hits, iou_threshold):
- self.max_age = max_age
- self.min_hits = min_hits
- self.iou_threshold = iou_threshold
- BoxTracker.n_trackers = 0
- super().__init__()
-
- def track(self, dets):
- self.n_frames += 1
-
- trackers = np.zeros((len(self.trackers), 5))
- for i in range(len(trackers)):
- trackers[i, :4] = self.trackers[i].predict()
- empty = np.isnan(trackers).any(axis=1)
- trackers = trackers[~empty]
- for ind in np.flatnonzero(empty)[::-1]:
- self.trackers.pop(ind)
-
- matched, unmatched_dets, unmatched_trks = self.match_detections_to_trackers(
- dets, trackers, self.iou_threshold
- )
-
- # update matched trackers with assigned detections
- animalindex = []
- for t, trk in enumerate(self.trackers):
- if t not in unmatched_trks:
- d = matched[np.where(matched[:, 1] == t)[0], 0]
- animalindex.append(d[0])
- trk.update(dets[d, :][0]) # update coordinates
- else:
- animalindex.append("nix") # lost trk!
-
- # create and initialise new trackers for unmatched detections
- for i in unmatched_dets:
- trk = BoxTracker(dets[i, :])
- self.trackers.append(trk)
- animalindex.append(i)
-
- i = len(self.trackers)
- ret = []
- for trk in reversed(self.trackers):
- d = trk.state
- if (trk.time_since_update < 1) and (
- trk.hit_streak >= self.min_hits or self.n_frames <= self.min_hits
- ):
- ret.append(
- np.concatenate((d, [trk.id, int(animalindex[i - 1])])).reshape(
- 1, -1
- )
- ) # for DLC we also return the original animalid
- # +1 as MOT benchmark requires positive >> this is removed for DLC!
- i -= 1
- # remove dead tracklet
- if trk.time_since_update > self.max_age:
- self.trackers.pop(i)
-
- if len(ret) > 0:
- return np.concatenate(ret)
- return np.empty((0, 5))
-
- @staticmethod
- def match_detections_to_trackers(detections, trackers, iou_threshold):
- """
- Assigns detections to tracked object (both represented as bounding boxes)
-
- Returns 3 lists of matches, unmatched_detections and unmatched_trackers
- """
- if not len(trackers):
- return (
- np.empty((0, 2), dtype=int),
- np.arange(len(detections)),
- np.empty((0, 5), dtype=int),
- )
- iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float32)
-
- for d, det in enumerate(detections):
- for t, trk in enumerate(trackers):
- iou_matrix[d, t] = calc_iou(det, trk)
- row_indices, col_indices = linear_sum_assignment(-iou_matrix)
-
- unmatched_detections = []
- for d, det in enumerate(detections):
- if d not in row_indices:
- unmatched_detections.append(d)
- unmatched_trackers = []
- for t, trk in enumerate(trackers):
- if t not in col_indices:
- unmatched_trackers.append(t)
-
- # filter out matched with low IOU
- matches = []
- for row, col in zip(row_indices, col_indices):
- if iou_matrix[row, col] < iou_threshold:
- unmatched_detections.append(row)
- unmatched_trackers.append(col)
- else:
- matches.append([row, col])
- if not len(matches):
- matches = np.empty((0, 2), dtype=int)
- else:
- matches = np.stack(matches)
- return matches, np.array(unmatched_detections), np.array(unmatched_trackers)
-
-
-def fill_tracklets(tracklets, trackers, animals, imname):
- for content in trackers:
- tracklet_id, pred_id = content[-2:].astype(int)
- if tracklet_id not in tracklets:
- tracklets[tracklet_id] = {}
- if pred_id != -1:
- tracklets[tracklet_id][imname] = animals[pred_id]
- else: # Resort to the tracker prediction
- xy = np.asarray(content[:-2])
- pred = np.insert(xy, range(2, len(xy) + 1, 2), 1)
- tracklets[tracklet_id][imname] = pred
-
-
-def calc_bboxes_from_keypoints(data, slack=0, offset=0):
- data = np.asarray(data)
- if data.shape[-1] < 3:
- raise ValueError("Data should be of shape (n_animals, n_bodyparts, 3)")
-
- if data.ndim != 3:
- data = np.expand_dims(data, axis=0)
- bboxes = np.full((data.shape[0], 5), np.nan)
- bboxes[:, :2] = np.nanmin(data[..., :2], axis=1) - slack # X1, Y1
- bboxes[:, 2:4] = np.nanmax(data[..., :2], axis=1) + slack # X2, Y2
- bboxes[:, -1] = np.nanmean(data[..., 2]) # Average confidence
- bboxes[:, [0, 2]] += offset
- return bboxes
-
-
-def reconstruct_all_ellipses(data, sd):
- xy = data.droplevel("scorer", axis=1).drop("likelihood", axis=1, level=-1)
- if "single" in xy:
- xy.drop("single", axis=1, level="individuals", inplace=True)
- animals = xy.columns.get_level_values("individuals").unique()
- nrows = xy.shape[0]
- ellipses = np.full((len(animals), nrows, 5), np.nan)
- fitter = EllipseFitter(sd)
- for n, animal in enumerate(animals):
- data = xy.xs(animal, axis=1, level="individuals").values.reshape((nrows, -1, 2))
- for i, coords in enumerate(tqdm(data)):
- el = fitter.fit(coords.astype(np.float64))
- if el is not None:
- ellipses[n, i] = el.parameters
- return ellipses
-
-
-def _track_individuals(
- individuals, min_hits=1, max_age=5, similarity_threshold=0.6, track_method="ellipse"
-):
- if track_method not in TRACK_METHODS:
- raise ValueError(f"Unknown {track_method} tracker.")
-
- if track_method == "ellipse":
- tracker = SORTEllipse(max_age, min_hits, similarity_threshold)
- elif track_method == "box":
- tracker = SORTBox(max_age, min_hits, similarity_threshold)
- else:
- n_bodyparts = individuals[0][0].shape[0]
- tracker = SORTSkeleton(n_bodyparts, max_age, min_hits, similarity_threshold)
-
- tracklets = defaultdict(dict)
- all_hyps = dict()
- for i, (multi, single) in enumerate(tqdm(individuals)):
- if single is not None:
- tracklets["single"][i] = single
- if multi is None:
- continue
- if track_method == "box":
- # TODO: get cropping parameters and utilize!
- xy = calc_bboxes_from_keypoints(multi)
- else:
- xy = multi[..., :2]
- hyps = tracker.track(xy)
- all_hyps[i] = hyps
- for hyp in hyps:
- tracklet_id, pred_id = hyp[-2:].astype(int)
- if pred_id != -1:
- tracklets[tracklet_id][i] = multi[pred_id]
- return tracklets, all_hyps
+from deeplabcut.core.trackingutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh b/deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh
index 520da2fe5f..a7cb80a9e5 100644
--- a/deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh
+++ b/deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh
@@ -3,4 +3,4 @@
curl http://download.tensorflow.org/models/resnet_v1_50_2016_08_28.tar.gz | tar xvz
curl http://download.tensorflow.org/models/resnet_v1_101_2016_08_28.tar.gz | tar xvz
-curl http://download.tensorflow.org/models/resnet_v1_152_2016_08_28.tar.gz | tar xvz
\ No newline at end of file
+curl http://download.tensorflow.org/models/resnet_v1_152_2016_08_28.tar.gz | tar xvz
diff --git a/deeplabcut/pose_estimation_tensorflow/modelzoo/__init__.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/__init__.py
new file mode 100644
index 0000000000..70c734c462
--- /dev/null
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/__init__.py
@@ -0,0 +1,11 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from .api import SpatiotemporalAdaptation
diff --git a/deeplabcut/modelzoo/api/__init__.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/__init__.py
similarity index 100%
rename from deeplabcut/modelzoo/api/__init__.py
rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/__init__.py
diff --git a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py
similarity index 58%
rename from deeplabcut/modelzoo/api/spatiotemporal_adapt.py
rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py
index bef6146ba2..8c43d3b5ea 100644
--- a/deeplabcut/modelzoo/api/spatiotemporal_adapt.py
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py
@@ -8,29 +8,38 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import deeplabcut
import glob
import os
-from deeplabcut.modelzoo.utils import parse_available_supermodels
-from deeplabcut.modelzoo.api import superanimal_inference
-from deeplabcut.utils.plotting import _plot_trajectories
+from collections.abc import Sequence
from pathlib import Path
+from deeplabcut.pose_estimation_tensorflow.modelzoo.api.superanimal_inference import (
+ video_inference,
+)
+from deeplabcut.utils.auxiliaryfunctions import (
+ get_deeplabcut_path,
+ load_analyzed_data,
+ read_config,
+)
+from deeplabcut.utils.deprecation import renamed_parameter
+from deeplabcut.utils.make_labeled_video import create_labeled_video
+from deeplabcut.utils.plotting import _plot_trajectories
+
class SpatiotemporalAdaptation:
+ @renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def __init__(
self,
video_path,
supermodel_name,
scale_list=None,
- videotype="mp4",
+ video_extensions: str | Sequence[str] | None = "mp4",
adapt_iterations=1000,
modelfolder="",
customized_pose_config="",
init_weights="",
):
- """
- This class supports video adaptation to a super model.
+ """This class supports video adaptation to a super model.
Parameters
----------
@@ -42,12 +51,20 @@ def __init__(
Currently we support supertopview(LabMice) and superquadruped (quadruped side-view animals)
scale_list: list
A list of different resolutions for the spatial pyramid
- videotype: string
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed. The default is ``.avi``
+ video_extensions: str | Sequence[str] | None, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
adapt_iterations: int
- Number of iterations for adaptation training. Empirically 1000 is sufficient. Training longer can cause worse performance depending whether there is occlusion in the video
+ Number of iterations for adaptation training. Empirically 1000 is sufficient. Training longer can cause worse
+ performance depending whether there is occlusion in the video
modelfolder: string, optional
- Because the API does not need a dlc project, the checkpoint and logs go to this temporary model folder, and otherwise model is saved to the current work place
+ Because the API does not need a dlc project, the checkpoint and logs go to this temporary model folder, and
+ otherwise model is saved to the current work place
customized_pose_config: string, optional
For future support of non modelzoo model
@@ -57,64 +74,71 @@ def __init__(
from deeplabcut.modelzoo.apis import SpatiotemporalAdaptation
video_path = '/mnt/md0/shaokai/openfield_video/m3v1mp4.mp4'
superanimal_name = 'superanimal_topviewmouse'
- videotype = 'mp4'
+ video_extensions = 'mp4'
>>> adapter = SpatiotemporalAdaptation(video_path,
superanimal_name,
modelfolder = "temp_topview",
- videotype = videotype)
+ video_extensions = video_extensions)
adapter.before_adapt_inference()
adapter.adaptation_training()
adapter.after_adapt_inference()
-
-
"""
if scale_list is None:
scale_list = []
- supermodels = parse_available_supermodels()
- if supermodel_name not in supermodels:
- raise ValueError(
- f"`supermodel_name` should be one of: {', '.join(supermodels)}."
- )
-
self.video_path = video_path
self.supermodel_name = supermodel_name
self.scale_list = scale_list
- self.videotype = videotype
+ self.video_extensions = video_extensions
vname = str(Path(self.video_path).stem)
self.adapt_modelprefix = vname + "_video_adaptation"
self.adapt_iterations = adapt_iterations
self.modelfolder = modelfolder
self.init_weights = init_weights
+ project_name = "_".join(supermodel_name.split("_")[:-1])
+ model_name = supermodel_name.split("_")[-1]
+ self.project_name = project_name
+ self.model_name = model_name
+
if not customized_pose_config:
- dlc_root_path = os.sep.join(deeplabcut.__file__.split(os.sep)[:-1])
- self.customized_pose_config = os.path.join(
- dlc_root_path,
- "pose_estimation_tensorflow",
- "superanimal_configs",
- supermodels[self.supermodel_name],
+ dlc_root_path = get_deeplabcut_path()
+
+ project_config = read_config(
+ os.path.join(dlc_root_path, "modelzoo", "project_configs", f"{project_name}.yaml")
)
+
+ model_config = read_config(os.path.join(dlc_root_path, "modelzoo", "model_configs", f"{model_name}.yaml"))
+
+ joints = [i for i in range(len(project_config["bodyparts"]))]
+ num_joints = len(joints)
+ model_config["all_joints"] = joints
+ model_config["all_joints_names"] = project_config["bodyparts"]
+ model_config["num_joints"] = num_joints
+ model_config["num_limbs"] = int((num_joints * (num_joints - 1)) // 2)
+ self.customized_pose_config = {**project_config, **model_config}
else:
self.customized_pose_config = customized_pose_config
def before_adapt_inference(self, make_video=False, **kwargs):
if self.init_weights != "":
print("using customized weights", self.init_weights)
- _, datafiles = superanimal_inference.video_inference(
+ _, datafiles = video_inference(
[self.video_path],
- self.supermodel_name,
- videotype=self.videotype,
+ self.project_name,
+ self.model_name,
+ video_extensions=self.video_extensions,
scale_list=self.scale_list,
init_weights=self.init_weights,
customized_test_config=self.customized_pose_config,
)
else:
- self.init_weights, datafiles = superanimal_inference.video_inference(
+ self.init_weights, datafiles = video_inference(
[self.video_path],
- self.supermodel_name,
- videotype=self.videotype,
+ self.project_name,
+ self.model_name,
+ video_extensions=self.video_extensions,
scale_list=self.scale_list,
customized_test_config=self.customized_pose_config,
)
@@ -125,14 +149,14 @@ def before_adapt_inference(self, make_video=False, **kwargs):
_plot_trajectories(datafiles[0])
if make_video:
- deeplabcut.create_labeled_video(
+ create_labeled_video(
"",
[self.video_path],
- videotype=self.videotype,
+ video_extensions=self.video_extensions,
filtered=False,
init_weights=self.init_weights,
draw_skeleton=True,
- superanimal_name=self.supermodel_name,
+ superanimal_name=self.project_name,
**kwargs,
)
@@ -157,8 +181,9 @@ def train_without_project(self, pseudo_label_path, **kwargs):
)
def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs):
- """
- There should be two choices, either taking a config, with is then assuming there is a DLC project.
+ """There should be two choices, either taking a config, with is then assuming
+ there is a DLC project.
+
Or we make up a fake one, then we use a light way convention to do adaptation
"""
@@ -167,32 +192,21 @@ def adaptation_training(self, displayiters=500, saveiters=1000, **kwargs):
vname = str(Path(self.video_path).stem)
video_root = Path(self.video_path).parent
- _, pseudo_label_path, _, _ = deeplabcut.auxiliaryfunctions.load_analyzed_data(
- video_root, vname, DLCscorer, False, ""
- )
+ _, pseudo_label_path, _, _ = load_analyzed_data(video_root, vname, DLCscorer, False, "")
if self.modelfolder != "":
os.makedirs(self.modelfolder, exist_ok=True)
self.adapt_iterations = kwargs.get("adapt_iterations", self.adapt_iterations)
- if os.path.exists(
- os.path.join(self.modelfolder, f"snapshot-{self.adapt_iterations}.index")
- ):
- print(
- f"model checkpoint snapshot-{self.adapt_iterations}.index exists, skipping the video adaptation"
- )
- else:
- self.train_without_project(
- pseudo_label_path,
- displayiters=displayiters,
- saveiters=saveiters,
- **kwargs,
- )
-
- def after_adapt_inference(self, **kwargs):
- pattern = os.path.join(
- self.modelfolder, f"snapshot-{self.adapt_iterations}.index"
+ self.train_without_project(
+ pseudo_label_path,
+ displayiters=displayiters,
+ saveiters=saveiters,
+ **kwargs,
)
+
+ def after_adapt_inference(self, create_labeled_video, **kwargs):
+ pattern = os.path.join(self.modelfolder, f"snapshot-{self.adapt_iterations}.index")
ref_proj_config_path = ""
files = glob.glob(pattern)
@@ -208,10 +222,11 @@ def after_adapt_inference(self, **kwargs):
# spatial pyramid can still be useful for reducing jittering and quantization error
- _, datafiles = superanimal_inference.video_inference(
+ _, datafiles = video_inference(
[self.video_path],
- self.supermodel_name,
- videotype=self.videotype,
+ self.project_name,
+ self.model_name,
+ video_extensions=self.video_extensions,
init_weights=adapt_weights,
scale_list=scale_list,
customized_test_config=self.customized_pose_config,
@@ -220,13 +235,14 @@ def after_adapt_inference(self, **kwargs):
if kwargs.pop("plot_trajectories", True):
_plot_trajectories(datafiles[0])
- deeplabcut.create_labeled_video(
- ref_proj_config_path,
- [self.video_path],
- videotype=self.videotype,
- filtered=False,
- init_weights=adapt_weights,
- draw_skeleton=True,
- superanimal_name=self.supermodel_name,
- **kwargs,
- )
+ if create_labeled_video:
+ create_labeled_video(
+ ref_proj_config_path,
+ [self.video_path],
+ video_extensions=self.video_extensions,
+ filtered=False,
+ init_weights=adapt_weights,
+ draw_skeleton=True,
+ superanimal_name=self.project_name,
+ **kwargs,
+ )
diff --git a/deeplabcut/modelzoo/api/superanimal_inference.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py
similarity index 64%
rename from deeplabcut/modelzoo/api/superanimal_inference.py
rename to deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py
index 7d5a93b2fd..8e6444a0a2 100644
--- a/deeplabcut/modelzoo/api/superanimal_inference.py
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py
@@ -8,10 +8,13 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+import glob
import os
import os.path
import pickle
import time
+import warnings
+from collections.abc import Sequence
from pathlib import Path
import imgaug.augmenters as iaa
@@ -20,18 +23,12 @@
from skimage.util import img_as_ubyte
from tqdm import tqdm
-from deeplabcut.modelzoo.utils import parse_available_supermodels
from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.core import predict as single_predict
from deeplabcut.pose_estimation_tensorflow.core import predict_multianimal as predict
from deeplabcut.utils import auxiliaryfunctions
-from deeplabcut.utils.auxfun_videos import VideoWriter
-from dlclibrary.dlcmodelzoo.modelzoo_download import (
- download_huggingface_model,
- MODELOPTIONS,
-)
-import glob
-import warnings
+from deeplabcut.utils.auxfun_videos import VideoWriter, collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
warnings.simplefilter("ignore", category=RuntimeWarning)
@@ -55,7 +52,7 @@ def get_multi_scale_frames(frame, scale_list):
def _project_pred_to_original_size(pred, old_shape, new_shape):
old_h, old_w, _ = old_shape
new_h, new_w, _ = new_shape
- ratio_h, ratio_w = old_h / new_h, old_w / new_w
+ ratio_h, _ratio_w = old_h / new_h, old_w / new_w
coordinate = pred["coordinates"][0]
confidence = pred["confidence"]
@@ -92,7 +89,7 @@ def _average_multiple_scale_preds(
continue
coordinates = pred["coordinates"][0]
confidence = pred["confidence"]
- for i, (coords, conf) in enumerate(zip(coordinates, confidence)):
+ for i, (coords, conf) in enumerate(zip(coordinates, confidence, strict=False)):
if not np.any(coords):
continue
xyp[scale_id, i, :2] = coords
@@ -127,8 +124,10 @@ def _video_inference(
cap,
nframes,
batchsize,
- scale_list=[],
+ scale_list=None,
):
+ if scale_list is None:
+ scale_list = []
strwidth = int(np.ceil(np.log10(nframes))) # width for strings
batch_ind = 0 # keeps track of which image within a batch should be written to
@@ -157,10 +156,7 @@ def _video_inference(
if multi_scale_batched_frames is None:
multi_scale_batched_frames = [
- np.empty(
- (batchsize, frame.shape[0], frame.shape[1], 3), dtype="ubyte"
- )
- for frame in frames
+ np.empty((batchsize, frame.shape[0], frame.shape[1], 3), dtype="ubyte") for frame in frames
]
for scale_id, frame in enumerate(frames):
@@ -170,9 +166,7 @@ def _video_inference(
preds = []
for scale_id, batched_frames in enumerate(multi_scale_batched_frames):
# batch full, start true inferencing
- D = predict.predict_batched_peaks_and_costs(
- test_cfg, batched_frames, sess, inputs, outputs
- )
+ D = predict.predict_batched_peaks_and_costs(test_cfg, batched_frames, sess, inputs, outputs)
preds.append(D)
# only do this when animal is detected
ind_start = inds[0]
@@ -186,9 +180,7 @@ def _video_inference(
else:
pred = preds[scale_id][i]
if pred != []:
- pred = _project_pred_to_original_size(
- pred, old_shape, frame_shapes[scale_id]
- )
+ pred = _project_pred_to_original_size(pred, old_shape, frame_shapes[scale_id])
PredicteData["frame" + str(ind).zfill(strwidth)].append(pred)
@@ -223,9 +215,7 @@ def _video_inference(
else:
pred = preds[scale_id][i]
if pred != []:
- pred = _project_pred_to_original_size(
- pred, old_shape, frame_shapes[scale_id]
- )
+ pred = _project_pred_to_original_size(pred, old_shape, frame_shapes[scale_id])
PredicteData["frame" + str(ind).zfill(strwidth)].append(pred)
break
@@ -245,24 +235,22 @@ def _video_inference(
"minimal confidence": test_cfg.get("minconfidence", None),
"sigma": test_cfg.get("sigma", 1),
"PAFgraph": test_cfg.get("partaffinityfield_graph", None),
- "PAFinds": test_cfg.get(
- "paf_best", np.arange(len(test_cfg["partaffinityfield_graph"]))
- ),
+ "PAFinds": test_cfg.get("paf_best", np.arange(len(test_cfg["partaffinityfield_graph"]))),
"all_joints": [[i] for i in range(len(test_cfg["all_joints"]))],
- "all_joints_names": [
- test_cfg["all_joints_names"][i] for i in range(len(test_cfg["all_joints"]))
- ],
+ "all_joints_names": [test_cfg["all_joints_names"][i] for i in range(len(test_cfg["all_joints"]))],
"nframes": nframes,
}
return PredicteData, nframes
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def video_inference(
videos,
- superanimal_name,
- scale_list=[],
- videotype="avi",
+ project_name,
+ model_name,
+ scale_list=None,
+ video_extensions: str | Sequence[str] | None = None,
destfolder=None,
batchsize=1,
robust_nframes=False,
@@ -270,57 +258,58 @@ def video_inference(
init_weights="",
customized_test_config="",
):
- if superanimal_name not in MODELOPTIONS:
- raise ValueError(f"{superanimal_name} not available. Available ones are: {MODELOPTIONS}. If you are confident `superanimal_name` is right, try updating `dlclibrary` with `pip install -U dlclibrary`.")
-
+ if scale_list is None:
+ scale_list = []
dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
if customized_test_config == "":
- supermodels = parse_available_supermodels()
- test_cfg = load_config(
+ project_cfg = load_config(
os.path.join(
dlc_root_path,
- "pose_estimation_tensorflow",
- "superanimal_configs",
- supermodels[superanimal_name],
+ "modelzoo",
+ "project_configs",
+ f"{project_name}.yaml",
)
)
- else:
- test_cfg = load_config(customized_test_config)
+ model_cfg = load_config(
+ os.path.join(
+ dlc_root_path,
+ "modelzoo",
+ "model_configs",
+ f"{model_name}.yaml",
+ )
+ )
+ test_cfg = {**project_cfg, **model_cfg}
+ test_cfg["all_joints"] = [i for i in range(len(test_cfg["bobyparts"]))]
+ test_cfg["all_joints_names"] = test_cfg["bobyparts"]
+ num_joints = len(test_cfg["all_joints"])
+ test_cfg["num_joints"] = num_joints
+ test_cfg["num_limbs"] = int((num_joints * (num_joints - 1)) // 2)
- # add a temp folder for checkpoint
- weight_folder = str(
- Path(dlc_root_path)
- / "pose_estimation_tensorflow"
- / "models"
- / "pretrained"
- / (superanimal_name + "_weights")
- )
- pat = os.path.join(weight_folder, "snapshot-*.index")
- snapshots = glob.glob(pat)
- if not len(snapshots):
- download_huggingface_model(superanimal_name, weight_folder)
- snapshots = glob.glob(pat)
else:
- print(f"{weight_folder} exists, using the downloaded weights")
+ test_cfg = customized_test_config
+ # add a temp folder for checkpoint
+ weight_folder = str(Path(dlc_root_path) / "modelzoo" / "checkpoints" / f"{project_name}_{model_name}")
+ snapshots = glob.glob(os.path.join(weight_folder, "snapshot-*.index"))
test_cfg["partaffinityfield_graph"] = []
test_cfg["partaffinityfield_predict"] = False
if init_weights != "":
test_cfg["init_weights"] = init_weights
else:
+ if len(snapshots) == 0:
+ raise FileNotFoundError(f"Did not find any super animal snapshots in {weight_folder}")
+
init_weights = os.path.abspath(snapshots[0]).replace(".index", "")
test_cfg["init_weights"] = init_weights
test_cfg["num_outputs"] = 1
test_cfg["batch_size"] = batchsize
- sess, inputs, outputs = single_predict.setup_pose_prediction(
- test_cfg, allow_growth=allow_growth
- )
+ sess, inputs, outputs = single_predict.setup_pose_prediction(test_cfg, allow_growth=allow_growth)
DLCscorer = "DLC_" + Path(test_cfg["init_weights"]).stem
- videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ videos = collect_video_paths(videos, extensions=video_extensions)
datafiles = []
for video in videos:
@@ -402,7 +391,7 @@ def video_inference(
"cropping_parameters": coords,
}
metadata = {"data": dictionary}
- print("Saving results in %s..." % (destfolder))
+ print(f"Saving results in {destfolder}...")
metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
@@ -426,7 +415,7 @@ def video_inference(
keypoints = dict_["coordinates"][0]
confidence = dict_["confidence"]
temp = np.full((len(keypoints), 3), np.nan)
- for n, (xy, c) in enumerate(zip(keypoints, confidence)):
+ for n, (xy, c) in enumerate(zip(keypoints, confidence, strict=False)):
if xy.size and c.size:
temp[n, :2] = xy
temp[n, 2] = c
@@ -435,3 +424,121 @@ def video_inference(
df.to_hdf(dataname, key="df_with_missing")
return init_weights, datafiles
+
+
+def _video_inference_superanimal(
+ videos,
+ project_name,
+ model_name,
+ scale_list=None,
+ video_extensions=".mp4",
+ video_adapt=False,
+ plot_trajectories=True,
+ pcutoff=0.1,
+ adapt_iterations=1000,
+ pseudo_threshold=0.1,
+ create_labeled_video: bool = True,
+):
+ """
+ WARNING: This function is an internal utility function and should not be
+ called directly. It is designed to be used by deeplabcut.modelzoo.api.video_inference.py
+
+ Makes prediction based on a super animal model. Note right now we only support single animal video inference
+
+ The index of the trained network is specified by parameters in the config file
+ (in particular the variable 'snapshotindex')
+
+ Output: The labels are stored as MultiIndex Pandas Array,
+ which contains the name of the network, body part name, (x, y) label position \n
+ in pixels, and the likelihood for each frame per body part.
+ These arrays are stored in an efficient Hierarchical Data Format (HDF) \n
+ in the same directory, where the video is stored.
+
+ Parameters
+ ----------
+ videos: list
+ A list of strings containing the full paths to videos for analysis or a path to the directory,
+ where all the videos with same extension are stored.
+
+ superanimal_name: str
+ The name of the superanimal model.
+ In TensorFlow, we only support "superanimal_quadruped", "superanimal_topviewmouse".
+ Check out the PyTorch version for active development,
+ better performance and additional models (humans, birds, ...)
+ scale_list: list
+ A list of int containing the target height of the multi scale test time augmentation.
+ By default it uses the original size.
+ Users are advised to try a wide range of scale list when the super model does not give reasonable results
+
+ video_extensions: string, optional
+ Checks for the extension of the video in case the input to the video is a directory.\n
+ Only videos with this extension are analyzed.
+ The default is ``.avi``
+
+ video_adapt: bool, optional
+ Set True if you want to apply video adaptation to make the resulted video less jittering and better.
+ However, adaptation training takes more time than usual video inference
+
+ plot_trajectories: bool, optional (default=True)
+ By default, plot the trajectories of various body parts across the video.
+
+ pcutoff: float, optional
+ Keypoints confidence that are under pcutoff will not be shown in the resulted video
+
+ adapt_iterations: int, optional:
+ Number of iterations for adaptation training
+
+ pseudo_threshold: float, default 0.1
+ Video adaptation only uses predictions that are above pseudo_threshold
+
+ create_labeled_video (bool):
+ Specifies if a labeled video needs to be created, True by default.
+
+ Given a list of scales for spatial pyramid, i.e. [600, 700]
+
+ scale_list = range(600,800,100)
+
+ superanimal_name = 'superanimal_topviewmouse'
+ video_extensions = 'mp4'
+ scale_list = [200, 300, 400]
+ deeplabcut.video_inference_superanimal(
+ video,
+ superanimal_name,
+ video_extensions = '.avi',
+ scale_list = scale_list,
+ )
+ >>>
+ """
+ from deeplabcut.pose_estimation_tensorflow.modelzoo.api import (
+ SpatiotemporalAdaptation,
+ )
+
+ if scale_list is None:
+ scale_list = []
+ superanimal_name = project_name + "_" + model_name
+ for video in videos:
+ modelfolder = Path(video).parent / f"{Path(video).stem}_video_adaptation"
+ modelfolder.mkdir(exist_ok=True, parents=True)
+
+ adapter = SpatiotemporalAdaptation(
+ video,
+ superanimal_name,
+ modelfolder=str(modelfolder),
+ video_extensions=video.split(".")[-1],
+ scale_list=scale_list,
+ )
+ if not video_adapt:
+ adapter.before_adapt_inference(
+ make_video=create_labeled_video, pcutoff=pcutoff, plot_trajectories=plot_trajectories
+ )
+ else:
+ adapter.before_adapt_inference(make_video=create_labeled_video)
+ adapter.adaptation_training(
+ adapt_iterations=adapt_iterations,
+ pseudo_threshold=pseudo_threshold,
+ )
+ adapter.after_adapt_inference(
+ pcutoff=pcutoff,
+ plot_trajectories=plot_trajectories,
+ create_labeled_video=create_labeled_video,
+ )
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py b/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py
index 6cd0a13cbd..ab0aca78fb 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py
@@ -9,13 +9,12 @@
# Licensed under GNU Lesser General Public License v3.0
#
-from .factory import PoseNetFactory
from .efficientnet import PoseEfficientNet
+from .factory import PoseNetFactory
from .mobilenet import PoseMobileNet
from .multi import PoseMultiNet
from .resnet import PoseResnet
-
__all__ = [
"PoseNetFactory",
"PoseEfficientNet",
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/base.py b/deeplabcut/pose_estimation_tensorflow/nnets/base.py
index 81b8eecd62..1737ba22c4 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/base.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/base.py
@@ -9,9 +9,12 @@
# Licensed under GNU Lesser General Public License v3.0
#
import abc
+
import tensorflow as tf
-from deeplabcut.pose_estimation_tensorflow.datasets import Batch
+
from deeplabcut.pose_estimation_tensorflow.core import predict_multianimal
+from deeplabcut.pose_estimation_tensorflow.datasets import Batch
+
from .layers import prediction_layer
from .utils import make_2d_gaussian_kernel
@@ -21,12 +24,10 @@ def __init__(self, cfg):
self.cfg = cfg
@abc.abstractmethod
- def extract_features(self, inputs):
- ...
+ def extract_features(self, inputs): ...
@abc.abstractmethod
- def get_net(self, inputs):
- ...
+ def get_net(self, inputs): ...
def train(self, batch):
heads = self.get_net(batch[Batch.inputs])
@@ -43,10 +44,7 @@ def add_part_loss(pred_layer):
loss = {"part_loss": add_part_loss("part_pred")}
total_loss = loss["part_loss"]
- if (
- self.cfg["intermediate_supervision"]
- and "efficientnet" not in self.cfg["net_type"]
- ):
+ if self.cfg["intermediate_supervision"] and "efficientnet" not in self.cfg["net_type"]:
loss["part_loss_interm"] = add_part_loss("part_pred_interm")
total_loss += loss["part_loss_interm"]
@@ -107,20 +105,14 @@ def prediction_layers(
"locref_pred",
n_joints * 2,
)
- if (
- self.cfg["pairwise_predict"]
- and "multi-animal" not in self.cfg["dataset_type"]
- ):
+ if self.cfg["pairwise_predict"] and "multi-animal" not in self.cfg["dataset_type"]:
out["pairwise_pred"] = prediction_layer(
self.cfg,
features,
"pairwise_pred",
n_joints * (n_joints - 1) * 2,
)
- if (
- self.cfg["partaffinityfield_predict"]
- and "multi-animal" in self.cfg["dataset_type"]
- ):
+ if self.cfg["partaffinityfield_predict"] and "multi-animal" in self.cfg["dataset_type"]:
out["pairwise_pred"] = prediction_layer(
self.cfg,
features,
@@ -132,6 +124,7 @@ def prediction_layers(
def inference(self, inputs):
"""Direct TF inference on GPU.
+
Added with: https://arxiv.org/abs/1909.11229
"""
heads = self.get_net(inputs)
@@ -145,21 +138,13 @@ def inference(self, inputs):
locref = tf.reshape(locref, (l_shape[0] * l_shape[1], -1, 2))
probs = tf.reshape(probs, (l_shape[0] * l_shape[1], -1))
maxloc = tf.argmax(input=probs, axis=0)
- loc = tf.unravel_index(
- maxloc, (tf.cast(l_shape[0], tf.int64), tf.cast(l_shape[1], tf.int64))
- )
+ loc = tf.unravel_index(maxloc, (tf.cast(l_shape[0], tf.int64), tf.cast(l_shape[1], tf.int64)))
maxloc = tf.reshape(maxloc, (1, -1))
- joints = tf.reshape(
- tf.range(0, tf.cast(l_shape[2], dtype=tf.int64)), (1, -1)
- )
+ joints = tf.reshape(tf.range(0, tf.cast(l_shape[2], dtype=tf.int64)), (1, -1))
else:
- l_shape = tf.shape(
- input=probs
- ) # batchsize times x times y times body parts
- locref = tf.reshape(
- locref, (l_shape[0], l_shape[1], l_shape[2], l_shape[3], 2)
- )
+ l_shape = tf.shape(input=probs) # batchsize times x times y times body parts
+ locref = tf.reshape(locref, (l_shape[0], l_shape[1], l_shape[2], l_shape[3], 2))
# turn into x times y time bs * bpts
locref = tf.transpose(a=locref, perm=[1, 2, 0, 3, 4])
probs = tf.transpose(a=probs, perm=[1, 2, 0, 3])
@@ -173,9 +158,7 @@ def inference(self, inputs):
maxloc, (tf.cast(l_shape[0], tf.int64), tf.cast(l_shape[1], tf.int64))
) # tuple of max indices
maxloc = tf.reshape(maxloc, (1, -1))
- joints = tf.reshape(
- tf.range(0, tf.cast(l_shape[2] * l_shape[3], dtype=tf.int64)), (1, -1)
- )
+ joints = tf.reshape(tf.range(0, tf.cast(l_shape[2] * l_shape[3], dtype=tf.int64)), (1, -1))
# extract corresponding locref x and y as well as probability
indices = tf.transpose(a=tf.concat([maxloc, joints], axis=0))
@@ -192,7 +175,7 @@ def inference(self, inputs):
return {"pose": pose}
def add_inference_layers(self, heads):
- """initialized during inference"""
+ """Initialized during inference."""
prob = tf.sigmoid(heads["part_pred"])
nms_radius = int(self.cfg.get("nmsradius", 5))
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py b/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
index 3f42784003..0f72ac187c 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
@@ -14,6 +14,7 @@
# limitations under the License.
#
"""Convolution blocks for mobilenet."""
+
import contextlib
import functools
@@ -80,9 +81,10 @@ def _split_divisible(num, num_ways, divisible_by=8):
@contextlib.contextmanager
def _v1_compatible_scope_naming(scope):
if scope is None: # Create uniqified separable blocks.
- with tf.compat.v1.variable_scope(
- None, default_name="separable"
- ) as s, tf.compat.v1.name_scope(s.original_name_scope):
+ with (
+ tf.compat.v1.variable_scope(None, default_name="separable") as s,
+ tf.compat.v1.name_scope(s.original_name_scope),
+ ):
yield ""
else:
# We use scope_depthwise, scope_pointwise for compatibility with V1 ckpts.
@@ -171,7 +173,7 @@ def expand_input_by_factor(n, divisible_by=8):
def expanded_conv(
input_tensor,
num_outputs,
- expansion_size=expand_input_by_factor(6),
+ expansion_size=None,
stride=1,
rate=1,
kernel_size=(3, 3),
@@ -238,19 +240,18 @@ def expanded_conv(
Raises:
TypeError: on inval
"""
- with tf.compat.v1.variable_scope(
- scope, default_name="expanded_conv"
- ) as s, tf.compat.v1.name_scope(s.original_name_scope):
+ with (
+ tf.compat.v1.variable_scope(scope, default_name="expanded_conv") as s,
+ tf.compat.v1.name_scope(s.original_name_scope),
+ ):
+ if expansion_size is None:
+ expansion_size = expand_input_by_factor(6)
prev_depth = input_tensor.get_shape().as_list()[3]
if depthwise_location not in [None, "input", "output", "expansion"]:
- raise TypeError(
- "%r is unknown value for depthwise_location" % depthwise_location
- )
+ raise TypeError(f"{depthwise_location!r} is unknown value for depthwise_location")
if use_explicit_padding:
if padding != "SAME":
- raise TypeError(
- "`use_explicit_padding` should only be used with " '"SAME" padding.'
- )
+ raise TypeError('`use_explicit_padding` should only be used with "SAME" padding.')
padding = "VALID"
depthwise_func = functools.partial(
slim.separable_conv2d,
@@ -366,8 +367,8 @@ def split_conv(input_tensor, num_outputs, num_ways, scope, divisible_by=8, **kwa
output_splits = _split_divisible(num_outputs, num_ways, divisible_by=divisible_by)
inputs = tf.split(input_tensor, input_splits, axis=3, name="split_" + scope)
base = scope
- for i, (input_tensor, out_size) in enumerate(zip(inputs, output_splits)):
- scope = base + "_part_%d" % (i,)
+ for i, (input_tensor, out_size) in enumerate(zip(inputs, output_splits, strict=False)):
+ scope = base + f"_part_{i}"
n = slim.conv2d(input_tensor, out_size, [1, 1], scope=scope, **kwargs)
n = tf.identity(n, scope + "_output")
outs.append(n)
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py b/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py
index 0c41b147c6..3da6d3cbbf 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py
@@ -8,16 +8,16 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-
-Effnet added by T. Biasi & AM
-Efficient Nets added by T. Biasi & AM
-See https://openaccess.thecvf.com/content/WACV2021/html/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.html
+"""Effnet added by T.
+Biasi & AM Efficient Nets added by T. Biasi & AM See
+https://openaccess.thecvf.com/content/WACV2021/html/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.html
"""
import tensorflow as tf
+
import deeplabcut.pose_estimation_tensorflow.backbones.efficientnet_builder as eff
+
from .base import BasePoseNet
from .factory import PoseNetFactory
@@ -25,7 +25,7 @@
@PoseNetFactory.register("efficientnet")
class PoseEfficientNet(BasePoseNet):
def __init__(self, cfg):
- super(PoseEfficientNet, self).__init__(cfg)
+ super().__init__(cfg)
if "use_batch_norm" not in self.cfg:
self.cfg["use_batch_norm"] = False
if "use_drop_out" not in self.cfg:
@@ -49,7 +49,5 @@ def get_net(self, inputs, use_batch_norm=False, use_drop_out=False):
return self.prediction_layers(net)
def test(self, inputs):
- heads = self.get_net(
- inputs, self.cfg["use_batch_norm"], self.cfg["use_drop_out"]
- )
+ heads = self.get_net(inputs, self.cfg["use_batch_norm"], self.cfg["use_drop_out"])
return self.add_inference_layers(heads)
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/factory.py b/deeplabcut/pose_estimation_tensorflow/nnets/factory.py
index cbfe98ab7a..61501353b3 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/factory.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/factory.py
@@ -18,7 +18,7 @@ class PoseNetFactory:
def register(cls, type_):
def wrapper(net):
if type_ in cls._nets:
- warnings.warn("Overwriting existing network {}.")
+ warnings.warn(f"Overwriting existing network {type_}.", stacklevel=2)
cls._nets[type_] = net
return net
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/layers.py b/deeplabcut/pose_estimation_tensorflow/nnets/layers.py
index 4bd530fc95..fc0d4d2c0d 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/layers.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/layers.py
@@ -11,7 +11,6 @@
import tensorflow as tf
import tf_slim as slim
-
# FIXME Fix wrong scope with Keras layers
# def prediction_layer(cfg, input, name, num_outputs):
# with tf.compat.v1.variable_scope(name):
@@ -36,9 +35,7 @@ def prediction_layer(cfg, input, name, num_outputs):
weights_regularizer=slim.l2_regularizer(cfg["weight_decay"]),
):
with tf.compat.v1.variable_scope(name):
- pred = slim.conv2d_transpose(
- input, num_outputs, kernel_size=[3, 3], stride=2, scope="block4"
- )
+ pred = slim.conv2d_transpose(input, num_outputs, kernel_size=[3, 3], stride=2, scope="block4")
return pred
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py b/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
index b78a379993..1d4f4adafb 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
@@ -23,12 +23,12 @@
import tf_slim as slim
from deeplabcut.pose_estimation_tensorflow.backbones import mobilenet_v2
+
from .base import BasePoseNet
from .factory import PoseNetFactory
from .layers import prediction_layer
from .utils import wrapper
-
networks = {
"mobilenet_v2_1.0": (mobilenet_v2.mobilenet_base, mobilenet_v2.training_scope),
"mobilenet_v2_0.75": (
@@ -61,7 +61,7 @@
@PoseNetFactory.register("mobilenet")
class PoseMobileNet(BasePoseNet):
def __init__(self, cfg):
- super(PoseMobileNet, self).__init__(cfg)
+ super().__init__(cfg)
def extract_features(self, inputs):
net_fun, net_arg_scope = networks[self.cfg["net_type"]]
@@ -78,7 +78,7 @@ def prediction_layers(
scope="pose",
reuse=None,
):
- out = super(PoseMobileNet, self).prediction_layers(
+ out = super().prediction_layers(
features,
scope,
reuse,
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/multi.py b/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
index f67504d091..28c08fdbde 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
@@ -10,23 +10,22 @@
#
import re
+
import tensorflow as tf
import tf_slim as slim
from tf_slim.nets import resnet_v1
import deeplabcut.pose_estimation_tensorflow.backbones.efficientnet_builder as eff
+from deeplabcut.pose_estimation_tensorflow.backbones import mobilenet, mobilenet_v2
from deeplabcut.pose_estimation_tensorflow.nnets import conv_blocks
-from deeplabcut.pose_estimation_tensorflow.backbones import mobilenet_v2, mobilenet
+
from .base import BasePoseNet
from .factory import PoseNetFactory
from .layers import prediction_layer_stage
from .utils import wrapper
-
# Change the stride from 2 to 1 to get 16x downscaling instead of 32x.
-mobilenet_v2.V2_DEF["spec"][14] = mobilenet.op(
- conv_blocks.expanded_conv, stride=1, num_outputs=160
-)
+mobilenet_v2.V2_DEF["spec"][14] = mobilenet.op(conv_blocks.expanded_conv, stride=1, num_outputs=160)
net_funcs = {
@@ -106,7 +105,7 @@ def prediction_layer(cfg, input, name, num_outputs):
@PoseNetFactory.register("multi")
class PoseMultiNet(BasePoseNet):
def __init__(self, cfg):
- super(PoseMultiNet, self).__init__(cfg)
+ super().__init__(cfg)
multi_stage = self.cfg.get("multi_stage", False)
# Multi stage is currently only implemented for resnets
self.cfg["multi_stage"] = multi_stage and "resnet" in self.cfg["net_type"]
@@ -117,9 +116,7 @@ def extract_features(self, inputs):
if "resnet" in net_type:
net_fun = net_funcs[net_type]
with slim.arg_scope(resnet_v1.resnet_arg_scope()):
- net, end_points = net_fun(
- im_centered, global_pool=False, output_stride=16, is_training=False
- )
+ net, end_points = net_fun(im_centered, global_pool=False, output_stride=16, is_training=False)
elif "mobilenet" in net_type:
net_fun = net_funcs[net_type]
with slim.arg_scope(mobilenet_v2.training_scope()):
@@ -153,15 +150,11 @@ def prediction_layers(
if self.cfg["multi_stage"]: # MuNet! (multi_stage decoder + multi_fusion)
# Defining multi_fusion backbone
num_layers = re.findall("resnet_([0-9]*)", net_type)[0]
- layer_name = (
- "resnet_v1_{}".format(num_layers) + "/block{}/unit_{}/bottleneck_v1"
- )
+ layer_name = f"resnet_v1_{num_layers}" + "/block{}/unit_{}/bottleneck_v1"
mid_pt_block1 = layer_name.format(1, 3)
mid_pt_block2 = layer_name.format(2, 3)
- final_dims = tf.math.ceil(
- tf.divide(input_shape[1:3], tf.convert_to_tensor(16))
- )
+ final_dims = tf.math.ceil(tf.divide(input_shape[1:3], tf.convert_to_tensor(16)))
interim_dims_s8 = tf.scalar_mul(2, final_dims)
interim_dims_s8 = tf.cast(interim_dims_s8, tf.int32)
@@ -269,30 +262,18 @@ def prediction_layers(
)
if self.cfg["location_refinement"]:
- out["locref"] = prediction_layer(
- self.cfg, net, "locref_pred", self.cfg["num_joints"] * 2
- )
- if (
- self.cfg["pairwise_predict"]
- and "multi-animal" not in self.cfg["dataset_type"]
- ):
+ out["locref"] = prediction_layer(self.cfg, net, "locref_pred", self.cfg["num_joints"] * 2)
+ if self.cfg["pairwise_predict"] and "multi-animal" not in self.cfg["dataset_type"]:
out["pairwise_pred"] = prediction_layer(
self.cfg,
net,
"pairwise_pred",
self.cfg["num_joints"] * (self.cfg["num_joints"] - 1) * 2,
)
- if (
- self.cfg["partaffinityfield_predict"]
- and "multi-animal" in self.cfg["dataset_type"]
- ):
- feature = slim.conv2d_transpose(
- net, self.cfg.get("bank3", 128), kernel_size=[3, 3], stride=2
- )
+ if self.cfg["partaffinityfield_predict"] and "multi-animal" in self.cfg["dataset_type"]:
+ feature = slim.conv2d_transpose(net, self.cfg.get("bank3", 128), kernel_size=[3, 3], stride=2)
- stage1_paf_out = prediction_layer(
- self.cfg, net, "pairwise_pred_s1", self.cfg["num_limbs"] * 2
- )
+ stage1_paf_out = prediction_layer(self.cfg, net, "pairwise_pred_s1", self.cfg["num_limbs"] * 2)
stage2_in = tf.concat([stage1_hm_out, stage1_paf_out, feature], 3)
stage_input = stage2_in
@@ -300,7 +281,6 @@ def prediction_layers(
stage_hm_output = stage1_hm_out
for i in range(2, 5):
- pre_stage_paf_output = stage_paf_output
pre_stage_hm_output = stage_hm_output
stage_paf_output = prediction_layer_stage(
@@ -321,9 +301,7 @@ def prediction_layers(
# stage_paf_output = stage_paf_output + pre_stage_paf_output
stage_hm_output = stage_hm_output + pre_stage_hm_output
- stage_input = tf.concat(
- [stage_hm_output, stage_paf_output, feature], 3
- )
+ stage_input = tf.concat([stage_hm_output, stage_paf_output, feature], 3)
out["part_pred"] = prediction_layer_stage(
self.cfg,
@@ -340,9 +318,7 @@ def prediction_layers(
)
if self.cfg["intermediate_supervision"]:
- interm_name = layer_name.format(
- 3, self.cfg["intermediate_supervision_layer"]
- )
+ interm_name = layer_name.format(3, self.cfg["intermediate_supervision_layer"])
block_interm_out = end_points[interm_name]
out["part_pred_interm"] = prediction_layer(
self.cfg,
@@ -363,9 +339,7 @@ def prediction_layers(
else:
raise ValueError(f"Unknown network of type {net_type}")
- final_dims = tf.math.ceil(
- tf.divide(input_shape[1:3], tf.convert_to_tensor(value=16))
- )
+ final_dims = tf.math.ceil(tf.divide(input_shape[1:3], tf.convert_to_tensor(value=16)))
interim_dims = tf.scalar_mul(2, final_dims)
interim_dims = tf.cast(interim_dims, tf.int32)
bank_3 = end_points[mid_pt]
@@ -375,9 +349,7 @@ def prediction_layers(
[slim.conv2d],
padding="SAME",
normalizer_fn=None,
- weights_regularizer=tf.keras.regularizers.l2(
- 0.5 * (self.cfg["weight_decay"])
- ),
+ weights_regularizer=tf.keras.regularizers.l2(0.5 * (self.cfg["weight_decay"])),
):
with tf.compat.v1.variable_scope("decoder_filters"):
bank_3 = slim.conv2d(
@@ -391,9 +363,7 @@ def prediction_layers(
[slim.conv2d_transpose],
padding="SAME",
normalizer_fn=None,
- weights_regularizer=tf.keras.regularizers.l2(
- 0.5 * (self.cfg["weight_decay"])
- ),
+ weights_regularizer=tf.keras.regularizers.l2(0.5 * (self.cfg["weight_decay"])),
):
with tf.compat.v1.variable_scope("upsampled_features"):
upsampled_features = slim.conv2d_transpose(
@@ -404,26 +374,19 @@ def prediction_layers(
scope="block4",
)
net = tf.concat([bank_3, upsampled_features], 3)
- out = super(PoseMultiNet, self).prediction_layers(
+ out = super().prediction_layers(
net,
scope,
reuse,
)
with tf.compat.v1.variable_scope(scope, reuse=reuse):
- if (
- self.cfg["intermediate_supervision"]
- and "efficientnet" not in net_type
- ):
+ if self.cfg["intermediate_supervision"] and "efficientnet" not in net_type:
if "mobilenet" in net_type:
- feat = end_points[
- f"layer_{self.cfg['intermediate_supervision_layer']}"
- ]
+ feat = end_points[f"layer_{self.cfg['intermediate_supervision_layer']}"]
elif "resnet" in net_type:
layer_name = "resnet_v1_{}/block{}/unit_{}/bottleneck_v1"
num_layers = re.findall("resnet_([0-9]*)", net_type)[0]
- interm_name = layer_name.format(
- num_layers, 3, self.cfg["intermediate_supervision_layer"]
- )
+ interm_name = layer_name.format(num_layers, 3, self.cfg["intermediate_supervision_layer"])
feat = end_points[interm_name]
else:
return out
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py b/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
index 68fab30ab8..b97bb130b9 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
@@ -13,6 +13,7 @@
#
import re
+
import tensorflow as tf
import tf_slim as slim
from tf_slim.nets import resnet_v1
@@ -21,7 +22,6 @@
from .factory import PoseNetFactory
from .layers import prediction_layer
-
net_funcs = {
"resnet_50": resnet_v1.resnet_v1_50,
"resnet_101": resnet_v1.resnet_v1_101,
@@ -32,7 +32,7 @@
@PoseNetFactory.register("resnet")
class PoseResnet(BasePoseNet):
def __init__(self, cfg):
- super(PoseResnet, self).__init__(cfg)
+ super().__init__(cfg)
def extract_features(self, inputs):
net_fun = net_funcs[self.cfg["net_type"]]
@@ -53,7 +53,7 @@ def prediction_layers(
scope="pose",
reuse=None,
):
- out = super(PoseResnet, self).prediction_layers(
+ out = super().prediction_layers(
features,
scope,
reuse,
@@ -63,9 +63,7 @@ def prediction_layers(
if self.cfg["intermediate_supervision"]:
layer_name = "resnet_v1_{}/block{}/unit_{}/bottleneck_v1"
num_layers = re.findall("resnet_([0-9]*)", self.cfg["net_type"])[0]
- interm_name = layer_name.format(
- num_layers, 3, self.cfg["intermediate_supervision_layer"]
- )
+ interm_name = layer_name.format(num_layers, 3, self.cfg["intermediate_supervision_layer"])
block_interm_out = end_points[interm_name]
out["part_pred_interm"] = prediction_layer(
self.cfg,
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/utils.py b/deeplabcut/pose_estimation_tensorflow/nnets/utils.py
index 8afd4c4552..1087fef575 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/utils.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/utils.py
@@ -15,11 +15,13 @@
#
import functools
+
import numpy as np
import tensorflow as tf
-from deeplabcut.pose_estimation_tensorflow.datasets import Batch
-from tensorflow.python.tpu.ops import tpu_ops
from tensorflow.python.tpu import tpu_function
+from tensorflow.python.tpu.ops import tpu_ops
+
+from deeplabcut.pose_estimation_tensorflow.datasets import Batch
def wrapper(func, *args, **kwargs):
@@ -52,9 +54,7 @@ def get_batch_spec(cfg):
batch_spec[Batch.locref_mask] = [batch_size, None, None, num_joints * 2]
if cfg["pairwise_predict"]:
print("Getting specs", cfg["dataset_type"], num_limbs, num_joints)
- if (
- "multi-animal" not in cfg["dataset_type"]
- ): # this can be used for pairwise conditional
+ if "multi-animal" not in cfg["dataset_type"]: # this can be used for pairwise conditional
batch_spec[Batch.pairwise_targets] = [
batch_size,
None,
@@ -86,8 +86,8 @@ def get_batch_spec(cfg):
def make_2d_gaussian_kernel(sigma, size):
sigma = tf.convert_to_tensor(sigma, dtype=tf.float32)
k = tf.range(-size // 2 + 1, size // 2 + 1)
- k = tf.cast(k ** 2, sigma.dtype)
- k = tf.nn.softmax(-k / (2 * (sigma ** 2)))
+ k = tf.cast(k**2, sigma.dtype)
+ k = tf.nn.softmax(-k / (2 * (sigma**2)))
return tf.einsum("i,j->ij", k, k)
@@ -105,29 +105,19 @@ def build_learning_rate(
if lr_decay_type == "exponential":
assert steps_per_epoch is not None
decay_steps = steps_per_epoch * decay_epochs
- lr = tf.compat.v1.train.exponential_decay(
- initial_lr, global_step, decay_steps, decay_factor, staircase=True
- )
+ lr = tf.compat.v1.train.exponential_decay(initial_lr, global_step, decay_steps, decay_factor, staircase=True)
elif lr_decay_type == "cosine":
assert total_steps is not None
- lr = (
- 0.5
- * initial_lr
- * (1 + tf.cos(np.pi * tf.cast(global_step, tf.float32) / total_steps))
- )
+ lr = 0.5 * initial_lr * (1 + tf.cos(np.pi * tf.cast(global_step, tf.float32) / total_steps))
elif lr_decay_type == "constant":
lr = initial_lr
else:
- assert False, "Unknown lr_decay_type : %s" % lr_decay_type
+ raise AssertionError(f"Unknown lr_decay_type : {lr_decay_type}")
if warmup_epochs:
- tf.compat.v1.logging.info("Learning rate warmup_epochs: %d" % warmup_epochs)
+ tf.compat.v1.logging.info(f"Learning rate warmup_epochs: {warmup_epochs}")
warmup_steps = int(warmup_epochs * steps_per_epoch)
- warmup_lr = (
- initial_lr
- * tf.cast(global_step, tf.float32)
- / tf.cast(warmup_steps, tf.float32)
- )
+ warmup_lr = initial_lr * tf.cast(global_step, tf.float32) / tf.cast(warmup_steps, tf.float32)
lr = tf.cond(
pred=global_step < warmup_steps,
true_fn=lambda: warmup_lr,
@@ -137,27 +127,19 @@ def build_learning_rate(
return lr
-def build_optimizer(
- learning_rate, optimizer_name="rmsprop", decay=0.9, epsilon=0.001, momentum=0.9
-):
+def build_optimizer(learning_rate, optimizer_name="rmsprop", decay=0.9, epsilon=0.001, momentum=0.9):
"""Build optimizer."""
if optimizer_name == "sgd":
tf.compat.v1.logging.info("Using SGD optimizer")
- optimizer = tf.compat.v1.train.GradientDescentOptimizer(
- learning_rate=learning_rate
- )
+ optimizer = tf.compat.v1.train.GradientDescentOptimizer(learning_rate=learning_rate)
elif optimizer_name == "momentum":
tf.compat.v1.logging.info("Using Momentum optimizer")
- optimizer = tf.compat.v1.train.MomentumOptimizer(
- learning_rate=learning_rate, momentum=momentum
- )
+ optimizer = tf.compat.v1.train.MomentumOptimizer(learning_rate=learning_rate, momentum=momentum)
elif optimizer_name == "rmsprop":
tf.compat.v1.logging.info("Using RMSProp optimizer")
- optimizer = tf.compat.v1.train.RMSPropOptimizer(
- learning_rate, decay, momentum, epsilon
- )
+ optimizer = tf.compat.v1.train.RMSPropOptimizer(learning_rate, decay, momentum, epsilon)
else:
- tf.compat.v1.logging.fatal("Unknown optimizer:", optimizer_name)
+ tf.compat.v1.logging.fatal(f"Unknown optimizer: {optimizer_name}")
return optimizer
@@ -167,7 +149,7 @@ class TpuBatchNormalization(tf.compat.v1.layers.BatchNormalization):
def __init__(self, fused=False, **kwargs):
if fused in (True, None):
raise ValueError("TpuBatchNormalization does not support fused=True.")
- super(TpuBatchNormalization, self).__init__(fused=fused, **kwargs)
+ super().__init__(fused=fused, **kwargs)
@staticmethod
def _cross_replica_average(t, num_shards_per_group):
@@ -176,41 +158,29 @@ def _cross_replica_average(t, num_shards_per_group):
group_assignment = None
if num_shards_per_group > 1:
if num_shards % num_shards_per_group != 0:
- raise ValueError(
- "num_shards: %d mod shards_per_group: %d, should be 0"
- % (num_shards, num_shards_per_group)
- )
+ raise ValueError(f"num_shards: {num_shards} mod shards_per_group: {num_shards_per_group}, should be 0")
num_groups = num_shards // num_shards_per_group
group_assignment = [
- [x for x in range(num_shards) if x // num_shards_per_group == y]
- for y in range(num_groups)
+ [x for x in range(num_shards) if x // num_shards_per_group == y] for y in range(num_groups)
]
- return tpu_ops.cross_replica_sum(t, group_assignment) / tf.cast(
- num_shards_per_group, t.dtype
- )
+ return tpu_ops.cross_replica_sum(t, group_assignment) / tf.cast(num_shards_per_group, t.dtype)
def _moments(self, inputs, reduction_axes, keep_dims):
"""Compute the mean and variance: it overrides the original _moments."""
- shard_mean, shard_variance = super(TpuBatchNormalization, self)._moments(
- inputs, reduction_axes, keep_dims=keep_dims
- )
+ shard_mean, shard_variance = super()._moments(inputs, reduction_axes, keep_dims=keep_dims)
num_shards = tpu_function.get_tpu_context().number_of_shards or 1
if num_shards <= 8: # Skip cross_replica for 2x2 or smaller slices.
num_shards_per_group = 1
else:
num_shards_per_group = max(8, num_shards // 8)
- tf.compat.v1.logging.info(
- "TpuBatchNormalization with num_shards_per_group %s", num_shards_per_group
- )
+ tf.compat.v1.logging.info(f"TpuBatchNormalization with num_shards_per_group {num_shards_per_group}")
if num_shards_per_group > 1:
# Compute variance using: Var[X]= E[X^2] - E[X]^2.
shard_square_of_mean = tf.math.square(shard_mean)
shard_mean_of_square = shard_variance + shard_square_of_mean
group_mean = self._cross_replica_average(shard_mean, num_shards_per_group)
- group_mean_of_square = self._cross_replica_average(
- shard_mean_of_square, num_shards_per_group
- )
+ group_mean_of_square = self._cross_replica_average(shard_mean_of_square, num_shards_per_group)
group_variance = group_mean_of_square - tf.math.square(group_mean)
return group_mean, group_variance
return shard_mean, shard_variance
@@ -220,7 +190,7 @@ class BatchNormalization(tf.compat.v1.layers.BatchNormalization):
"""Fixed default name of BatchNormalization to match TpuBatchNormalization."""
def __init__(self, name="tpu_batch_normalization", **kwargs):
- super(BatchNormalization, self).__init__(name=name, **kwargs)
+ super().__init__(name=name, **kwargs)
def drop_connect(inputs, is_training, drop_connect_rate):
diff --git a/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py b/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
index f9c943a4fa..00c23b24dd 100644
--- a/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
@@ -9,7 +9,6 @@
# Licensed under GNU Lesser General Public License v3.0
#
-import os
import pickle
import shelve
import time
@@ -21,9 +20,8 @@
from tqdm import tqdm
from deeplabcut.pose_estimation_tensorflow.core import predict_multianimal as predict
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
+from deeplabcut.utils import auxfun_multianimal
from deeplabcut.utils.auxfun_videos import VideoWriter
-import pickle
def extract_bpt_feature_from_video(
@@ -40,70 +38,61 @@ def extract_bpt_feature_from_video(
robust_nframes=False,
):
print("Starting to analyze % ", video)
- vname = Path(video).stem
- videofolder = str(Path(video).parents[0])
- if destfolder is None:
- destfolder = videofolder
- auxiliaryfunctions.attempt_to_make_folder(destfolder)
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
-
- assemble_filename = dataname.split(".h5")[0] + "_assemblies.pickle"
+ video = Path(video)
+ destfolder = video.parent if destfolder is None else Path(destfolder)
+ destfolder.mkdir(exist_ok=True, parents=True)
+ basename = f"{video.stem}{DLCscorer}"
feature_dict = shelve.open(
- dataname.split(".h5")[0] + "_bpt_features.pickle",
+ str(destfolder / f"{basename}_bpt_features.pickle"),
protocol=pickle.DEFAULT_PROTOCOL,
)
- with open(assemble_filename, "rb") as f:
+ with open(destfolder / f"{basename}_assemblies.pickle", "rb") as f:
assemblies = pickle.load(f)
- print("Loading ", video)
- vid = VideoWriter(video)
- if robust_nframes:
- nframes = vid.get_n_frames(robust=True)
- duration = vid.calc_duration(robust=True)
- fps = nframes / duration
- else:
- nframes = len(vid)
- duration = vid.calc_duration(robust=False)
- fps = vid.fps
-
- nx, ny = vid.dimensions
- print(
- "Duration of video [s]: ",
- round(duration, 2),
- ", recorded with ",
- round(fps, 2),
- "fps!",
- )
- print(
- "Overall # of frames: ",
- nframes,
- " found with (before cropping) frame dimensions: ",
- nx,
- ny,
- )
- start = time.time()
- print("Starting to extract posture")
- if int(dlc_cfg["batch_size"]) > 1:
- # for multi animal, seems only this is used
- PredicteData, nframes = GetPoseandCostsF_from_assemblies(
- cfg,
- dlc_cfg,
- sess,
- inputs,
- outputs,
- vid,
- nframes,
- int(dlc_cfg["batch_size"]),
- assemblies,
- feature_dict,
- extra_dict,
- )
- else:
- raise NotImplementedError(
- "Not implemented yet, please raise an GitHub issue if you need this."
- )
+ print("Loading ", video)
+ vid = VideoWriter(str(video))
+ if robust_nframes:
+ nframes = vid.get_n_frames(robust=True)
+ duration = vid.calc_duration(robust=True)
+ fps = nframes / duration
+ else:
+ nframes = len(vid)
+ duration = vid.calc_duration(robust=False)
+ fps = vid.fps
+
+ print(
+ "Duration of video [s]: ",
+ round(duration, 2),
+ ", recorded with ",
+ round(fps, 2),
+ "fps!",
+ )
+ print(
+ "Overall # of frames: ",
+ nframes,
+ " found with (before cropping) frame dimensions: ",
+ vid.dimensions,
+ )
+
+ print("Starting to extract posture")
+ if int(dlc_cfg["batch_size"]) <= 1:
+ raise NotImplementedError("Not implemented yet, please raise an GitHub issue if you need this.")
+ # for multi animal, seems only 'dlc_cfg["batch_size"]) > 1' is used
+ predicted_data, nframes = GetPoseandCostsF_from_assemblies(
+ cfg,
+ dlc_cfg,
+ sess,
+ inputs,
+ outputs,
+ vid,
+ nframes,
+ int(dlc_cfg["batch_size"]),
+ assemblies,
+ feature_dict,
+ extra_dict,
+ )
def AnalyzeMultiAnimalVideo(
@@ -122,129 +111,119 @@ def AnalyzeMultiAnimalVideo(
"""Helper function for analyzing a video with multiple individuals"""
print("Starting to analyze % ", video)
- vname = Path(video).stem
- videofolder = str(Path(video).parents[0])
- if destfolder is None:
- destfolder = videofolder
- auxiliaryfunctions.attempt_to_make_folder(destfolder)
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
-
- if os.path.isfile(dataname.split(".h5")[0] + "_full.pickle"):
- print("Video already analyzed!", dataname)
+ video = Path(video)
+ destfolder = video.parent if destfolder is None else Path(destfolder)
+ destfolder.mkdir(exist_ok=True, parents=True)
+ basename = f"{video.stem}{DLCscorer}"
+ full_pickle = destfolder / f"{basename}_full.pickle"
+
+ if full_pickle.is_file():
+ print("Video already analyzed!", full_pickle)
+ return None
+
+ print("Loading ", video)
+ vid = VideoWriter(str(video))
+ if robust_nframes:
+ nframes = vid.get_n_frames(robust=True)
+ duration = vid.calc_duration(robust=True)
+ fps = nframes / duration
else:
- print("Loading ", video)
- vid = VideoWriter(video)
- if robust_nframes:
- nframes = vid.get_n_frames(robust=True)
- duration = vid.calc_duration(robust=True)
- fps = nframes / duration
- else:
- nframes = len(vid)
- duration = vid.calc_duration(robust=False)
- fps = vid.fps
-
- nx, ny = vid.dimensions
- print(
- "Duration of video [s]: ",
- round(duration, 2),
- ", recorded with ",
- round(fps, 2),
- "fps!",
+ nframes = len(vid)
+ duration = vid.calc_duration(robust=False)
+ fps = vid.fps
+
+ print(
+ "Duration of video [s]: ",
+ round(duration, 2),
+ ", recorded with ",
+ round(fps, 2),
+ "fps!",
+ )
+ print(
+ "Overall # of frames: ",
+ nframes,
+ " found with (before cropping) frame dimensions: ",
+ vid.dimensions,
+ )
+ start = time.time()
+
+ print(
+ "Starting to extract posture from the video(s) with batchsize:",
+ dlc_cfg["batch_size"],
+ )
+
+ shelf_path = str(full_pickle) if use_shelve else ""
+ if int(dlc_cfg["batch_size"]) > 1:
+ predicted_data, nframes = GetPoseandCostsF(
+ cfg,
+ dlc_cfg,
+ sess,
+ inputs,
+ outputs,
+ vid,
+ nframes,
+ int(dlc_cfg["batch_size"]),
+ shelf_path,
)
- print(
- "Overall # of frames: ",
+ else:
+ predicted_data, nframes = GetPoseandCostsS(
+ cfg,
+ dlc_cfg,
+ sess,
+ inputs,
+ outputs,
+ vid,
nframes,
- " found with (before cropping) frame dimensions: ",
- nx,
- ny,
+ shelf_path,
)
- start = time.time()
- print(
- "Starting to extract posture from the video(s) with batchsize:",
- dlc_cfg["batch_size"],
- )
- if use_shelve:
- shelf_path = dataname.split(".h5")[0] + "_full.pickle"
- else:
- shelf_path = ""
- if int(dlc_cfg["batch_size"]) > 1:
- PredicteData, nframes = GetPoseandCostsF(
- cfg,
- dlc_cfg,
- sess,
- inputs,
- outputs,
- vid,
- nframes,
- int(dlc_cfg["batch_size"]),
- shelf_path,
- )
- else:
- PredicteData, nframes = GetPoseandCostsS(
- cfg,
- dlc_cfg,
- sess,
- inputs,
- outputs,
- vid,
- nframes,
- shelf_path,
- )
+ stop = time.time()
- stop = time.time()
-
- if cfg["cropping"] == True:
- coords = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]]
- else:
- coords = [0, nx, 0, ny]
-
- dictionary = {
- "start": start,
- "stop": stop,
- "run_duration": stop - start,
- "Scorer": DLCscorer,
- "DLC-model-config file": dlc_cfg,
- "fps": fps,
- "batch_size": dlc_cfg["batch_size"],
- "frame_dimensions": (ny, nx),
- "nframes": nframes,
- "iteration (active-learning)": cfg["iteration"],
- "training set fraction": trainFraction,
- "cropping": cfg["cropping"],
- "cropping_parameters": coords,
- }
- metadata = {"data": dictionary}
- print("Video Analyzed. Saving results in %s..." % (destfolder))
-
- if use_shelve:
- metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
- with open(metadata_path, "wb") as f:
- pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
- else:
- _ = auxfun_multianimal.SaveFullMultiAnimalData(
- PredicteData, metadata, dataname
- )
+ nx, ny = vid.dimensions
+ if cfg["cropping"]:
+ coords = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]]
+ else:
+ coords = [0, nx, 0, ny]
+
+ dictionary = {
+ "start": start,
+ "stop": stop,
+ "run_duration": stop - start,
+ "Scorer": DLCscorer,
+ "DLC-model-config file": dlc_cfg,
+ "fps": fps,
+ "batch_size": dlc_cfg["batch_size"],
+ "frame_dimensions": (ny, nx),
+ "nframes": nframes,
+ "iteration (active-learning)": cfg["iteration"],
+ "training set fraction": trainFraction,
+ "cropping": cfg["cropping"],
+ "cropping_parameters": coords,
+ }
+ metadata = {"data": dictionary}
+ print(f"Video Analyzed. Saving results in {destfolder}")
+
+ if use_shelve:
+ with open(destfolder / f"{basename}_meta.pickle", "wb") as f:
+ pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
+ else:
+ auxfun_multianimal.SaveFullMultiAnimalData(predicted_data, metadata, str(destfolder / f"{basename}.h5"))
def _get_features_dict(raw_coords, features, stride):
from deeplabcut.pose_tracking_pytorch import (
- load_features_from_coord,
convert_coord_from_img_space_to_feature_space,
+ load_features_from_coord,
)
- coords_img_space = np.array(
- [coord[:, :2] for coord in raw_coords]
- ) # only first two columns are useful
+ coords_img_space = np.array([coord[:, :2] for coord in raw_coords]) # only first two columns are useful
coords_feature_space = convert_coord_from_img_space_to_feature_space(
coords_img_space,
stride,
)
- bpt_features = load_features_from_coord(
- features.astype(np.float16), coords_feature_space
- )
+ bpt_features = load_features_from_coord(features.astype(np.float16), coords_feature_space)
return {"features": bpt_features, "coordinates": coords_img_space}
@@ -269,14 +248,12 @@ def GetPoseandCostsF_from_assemblies(
cap.set_bbox(cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
nx, ny = cap.dimensions
- frames = np.empty(
- (batchsize, ny, nx, 3), dtype="ubyte"
- ) # this keeps all frames in a batch
+ frames = np.empty((batchsize, ny, nx, 3), dtype="ubyte") # this keeps all frames in a batch
pbar = tqdm(total=nframes)
counter = 0
inds = []
- PredicteData = {}
+ predicted_data = {}
while cap.video.isOpened():
frame = cap.read_frame(crop=cfg["cropping"])
@@ -300,8 +277,8 @@ def GetPoseandCostsF_from_assemblies(
continue
D, features = preds
- for i, (ind, data) in enumerate(zip(inds, D)):
- PredicteData["frame" + str(ind).zfill(strwidth)] = data
+ for i, (ind, data) in enumerate(zip(inds, D, strict=False)):
+ predicted_data["frame" + str(ind).zfill(strwidth)] = data
raw_coords = assemblies.get(ind)
if raw_coords is None:
continue
@@ -326,8 +303,8 @@ def GetPoseandCostsF_from_assemblies(
continue
D, features = preds
- for i, (ind, data) in enumerate(zip(inds, D)):
- PredicteData["frame" + str(ind).zfill(strwidth)] = data
+ for i, (ind, data) in enumerate(zip(inds, D, strict=False)):
+ predicted_data["frame" + str(ind).zfill(strwidth)] = data
raw_coords = assemblies.get(ind)
if raw_coords is None:
continue
@@ -345,21 +322,17 @@ def GetPoseandCostsF_from_assemblies(
cap.close()
pbar.close()
feature_dict.close()
- PredicteData["metadata"] = {
+ predicted_data["metadata"] = {
"nms radius": dlc_cfg["nmsradius"],
"minimal confidence": dlc_cfg["minconfidence"],
"sigma": dlc_cfg.get("sigma", 1),
"PAFgraph": dlc_cfg["partaffinityfield_graph"],
- "PAFinds": dlc_cfg.get(
- "paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))
- ),
+ "PAFinds": dlc_cfg.get("paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))),
"all_joints": [[i] for i in range(len(dlc_cfg["all_joints"]))],
- "all_joints_names": [
- dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))
- ],
+ "all_joints_names": [dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))],
"nframes": nframes,
}
- return PredicteData, nframes
+ return predicted_data, nframes
def GetPoseandCostsF(
@@ -381,9 +354,7 @@ def GetPoseandCostsF(
cap.set_bbox(cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
nx, ny = cap.dimensions
- frames = np.empty(
- (batchsize, ny, nx, 3), dtype="ubyte"
- ) # this keeps all frames in a batch
+ frames = np.empty((batchsize, ny, nx, 3), dtype="ubyte") # this keeps all frames in a batch
pbar = tqdm(total=nframes)
counter = 0
inds = []
@@ -400,13 +371,9 @@ def GetPoseandCostsF(
"minimal confidence": dlc_cfg["minconfidence"],
"sigma": dlc_cfg.get("sigma", 1),
"PAFgraph": dlc_cfg["partaffinityfield_graph"],
- "PAFinds": dlc_cfg.get(
- "paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))
- ),
+ "PAFinds": dlc_cfg.get("paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))),
"all_joints": [[i] for i in range(len(dlc_cfg["all_joints"]))],
- "all_joints_names": [
- dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))
- ],
+ "all_joints_names": [dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))],
"nframes": nframes,
}
while cap.video.isOpened():
@@ -429,7 +396,7 @@ def GetPoseandCostsF(
inputs,
outputs,
)
- for ind, data in zip(inds, D):
+ for ind, data in zip(inds, D, strict=False):
db["frame" + str(ind).zfill(strwidth)] = data
del D
batch_ind = 0
@@ -446,7 +413,7 @@ def GetPoseandCostsF(
inputs,
outputs,
)
- for ind, data in zip(inds, D):
+ for ind, data in zip(inds, D, strict=False):
db["frame" + str(ind).zfill(strwidth)] = data
del D
break
@@ -480,13 +447,9 @@ def GetPoseandCostsS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, shelf_pa
"minimal confidence": dlc_cfg["minconfidence"],
"sigma": dlc_cfg.get("sigma", 1),
"PAFgraph": dlc_cfg["partaffinityfield_graph"],
- "PAFinds": dlc_cfg.get(
- "paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))
- ),
+ "PAFinds": dlc_cfg.get("paf_best", np.arange(len(dlc_cfg["partaffinityfield_graph"]))),
"all_joints": [[i] for i in range(len(dlc_cfg["all_joints"]))],
- "all_joints_names": [
- dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))
- ],
+ "all_joints_names": [dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))],
"nframes": nframes,
}
pbar = tqdm(total=nframes)
diff --git a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py b/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py
deleted file mode 100644
index 1933e013b3..0000000000
--- a/deeplabcut/pose_estimation_tensorflow/predict_supermodel.py
+++ /dev/null
@@ -1,110 +0,0 @@
-#
-# DeepLabCut Toolbox (deeplabcut.org)
-# © A. & M.W. Mathis Labs
-# https://github.com/DeepLabCut/DeepLabCut
-#
-# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-#
-# Licensed under GNU Lesser General Public License v3.0
-#
-from pathlib import Path
-from deeplabcut.modelzoo.api import SpatiotemporalAdaptation
-
-
-def video_inference_superanimal(
- videos,
- superanimal_name,
- scale_list=[],
- videotype=".mp4",
- video_adapt=False,
- plot_trajectories=True,
- pcutoff=0.1,
- adapt_iterations=1000,
- pseudo_threshold=0.1,
-):
- """
- Makes prediction based on a super animal model. Note right now we only support single animal video inference
-
- The index of the trained network is specified by parameters in the config file (in particular the variable 'snapshotindex')
-
- Output: The labels are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n
- in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n
- in the same directory, where the video is stored.
-
- Parameters
- ----------
- videos: list
- A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored.
-
- superanimal_name: str
- The name of the superanimal model. We currently only support "superanimal_quadruped" and "superanimal_topviewmouse"
- scale_list: list
- A list of int containing the target height of the multi scale test time augmentation. By default it uses the original size. Users are advised to try a wide range of scale list when the super model does not give reasonable results
-
- videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed. The default is ``.avi``
-
- video_adapt: bool, optional
- Set True if you want to apply video adaptation to make the resulted video less jittering and better. However, adaptation training takes more time than usual video inference
-
- plot_trajectories: bool, optional (default=True)
- By default, plot the trajectories of various body parts across the video.
-
- pcutoff: float, optional
- Keypoints confidence that are under pcutoff will not be shown in the resulted video
-
- adapt_iterations: int, optional:
- Number of iterations for adaptation training
-
- pseudo_threshold: float, default 0.1
- Video adaptation only uses predictions that are above pseudo_threshold
-
- Given a list of scales for spatial pyramid, i.e. [600, 700]
-
- scale_list = range(600,800,100)
-
- superanimal_name = 'superanimal_topviewmouse'
- videotype = 'mp4'
- scale_list = [200, 300, 400]
- deeplabcut.video_inference_superanimal(
- video,
- superanimal_name,
- videotype = '.avi',
- scale_list = scale_list,
- )
- >>>
- """
- from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path
-
- for video in videos:
- vname = Path(video).stem
- dlcparent_path = get_deeplabcut_path()
- modelfolder = (
- Path(dlcparent_path)
- / "pose_estimation_tensorflow"
- / "models"
- / "pretrained"
- / (superanimal_name + "_" + vname + "_weights")
- )
- adapter = SpatiotemporalAdaptation(
- video,
- superanimal_name,
- modelfolder=modelfolder,
- videotype=videotype,
- scale_list=scale_list,
- )
- if not video_adapt:
- adapter.before_adapt_inference(
- make_video=True, pcutoff=pcutoff, plot_trajectories=plot_trajectories
- )
- else:
- adapter.before_adapt_inference(make_video=False)
- adapter.adaptation_training(
- adapt_iterations=adapt_iterations,
- pseudo_threshold=pseudo_threshold,
- )
- adapter.after_adapt_inference(
- pcutoff=pcutoff,
- plot_trajectories=plot_trajectories,
- )
diff --git a/deeplabcut/pose_estimation_tensorflow/predict_videos.py b/deeplabcut/pose_estimation_tensorflow/predict_videos.py
index 3a994388fa..a3b11440a3 100644
--- a/deeplabcut/pose_estimation_tensorflow/predict_videos.py
+++ b/deeplabcut/pose_estimation_tensorflow/predict_videos.py
@@ -21,6 +21,7 @@
import re
import time
import warnings
+from collections.abc import Sequence
from pathlib import Path
import cv2
@@ -31,28 +32,29 @@
from skimage.util import img_as_ubyte
from tqdm import tqdm
+from deeplabcut.core import inferenceutils, trackingutils
from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.core import predict
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils, trackingutils
-
-from deeplabcut.refine_training_dataset.stitch import stitch_tracklets
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, auxfun_models
from deeplabcut.pose_estimation_tensorflow.core.openvino.session import (
GetPoseF_OV,
is_openvino_available,
)
-
+from deeplabcut.refine_training_dataset.stitch import stitch_tracklets
+from deeplabcut.utils import auxfun_models, auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
####################################################
# Loading data, and defining model folder
####################################################
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_tracking_dataset(
config,
videos,
track_method,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
gputouse=None,
@@ -68,16 +70,18 @@ def create_tracking_dataset(
):
try:
from deeplabcut.pose_tracking_pytorch import create_triplets_dataset
- except ModuleNotFoundError:
+ except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"Unsupervised identity learning requires PyTorch. Please run `pip install torch`."
- )
+ ) from e
from deeplabcut.pose_estimation_tensorflow.predict_multianimal import (
extract_bpt_feature_from_video,
)
- # allow_growth must be true here because tensorflow does not automatically free gpu memory and setting it as false occupies all gpu memory so that pytorch cannot kick in
+ # allow_growth must be true here because tensorflow does not automatically
+ # free gpu memory and setting it as false occupies all gpu memory so that
+ # pytorch cannot kick in
allow_growth = True
if "TF_CUDNN_USE_AUTOTUNE" in os.environ:
@@ -100,57 +104,39 @@ def create_tracking_dataset(
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
try:
dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
- # Check which snapshots are available and sort them by # iterations
- try:
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(modelfolder, "train"))
- if "index" in fn
- ]
- )
- except FileNotFoundError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
+ )
if cfg["snapshotindex"] == "all":
print(
- "Snapshotindex is set to 'all' in the config.yaml file. Running video analysis with all snapshots is very costly! Use the function 'evaluate_network' to choose the best the snapshot. For now, changing snapshot index to -1!"
+ "Snapshotindex is set to 'all' in the config.yaml file. "
+ "Running video analysis with all snapshots is very costly! "
+ "Use the function 'evaluate_network' to choose the best the snapshot. "
+ "For now, changing snapshot index to -1!"
)
snapshotindex = -1
else:
snapshotindex = cfg["snapshotindex"]
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
-
- print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder)
+ print(f"Using {Snapshots[snapshotindex]}", "for model", modelfolder)
##################################################
# Load and setup CNN part detector
##################################################
# Check if data already was generated:
- dlc_cfg["init_weights"] = os.path.join(
- modelfolder, "train", Snapshots[snapshotindex]
- )
+ dlc_cfg["init_weights"] = os.path.join(modelfolder, "train", Snapshots[snapshotindex])
trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
# Update number of output and batchsize
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
@@ -173,7 +159,9 @@ def create_tracking_dataset(
TFGPUinference = False
dlc_cfg["batch_size"] = 1
print(
- "Switching batchsize to 1, num_outputs (per animal) to 1 and TFGPUinference to False (all these features are not supported in this mode)."
+ "Switching batchsize to 1, "
+ "num_outputs (per animal) to 1 and TFGPUinference to False "
+ "(all these features are not supported in this mode)."
)
# Name for scorer:
@@ -187,7 +175,8 @@ def create_tracking_dataset(
if dlc_cfg["num_outputs"] > 1:
if TFGPUinference:
print(
- "Switching to numpy-based keypoint extraction code, as multiple point extraction is not supported by TF code currently."
+ "Switching to numpy-based keypoint extraction code, "
+ "as multiple point extraction is not supported by TF code currently."
)
TFGPUinference = False
print("Extracting ", dlc_cfg["num_outputs"], "instances per bodypart")
@@ -199,15 +188,13 @@ def create_tracking_dataset(
xyz_labs = ["x", "y", "likelihood"]
if TFGPUinference:
- sess, inputs, outputs = predict.setup_GPUpose_prediction(
- dlc_cfg, allow_growth=allow_growth
- )
+ sess, inputs, outputs = predict.setup_GPUpose_prediction(dlc_cfg, allow_growth=allow_growth)
else:
sess, inputs, outputs, extra_dict = predict.setup_pose_prediction(
dlc_cfg, allow_growth=allow_growth, collect_extra=True
)
- pdindex = pd.MultiIndex.from_product(
+ pd.MultiIndex.from_product(
[[DLCscorer], dlc_cfg["all_joints_names"], xyz_labs],
names=["scorer", "bodyparts", "coords"],
)
@@ -215,7 +202,7 @@ def create_tracking_dataset(
##################################################
# Looping over videos
##################################################
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if len(Videos) > 0:
if "multi-animal" in dlc_cfg["dataset_type"]:
for video in Videos:
@@ -250,25 +237,29 @@ def create_tracking_dataset(
os.chdir(str(start_path))
if "multi-animal" in dlc_cfg["dataset_type"]:
print(
- "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. "
+ "You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
)
else:
print(
- "The videos are analyzed. Now your research can truly start! \n You can create labeled videos with 'create_labeled_video'"
+ "The videos are analyzed. Now your research can truly start! "
+ "\n You can create labeled videos with 'create_labeled_video'"
)
print(
- "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. "
+ "You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
)
return DLCscorer # note: this is either DLCscorer or DLCscorerlegacy depending on what was used!
else:
- print("No video(s) were found. Please check your paths and/or 'videotype'.")
+ print("No video(s) were found. Please check your paths and/or video extensions filter.")
return DLCscorer
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def analyze_videos(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
gputouse=None,
@@ -285,6 +276,7 @@ def analyze_videos(
use_shelve=False,
auto_track=True,
n_tracks=None,
+ animal_names=None,
calibrate=False,
identity_only=False,
use_openvino="CPU" if is_openvino_available else None,
@@ -311,10 +303,14 @@ def analyze_videos(
A list of strings containing the full paths to videos for analysis or a path to
the directory, where all the videos with same extension are stored.
- videotype: str, optional, default=""
- Checks for the extension of the video in case the input to the video is a
- directory. Only videos with this extension are analyzed. If left unspecified,
- videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle: int, optional, default=1
An integer specifying the shuffle index of the training dataset used for
@@ -357,10 +353,14 @@ def analyze_videos(
Source: https://arxiv.org/abs/1909.11229
dynamic: tuple(bool, float, int) triple containing (state, detectiontreshold, margin)
- If the state is true, then dynamic cropping will be performed. That means that if an object is detected (i.e. any body part > detectiontreshold),
- then object boundaries are computed according to the smallest/largest x position and smallest/largest y position of all body parts. This window is
- expanded by the margin and from then on only the posture within this crop is analyzed (until the object is lost, i.e. detectiontreshold),
+ then object boundaries are computed according to
+ the smallest/largest x position and smallest/largest y position of all body parts.
+ This window is expanded by the margin and from then on only the posture within
+ this crop is analyzed (until the object is lost, i.e. >> deeplabcut.analyze_videos(
'/analysis/project/reaching-task/config.yaml',
['/analysis/project/videos'],
- videotype='.avi',
+ video_extensions='.avi',
)
Analyze multiple videos
@@ -498,57 +505,40 @@ def analyze_videos(
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
try:
dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for iteration %s and shuffle %s and trainFraction %s does not exist."
- % (iteration, shuffle, trainFraction)
- )
+ f"It seems the model for iteration {iteration} and shuffle "
+ f"{shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
- # Check which snapshots are available and sort them by # iterations
- try:
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(modelfolder, "train"))
- if "index" in fn
- ]
- )
- except FileNotFoundError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Be sure you also have the intended iteration number set.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
+ )
if cfg["snapshotindex"] == "all":
print(
- "Snapshotindex is set to 'all' in the config.yaml file. Running video analysis with all snapshots is very costly! Use the function 'evaluate_network' to choose the best the snapshot. For now, changing snapshot index to -1!"
+ "Snapshotindex is set to 'all' in the config.yaml file."
+ "Running video analysis with all snapshots is very costly! "
+ "Use the function 'evaluate_network' to choose the best the snapshot. "
+ "For now, changing snapshot index to -1!"
)
snapshotindex = -1
else:
snapshotindex = cfg["snapshotindex"]
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
-
- print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder)
+ print(f"Using {Snapshots[snapshotindex]}", "for model", modelfolder)
##################################################
# Load and setup CNN part detector
##################################################
# Check if data already was generated:
- dlc_cfg["init_weights"] = os.path.join(
- modelfolder, "train", Snapshots[snapshotindex]
- )
+ dlc_cfg["init_weights"] = os.path.join(modelfolder, "train", Snapshots[snapshotindex])
trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
# Update number of output and batchsize
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
@@ -571,7 +561,8 @@ def analyze_videos(
TFGPUinference = False
dlc_cfg["batch_size"] = 1
print(
- "Switching batchsize to 1, num_outputs (per animal) to 1 and TFGPUinference to False (all these features are not supported in this mode)."
+ "Switching batchsize to 1, num_outputs (per animal) to 1 "
+ "and TFGPUinference to False (all these features are not supported in this mode)."
)
# Name for scorer:
@@ -585,7 +576,8 @@ def analyze_videos(
if dlc_cfg["num_outputs"] > 1:
if TFGPUinference:
print(
- "Switching to numpy-based keypoint extraction code, as multiple point extraction is not supported by TF code currently."
+ "Switching to numpy-based keypoint extraction code, "
+ "as multiple point extraction is not supported by TF code currently."
)
TFGPUinference = False
print("Extracting ", dlc_cfg["num_outputs"], "instances per bodypart")
@@ -597,17 +589,11 @@ def analyze_videos(
xyz_labs = ["x", "y", "likelihood"]
if use_openvino:
- sess, inputs, outputs = predict.setup_openvino_pose_prediction(
- dlc_cfg, device=use_openvino
- )
+ sess, inputs, outputs = predict.setup_openvino_pose_prediction(dlc_cfg, device=use_openvino)
elif TFGPUinference:
- sess, inputs, outputs = predict.setup_GPUpose_prediction(
- dlc_cfg, allow_growth=allow_growth
- )
+ sess, inputs, outputs = predict.setup_GPUpose_prediction(dlc_cfg, allow_growth=allow_growth)
else:
- sess, inputs, outputs = predict.setup_pose_prediction(
- dlc_cfg, allow_growth=allow_growth
- )
+ sess, inputs, outputs = predict.setup_pose_prediction(dlc_cfg, allow_growth=allow_growth)
pdindex = pd.MultiIndex.from_product(
[[DLCscorer], dlc_cfg["all_joints_names"], xyz_labs],
@@ -617,7 +603,7 @@ def analyze_videos(
##################################################
# Looping over videos
##################################################
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype, in_random_order)
+ Videos = collect_video_paths(videos, extensions=video_extensions, shuffle=in_random_order)
if len(Videos) > 0:
if "multi-animal" in dlc_cfg["dataset_type"]:
from deeplabcut.pose_estimation_tensorflow.predict_multianimal import (
@@ -642,7 +628,7 @@ def analyze_videos(
convert_detections2tracklets(
config,
[video],
- videotype,
+ video_extensions,
shuffle,
trainingsetindex,
destfolder=destfolder,
@@ -653,11 +639,12 @@ def analyze_videos(
stitch_tracklets(
config,
[video],
- videotype,
+ video_extensions,
shuffle,
trainingsetindex,
destfolder=destfolder,
n_tracks=n_tracks,
+ animal_names=animal_names,
modelprefix=modelprefix,
save_as_csv=save_as_csv,
)
@@ -684,28 +671,33 @@ def analyze_videos(
os.chdir(str(start_path))
if "multi-animal" in dlc_cfg["dataset_type"]:
print(
- "The videos are analyzed. Time to assemble animals and track 'em... \n Call 'create_video_with_all_detections' to check multi-animal detection quality before tracking."
+ "The videos are analyzed. Time to assemble animals and track 'em... \n"
+ " Call 'create_video_with_all_detections' to check multi-animal detection quality before tracking."
)
print(
- "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. "
+ "You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
)
else:
print(
- "The videos are analyzed. Now your research can truly start! \n You can create labeled videos with 'create_labeled_video'"
+ "The videos are analyzed. Now your research can truly start! \n "
+ "You can create labeled videos with 'create_labeled_video'"
)
print(
- "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. "
+ "You can use the function 'extract_outlier_frames' to extract a few representative outlier frames."
)
return DLCscorer # note: this is either DLCscorer or DLCscorerlegacy depending on what was used!
else:
- print("No video(s) were found. Please check your paths and/or 'video_type'.")
+ print("No video(s) were found. Please check your paths and/or video_extensions filter.")
return DLCscorer
def checkcropping(cfg, cap):
print(
- "Cropping based on the x1 = %s x2 = %s y1 = %s y2 = %s. You can adjust the cropping coordinates in the config.yaml file."
- % (cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
+ "Cropping based on the "
+ f"x1 = {cfg['x1']} x2 = {cfg['x2']} y1 = {cfg['y1']} y2 = {cfg['y2']}. "
+ "You can adjust the cropping coordinates in the config.yaml file."
)
nx = cfg["x2"] - cfg["x1"]
ny = cfg["y2"] - cfg["y1"]
@@ -726,21 +718,15 @@ def checkcropping(cfg, cap):
def GetPoseF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize):
- """Batchwise prediction of pose"""
- PredictedData = np.zeros(
- (nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"]))
- )
+ """Batchwise prediction of pose."""
+ PredictedData = np.zeros((nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"])))
batch_ind = 0 # keeps track of which image within a batch should be written to
batch_num = 0 # keeps track of which batch you are at
- ny, nx = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(
- cap.get(cv2.CAP_PROP_FRAME_WIDTH)
- )
+ ny, nx = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
if cfg["cropping"]:
ny, nx = checkcropping(cfg, cap)
- frames = np.empty(
- (batchsize, ny, nx, 3), dtype="ubyte"
- ) # this keeps all frames in a batch
+ frames = np.empty((batchsize, ny, nx, 3), dtype="ubyte") # this keeps all frames in a batch
pbar = tqdm(total=nframes)
counter = 0
step = max(10, int(nframes / 100))
@@ -752,9 +738,7 @@ def GetPoseF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize):
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if cfg["cropping"]:
- frames[batch_ind] = img_as_ubyte(
- frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- )
+ frames[batch_ind] = img_as_ubyte(frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])
else:
frames[batch_ind] = img_as_ubyte(frame)
inds.append(counter)
@@ -784,9 +768,7 @@ def GetPoseS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
if cfg["cropping"]:
ny, nx = checkcropping(cfg, cap)
- PredictedData = np.zeros(
- (nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"]))
- )
+ PredictedData = np.zeros((nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"])))
pbar = tqdm(total=nframes)
counter = 0
step = max(10, int(nframes / 100))
@@ -798,15 +780,11 @@ def GetPoseS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if cfg["cropping"]:
- frame = img_as_ubyte(
- frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- )
+ frame = img_as_ubyte(frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])
else:
frame = img_as_ubyte(frame)
pose = predict.getpose(frame, dlc_cfg, sess, inputs, outputs)
- PredictedData[
- counter, :
- ] = (
+ PredictedData[counter, :] = (
pose.flatten()
) # NOTE: thereby cfg['all_joints_names'] should be same order as bodyparts!
elif counter >= nframes:
@@ -822,9 +800,7 @@ def GetPoseS_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
if cfg["cropping"]:
ny, nx = checkcropping(cfg, cap)
- pose_tensor = predict.extract_GPUprediction(
- outputs, dlc_cfg
- ) # extract_output_tensor(outputs, dlc_cfg)
+ pose_tensor = predict.extract_GPUprediction(outputs, dlc_cfg) # extract_output_tensor(outputs, dlc_cfg)
PredictedData = np.zeros((nframes, 3 * len(dlc_cfg["all_joints_names"])))
pbar = tqdm(total=nframes)
counter = 0
@@ -837,9 +813,7 @@ def GetPoseS_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if cfg["cropping"]:
- frame = img_as_ubyte(
- frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- )
+ frame = img_as_ubyte(frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])
else:
frame = img_as_ubyte(frame)
@@ -849,9 +823,7 @@ def GetPoseS_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
)
pose[:, [0, 1, 2]] = pose[:, [1, 0, 2]]
# pose = predict.getpose(frame, dlc_cfg, sess, inputs, outputs)
- PredictedData[
- counter, :
- ] = (
+ PredictedData[counter, :] = (
pose.flatten()
) # NOTE: thereby cfg['all_joints_names'] should be same order as bodyparts!
elif counter >= nframes:
@@ -863,7 +835,7 @@ def GetPoseS_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes):
def GetPoseF_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize):
- """Batchwise prediction of pose"""
+ """Batchwise prediction of pose."""
PredictedData = np.zeros((nframes, 3 * len(dlc_cfg["all_joints_names"])))
batch_ind = 0 # keeps track of which image within a batch should be written to
batch_num = 0 # keeps track of which batch you are at
@@ -885,7 +857,7 @@ def GetPoseF_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize):
ret, frame = cap.read()
counter += 1
if not ret:
- warnings.warn(f"Could not decode frame #{counter}.")
+ warnings.warn(f"Could not decode frame #{counter}.", stacklevel=2)
continue
if cfg["cropping"]:
@@ -921,16 +893,13 @@ def getboundingbox(x, y, nx, ny, margin):
return x1, x2, y1, y2
-def GetPoseDynamic(
- cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, detectiontreshold, margin
-):
- """Non batch wise pose estimation for video cap by dynamically cropping around previously detected parts."""
+def GetPoseDynamic(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, detectiontreshold, margin):
+ """Non batch wise pose estimation for video cap by dynamically cropping around
+ previously detected parts."""
if cfg["cropping"]:
ny, nx = checkcropping(cfg, cap)
else:
- ny, nx = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(
- cap.get(cv2.CAP_PROP_FRAME_WIDTH)
- )
+ ny, nx = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
x1, x2, y1, y2 = 0, nx, 0, ny
detected = False
# TODO: perform detection on resized image (For speed)
@@ -948,9 +917,7 @@ def GetPoseDynamic(
# print(counter,x1,x2,y1,y2,detected)
originalframe = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if cfg["cropping"]:
- frame = img_as_ubyte(
- originalframe[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- )[y1:y2, x1:x2]
+ frame = img_as_ubyte(originalframe[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])[y1:y2, x1:x2]
else:
frame = img_as_ubyte(originalframe[y1:y2, x1:x2])
@@ -969,19 +936,16 @@ def GetPoseDynamic(
else:
if (
detected and (x1 + y1 + y2 - ny + x2 - nx) != 0
- ): # was detected in last frame and dyn. cropping was performed >> but object lost in cropped variant >> re-run on full frame!
+ ): # was detected in last frame and dyn. cropping was performed >>
+ # but object lost in cropped variant >> re-run on full frame!
# print("looking again, lost!")
if cfg["cropping"]:
- frame = img_as_ubyte(
- originalframe[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]]
- )
+ frame = img_as_ubyte(originalframe[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])
else:
frame = img_as_ubyte(originalframe)
- pose = predict.getpose(
- frame, dlc_cfg, sess, inputs, outputs
- ).flatten() # no offset is necessary
+ pose = predict.getpose(frame, dlc_cfg, sess, inputs, outputs).flatten() # no offset is necessary
- x0, y0 = x1, y1
+ _x0, _y0 = x1, y1
x1, x2, y1, y2 = 0, nx, 0, ny
detected = False
@@ -1020,13 +984,11 @@ def AnalyzeVideo(
vname = Path(video).stem
try:
_ = auxiliaryfunctions.load_analyzed_data(destfolder, vname, DLCscorer)
- except FileNotFoundError:
+ except FileNotFoundError as e:
print("Loading ", video)
cap = cv2.VideoCapture(video)
if not cap.isOpened():
- raise IOError(
- "Video could not be opened. Please check that the the file integrity."
- )
+ raise OSError("Video could not be opened. Please check the file integrity.") from e
# https://docs.opencv.org/2.4/modules/highgui/doc/reading_and_writing_images_and_video.html#videocapture-get
fps = cap.get(cv2.CAP_PROP_FPS)
nframes = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
@@ -1087,16 +1049,12 @@ def AnalyzeVideo(
PredictedData, nframes = GetPoseF(*args)
else:
if TFGPUinference:
- PredictedData, nframes = GetPoseS_GTF(
- cfg, dlc_cfg, sess, inputs, outputs, cap, nframes
- )
+ PredictedData, nframes = GetPoseS_GTF(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes)
else:
- PredictedData, nframes = GetPoseS(
- cfg, dlc_cfg, sess, inputs, outputs, cap, nframes
- )
+ PredictedData, nframes = GetPoseS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes)
stop = time.time()
- if cfg["cropping"] == True:
+ if cfg["cropping"]:
coords = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]]
else:
coords = [0, nx, 0, ny]
@@ -1114,7 +1072,7 @@ def AnalyzeVideo(
"iteration (active-learning)": cfg["iteration"],
"training set fraction": trainFraction,
"cropping": cfg["cropping"],
- "cropping_parameters": coords
+ "cropping_parameters": coords,
# "gpu_info": device_lib.list_local_devices()
}
metadata = {"data": dictionary}
@@ -1129,14 +1087,11 @@ def AnalyzeVideo(
range(nframes),
save_as_csv,
)
- finally:
- return DLCscorer
+ return DLCscorer
-def GetPosesofFrames(
- cfg, dlc_cfg, sess, inputs, outputs, directory, framelist, nframes, batchsize
-):
- """Batchwise prediction of pose for frame list in directory"""
+def GetPosesofFrames(cfg, dlc_cfg, sess, inputs, outputs, directory, framelist, nframes, batchsize):
+ """Batchwise prediction of pose for frame list in directory."""
from deeplabcut.utils.auxfun_videos import imread
print("Starting to extract posture")
@@ -1151,27 +1106,22 @@ def GetPosesofFrames(
ny,
)
- PredictedData = np.zeros(
- (nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"]))
- )
+ PredictedData = np.zeros((nframes, dlc_cfg["num_outputs"] * 3 * len(dlc_cfg["all_joints_names"])))
batch_ind = 0 # keeps track of which image within a batch should be written to
batch_num = 0 # keeps track of which batch you are at
if cfg["cropping"]:
print(
- "Cropping based on the x1 = %s x2 = %s y1 = %s y2 = %s. You can adjust the cropping coordinates in the config.yaml file."
- % (cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
+ "Cropping based on the x1 = {} x2 = {} y1 = {} y2 = {}. "
+ "You can adjust the cropping coordinates in the config.yaml file.".format(
+ cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]
+ )
)
nx, ny = cfg["x2"] - cfg["x1"], cfg["y2"] - cfg["y1"]
if nx > 0 and ny > 0:
pass
else:
raise Exception("Please check the order of cropping parameter!")
- if (
- cfg["x1"] >= 0
- and cfg["x2"] < int(np.shape(im)[1])
- and cfg["y1"] >= 0
- and cfg["y2"] < int(np.shape(im)[0])
- ):
+ if cfg["x1"] >= 0 and cfg["x2"] < int(np.shape(im)[1]) and cfg["y1"] >= 0 and cfg["y2"] < int(np.shape(im)[0]):
pass # good cropping box
else:
raise Exception("Please check the boundary of cropping!")
@@ -1188,18 +1138,14 @@ def GetPosesofFrames(
pbar.update(step)
if cfg["cropping"]:
- frame = img_as_ubyte(
- im[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"], :]
- )
+ frame = img_as_ubyte(im[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"], :])
else:
frame = img_as_ubyte(im)
pose = predict.getpose(frame, dlc_cfg, sess, inputs, outputs)
PredictedData[counter, :] = pose.flatten()
else:
- frames = np.empty(
- (batchsize, ny, nx, 3), dtype="ubyte"
- ) # this keeps all the frames of a batch
+ frames = np.empty((batchsize, ny, nx, 3), dtype="ubyte") # this keeps all the frames of a batch
for counter, framename in enumerate(framelist):
im = imread(os.path.join(directory, framename), mode="skimage")
@@ -1207,31 +1153,23 @@ def GetPosesofFrames(
pbar.update(step)
if cfg["cropping"]:
- frames[batch_ind] = img_as_ubyte(
- im[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"], :]
- )
+ frames[batch_ind] = img_as_ubyte(im[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"], :])
else:
frames[batch_ind] = img_as_ubyte(im)
if batch_ind == batchsize - 1:
pose = predict.getposeNP(frames, dlc_cfg, sess, inputs, outputs)
- PredictedData[
- batch_num * batchsize : (batch_num + 1) * batchsize, :
- ] = pose
+ PredictedData[batch_num * batchsize : (batch_num + 1) * batchsize, :] = pose
batch_ind = 0
batch_num += 1
else:
batch_ind += 1
- if (
- batch_ind > 0
- ): # take care of the last frames (the batch that might have been processed)
+ if batch_ind > 0: # take care of the last frames (the batch that might have been processed)
pose = predict.getposeNP(
frames, dlc_cfg, sess, inputs, outputs
) # process the whole batch (some frames might be from previous batch!)
- PredictedData[
- batch_num * batchsize : batch_num * batchsize + batch_ind, :
- ] = pose[:batch_ind, :]
+ PredictedData[batch_num * batchsize : batch_num * batchsize + batch_ind, :] = pose[:batch_ind, :]
pbar.close()
return PredictedData, nframes, nx, ny
@@ -1247,15 +1185,20 @@ def analyze_time_lapse_frames(
save_as_csv=False,
modelprefix="",
):
- """
- Analyzed all images (of type = frametype) in a folder and stores the output in one file.
+ """Analyzed all images (of type = frametype) in a folder and stores the output in
+ one file.
- You can crop the frames (before analysis), by changing 'cropping'=True and setting 'x1','x2','y1','y2' in the config file.
+ You can crop the frames (before analysis),
+ by changing 'cropping'=True and setting 'x1','x2','y1','y2' in the config file.
- Output: The labels are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n
- in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n
- in the same directory, where the video is stored. However, if the flag save_as_csv is set to True, the data can also be exported in \n
- comma-separated values format (.csv), which in turn can be imported in many programs, such as MATLAB, R, Prism, etc.
+ Output:
+ The labels are stored as MultiIndex Pandas Array,
+ which contains the name of the network, body part name, (x, y) label position \n
+ in pixels, and the likelihood for each frame per body part.
+ These arrays are stored in an efficient Hierarchical Data Format (HDF) \n
+ in the same directory, where the video is stored.
+ However, if the flag save_as_csv is set to True, the data can also be exported in \n
+ comma-separated values format (.csv), which in turn can be imported in many programs, such as MATLAB, R, Prism, etc.
Parameters
----------
@@ -1266,27 +1209,35 @@ def analyze_time_lapse_frames(
Full path to directory containing the frames that shall be analyzed
frametype: string, optional
- Checks for the file extension of the frames. Only images with this extension are analyzed. The default is ``.png``
+ Checks for the file extension of the frames.
+ Only images with this extension are analyzed. The default is ``.png``
shuffle: int, optional
An integer specifying the shuffle index of the training dataset used for training the network. The default is 1.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
- gputouse: int, optional. Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU put None.
+ gputouse: int, optional. Natural number indicating the number of your GPU (see number in nvidia-smi).
+ If you do not have a GPU, set to None.
See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
save_as_csv: bool, optional
- Saves the predictions in a .csv file. The default is ``False``; if provided it must be either ``True`` or ``False``
+ Saves the predictions in a .csv file. The default is ``False``;
+ if provided it must be either ``True`` or ``False``
Examples
--------
If you want to analyze all frames in /analysis/project/timelapseexperiment1
- >>> deeplabcut.analyze_videos('/analysis/project/reaching-task/config.yaml','/analysis/project/timelapseexperiment1')
+ >>> deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ '/analysis/project/timelapseexperiment1'
+ )
--------
- Note: for test purposes one can extract all frames from a video with ffmeg, e.g. ffmpeg -i testvideo.avi thumb%04d.png
+ Note: for test purposes one can extract all frames from a video with ffmpeg,
+ e.g. ffmpeg -i testvideo.avi thumb%04d.png
"""
if "TF_CUDNN_USE_AUTOTUNE" in os.environ:
del os.environ["TF_CUDNN_USE_AUTOTUNE"] # was potentially set during training
@@ -1301,56 +1252,39 @@ def analyze_time_lapse_frames(
trainFraction = cfg["TrainingFraction"][trainingsetindex]
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
try:
dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
- # Check which snapshots are available and sort them by # iterations
- try:
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(modelfolder, "train"))
- if "index" in fn
- ]
- )
- except FileNotFoundError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
+
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
+ )
if cfg["snapshotindex"] == "all":
print(
- "Snapshotindex is set to 'all' in the config.yaml file. Running video analysis with all snapshots is very costly! Use the function 'evaluate_network' to choose the best the snapshot. For now, changing snapshot index to -1!"
+ "Snapshotindex is set to 'all' in the config.yaml file. "
+ "Running video analysis with all snapshots is very costly! "
+ "Use the function 'evaluate_network' to choose the best the snapshot. "
+ "For now, changing snapshot index to -1!"
)
snapshotindex = -1
else:
snapshotindex = cfg["snapshotindex"]
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
-
- print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder)
+ print(f"Using {Snapshots[snapshotindex]}", "for model", modelfolder)
##################################################
# Load and setup CNN part detector
##################################################
# Check if data already was generated:
- dlc_cfg["init_weights"] = os.path.join(
- modelfolder, "train", Snapshots[snapshotindex]
- )
+ dlc_cfg["init_weights"] = os.path.join(modelfolder, "train", Snapshots[snapshotindex])
trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
# update batchsize (based on parameters in config.yaml)
@@ -1386,10 +1320,8 @@ def analyze_time_lapse_frames(
# Loading the images
##################################################
# checks if input is a directory
- if os.path.isdir(directory) == True:
- """
- Analyzes all the frames in the directory.
- """
+ if os.path.isdir(directory):
+ """Analyzes all the frames in the directory."""
print("Analyzing all frames in the directory: ", directory)
os.chdir(directory)
framelist = np.sort([fn for fn in os.listdir(os.curdir) if (frametype in fn)])
@@ -1415,7 +1347,7 @@ def analyze_time_lapse_frames(
)
stop = time.time()
- if cfg["cropping"] == True:
+ if cfg["cropping"]:
coords = [cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"]]
else:
coords = [0, nx, 0, ny]
@@ -1435,7 +1367,7 @@ def analyze_time_lapse_frames(
}
metadata = {"data": dictionary}
- print("Saving results in %s..." % (directory))
+ print(f"Saving results in {directory}...")
auxiliaryfunctions.save_data(
PredictedData[:nframes, :],
@@ -1446,13 +1378,9 @@ def analyze_time_lapse_frames(
save_as_csv,
)
print("The folder was analyzed. Now your research can truly start!")
- print(
- "If the tracking is not satisfactory for some frame, consider expanding the training set."
- )
+ print("If the tracking is not satisfactory for some frame, consider expanding the training set.")
else:
- print(
- "No frames were found. Consider changing the path or the frametype."
- )
+ print("No frames were found. Consider changing the path or the frametype.")
os.chdir(str(start_path))
@@ -1472,6 +1400,9 @@ def _convert_detections_to_tracklets(
f"Invalid tracking method. Only {', '.join(trackingutils.TRACK_METHODS)} are currently supported."
)
+ if track_method == "ctd":
+ raise ValueError("CTD tracking is only available for BUCTD models with the PyTorch engine.")
+
joints = data["metadata"]["all_joints_names"]
partaffinityfield_graph = data["metadata"]["PAFgraph"]
paf_inds = data["metadata"]["PAFinds"]
@@ -1480,7 +1411,7 @@ def _convert_detections_to_tracklets(
mot_tracker = trackingutils.SORTBox(
inference_cfg["max_age"],
inference_cfg["min_hits"],
- inference_cfg.get("oks_threshold", 0.3),
+ inference_cfg.get("iou_threshold", 0.3),
)
elif track_method == "skeleton":
mot_tracker = trackingutils.SORTSkeleton(
@@ -1506,6 +1437,7 @@ def _convert_detections_to_tracklets(
greedy=greedy,
pcutoff=inference_cfg.get("pcutoff", 0.1),
min_affinity=inference_cfg.get("pafthreshold", 0.05),
+ min_n_links=inference_cfg["minimalnumberofconnections"],
)
if calibrate:
trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
@@ -1529,9 +1461,7 @@ def _convert_detections_to_tracklets(
assemblies = assembly_builder.assemblies.get(i)
if assemblies is None:
continue
- animals = np.stack(
- [assembly_builder.data[:, :3] for assembly_builder in assemblies]
- )
+ animals = np.stack([assembly_builder.data[:, :3] for assembly_builder in assemblies])
if track_method == "box":
xy = trackingutils.calc_bboxes_from_keypoints(
animals, inference_cfg.get("boundingboxslack", 0)
@@ -1554,10 +1484,11 @@ def _convert_detections_to_tracklets(
pickle.dump(tracklets, f, pickle.HIGHEST_PROTOCOL)
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def convert_detections2tracklets(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
overwrite=False,
@@ -1571,8 +1502,8 @@ def convert_detections2tracklets(
identity_only=False,
track_method="",
):
- """
- This should be called at the end of deeplabcut.analyze_videos for multianimal projects!
+ """This should be called at the end of deeplabcut.analyze_videos for multianimal
+ projects!
Parameters
----------
@@ -1580,24 +1511,32 @@ def convert_detections2tracklets(
Full path of the config.yaml file as a string.
videos : list
- A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored.
-
- videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ A list of strings containing the full paths to videos for analysis
+ or a path to the directory, where all the videos with same extension are stored.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle: int, optional
- An integer specifying the shuffle index of the training dataset used for training the network. The default is 1.
+ An integer specifying the shuffle index of the training dataset used for training the network.
+ The default is 1.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
overwrite: bool, optional.
Overwrite tracks file i.e. recompute tracks from full detections and overwrite.
destfolder: string, optional
- Specifies the destination folder for analysis data (default is the path of the video). Note that for subsequent analysis this
- folder also needs to be passed.
+ Specifies the destination folder for analysis data (default is the path of the video).
+ Note that for subsequent analysis this folder also needs to be passed.
ignore_bodyparts: optional
List of body part names that should be ignored during tracking (advanced).
@@ -1632,19 +1571,27 @@ def convert_detections2tracklets(
Examples
--------
If you want to convert detections to tracklets:
- >>> deeplabcut.convert_detections2tracklets('/analysis/project/reaching-task/config.yaml',[]'/analysis/project/video1.mp4'], videotype='.mp4')
+ >>> deeplabcut.convert_detections2tracklets(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/video1.mp4'],
+ video_extensions='.mp4'
+ )
If you want to convert detections to tracklets based on box_tracker:
- >>> deeplabcut.convert_detections2tracklets('/analysis/project/reaching-task/config.yaml',[]'/analysis/project/video1.mp4'], videotype='.mp4',track_method='box')
+ >>> deeplabcut.convert_detections2tracklets(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/video1.mp4'],
+ video_extensions='.mp4',
+ track_method='box'
+ )
--------
-
"""
cfg = auxiliaryfunctions.read_config(config)
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box":
- warnings.warn("Switching to `box` tracker for single point tracking...")
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
track_method = "box"
cfg["default_track_method"] = track_method
auxiliaryfunctions.write_config(config, cfg)
@@ -1661,20 +1608,15 @@ def convert_detections2tracklets(
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
try:
dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
if "multi-animal" not in dlc_cfg["dataset_type"]:
raise ValueError("This function is only required for multianimal projects!")
@@ -1686,41 +1628,29 @@ def convert_detections2tracklets(
auxfun_multianimal.check_inferencecfg_sanity(cfg, inferencecfg)
if len(cfg["multianimalbodyparts"]) == 1 and track_method != "box":
- warnings.warn("Switching to `box` tracker for single point tracking...")
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
track_method = "box"
# Also ensure `boundingboxslack` is greater than zero, otherwise overlap
# between trackers cannot be evaluated, resulting in empty tracklets.
inferencecfg["boundingboxslack"] = max(inferencecfg["boundingboxslack"], 40)
- # Check which snapshots are available and sort them by # iterations
- try:
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(modelfolder, "train"))
- if "index" in fn
- ]
- )
- except FileNotFoundError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s has not been trained/does not exist.\n Please train it before using it to analyze videos.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
+ )
if cfg["snapshotindex"] == "all":
print(
- "Snapshotindex is set to 'all' in the config.yaml file. Running video analysis with all snapshots is very costly! Use the function 'evaluate_network' to choose the best the snapshot. For now, changing snapshot index to -1!"
+ "Snapshotindex is set to 'all' in the config.yaml file. "
+ "Running video analysis with all snapshots is very costly! "
+ "Use the function 'evaluate_network' to choose the best the snapshot. "
+ "For now, changing snapshot index to -1!"
)
snapshotindex = -1
else:
snapshotindex = cfg["snapshotindex"]
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
- print("Using %s" % Snapshots[snapshotindex], "for model", modelfolder)
- dlc_cfg["init_weights"] = os.path.join(
- modelfolder, "train", Snapshots[snapshotindex]
- )
+ print(f"Using {Snapshots[snapshotindex]}", "for model", modelfolder)
+ dlc_cfg["init_weights"] = os.path.join(modelfolder, "train", Snapshots[snapshotindex])
trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
# Name for scorer:
@@ -1735,7 +1665,7 @@ def convert_detections2tracklets(
##################################################
# Looping over videos
##################################################
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if len(Videos) > 0:
for video in Videos:
print("Processing... ", video)
@@ -1755,9 +1685,7 @@ def convert_detections2tracklets(
trackname = dataname.split(".h5")[0] + f"_{method}.pickle"
# NOTE: If dataname line above is changed then line below is obsolete?
# trackname = trackname.replace(videofolder, destfolder)
- if (
- os.path.isfile(trackname) and not overwrite
- ): # TODO: check if metadata are identical (same parameters!)
+ if os.path.isfile(trackname) and not overwrite: # TODO: check if metadata are identical (same parameters!)
print("Tracklets already computed", trackname)
print("Set overwrite = True to overwrite.")
else:
@@ -1768,10 +1696,9 @@ def convert_detections2tracklets(
numjoints = len(all_jointnames)
# TODO: adjust this for multi + unique bodyparts!
- # this is only for multianimal parts and uniquebodyparts as one (not one uniquebodyparts guy tracked etc. )
- bodypartlabels = [
- bpt for i, bpt in enumerate(all_jointnames) for _ in range(3)
- ]
+ # this is only for multianimal parts and uniquebodyparts as one (not one
+ # uniquebodyparts guy tracked etc. )
+ bodypartlabels = [bpt for i, bpt in enumerate(all_jointnames) for _ in range(3)]
scorers = len(bodypartlabels) * [DLCscorer]
xylvalue = int(len(bodypartlabels) / 3) * ["x", "y", "likelihood"]
pdindex = pd.MultiIndex.from_arrays(
@@ -1785,7 +1712,7 @@ def convert_detections2tracklets(
mot_tracker = trackingutils.SORTBox(
inferencecfg["max_age"],
inferencecfg["min_hits"],
- inferencecfg.get("oks_threshold", 0.3),
+ inferencecfg.get("iou_threshold", 0.3),
)
elif track_method == "skeleton":
mot_tracker = trackingutils.SORTSkeleton(
@@ -1811,13 +1738,12 @@ def convert_detections2tracklets(
min_affinity=inferencecfg.get("pafthreshold", 0.05),
window_size=window_size,
identity_only=identity_only,
+ min_n_links=inferencecfg["minimalnumberofconnections"],
)
assemblies_filename = dataname.split(".h5")[0] + "_assemblies.pickle"
if not os.path.exists(assemblies_filename) or overwrite:
if calibrate:
- trainingsetfolder = auxiliaryfunctions.get_training_set_folder(
- cfg
- )
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
train_data_file = os.path.join(
cfg["project_path"],
str(trainingsetfolder),
@@ -1834,9 +1760,7 @@ def convert_detections2tracklets(
except AttributeError:
pass
- if cfg[
- "uniquebodyparts"
- ]: # Initialize storage of the 'single' individual track
+ if cfg["uniquebodyparts"]: # Initialize storage of the 'single' individual track
tracklets["single"] = {}
_single = {}
for index, imname in enumerate(imnames):
@@ -1861,9 +1785,7 @@ def convert_detections2tracklets(
assemblies = assembly_builder.assemblies.get(index)
if assemblies is None:
continue
- animals = np.stack(
- [assembly_builder.data for assembly_builder in assemblies]
- )
+ animals = np.stack([assembly_builder.data for assembly_builder in assemblies])
if not identity_only:
if track_method == "box":
xy = trackingutils.calc_bboxes_from_keypoints(
@@ -1875,17 +1797,13 @@ def convert_detections2tracklets(
trackers = mot_tracker.track(xy)
else:
# Optimal identity assignment based on soft voting
- mat = np.zeros(
- (len(assemblies), inferencecfg["topktoretain"])
- )
+ mat = np.zeros((len(assemblies), inferencecfg["topktoretain"]))
for nrow, assembly in enumerate(assemblies):
for k, v in assembly.soft_identity.items():
mat[nrow, k] = v
inds = linear_sum_assignment(mat, maximize=True)
trackers = np.c_[inds][:, ::-1]
- trackingutils.fill_tracklets(
- tracklets, trackers, animals, imname
- )
+ trackingutils.fill_tracklets(tracklets, trackers, animals, imname)
tracklets["header"] = pdindex
with open(trackname, "wb") as f:
@@ -1894,7 +1812,8 @@ def convert_detections2tracklets(
os.chdir(str(start_path))
print(
- "The tracklets were created (i.e., under the hood deeplabcut.convert_detections2tracklets was run). Now you can 'refine_tracklets' in the GUI, or run 'deeplabcut.stitch_tracklets'."
+ "The tracklets were created (i.e., under the hood deeplabcut.convert_detections2tracklets was run). "
+ "Now you can 'refine_tracklets' in the GUI, or run 'deeplabcut.stitch_tracklets'."
)
else:
print("No video(s) found. Please check your path!")
diff --git a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml b/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml
deleted file mode 100644
index b6088f9c9b..0000000000
--- a/deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml
+++ /dev/null
@@ -1,150 +0,0 @@
-all_joints:
-- - 0
-- - 1
-- - 2
-- - 3
-- - 4
-- - 5
-- - 6
-- - 7
-- - 8
-- - 9
-- - 10
-- - 11
-- - 12
-- - 13
-- - 14
-- - 15
-- - 16
-- - 17
-- - 18
-- - 19
-- - 20
-- - 21
-- - 22
-- - 23
-- - 24
-- - 25
-- - 26
-- - 27
-- - 28
-- - 29
-- - 30
-- - 31
-- - 32
-- - 33
-- - 34
-- - 35
-- - 36
-- - 37
-- - 38
-all_joints_names:
-- nose
-- upper_jaw
-- lower_jaw
-- mouth_end_right
-- mouth_end_left
-- right_eye
-- right_earbase
-- right_earend
-- right_antler_base
-- right_antler_end
-- left_eye
-- left_earbase
-- left_earend
-- left_antler_base
-- left_antler_end
-- neck_base
-- neck_end
-- throat_base
-- throat_end
-- back_base
-- back_end
-- back_middle
-- tail_base
-- tail_end
-- front_left_thai
-- front_left_knee
-- front_left_paw
-- front_right_thai
-- front_right_knee
-- front_right_paw
-- back_left_paw
-- back_left_thai
-- back_right_thai
-- back_left_knee
-- back_right_knee
-- back_right_paw
-- belly_bottom
-- body_middle_right
-- body_middle_left
-alpha_r: 0.02
-apply_prob: 0.5
-batch_size: 1
-clahe: true
-claheratio: 0.1
-crop_sampling: hybrid
-crop_size:
-- 400
-- 400
-cropratio: 0.4
-dataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_superquadrupedMarch30/ma_superquadruped_maDLC_scorer85shuffle1.pickle
-dataset_type: multi-animal-imgaug
-decay_steps: 30000
-display_iters: 500
-edge: false
-emboss:
- alpha:
- - 0.0
- - 1.0
- embossratio: 0.1
- strength:
- - 0.5
- - 1.5
-global_scale: 0.8
-histeq: true
-histeqratio: 0.1
-init_weights:
-intermediate_supervision: false
-intermediate_supervision_layer: 12
-location_refinement: true
-locref_huber_loss: true
-locref_loss_weight: 0.05
-locref_stdev: 7.2801
-lr_init: 0.0005
-max_input_size: 1500
-max_shift: 0.4
-metadataset: training-datasets/iteration-0/UnaugmentedDataSet_ma_superquadrupedMarch30/Documentation_data-ma_superquadruped_85shuffle1.pickle
-min_input_size: 64
-mirror: false
-multi_stage: true
-multi_step:
-- - 0.0001
- - 7500
-- - 5.0e-05
- - 12000
-- - 1.0e-05
- - 1000000
-net_type: resnet_50
-num_idchannel: 0
-num_joints: 39
-num_limbs: 741
-optimizer: adam
-pafwidth: 20
-pairwise_huber_loss: false
-pairwise_loss_weight: 0.1
-pairwise_predict: false
-partaffinityfield_graph: []
-partaffinityfield_predict: false
-pos_dist_thresh: 17
-pre_resize: []
-project_path:
-rotation: 25
-rotratio: 0.4
-save_iters: 10000
-scale_jitter_lo: 0.5
-scale_jitter_up: 1.25
-sharpen: false
-sharpenratio: 0.3
-weigh_only_present_joints: false
-gradient_masking: true
diff --git a/deeplabcut/pose_estimation_tensorflow/training.py b/deeplabcut/pose_estimation_tensorflow/training.py
index 7ac99815ca..61790e6334 100644
--- a/deeplabcut/pose_estimation_tensorflow/training.py
+++ b/deeplabcut/pose_estimation_tensorflow/training.py
@@ -15,7 +15,9 @@
def return_train_network_path(config, shuffle=1, trainingsetindex=0, modelprefix=""):
- """Returns the training and test pose config file names as well as the folder where the snapshot is
+ """Returns the training and test pose config file names as well as the folder where
+ the snapshot is.
+
Parameters
----------
config : string
@@ -25,7 +27,8 @@ def return_train_network_path(config, shuffle=1, trainingsetindex=0, modelprefix
Integer value specifying the shuffle index to select for training.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+ Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list
+ in config.yaml).
Returns the triple: trainposeconfigfile, testposeconfigfile, snapshotfolder
"""
@@ -35,17 +38,9 @@ def return_train_network_path(config, shuffle=1, trainingsetindex=0, modelprefix
modelfoldername = auxiliaryfunctions.get_model_folder(
cfg["TrainingFraction"][trainingsetindex], shuffle, cfg, modelprefix=modelprefix
)
- trainposeconfigfile = Path(
- os.path.join(
- cfg["project_path"], str(modelfoldername), "train", "pose_cfg.yaml"
- )
- )
- testposeconfigfile = Path(
- os.path.join(cfg["project_path"], str(modelfoldername), "test", "pose_cfg.yaml")
- )
- snapshotfolder = Path(
- os.path.join(cfg["project_path"], str(modelfoldername), "train")
- )
+ trainposeconfigfile = Path(os.path.join(cfg["project_path"], str(modelfoldername), "train", "pose_cfg.yaml"))
+ testposeconfigfile = Path(os.path.join(cfg["project_path"], str(modelfoldername), "test", "pose_cfg.yaml"))
+ snapshotfolder = Path(os.path.join(cfg["project_path"], str(modelfoldername), "train"))
return trainposeconfigfile, testposeconfigfile, snapshotfolder
@@ -157,12 +152,12 @@ def train_network(
if allow_growth:
os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
- import tensorflow as tf
-
# reload logger.
import importlib
import logging
+ import tensorflow as tf
+
importlib.reload(logging)
logging.shutdown()
@@ -176,24 +171,17 @@ def train_network(
modelfoldername = auxiliaryfunctions.get_model_folder(
cfg["TrainingFraction"][trainingsetindex], shuffle, cfg, modelprefix=modelprefix
)
- poseconfigfile = Path(
- os.path.join(
- cfg["project_path"], str(modelfoldername), "train", "pose_cfg.yaml"
- )
- )
+ poseconfigfile = Path(os.path.join(cfg["project_path"], str(modelfoldername), "train", "pose_cfg.yaml"))
if not poseconfigfile.is_file():
print("The training datafile ", poseconfigfile, " is not present.")
+ print("Probably, the training dataset for this specific shuffle index was not created.")
print(
- "Probably, the training dataset for this specific shuffle index was not created."
- )
- print(
- "Try with a different shuffle/trainingsetfraction or use function 'create_training_dataset' to create a new trainingdataset with this shuffle index."
+ "Try with a different shuffle/trainingsetfraction or use function 'create_training_dataset' to create a new"
+ "trainingdataset with this shuffle index."
)
else:
# Set environment variables
- if (
- autotune is not False
- ): # see: https://github.com/tensorflow/tensorflow/issues/13317
+ if autotune is not False: # see: https://github.com/tensorflow/tensorflow/issues/13317
os.environ["TF_CUDNN_USE_AUTOTUNE"] = "0"
if gputouse is not None:
os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
@@ -201,15 +189,17 @@ def train_network(
cfg_dlc = auxiliaryfunctions.read_plainconfig(poseconfigfile)
if superanimal_name != "":
- from deeplabcut.modelzoo.utils import parse_available_supermodels
+ import glob
+
from dlclibrary.dlcmodelzoo.modelzoo_download import (
- download_huggingface_model,
MODELOPTIONS,
+ download_huggingface_model,
)
- import glob
+
+ from deeplabcut.modelzoo.utils import parse_available_supermodels
dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
- supermodels = parse_available_supermodels()
+ parse_available_supermodels()
weight_folder = str(
Path(dlc_root_path)
/ "pose_estimation_tensorflow"
@@ -246,9 +236,7 @@ def train_network(
keepdeconvweights=keepdeconvweights,
allow_growth=allow_growth,
init_weights=init_weights,
- remove_head=True
- if superanimal_name != "" and superanimal_transfer_learning
- else False,
+ remove_head=(True if superanimal_name != "" and superanimal_transfer_learning else False),
) # pass on path and file name for pose_cfg.yaml!
elif "multi-animal" in cfg_dlc["dataset_type"]:
diff --git a/deeplabcut/pose_estimation_tensorflow/util/logging.py b/deeplabcut/pose_estimation_tensorflow/util/logging.py
index dc355b555d..42f0ab7ea2 100644
--- a/deeplabcut/pose_estimation_tensorflow/util/logging.py
+++ b/deeplabcut/pose_estimation_tensorflow/util/logging.py
@@ -15,6 +15,7 @@
Adapted from DeeperCut by Eldar Insafutdinov
https://github.com/eldar/pose-tensorflow
"""
+
import logging
import os
diff --git a/deeplabcut/pose_estimation_tensorflow/util/visualize.py b/deeplabcut/pose_estimation_tensorflow/util/visualize.py
index 29fd039fc2..c84a11ed07 100644
--- a/deeplabcut/pose_estimation_tensorflow/util/visualize.py
+++ b/deeplabcut/pose_estimation_tensorflow/util/visualize.py
@@ -18,9 +18,9 @@
import math
+import cv2
import matplotlib.pyplot as plt
import numpy as np
-import cv2
from deeplabcut.utils.auxfun_videos import imresize
@@ -31,12 +31,9 @@ def _npcircle(image, cx, cy, radius, color, transparency=0.0):
cx = int(cx)
cy = int(cy)
y, x = np.ogrid[-radius:radius, -radius:radius]
- index = x ** 2 + y ** 2 <= radius ** 2
+ index = x**2 + y**2 <= radius**2
image[cy - radius : cy + radius, cx - radius : cx + radius][index] = (
- image[cy - radius : cy + radius, cx - radius : cx + radius][index].astype(
- "float32"
- )
- * transparency
+ image[cy - radius : cy + radius, cx - radius : cx + radius][index].astype("float32") * transparency
+ np.array(color).astype("float32") * (1.0 - transparency)
).astype("uint8")
diff --git a/deeplabcut/pose_estimation_tensorflow/vis_dataset.py b/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
index 35349e9257..a3414a50f3 100644
--- a/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
+++ b/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
@@ -15,10 +15,9 @@
import logging
+import cv2
import matplotlib.pyplot as plt
import numpy as np
-import cv2
-
from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.datasets import (
@@ -70,7 +69,7 @@ def display_dataset():
scmap_part = imresize(scmap_part, 8.0, interpolationmethod=cv2.INTER_NEAREST)
scmap_part = np.lib.pad(scmap_part, ((4, 0), (4, 0)), "minimum")
- curr_plot.set_title("{}".format(j + 1))
+ curr_plot.set_title(f"{j + 1}")
curr_plot.imshow(img)
curr_plot.hold(True)
curr_plot.imshow(scmap_part, alpha=0.5)
diff --git a/deeplabcut/pose_estimation_tensorflow/visualizemaps.py b/deeplabcut/pose_estimation_tensorflow/visualizemaps.py
index 5c417e5ef2..3cd8b3d6eb 100644
--- a/deeplabcut/pose_estimation_tensorflow/visualizemaps.py
+++ b/deeplabcut/pose_estimation_tensorflow/visualizemaps.py
@@ -8,13 +8,17 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-
-
import os
+
import matplotlib.pyplot as plt
-import numpy as np
from skimage.transform import resize
+from deeplabcut.core.visualization import (
+ visualize_locrefs,
+ visualize_paf,
+ visualize_scoremaps,
+)
+
def extract_maps(
config,
@@ -25,11 +29,11 @@ def extract_maps(
Indices=None,
modelprefix="",
):
- """
- Extracts the scoremap, locref, partaffinityfields (if available).
+ """Extracts the scoremap, locref, partaffinityfields (if available).
Returns a dictionary indexed by: trainingsetfraction, snapshotindex, and imageindex
- for those keys, each item contains: (image,scmap,locref,paf,bpt names,partaffinity graph, imagename, True/False if this image was in trainingset)
+ for those keys, each item contains: (image,scmap,locref,paf,bpt names,partaffinity graph,
+ imagename, True/False if this image was in trainingset)
----------
config : string
Full path of the config.yaml file as a string.
@@ -38,36 +42,42 @@ def extract_maps(
integers specifying shuffle index of the training dataset. The default is 0.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This
- variable can also be set to "all".
+ Integer specifying which TrainingsetFraction to use. By default the first
+ (note that TrainingFraction is a list in config.yaml).
+ This variable can also be set to "all".
rescale: bool, default False
- Evaluate the model at the 'global_scale' variable (as set in the test/pose_config.yaml file for a particular project). I.e. every
- image will be resized according to that scale and prediction will be compared to the resized ground truth. The error will be reported
- in pixels at rescaled to the *original* size. I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
- on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!. The evaluation images are also shown for the
- original size!
+ Evaluate the model at the 'global_scale' variable
+ (as set in the test/pose_config.yaml file for a particular project).
+ I.e. every image will be resized according to that scale
+ and prediction will be compared to the resized ground truth.
+ The error will be reported in pixels at rescaled to the *original* size.
+ I.e. For a [200,200] pixel image evaluated at global_scale=.5, the predictions are calculated
+ on [100,100] pixel images, compared to 1/2*ground truth and this error is then multiplied by 2!.
+ The evaluation images are also shown for the original size!
Examples
--------
If you want to extract the data for image 0 and 103 (of the training set) for model trained with shuffle 0.
>>> deeplabcut.extract_maps(configfile,0,Indices=[0,103])
-
"""
- from deeplabcut.utils.auxfun_videos import imread, imresize
+ from pathlib import Path
+
+ import numpy as np
+ import pandas as pd
+ import tensorflow as tf
+ from tqdm import tqdm
+
+ from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.core import (
predict,
+ )
+ from deeplabcut.pose_estimation_tensorflow.core import (
predict_multianimal as predictma,
)
- from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.pose_estimation_tensorflow.datasets.utils import data_to_input
from deeplabcut.utils import auxiliaryfunctions
- from tqdm import tqdm
- import tensorflow as tf
-
- import pandas as pd
- from pathlib import Path
- import numpy as np
+ from deeplabcut.utils.auxfun_videos import imread, imresize
tf.compat.v1.reset_default_graph()
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" #
@@ -104,9 +114,7 @@ def extract_maps(
)
# Make folder for evaluation
- auxiliaryfunctions.attempt_to_make_folder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(str(cfg["project_path"] + "/evaluation-results/"))
Maps = {}
for trainFraction in TrainingFractions:
@@ -120,11 +128,7 @@ def extract_maps(
modelfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
# Load meta data
@@ -133,16 +137,13 @@ def extract_maps(
trainIndices,
testIndices,
trainFraction,
- ) = auxiliaryfunctions.load_metadata(
- os.path.join(cfg["project_path"], metadatafn)
- )
+ ) = auxiliaryfunctions.load_metadata(os.path.join(cfg["project_path"], metadatafn))
try:
dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ ) from e
# change batch size, if it was edited during analysis!
dlc_cfg["batch_size"] = 1 # in case this was edited for analysis.
@@ -150,33 +151,13 @@ def extract_maps(
# Create folder structure to store results.
evaluationfolder = os.path.join(
cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ str(auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
)
auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True)
- # path_train_config = modelfolder / 'train' / 'pose_cfg.yaml'
-
- # Check which snapshots are available and sort them by # iterations
- Snapshots = np.array(
- [
- fn.split(".")[0]
- for fn in os.listdir(os.path.join(str(modelfolder), "train"))
- if "index" in fn
- ]
- )
- try: # check if any where found?
- Snapshots[0]
- except IndexError:
- raise FileNotFoundError(
- "Snapshots not found! It seems the dataset for shuffle %s and trainFraction %s is not trained.\nPlease train it before evaluating.\nUse the function 'train_network' to do so."
- % (shuffle, trainFraction)
- )
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
+ Snapshots = auxiliaryfunctions.get_snapshots_from_folder(
+ train_folder=Path(modelfolder) / "train",
+ )
if cfg["snapshotindex"] == -1:
snapindices = [-1]
@@ -185,38 +166,35 @@ def extract_maps(
elif cfg["snapshotindex"] < len(Snapshots):
snapindices = [cfg["snapshotindex"]]
else:
- print(
- "Invalid choice, only -1 (last), any integer up to last, or all (as string)!"
- )
+ print("Invalid choice, only -1 (last), any integer up to last, or all (as string)!")
########################### RESCALING (to global scale)
scale = dlc_cfg["global_scale"] if rescale else 1
Data *= scale
- bptnames = [
- dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))
- ]
+ bptnames = [dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))]
for snapindex in snapindices:
dlc_cfg["init_weights"] = os.path.join(
str(modelfolder), "train", Snapshots[snapindex]
) # setting weights to corresponding snapshot.
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split(
- "-"
- )[
+ (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[
-1
] # read how many training siterations that corresponds to.
# Name for deeplabcut net (based on its parameters)
- # DLCscorer,DLCscorerlegacy = auxiliaryfunctions.GetScorerName(cfg,shuffle,trainFraction,trainingsiterations)
- # notanalyzed, resultsfilename, DLCscorer=auxiliaryfunctions.CheckifNotEvaluated(str(evaluationfolder),DLCscorer,DLCscorerlegacy,Snapshots[snapindex])
+ # DLCscorer,DLCscorerlegacy =
+ # auxiliaryfunctions.GetScorerName(cfg,shuffle,trainFraction,trainingsiterations)
+ # notanalyzed, resultsfilename,
+ # DLCscorer=auxiliaryfunctions.CheckifNotEvaluated(str(evaluationfolder),
+ # DLCscorer,DLCscorerlegacy,Snapshots[snapindex])
# print("Extracting maps for ", DLCscorer, " with # of trainingiterations:", trainingsiterations)
# if notanalyzed: #this only applies to ask if h5 exists...
# Specifying state of model (snapshot / training state)
sess, inputs, outputs = predict.setup_pose_prediction(dlc_cfg)
Numimages = len(Data.index)
- PredicteData = np.zeros((Numimages, 3 * len(dlc_cfg["all_joints_names"])))
+ np.zeros((Numimages, 3 * len(dlc_cfg["all_joints_names"])))
print("Analyzing data...")
if Indices is None:
Indices = enumerate(Data.index)
@@ -226,9 +204,7 @@ def extract_maps(
DATA = {}
for imageindex, imagename in tqdm(Indices):
- image = imread(
- os.path.join(cfg["project_path"], *imagename), mode="skimage"
- )
+ image = imread(os.path.join(cfg["project_path"], *imagename), mode="skimage")
if scale != 1:
image = imresize(image, scale)
@@ -239,9 +215,7 @@ def extract_maps(
outputs_np = sess.run(outputs, feed_dict={inputs: image_batch})
if cfg.get("multianimalproject", False):
- scmap, locref, paf = predictma.extract_cnn_output(
- outputs_np, dlc_cfg
- )
+ scmap, locref, paf = predictma.extract_cnn_output(outputs_np, dlc_cfg)
pagraph = dlc_cfg["partaffinityfield_graph"]
else:
scmap, locref = predict.extract_cnn_output(outputs_np, dlc_cfg)
@@ -284,84 +258,9 @@ def resize_all_maps(image, scmap, locref, paf):
return scmap, (locref_x, locref_y), paf
-def form_figure(nx, ny):
- fig, ax = plt.subplots(frameon=False)
- ax.set_xlim(0, nx)
- ax.set_ylim(0, ny)
- ax.axis("off")
- ax.invert_yaxis()
- fig.tight_layout()
- return fig, ax
-
-
-def visualize_scoremaps(image, scmap):
- ny, nx = np.shape(image)[:2]
- fig, ax = form_figure(nx, ny)
- ax.imshow(image)
- ax.imshow(scmap, alpha=0.5)
- return fig, ax
-
-
-def visualize_locrefs(image, scmap, locref_x, locref_y, step=5, zoom_width=0):
- fig, ax = visualize_scoremaps(image, scmap)
- X, Y = np.meshgrid(np.arange(locref_x.shape[1]), np.arange(locref_x.shape[0]))
- M = np.zeros(locref_x.shape, dtype=bool)
- M[scmap < 0.5] = True
- U = np.ma.masked_array(locref_x, mask=M)
- V = np.ma.masked_array(locref_y, mask=M)
- ax.quiver(
- X[::step, ::step],
- Y[::step, ::step],
- U[::step, ::step],
- V[::step, ::step],
- color="r",
- units="x",
- scale_units="xy",
- scale=1,
- angles="xy",
- )
- if zoom_width > 0:
- maxloc = np.unravel_index(np.argmax(scmap), scmap.shape)
- ax.set_xlim(maxloc[1] - zoom_width, maxloc[1] + zoom_width)
- ax.set_ylim(maxloc[0] + zoom_width, maxloc[0] - zoom_width)
- return fig, ax
-
-
-def visualize_paf(image, paf, step=5, colors=None):
- ny, nx = np.shape(image)[:2]
- fig, ax = form_figure(nx, ny)
- ax.imshow(image)
- n_fields = paf.shape[2]
- if colors is None:
- colors = ["r"] * n_fields
- for n in range(n_fields):
- U = paf[:, :, n, 0]
- V = paf[:, :, n, 1]
- X, Y = np.meshgrid(np.arange(U.shape[1]), np.arange(U.shape[0]))
- M = np.zeros(U.shape, dtype=bool)
- M[U ** 2 + V ** 2 < 0.5 * 0.5 ** 2] = True
- U = np.ma.masked_array(U, mask=M)
- V = np.ma.masked_array(V, mask=M)
- ax.quiver(
- X[::step, ::step],
- Y[::step, ::step],
- U[::step, ::step],
- V[::step, ::step],
- scale=50,
- headaxislength=4,
- alpha=1,
- width=0.002,
- color=colors[n],
- angles="xy",
- )
- return fig, ax
-
-
def _save_individual_subplots(fig, axes, labels, output_path):
- for ax, label in zip(axes, labels):
- extent = ax.get_tightbbox(fig.canvas.renderer).transformed(
- fig.dpi_scale_trans.inverted()
- )
+ for ax, label in zip(axes, labels, strict=False):
+ extent = ax.get_tightbbox(fig.canvas.renderer).transformed(fig.dpi_scale_trans.inverted())
fig.savefig(output_path.format(bp=label), bbox_inches=extent)
@@ -390,8 +289,9 @@ def extract_save_all_maps(
integers specifying shuffle index of the training dataset. The default is 1.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml). This
- variable can also be set to "all".
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ This variable can also be set to "all".
comparisonbodyparts: list of bodyparts, Default is "all".
The average error will be computed for those body parts only (Has to be a subset of the body parts).
@@ -417,22 +317,19 @@ def extract_save_all_maps(
"""
+ from tqdm import tqdm
+
from deeplabcut.utils.auxiliaryfunctions import (
- read_config,
attempt_to_make_folder,
get_evaluation_folder,
intersection_of_body_parts_and_ones_given_by_user,
+ read_config,
)
- from tqdm import tqdm
cfg = read_config(config)
- data = extract_maps(
- config, shuffle, trainingsetindex, gputouse, rescale, Indices, modelprefix
- )
+ data = extract_maps(config, shuffle, trainingsetindex, gputouse, rescale, Indices, modelprefix)
- comparisonbodyparts = intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts
- )
+ comparisonbodyparts = intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
print("Saving plots...")
for frac, values in data.items():
@@ -462,18 +359,12 @@ def extract_save_all_maps(
paf = None
label = "train" if trainingframe else "test"
imname = impath[-1]
- scmap, (locref_x, locref_y), paf = resize_all_maps(
- image, scmap, locref, paf
- )
- to_plot = [
- i for i, bpt in enumerate(bptnames) if bpt in comparisonbodyparts
- ]
+ scmap, (locref_x, locref_y), paf = resize_all_maps(image, scmap, locref, paf)
+ to_plot = [i for i, bpt in enumerate(bptnames) if bpt in comparisonbodyparts]
list_of_inds = []
for n, edge in enumerate(pafgraph):
if any(ind in to_plot for ind in edge):
- list_of_inds.append(
- [(2 * n, 2 * n + 1), (bptnames[edge[0]], bptnames[edge[1]])]
- )
+ list_of_inds.append([(2 * n, 2 * n + 1), (bptnames[edge[0]], bptnames[edge[1]])])
if len(to_plot) > 1:
map_ = scmap[:, :, to_plot].sum(axis=2)
locref_x_ = locref_x[:, :, to_plot].sum(axis=2)
@@ -514,7 +405,7 @@ def extract_save_all_maps(
fig3, _ = visualize_paf(image, paf[:, :, [inds]])
temp = dest_path.format(
imname=imname,
- map=f'paf_{"_".join(names)}',
+ map=f"paf_{'_'.join(names)}",
label=label,
shuffle=shuffle,
frac=frac,
@@ -529,7 +420,7 @@ def extract_save_all_maps(
fig3, _ = visualize_paf(image, paf[:, :, inds], colors=colors)
temp = dest_path.format(
imname=imname,
- map=f"paf",
+ map="paf",
label=label,
shuffle=shuffle,
frac=frac,
diff --git a/deeplabcut/pose_tracking_pytorch/__init__.py b/deeplabcut/pose_tracking_pytorch/__init__.py
index 54b4f0d0ee..f5e3b9925a 100644
--- a/deeplabcut/pose_tracking_pytorch/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/__init__.py
@@ -9,7 +9,7 @@
# Licensed under GNU Lesser General Public License v3.0
#
+from .apis import transformer_reID
from .create_dataset import *
from .tracking_utils.preprocessing import *
from .train_dlctransreid import train_tracking_transformer
-from .apis import transformer_reID
diff --git a/deeplabcut/pose_tracking_pytorch/apis.py b/deeplabcut/pose_tracking_pytorch/apis.py
index 0e3671e16c..20a80e7088 100644
--- a/deeplabcut/pose_tracking_pytorch/apis.py
+++ b/deeplabcut/pose_tracking_pytorch/apis.py
@@ -9,32 +9,36 @@
# Licensed under GNU Lesser General Public License v3.0
#
+from collections.abc import Sequence
+from deeplabcut.utils.deprecation import renamed_parameter
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def transformer_reID(
- config,
- videos,
- videotype="",
- shuffle=1,
- trainingsetindex=0,
- track_method="ellipse",
- n_tracks=None,
- n_triplets=1000,
- train_epochs=100,
- train_frac=0.8,
- modelprefix="",
- destfolder=None,
+ config: str,
+ videos: list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ track_method: str = "ellipse",
+ n_tracks: int | None = None,
+ n_triplets: int = 1000,
+ train_epochs: int = 100,
+ train_frac: float = 0.8,
+ modelprefix: str = "",
+ destfolder: str = None,
):
- """
- Enables tracking with transformer.
+ """Enables tracking with transformer.
Substeps include:
+ - Mines triplets from tracklets in videos (from another tracker)
+ - These triplets are later used to tran a transformer with triplet loss
+ - The transformer derived appearance similarity is then used as a stitching loss
+ when tracklets are stitched during tracking.
- - Mines triplets from tracklets in videos (from another tracker)
- - These triplets are later used to tran a transformer with triplet loss
- - The transformer derived appearance similarity is then used as a stitching loss when tracklets are
- stitched during tracking.
-
- Outputs: The tracklet file is saved in the same folder where the non-transformer tracklet file is stored.
+ Outputs: The tracklet file is saved in the same folder where the non-transformer
+ tracklet file is stored.
Parameters
----------
@@ -42,11 +46,17 @@ def transformer_reID(
Full path of the config.yaml file as a string.
videos: list
- A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored.
-
- videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ A list of strings containing the full paths to videos for analysis or a path to
+ the directory, where all the videos with same extension are stored.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle : int, optional
which shuffle to use
@@ -74,13 +84,20 @@ def transformer_reID(
--------
Training model for one video based on ellipse-tracker derived tracklets
- >>> deeplabcut.transformer_reID(path_config_file,[''/home/alex/video.mp4'],track_method="ellipse")
-
+ >>> config = "/home/users/.../dlc-project-2025-01-01/config.yaml"
+ >>> videos = ['/home/alex/video.mp4']
+ >>> deeplabcut.transformer_reID(config, videos, shuffle=1, track_method="ellipse")
+ >>> deeplabcut.create_labeled_video(
+ >>> config,
+ >>> videos,
+ >>> shuffle=1,
+ >>> track_method="transformer",
+ >>> )
--------
-
"""
- import deeplabcut
import os
+
+ import deeplabcut
from deeplabcut.utils import auxiliaryfunctions
# calling create_tracking_dataset, train_tracking_transformer, stitch_tracklets
@@ -94,11 +111,11 @@ def transformer_reID(
modelprefix=modelprefix,
)
- deeplabcut.pose_estimation_tensorflow.create_tracking_dataset(
+ deeplabcut.compat.create_tracking_dataset(
config,
videos,
track_method,
- videotype=videotype,
+ video_extensions=video_extensions,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
modelprefix=modelprefix,
@@ -121,7 +138,7 @@ def transformer_reID(
config,
DLCscorer,
videos,
- videotype=videotype,
+ video_extensions=video_extensions,
train_frac=train_frac,
modelprefix=modelprefix,
train_epochs=train_epochs,
@@ -129,9 +146,7 @@ def transformer_reID(
destfolder=destfolder,
)
- transformer_checkpoint = os.path.join(
- snapshotfolder, f"dlc_transreid_{train_epochs}.pth"
- )
+ transformer_checkpoint = os.path.join(snapshotfolder, f"dlc_transreid_{train_epochs}.pth")
if not os.path.exists(transformer_checkpoint):
raise FileNotFoundError(f"checkpoint {transformer_checkpoint} not found")
@@ -139,7 +154,7 @@ def transformer_reID(
deeplabcut.stitch_tracklets(
config,
videos,
- videotype=videotype,
+ video_extensions=video_extensions,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
track_method=track_method,
diff --git a/deeplabcut/pose_tracking_pytorch/config/__init__.py b/deeplabcut/pose_tracking_pytorch/config/__init__.py
index fcd59e9fce..be04343413 100644
--- a/deeplabcut/pose_tracking_pytorch/config/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/config/__init__.py
@@ -10,12 +10,12 @@
#
import os
+
from deeplabcut.utils.auxiliaryfunctions import (
- read_plainconfig,
get_deeplabcut_path,
+ read_plainconfig,
)
-
dlcparent_path = get_deeplabcut_path()
reid_config = os.path.join(dlcparent_path, "reid_cfg.yaml")
cfg = read_plainconfig(reid_config)
diff --git a/deeplabcut/pose_tracking_pytorch/create_dataset.py b/deeplabcut/pose_tracking_pytorch/create_dataset.py
index 9660d85256..e7e3e77522 100644
--- a/deeplabcut/pose_tracking_pytorch/create_dataset.py
+++ b/deeplabcut/pose_tracking_pytorch/create_dataset.py
@@ -9,13 +9,16 @@
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
import os
import pickle
import shelve
-from deeplabcut.pose_estimation_tensorflow.lib import trackingutils
-from deeplabcut.refine_training_dataset.stitch import TrackletStitcher
from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.core import trackingutils
+from deeplabcut.refine_training_dataset.stitch import TrackletStitcher
+
from .tracking_utils.preprocessing import query_feature_by_coord_in_img_space
np.random.seed(0)
@@ -33,8 +36,7 @@ def save_train_triplets(feature_fname, triplets, out_name):
feature_dict = shelve.open(feature_fname, protocol=pickle.DEFAULT_PROTOCOL)
- nframes = len(feature_dict.keys())
-
+ nframes = max(len(feature_dict.keys()), 2)
zfill_width = int(np.ceil(np.log10(nframes)))
for triplet in triplets:
@@ -48,22 +50,12 @@ def save_train_triplets(feature_fname, triplets, out_name):
pos_frame = "frame" + str(pos_frame).zfill(zfill_width)
neg_frame = "frame" + str(neg_frame).zfill(zfill_width)
- if (
- anchor_frame in feature_dict
- and pos_frame in feature_dict
- and neg_frame in feature_dict
- ):
+ if anchor_frame in feature_dict and pos_frame in feature_dict and neg_frame in feature_dict:
# only try to find these features if they are in the dictionary
- anchor_vec = query_feature_by_coord_in_img_space(
- feature_dict, anchor_frame, anchor_coord
- )
- pos_vec = query_feature_by_coord_in_img_space(
- feature_dict, pos_frame, pos_coord
- )
- neg_vec = query_feature_by_coord_in_img_space(
- feature_dict, neg_frame, neg_coord
- )
+ anchor_vec = query_feature_by_coord_in_img_space(feature_dict, anchor_frame, anchor_coord)
+ pos_vec = query_feature_by_coord_in_img_space(feature_dict, pos_frame, pos_coord)
+ neg_vec = query_feature_by_coord_in_img_space(feature_dict, neg_frame, neg_coord)
ret_vecs.append([anchor_vec, pos_vec, neg_vec])
@@ -74,15 +66,11 @@ def save_train_triplets(feature_fname, triplets, out_name):
def create_train_using_pickle(feature_fname, path_to_pickle, out_name, n_triplets=1000):
- triplets = generate_train_triplets_from_pickle(
- path_to_pickle, n_triplets=n_triplets
- )
+ triplets = generate_train_triplets_from_pickle(path_to_pickle, n_triplets=n_triplets)
save_train_triplets(feature_fname, triplets, out_name)
-def create_triplets_dataset(
- videos, dlcscorer, track_method, n_triplets=1000, destfolder=None
-):
+def create_triplets_dataset(videos, dlcscorer, track_method, n_triplets=1000, destfolder=None):
# 1) reference to video folder and get the proper bpt_feature file for feature table
# 2) get either the path to gt or the path to track pickle
@@ -91,13 +79,16 @@ def create_triplets_dataset(
videofolder = str(Path(video).parents[0])
if destfolder is None:
destfolder = videofolder
- feature_fname = os.path.join(
- destfolder, vname + dlcscorer + "_bpt_features.pickle"
- )
+ feature_fname = os.path.join(destfolder, vname + dlcscorer + "_bpt_features.pickle")
method = trackingutils.TRACK_METHODS[track_method]
track_file = os.path.join(destfolder, vname + dlcscorer + f"{method}.pickle")
+ if not Path(track_file).exists():
+ raise ValueError(
+ f"Tracklet file {track_file} does not exist. Please run "
+ f"`analyze_videos` with the {method} tracker before using the ReID "
+ "transformer."
+ )
+
out_fname = os.path.join(destfolder, vname + dlcscorer + "_triplet_vector.npy")
- create_train_using_pickle(
- feature_fname, track_file, out_fname, n_triplets=n_triplets
- )
+ create_train_using_pickle(feature_fname, track_file, out_fname, n_triplets=n_triplets)
diff --git a/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py b/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
index fa1512924a..ae707771ba 100644
--- a/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
+++ b/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
@@ -9,8 +9,8 @@
# Licensed under GNU Lesser General Public License v3.0
#
-from torch.utils.data import Dataset
import numpy as np
+from torch.utils.data import Dataset
class TripletDataset(Dataset):
diff --git a/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py b/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
index e96c9fa481..660b960003 100644
--- a/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
+++ b/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
@@ -10,6 +10,7 @@
#
from torch.utils.data import DataLoader
+
from .dlc_vec import TripletDataset
diff --git a/deeplabcut/pose_tracking_pytorch/inference.py b/deeplabcut/pose_tracking_pytorch/inference.py
index ddec2ed96e..debea750a4 100644
--- a/deeplabcut/pose_tracking_pytorch/inference.py
+++ b/deeplabcut/pose_tracking_pytorch/inference.py
@@ -9,18 +9,18 @@
# Licensed under GNU Lesser General Public License v3.0
#
+import numpy as np
import torch
import torch.nn as nn
-import numpy as np
+
from deeplabcut.pose_tracking_pytorch.config import cfg
from deeplabcut.pose_tracking_pytorch.model import build_dlc_transformer
from deeplabcut.pose_tracking_pytorch.model.backbones import dlc_base_kpt_TransReID
+from deeplabcut.pose_tracking_pytorch.processor import default_device
from deeplabcut.pose_tracking_pytorch.tracking_utils import (
query_feature_by_coord_in_img_space,
)
-from deeplabcut.pose_tracking_pytorch.processor import default_device
-
inference_factory = {"dlc_transreid": dlc_base_kpt_TransReID}
@@ -30,9 +30,7 @@ def __init__(self, checkpoint):
ckpt_dict = torch.load(self.checkpoint)
- self.model = build_dlc_transformer(
- cfg, ckpt_dict["feature_dim"], ckpt_dict["num_kpts"], inference_factory
- )
+ self.model = build_dlc_transformer(cfg, ckpt_dict["feature_dim"], ckpt_dict["num_kpts"], inference_factory)
self.cos = nn.CosineSimilarity(dim=1, eps=1e-6)
diff --git a/deeplabcut/pose_tracking_pytorch/model/__init__.py b/deeplabcut/pose_tracking_pytorch/model/__init__.py
index cd526a115f..573d1ecd61 100644
--- a/deeplabcut/pose_tracking_pytorch/model/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/model/__init__.py
@@ -9,4 +9,4 @@
# Licensed under GNU Lesser General Public License v3.0
#
-from .make_model import make_dlc_model, build_dlc_transformer
+from .make_model import build_dlc_transformer, make_dlc_model
diff --git a/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py b/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
index ef68ffd81f..31476c2665 100644
--- a/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
+++ b/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
@@ -8,7 +8,7 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-""" Vision Transformer (ViT) in PyTorch
+"""Vision Transformer (ViT) in PyTorch.
A PyTorch implement of Vision Transformers as described in
'An Image Is Worth 16 x 16 Words: Transformers for Image Recognition at Scale' - https://arxiv.org/abs/2010.11929
@@ -30,6 +30,7 @@
Hacked together by / Copyright 2020 Ross Wightman
"""
+
import math
from functools import partial
@@ -39,21 +40,19 @@
def drop_path(x, drop_prob: float = 0.0, training: bool = False):
- """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks).
+ """Drop paths (Stochastic Depth) per sample (when applied in main path of residual
+ blocks).
This is the same as the DropConnect impl I created for EfficientNet, etc networks, however,
the original name is misleading as 'Drop Connect' is a different form of dropout in a separate paper...
See discussion: https://github.com/tensorflow/tpu/issues/494#issuecomment-532968956 ... I've opted for
changing the layer and argument names to 'drop path' rather than mix DropConnect as a layer name and use
'survival rate' as the argument.
-
"""
if drop_prob == 0.0 or not training:
return x
keep_prob = 1 - drop_prob
- shape = (x.shape[0],) + (1,) * (
- x.ndim - 1
- ) # work with diff dim tensors, not just 2D ConvNets
+ shape = (x.shape[0],) + (1,) * (x.ndim - 1) # work with diff dim tensors, not just 2D ConvNets
random_tensor = keep_prob + torch.rand(shape, dtype=x.dtype, device=x.device)
random_tensor.floor_() # binarize
output = x.div(keep_prob) * random_tensor
@@ -61,10 +60,11 @@ def drop_path(x, drop_prob: float = 0.0, training: bool = False):
class DropPath(nn.Module):
- """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks)."""
+ """Drop paths (Stochastic Depth) per sample (when applied in main path of residual
+ blocks)."""
def __init__(self, drop_prob=None):
- super(DropPath, self).__init__()
+ super().__init__()
self.drop_prob = drop_prob
def forward(self, x):
@@ -111,7 +111,7 @@ def __init__(
self.num_heads = num_heads
head_dim = dim // num_heads
# NOTE scale factor was wrong in my original version, can set manually to be compat with prev weights
- self.scale = qk_scale or head_dim ** -0.5
+ self.scale = qk_scale or head_dim**-0.5
self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
self.attn_drop = nn.Dropout(attn_drop)
@@ -120,11 +120,7 @@ def __init__(
def forward(self, x):
B, N, C = x.shape
- qkv = (
- self.qkv(x)
- .reshape(B, N, 3, self.num_heads, C // self.num_heads)
- .permute(2, 0, 3, 1, 4)
- )
+ qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
q, k, v = (
qkv[0],
qkv[1],
@@ -273,13 +269,11 @@ def get_classifier(self):
def reset_classifier(self, num_classes, global_pool=""):
self.num_classes = num_classes
- self.fc = (
- nn.Linear(self.embed_dim, num_classes) if num_classes > 0 else nn.Identity()
- )
+ self.fc = nn.Linear(self.embed_dim, num_classes) if num_classes > 0 else nn.Identity()
def forward_features(self, x):
# x: inputs
- B = x.shape[0]
+ x.shape[0]
# (B, 12, 768)
x = self.kpt_embed(x)
@@ -330,17 +324,13 @@ def load_param(self, model_path):
if "distilled" in model_path:
print("distill need to choose right cls token in the pth")
v = torch.cat([v[:, 0:1], v[:, 2:]], dim=1)
- v = resize_pos_embed(
- v, self.pos_embed, self.patch_embed.num_y, self.patch_embed.num_x
- )
+ v = resize_pos_embed(v, self.pos_embed, self.patch_embed.num_y, self.patch_embed.num_x)
try:
self.state_dict()[k].copy_(v)
- except:
+ except Exception:
print("===========================ERROR=========================")
print(
- "shape do not match in k :{}: param_dict{} vs self.state_dict(){}".format(
- k, v.shape, self.state_dict()[k].shape
- )
+ f"shape do not match in k :{k}: param_dict{v.shape} vsself.state_dict(){self.state_dict()[k].shape}"
)
@@ -354,9 +344,8 @@ def resize_pos_embed(posemb, posemb_new, height, width):
gs_old = int(math.sqrt(len(posemb_grid)))
print(
- "Resized position embedding from size:{} to size: {} with height:{} width: {}".format(
- posemb.shape, posemb_new.shape, height, width
- )
+ f"Resized position embedding from size:{posemb.shape} to size: {posemb_new.shape} with height:{height} width:"
+ f"{width}"
)
posemb_grid = posemb_grid.reshape(1, gs_old, gs_old, -1).permute(0, 3, 1, 2)
posemb_grid = F.interpolate(posemb_grid, size=(height, width), mode="bilinear")
@@ -438,8 +427,9 @@ def norm_cdf(x):
def trunc_normal_(tensor, mean=0.0, std=1.0, a=-2.0, b=2.0):
# type: (Tensor, float, float, float, float) -> Tensor
- r"""Fills the input Tensor with values drawn from a truncated
- normal distribution. The values are effectively drawn from the
+ r"""Fills the input Tensor with values drawn from a truncated normal distribution.
+
+ The values are effectively drawn from the
normal distribution :math:`\mathcal{N}(\text{mean}, \text{std}^2)`
with values outside :math:`[a, b]` redrawn until they are within
the bounds. The method used for generating the random values works
diff --git a/deeplabcut/pose_tracking_pytorch/model/make_model.py b/deeplabcut/pose_tracking_pytorch/model/make_model.py
index fa25207f48..51142aa16d 100644
--- a/deeplabcut/pose_tracking_pytorch/model/make_model.py
+++ b/deeplabcut/pose_tracking_pytorch/model/make_model.py
@@ -11,12 +11,13 @@
import torch
import torch.nn as nn
+
from .backbones.vit_pytorch import dlc_base_kpt_TransReID
class build_dlc_transformer(nn.Module):
def __init__(self, cfg, in_chans, kpt_num, factory):
- super(build_dlc_transformer, self).__init__()
+ super().__init__()
self.cos_layer = cfg["cos_layer"]
self.in_planes = 128
self.kpt_num = kpt_num
@@ -47,10 +48,12 @@ def forward(self, x):
return q
def load_param(self, trained_path):
- param_dict = torch.load(trained_path)
+ # Use CUDA if available, otherwise use CPU
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ param_dict = torch.load(trained_path, map_location=device)
for i in param_dict:
self.state_dict()[i.replace("module.", "")].copy_(param_dict[i])
- print("Loading pretrained model from {}".format(trained_path))
+ print(f"Loading pretrained model from {trained_path}")
__factory_T_type = {
diff --git a/deeplabcut/pose_tracking_pytorch/processor/__init__.py b/deeplabcut/pose_tracking_pytorch/processor/__init__.py
index d2253dbf25..2a49144cdf 100644
--- a/deeplabcut/pose_tracking_pytorch/processor/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/processor/__init__.py
@@ -10,8 +10,8 @@
#
from .processor import (
- do_dlc_train,
+ default_device,
do_dlc_inference,
do_dlc_pair_inference,
- default_device,
+ do_dlc_train,
)
diff --git a/deeplabcut/pose_tracking_pytorch/processor/processor.py b/deeplabcut/pose_tracking_pytorch/processor/processor.py
index 622c8b44fa..05374b54b2 100644
--- a/deeplabcut/pose_tracking_pytorch/processor/processor.py
+++ b/deeplabcut/pose_tracking_pytorch/processor/processor.py
@@ -11,24 +11,25 @@
import logging
import os
+import pickle
import time
+
+import numpy as np
import torch
import torch.nn as nn
+
from ..tracking_utils.meter import AverageMeter
from ..tracking_utils.metrics import R1_mAP_eval
-import torch.distributed as dist
-import pickle
-import numpy as np
-def dist(a, b):
+def custom_dist(a, b):
return torch.sqrt(torch.sum((a - b) ** 2, dim=1))
def calc_correct(anchor, pos, neg):
# cos = torch.cdist
- ap_dist = dist(anchor, pos)
- an_dist = dist(anchor, neg)
+ ap_dist = custom_dist(anchor, pos)
+ an_dist = custom_dist(anchor, neg)
indices = ap_dist < an_dist
return torch.sum(indices)
@@ -126,13 +127,10 @@ def do_dlc_train(
if (n_iter + 1) % log_period == 0:
logger.info(
- "Epoch[{}] Iteration[{}/{}] Loss: {:.3f}, , Base Lr: {:.2e}".format(
- epoch,
- (n_iter + 1),
- len(train_loader),
- loss_meter.avg,
- scheduler._get_lr(epoch)[0],
- )
+ f"Epoch[{epoch}] "
+ f"Iteration[{n_iter + 1}/{len(train_loader)}] "
+ f"Loss: {loss_meter.avg:.3f} "
+ f"Base Lr: {scheduler._get_lr(epoch)[0]:.2e}"
)
end_time = time.time()
@@ -144,12 +142,12 @@ def do_dlc_train(
pass
else:
logger.info(
- "Epoch {} done. Time per batch: {:.3f}[s] Speed: {:.1f}[samples/s]".format(
- epoch, time_per_batch, train_loader.batch_size / time_per_batch
- )
+ f"Epoch {epoch} done. "
+ f"Time per batch: {time_per_batch:.3f}[s] "
+ f"Speed: {train_loader.batch_size / time_per_batch:.1f}[samples/s]"
)
- model_name = f"dlc_transreid"
+ model_name = "dlc_transreid"
if epoch % checkpoint_period == 0:
torch.save(
@@ -158,7 +156,7 @@ def do_dlc_train(
"num_kpts": num_kpts,
"feature_dim": feature_dim,
},
- os.path.join(ckpt_folder, model_name + "_{}.pth".format(epoch)),
+ os.path.join(ckpt_folder, model_name + f"_{epoch}.pth"),
)
if epoch % eval_period == 0:
@@ -180,7 +178,7 @@ def do_dlc_train(
total_n += anchor_feat.shape[0]
total_correct += calc_correct(anchor_feat, pos_feat, neg_feat)
- logger.info("Validation Results - Epoch: {}".format(epoch))
+ logger.info(f"Validation Results - Epoch: {epoch}")
# print (f'validation loss {val_loss/len(val_loader)}')
test_acc = total_correct / total_n
@@ -195,9 +193,7 @@ def do_dlc_train(
plot_dict["test_acc"] = test_acc_list
plot_dict["epochs"] = epoch_list
- with open(
- os.path.join(ckpt_folder, "dlc_transreid_results.pickle"), "wb"
- ) as handle:
+ with open(os.path.join(ckpt_folder, "dlc_transreid_results.pickle"), "wb") as handle:
pickle.dump(plot_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
@@ -212,7 +208,7 @@ def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
if device:
if torch.cuda.device_count() > 1:
- print("Using {} GPUs for inference".format(torch.cuda.device_count()))
+ print(f"Using {torch.cuda.device_count()} GPUs for inference")
model = nn.DataParallel(model)
model.to(device)
@@ -223,7 +219,7 @@ def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
labels_list = []
total_n = 0.0
total_correct = 0.0
- for n_iter, (anchor, pos, neg) in enumerate(val_loader):
+ for _n_iter, (anchor, pos, neg) in enumerate(val_loader):
with torch.no_grad():
anchor = anchor.to(device)
pos = pos.to(device)
@@ -235,7 +231,7 @@ def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
features_list.append(pos_feat.cpu().detach().numpy())
features_list.append(neg_feat.cpu().detach().numpy())
- for i in range(neg.shape[0]):
+ for _i in range(neg.shape[0]):
labels_list.append(0)
labels_list.append(1)
total_n += anchor_feat.shape[0]
@@ -255,8 +251,8 @@ def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
np.save(f, features_list)
with open("labels.npy", "wb") as f:
np.save(f, labels_list)
- print(f"validation loss {val_loss/len(val_loader)}")
- print(f" acc {total_correct/total_n}")
+ print(f"validation loss {val_loss / len(val_loader)}")
+ print(f" acc {total_correct / total_n}")
logger.info("Validation Results ")
@@ -271,16 +267,15 @@ def do_dlc_pair_inference(cfg, model, val_loader, num_query):
if device and torch.cuda.is_available():
if torch.cuda.device_count() > 1:
- print("Using {} GPUs for inference".format(torch.cuda.device_count()))
+ print(f"Using {torch.cuda.device_count()} GPUs for inference")
model = nn.DataParallel(model)
model.to(device)
model.eval()
- val_loss = 0.0
total_n = 0.0
total_correct = 0.0
- for n_iter, ((vec1, gt1), (vec2, gt2)) in enumerate(val_loader):
+ for _n_iter, ((vec1, gt1), (vec2, gt2)) in enumerate(val_loader):
with torch.no_grad():
gt1 = gt1.to(device)
gt2 = gt2.to(device)
@@ -293,5 +288,5 @@ def do_dlc_pair_inference(cfg, model, val_loader, num_query):
total_n += vec1_feat.shape[0]
total_correct += calc_cos_correct(vec1_feat, gt1, vec2_feat, gt2)
- print(f" acc {total_correct/total_n}")
+ print(f" acc {total_correct / total_n}")
logger.info("Validation Results ")
diff --git a/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py b/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
index 7e7aeba855..246360d5b5 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
@@ -8,19 +8,20 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-""" Cosine Scheduler
+"""Cosine Scheduler.
Cosine LR schedule with warmup, cycle/restarts, noise.
Hacked together by / Copyright 2020 Ross Wightman
"""
+
import logging
import math
+
import torch
from .scheduler import Scheduler
-
_logger = logging.getLogger(__name__)
@@ -78,9 +79,7 @@ def __init__(
self.warmup_prefix = warmup_prefix
self.t_in_epochs = t_in_epochs
if self.warmup_t:
- self.warmup_steps = [
- (v - warmup_lr_init) / self.warmup_t for v in self.base_values
- ]
+ self.warmup_steps = [(v - warmup_lr_init) / self.warmup_t for v in self.base_values]
super().update_groups(self.warmup_lr_init)
else:
self.warmup_steps = [1 for _ in self.base_values]
@@ -93,24 +92,21 @@ def _get_lr(self, t):
t = t - self.warmup_t
if self.t_mul != 1:
- i = math.floor(
- math.log(1 - t / self.t_initial * (1 - self.t_mul), self.t_mul)
- )
- t_i = self.t_mul ** i * self.t_initial
- t_curr = t - (1 - self.t_mul ** i) / (1 - self.t_mul) * self.t_initial
+ i = math.floor(math.log(1 - t / self.t_initial * (1 - self.t_mul), self.t_mul))
+ t_i = self.t_mul**i * self.t_initial
+ t_curr = t - (1 - self.t_mul**i) / (1 - self.t_mul) * self.t_initial
else:
i = t // self.t_initial
t_i = self.t_initial
t_curr = t - (self.t_initial * i)
- gamma = self.decay_rate ** i
+ gamma = self.decay_rate**i
lr_min = self.lr_min * gamma
lr_max_values = [v * gamma for v in self.base_values]
if self.cycle_limit == 0 or (self.cycle_limit > 0 and i < self.cycle_limit):
lrs = [
- lr_min
- + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * t_curr / t_i))
+ lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * t_curr / t_i))
for lr_max in lr_max_values
]
else:
@@ -137,8 +133,4 @@ def get_cycle_length(self, cycles=0):
if self.t_mul == 1.0:
return self.t_initial * cycles
else:
- return int(
- math.floor(
- -self.t_initial * (self.t_mul ** cycles - 1) / (1 - self.t_mul)
- )
- )
+ return int(math.floor(-self.t_initial * (self.t_mul**cycles - 1) / (1 - self.t_mul)))
diff --git a/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py b/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py
index e0582b395d..076ea071b7 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py
@@ -29,13 +29,9 @@ def make_easy_optimizer(cfg, model):
params += [{"params": [value], "lr": lr, "weight_decay": weight_decay}]
optimizer_name = cfg["optimizer_name"]
if optimizer_name == "SGD":
- optimizer = getattr(torch.optim, optimizer_name)(
- params, momentum=cfg["momentum"]
- )
+ optimizer = getattr(torch.optim, optimizer_name)(params, momentum=cfg["momentum"])
elif optimizer_name == "AdamW":
- optimizer = torch.optim.AdamW(
- params, lr=cfg["base_lr"], weight_decay=cfg["weight_decay"]
- )
+ optimizer = torch.optim.AdamW(params, lr=cfg["base_lr"], weight_decay=cfg["weight_decay"])
else:
optimizer = getattr(torch.optim, optimizer_name)(params)
diff --git a/deeplabcut/pose_tracking_pytorch/solver/scheduler.py b/deeplabcut/pose_tracking_pytorch/solver/scheduler.py
index 699f882b88..8da7aa5ce8 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/scheduler.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/scheduler.py
@@ -8,14 +8,14 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-from typing import Dict, Any
+from typing import Any
import torch
class Scheduler:
- """Parameter Scheduler Base Class
- A scheduler base class that can be used to schedule any optimizer parameter groups.
+ """Parameter Scheduler Base Class A scheduler base class that can be used to
+ schedule any optimizer parameter groups.
Unlike the builtin PyTorch schedulers, this is intended to be consistently called
* At the END of each epoch, before incrementing the epoch count, to calculate next epoch's value
@@ -49,22 +49,13 @@ def __init__(
if initialize:
for i, group in enumerate(self.optimizer.param_groups):
if param_group_field not in group:
- raise KeyError(
- f"{param_group_field} missing from param_groups[{i}]"
- )
- group.setdefault(
- self._initial_param_group_field, group[param_group_field]
- )
+ raise KeyError(f"{param_group_field} missing from param_groups[{i}]")
+ group.setdefault(self._initial_param_group_field, group[param_group_field])
else:
for i, group in enumerate(self.optimizer.param_groups):
if self._initial_param_group_field not in group:
- raise KeyError(
- f"{self._initial_param_group_field} missing from param_groups[{i}]"
- )
- self.base_values = [
- group[self._initial_param_group_field]
- for group in self.optimizer.param_groups
- ]
+ raise KeyError(f"{self._initial_param_group_field} missing from param_groups[{i}]")
+ self.base_values = [group[self._initial_param_group_field] for group in self.optimizer.param_groups]
self.metric = None # any point to having this for all?
self.noise_range_t = noise_range_t
self.noise_pct = noise_pct
@@ -73,12 +64,10 @@ def __init__(
self.noise_seed = noise_seed if noise_seed is not None else 42
self.update_groups(self.base_values)
- def state_dict(self) -> Dict[str, Any]:
- return {
- key: value for key, value in self.__dict__.items() if key != "optimizer"
- }
+ def state_dict(self) -> dict[str, Any]:
+ return {key: value for key, value in self.__dict__.items() if key != "optimizer"}
- def load_state_dict(self, state_dict: Dict[str, Any]) -> None:
+ def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self.__dict__.update(state_dict)
def get_epoch_values(self, epoch: int):
@@ -104,7 +93,7 @@ def step_update(self, num_updates: int, metric: float = None):
def update_groups(self, values):
if not isinstance(values, (list, tuple)):
values = [values] * len(self.optimizer.param_groups)
- for param_group, value in zip(self.optimizer.param_groups, values):
+ for param_group, value in zip(self.optimizer.param_groups, values, strict=False):
param_group[self.param_group_field] = value
def _add_noise(self, lrs, t):
@@ -123,8 +112,6 @@ def _add_noise(self, lrs, t):
if abs(noise) < self.noise_pct:
break
else:
- noise = (
- 2 * (torch.rand(1, generator=g).item() - 0.5) * self.noise_pct
- )
+ noise = 2 * (torch.rand(1, generator=g).item() - 0.5) * self.noise_pct
lrs = [v + v * noise for v in lrs]
return lrs
diff --git a/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py b/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
index fcbd8270a7..ed42cc8849 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
@@ -16,9 +16,8 @@
# Hacked together by / Copyright 2020 Ross Wightman
# https://github.com/rwightman/pytorch-image-models/blob/main/timm/scheduler/scheduler_factory.py
#
-""" Scheduler Factory
-Hacked together by / Copyright 2020 Ross Wightman
-"""
+"""Scheduler Factory Hacked together by / Copyright 2020 Ross Wightman."""
+
from .cosine_lr import CosineLRScheduler
diff --git a/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py b/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py
index d3a9fbccb8..1ee383d0e4 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py
@@ -9,7 +9,7 @@
# Licensed under GNU Lesser General Public License v3.0
#
from .preprocessing import (
- load_features_from_coord,
convert_coord_from_img_space_to_feature_space,
+ load_features_from_coord,
query_feature_by_coord_in_img_space,
)
diff --git a/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py b/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py
index c26c07655b..3b56c2643d 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py
@@ -8,8 +8,8 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-class AverageMeter(object):
- """Computes and stores the average and current value"""
+class AverageMeter:
+ """Computes and stores the average and current value."""
def __init__(self):
self.val = 0
diff --git a/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py b/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py
index cc1c73c270..37eab5fc38 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py
@@ -8,8 +8,9 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import torch
import numpy as np
+import torch
+
from ..tracking_utils.reranking import re_ranking
@@ -47,7 +48,7 @@ def eval_func(distmat, q_pids, g_pids, q_camids, g_camids, max_rank=50):
# 4 1 2 3
if num_g < max_rank:
max_rank = num_g
- print("Note: number of gallery samples is quite small, got {}".format(num_g))
+ print(f"Note: number of gallery samples is quite small, got {num_g}")
indices = np.argsort(distmat, axis=1)
# 0, 2, 1, 3
# 1, 2, 3, 0
@@ -101,7 +102,7 @@ def eval_func(distmat, q_pids, g_pids, q_camids, g_camids, max_rank=50):
class R1_mAP_eval:
def __init__(self, num_query, max_rank=50, feat_norm=True, reranking=False):
- super(R1_mAP_eval, self).__init__()
+ super().__init__()
self.num_query = num_query
self.max_rank = max_rank
self.feat_norm = feat_norm
diff --git a/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py b/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py
index 6f1597157e..749b1b8ba1 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py
@@ -12,7 +12,7 @@
def load_features_from_coord(feature, coords, valid_mask_for_fish=False):
- """extract the deep feature at the location of the keypoint (x,y)"""
+ """Extract the deep feature at the location of the keypoint (x,y)"""
if valid_mask_for_fish:
mask = np.array([1, 2, 6])
coords = coords[mask, :]
@@ -32,14 +32,8 @@ def load_features_from_coord(feature, coords, valid_mask_for_fish=False):
def convert_coord_from_img_space_to_feature_space(arr, stride):
- """
- if stride ==8:
- stride = stride * 2
- elif stride == 4:
- stride = stride *4
- elif stride ==2:
- stride = stride *8
- """
+ """If stride ==8: stride = stride * 2 elif stride == 4: stride = stride *4 elif
+ stride ==2: stride = stride *8."""
# More elegantly one can simply define:
stride = 16
@@ -58,6 +52,6 @@ def query_feature_by_coord_in_img_space(feature_dict, frame_id, ref_coord):
diff = coordinates - ref_coord
diff[np.where(np.logical_or(diff > 9000, diff < 0))] = np.nan
- match_id = np.argmin(np.nanmean(diff, axis=(1, 2)))
-
+ masked_means = np.ma.masked_invalid(np.nanmean(diff, axis=(1, 2)))
+ match_id = np.argmin(masked_means)
return features[match_id]
diff --git a/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py b/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py
index e290a494ee..e4117b7da6 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py
@@ -12,9 +12,7 @@
import torch
-def re_ranking(
- probFea, galFea, k1, k2, lambda_value, local_distmat=None, only_local=False
-):
+def re_ranking(probFea, galFea, k1, k2, lambda_value, local_distmat=None, only_local=False):
"""
probFea: all feature vectors of the query set (torch tensor)
@@ -41,7 +39,7 @@ def re_ranking(
distmat.addmm_(1, -2, feat, feat.t())
original_dist = distmat.cpu().numpy()
del feat
- if not local_distmat is None:
+ if local_distmat is not None:
original_dist = original_dist + local_distmat
gallery_num = original_dist.shape[0]
original_dist = np.transpose(original_dist / np.max(original_dist, axis=0))
@@ -58,27 +56,21 @@ def re_ranking(
k_reciprocal_expansion_index = k_reciprocal_index
for j in range(len(k_reciprocal_index)):
candidate = k_reciprocal_index[j]
- candidate_forward_k_neigh_index = initial_rank[
- candidate, : int(np.around(k1 / 2)) + 1
- ]
+ candidate_forward_k_neigh_index = initial_rank[candidate, : int(np.around(k1 / 2)) + 1]
candidate_backward_k_neigh_index = initial_rank[
candidate_forward_k_neigh_index, : int(np.around(k1 / 2)) + 1
]
fi_candidate = np.where(candidate_backward_k_neigh_index == candidate)[0]
candidate_k_reciprocal_index = candidate_forward_k_neigh_index[fi_candidate]
- if len(
- np.intersect1d(candidate_k_reciprocal_index, k_reciprocal_index)
- ) > 2 / 3 * len(candidate_k_reciprocal_index):
- k_reciprocal_expansion_index = np.append(
- k_reciprocal_expansion_index, candidate_k_reciprocal_index
- )
+ if len(np.intersect1d(candidate_k_reciprocal_index, k_reciprocal_index)) > 2 / 3 * len(
+ candidate_k_reciprocal_index
+ ):
+ k_reciprocal_expansion_index = np.append(k_reciprocal_expansion_index, candidate_k_reciprocal_index)
k_reciprocal_expansion_index = np.unique(k_reciprocal_expansion_index)
weight = np.exp(-original_dist[i, k_reciprocal_expansion_index])
V[i, k_reciprocal_expansion_index] = weight / np.sum(weight)
- original_dist = original_dist[
- :query_num,
- ]
+ original_dist = original_dist[:query_num,]
if k2 != 1:
V_qe = np.zeros_like(V, dtype=np.float16)
for i in range(all_num):
diff --git a/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py b/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py
index 88bad6a31c..3ba46b72f3 100644
--- a/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py
+++ b/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py
@@ -13,22 +13,25 @@
try:
import torch
-except ModuleNotFoundError:
- raise ModuleNotFoundError(
- "Unsupervised identity learning requires PyTorch. Please run `pip install torch`."
- )
-import numpy as np
-import os
+except ModuleNotFoundError as e:
+ raise ModuleNotFoundError("Unsupervised identity learning requires PyTorch. Please run `pip install torch`.") from e
import glob
-from deeplabcut.utils import auxiliaryfunctions
+import os
+from collections.abc import Sequence
from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+
from .config import cfg
from .datasets import make_dlc_dataloader
+from .loss import easy_triplet_loss
from .model import make_dlc_model
+from .processor import do_dlc_train
from .solver import make_easy_optimizer
from .solver.scheduler_factory import create_scheduler
-from .loss import easy_triplet_loss
-from .processor import do_dlc_train
def set_seed(seed):
@@ -45,7 +48,6 @@ def set_seed(seed):
def split_train_test(npy_list, train_frac):
# with npy list form videos, split each to train and test
- x_list = []
train_list = []
test_list = []
@@ -66,11 +68,12 @@ def split_train_test(npy_list, train_frac):
return train_list, test_list
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def train_tracking_transformer(
path_config_file,
dlcscorer,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
train_frac=0.8,
modelprefix="",
train_epochs=100,
@@ -79,7 +82,7 @@ def train_tracking_transformer(
destfolder=None,
):
npy_list = []
- videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ videos = collect_video_paths(videos, extensions=video_extensions)
for video in videos:
videofolder = str(Path(video).parents[0])
if destfolder is None:
diff --git a/deeplabcut/post_processing/analyze_skeleton.py b/deeplabcut/post_processing/analyze_skeleton.py
index 172f0c9f26..1899bcdb79 100644
--- a/deeplabcut/post_processing/analyze_skeleton.py
+++ b/deeplabcut/post_processing/analyze_skeleton.py
@@ -14,19 +14,24 @@
"""
import argparse
+import os
+from collections.abc import Sequence
from math import atan2, degrees
from pathlib import Path
-import os
+
import numpy as np
import pandas as pd
from scipy.spatial import distance
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
# utility functions
def calc_distance_between_points_two_vectors_2d(v1, v2):
- """calc_distance_between_points_two_vectors_2d [pairwise distance between vectors points]
+ """calc_distance_between_points_two_vectors_2d [pairwise distance between vectors
+ points]
Arguments:
v1 {[np.array]} -- [description]
@@ -56,14 +61,15 @@ def calc_distance_between_points_two_vectors_2d(v1, v2):
raise ValueError("Error: input arrays should have the same length")
# Calculate distance
- dist = [distance.euclidean(p1, p2) for p1, p2 in zip(v1, v2)]
+ dist = [distance.euclidean(p1, p2) for p1, p2 in zip(v1, v2, strict=False)]
return dist
def angle_between_points_2d_anticlockwise(p1, p2):
- """angle_between_points_2d_clockwise [Determines the angle of a straight line drawn between point one and two.
- The number returned, which is a double in degrees, tells us how much we have to rotate
- a horizontal line anti-clockwise for it to match the line between the two points.]
+ """angle_between_points_2d_clockwise [Determines the angle of a straight line drawn
+ between point one and two. The number returned, which is a double in degrees, tells
+ us how much we have to rotate a horizontal line anti-clockwise for it to match the
+ line between the two points.]
Arguments:
p1 {[np.ndarray, list]} -- np.array or list [ with the X and Y coordinates of the point]
@@ -97,7 +103,8 @@ def angle_between_points_2d_anticlockwise(p1, p2):
def calc_angle_between_vectors_of_points_2d(v1, v2):
- """calc_angle_between_vectors_of_points_2d [calculates the clockwise angle between each set of point for two 2d arrays of points]
+ """calc_angle_between_vectors_of_points_2d [calculates the clockwise angle between
+ each set of point for two 2d arrays of points]
Arguments:
v1 {[np.ndarray]} -- [2d array with X,Y position at each timepoint]
@@ -116,17 +123,10 @@ def calc_angle_between_vectors_of_points_2d(v1, v2):
"""
# Check data format
- if (
- v1 is None
- or v2 is None
- or not isinstance(v1, np.ndarray)
- or not isinstance(v2, np.ndarray)
- ):
+ if v1 is None or v2 is None or not isinstance(v1, np.ndarray) or not isinstance(v2, np.ndarray):
raise ValueError("Invalid format for input arguments")
if len(v1) != len(v2):
- raise ValueError(
- "Input arrays should have the same length, instead: ", len(v1), len(v2)
- )
+ raise ValueError("Input arrays should have the same length, instead: ", len(v1), len(v2))
if not v1.shape[0] == 2 or not v2.shape[0] == 2:
raise ValueError("Invalid shape for input arrays: ", v1.shape, v2.shape)
@@ -160,19 +160,18 @@ def analyzebone(bp1, bp2):
likelihood = np.min(likelihoods, 1)
# Create dataframe and return
- df = pd.DataFrame.from_dict(
- dict(length=bone_length, orientation=bone_orientation, likelihood=likelihood)
- )
+ df = pd.DataFrame.from_dict(dict(length=bone_length, orientation=bone_orientation, likelihood=likelihood))
# df.index.name=name
return df
# MAIN FUNC
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def analyzeskeleton(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtered=False,
@@ -181,6 +180,7 @@ def analyzeskeleton(
modelprefix="",
track_method="",
return_data=False,
+ **kwargs,
):
"""Extracts length and orientation of each "bone" of the skeleton.
@@ -195,11 +195,14 @@ def analyzeskeleton(
The full paths to videos for analysis or a path to the directory, where all the
videos with same extension are stored.
- videotype: str, optional, default=""
- Checks for the extension of the video in case the input to the video is a
- directory. Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions
- ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle : int, optional, default=1
The shuffle index of training dataset. The extracted frames will be stored in
@@ -235,6 +238,11 @@ def analyzeskeleton(
return_data: bool, optional, default=False
If True, returns a dictionary of the filtered data keyed by video names.
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
video_to_skeleton_df
@@ -257,11 +265,12 @@ def analyzeskeleton(
shuffle,
trainFraction=cfg["TrainingFraction"][trainingsetindex],
modelprefix=modelprefix,
+ **kwargs,
)
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
for video in Videos:
- print("Processing %s" % (video))
+ print(f"Processing {video}")
if destfolder is None:
destfolder = str(Path(video).parents[0])
@@ -275,7 +284,7 @@ def analyzeskeleton(
video_to_skeleton_df[video] = None
continue
- output_name = filepath.replace(".h5", f"_skeleton.h5")
+ output_name = filepath.replace(".h5", "_skeleton.h5")
if os.path.isfile(output_name):
print(f"Skeleton in video {vname} already processed. Skipping...")
video_to_skeleton_df[video] = pd.read_hdf(output_name, "df_with_missing")
@@ -287,16 +296,16 @@ def analyzeskeleton(
temp = df_.droplevel(["scorer", "individuals"], axis=1)
if animal_name != "single":
for bp1, bp2 in cfg["skeleton"]:
- name = "{}_{}_{}".format(animal_name, bp1, bp2)
+ name = f"{animal_name}_{bp1}_{bp2}"
bones[name] = analyzebone(temp[bp1], temp[bp2])
else:
for bp1, bp2 in cfg["skeleton"]:
- name = "{}_{}".format(bp1, bp2)
+ name = f"{bp1}_{bp2}"
bones[name] = analyzebone(df[scorer][bp1], df[scorer][bp2])
skeleton = pd.concat(bones, axis=1)
video_to_skeleton_df[video] = skeleton
- skeleton.to_hdf(output_name, "df_with_missing", format="table", mode="w")
+ skeleton.to_hdf(output_name, key="df_with_missing", format="table", mode="w")
if save_as_csv:
skeleton.to_csv(output_name.replace(".h5", ".csv"))
diff --git a/deeplabcut/post_processing/filtering.py b/deeplabcut/post_processing/filtering.py
index d462bbd249..cb9c81445d 100644
--- a/deeplabcut/post_processing/filtering.py
+++ b/deeplabcut/post_processing/filtering.py
@@ -10,6 +10,7 @@
#
import argparse
+from collections.abc import Sequence
from pathlib import Path
import numpy as np
@@ -18,14 +19,14 @@
from scipy.interpolate import CubicSpline
from deeplabcut.refine_training_dataset.outlier_frames import FitSARIMAXModel
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
def columnwise_spline_interp(data, max_gap=0):
- """
- Perform cubic spline interpolation over the columns of *data*.
- All gaps of size lower than or equal to *max_gap* are filled,
- and data slightly smoothed.
+ """Perform cubic spline interpolation over the columns of *data*. All gaps of size
+ lower than or equal to *max_gap* are filled, and data slightly smoothed.
Parameters
----------
@@ -46,9 +47,7 @@ def columnwise_spline_interp(data, max_gap=0):
x = np.arange(nrows)
for i in range(ncols):
mask = valid[:, i]
- if (
- np.sum(mask) > 3
- ): # Make sure there are enough points to fit the cubic spline
+ if np.sum(mask) > 3: # Make sure there are enough points to fit the cubic spline
spl = CubicSpline(x[mask], temp[mask, i])
y = spl(x)
if max_gap > 0:
@@ -56,7 +55,7 @@ def columnwise_spline_interp(data, max_gap=0):
count = np.diff(inds)
inds = inds[:-1]
to_fill = np.ones_like(mask)
- for ind, n, is_nan in zip(inds, count, ~mask[inds]):
+ for ind, n, is_nan in zip(inds, count, ~mask[inds], strict=False):
if is_nan and n > max_gap:
to_fill[ind : ind + n] = False
y[~to_fill] = np.nan
@@ -66,10 +65,11 @@ def columnwise_spline_interp(data, max_gap=0):
return temp
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def filterpredictions(
config,
video,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtertype="median",
@@ -83,6 +83,7 @@ def filterpredictions(
modelprefix="",
track_method="",
return_data=False,
+ **kwargs,
):
"""Fits frame-by-frame pose predictions.
@@ -98,6 +99,15 @@ def filterpredictions(
Full path of the video to extract the frame from. Make sure that this video is
already analyzed.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
+
shuffle : int, optional, default=1
The shuffle index of training dataset. The extracted frames will be stored in
the labeled-dataset for the corresponding shuffle of training dataset.
@@ -152,6 +162,11 @@ def filterpredictions(
return_data: bool, optional, default=False
If True, returns a dictionary of the filtered data keyed by video names.
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
video_to_filtered_df
@@ -208,13 +223,14 @@ def filterpredictions(
shuffle,
trainFraction=cfg["TrainingFraction"][trainingsetindex],
modelprefix=modelprefix,
+ **kwargs,
)
- Videos = auxiliaryfunctions.get_list_of_videos(video, videotype)
+ Videos = collect_video_paths(video, extensions=video_extensions)
video_to_filtered_df = {}
if not len(Videos):
- print("No video(s) were found. Please check your paths and/or 'videotype'.")
+ print("No video(s) were found. Please check your paths and/or extensions filter.")
if return_data:
return video_to_filtered_df
@@ -222,13 +238,11 @@ def filterpredictions(
if destfolder is None:
destfolder = str(Path(video).parents[0])
- print("Filtering with %s model %s" % (filtertype, video))
+ print(f"Filtering with {filtertype} model {video}")
vname = Path(video).stem
try:
- df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(
- destfolder, vname, DLCscorer, True, track_method
- )
+ df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(destfolder, vname, DLCscorer, True, track_method)
print(f"Data from {vname} were already filtered. Skipping...")
video_to_filtered_df[video] = df
# Data has been filtered so continue to the next video
@@ -252,12 +266,8 @@ def filterpredictions(
placeholder = np.empty_like(temp)
for i in range(temp.shape[1]):
x, y, p = temp[:, i].T
- meanx, _ = FitSARIMAXModel(
- x, p, p_bound, alpha, ARdegree, MAdegree, False
- )
- meany, _ = FitSARIMAXModel(
- y, p, p_bound, alpha, ARdegree, MAdegree, False
- )
+ meanx, _ = FitSARIMAXModel(x, p, p_bound, alpha, ARdegree, MAdegree, False)
+ meany, _ = FitSARIMAXModel(y, p, p_bound, alpha, ARdegree, MAdegree, False)
meanx[0] = x[0]
meany[0] = y[0]
placeholder[:, i] = np.c_[meanx, meany, p]
@@ -269,9 +279,7 @@ def filterpredictions(
elif filtertype == "median":
data = df.copy()
mask = data.columns.get_level_values("coords") != "likelihood"
- data.loc[:, mask] = df.loc[:, mask].apply(
- signal.medfilt, args=(windowlength,), axis=0
- )
+ data.loc[:, mask] = df.loc[:, mask].apply(signal.medfilt, args=(windowlength,), axis=0)
elif filtertype == "spline":
data = df.copy()
mask_data = data.columns.get_level_values("coords").isin(("x", "y"))
@@ -295,7 +303,7 @@ def filterpredictions(
video_to_filtered_df[video] = data
outdataname = filepath.replace(".h5", "_filtered.h5")
- data.to_hdf(outdataname, "df_with_missing", format="table", mode="w")
+ data.to_hdf(outdataname, key="df_with_missing", format="table", mode="w")
if save_as_csv:
print("Saving filtered csv poses!")
data.to_csv(outdataname.split(".h5")[0] + ".csv")
diff --git a/deeplabcut/refine_training_dataset/__init__.py b/deeplabcut/refine_training_dataset/__init__.py
index 6c04417f93..9dc09adde2 100644
--- a/deeplabcut/refine_training_dataset/__init__.py
+++ b/deeplabcut/refine_training_dataset/__init__.py
@@ -10,5 +10,5 @@
#
-from deeplabcut.refine_training_dataset.tracklets import *
from deeplabcut.refine_training_dataset.outlier_frames import *
+from deeplabcut.refine_training_dataset.tracklets import *
diff --git a/deeplabcut/refine_training_dataset/outlier_frames.py b/deeplabcut/refine_training_dataset/outlier_frames.py
index 687ce93c36..38fc040847 100644
--- a/deeplabcut/refine_training_dataset/outlier_frames.py
+++ b/deeplabcut/refine_training_dataset/outlier_frames.py
@@ -14,8 +14,8 @@
import os
import pickle
import re
+from collections.abc import Sequence
from pathlib import Path
-from typing import List, Optional
import matplotlib.pyplot as plt
import numpy as np
@@ -23,15 +23,16 @@
import statsmodels.api as sm
from skimage.util import img_as_ubyte
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils
+from deeplabcut.core import inferenceutils
from deeplabcut.utils import (
- auxiliaryfunctions,
auxfun_multianimal,
+ auxiliaryfunctions,
conversioncode,
- visualization,
frameselectiontools,
+ visualization,
)
-from deeplabcut.utils.auxfun_videos import VideoWriter
+from deeplabcut.utils.auxfun_videos import VideoWriter, collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
def find_outliers_in_raw_data(
@@ -44,8 +45,8 @@ def find_outliers_in_raw_data(
extraction_algo="kmeans",
copy_videos=False,
):
- """
- Extract outlier frames from either raw detections or assemblies of multiple animals.
+ """Extract outlier frames from either raw detections or assemblies of multiple
+ animals.
Parameter
----------
@@ -76,7 +77,6 @@ def find_outliers_in_raw_data(
copy_videos : bool, optional (default=False)
If True, newly-added videos (from which outlier frames are extracted) are
copied to the project folder. By default, symbolic links are created instead.
-
"""
if extraction_algo not in ("kmeans", "uniform"):
raise ValueError(f"Unsupported extraction algorithm {extraction_algo}.")
@@ -104,7 +104,7 @@ def find_outliers_in_raw_data(
assemblies[k] = ass
inds = inferenceutils.find_outlier_assemblies(assemblies, qs=percentiles)
else:
- raise IOError(f"Raw data file {pickle_file} could not be parsed.")
+ raise OSError(f"Raw data file {pickle_file} could not be parsed.")
cfg = auxiliaryfunctions.read_config(config)
ExtractFramesbasedonPreselection(
@@ -120,11 +120,8 @@ def find_outliers_in_raw_data(
)
-def find_outliers_in_raw_detections(
- pickled_data, algo="uncertain", threshold=0.1, kept_keypoints=None
-):
- """
- Find outlier frames from the raw detections of multiple animals.
+def find_outliers_in_raw_detections(pickled_data, algo="uncertain", threshold=0.1, kept_keypoints=None):
+ """Find outlier frames from the raw detections of multiple animals.
Parameter
----------
@@ -149,7 +146,7 @@ def find_outliers_in_raw_detections(
Indices of video frames containing potential outliers
"""
if algo != "uncertain":
- raise ValueError(f"Only method 'uncertain' is currently supported.")
+ raise ValueError("Only method 'uncertain' is currently supported.")
try:
_ = pickled_data.pop("metadata")
@@ -176,10 +173,36 @@ def get_frame_ind(s):
return candidates, data
+def _read_video_specific_cropping_margins(config: str | Path | dict, video_path: str | Path) -> tuple[int, int]:
+ if isinstance(config, (str, Path)):
+ config = auxiliaryfunctions.read_config(config)
+ output_crop = config["video_sets"].get(str(video_path), {}).get("crop")
+ if output_crop is None:
+ x1, _, y1, _ = (0, 0, 0, 0)
+ else:
+ # Accept comma-separated values with optional spaces, and validate format.
+ parts = [p.strip() for p in str(output_crop).split(",")]
+ if len(parts) != 4:
+ raise ValueError(
+ f"Invalid crop specification {output_crop!r} for video {video_path!r} "
+ "in config: expected exactly 4 comma-separated integers "
+ "in the form 'x1,x2,y1,y2'."
+ )
+ try:
+ x1, _, y1, _ = map(int, parts)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ f"Invalid crop specification {output_crop!r} for video {video_path!r} "
+ "in config: values must be integers in the form 'x1,x2,y1,y2'."
+ ) from exc
+ return x1, y1
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def extract_outlier_frames(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
outlieralgorithm="jump",
@@ -200,6 +223,7 @@ def extract_outlier_frames(
destfolder=None,
modelprefix="",
track_method="",
+ **kwargs,
):
"""Extracts the outlier frames.
@@ -218,11 +242,14 @@ def extract_outlier_frames(
The full paths to videos for analysis or a path to the directory, where all the
videos with same extension are stored.
- videotype: str, optional, default=""
- Checks for the extension of the video in case the input to the video is a
- directory. Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions
- ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle : int, optional, default=1
The shuffle index of training dataset. The extracted frames will be stored in
@@ -235,13 +262,15 @@ def extract_outlier_frames(
outlieralgorithm: str, optional, default="jump".
String specifying the algorithm used to detect the outliers.
- * ``'Fitting'`` fits a Auto Regressive Integrated Moving Average model to the
+ * ``'fitting'`` fits an Auto Regressive Integrated Moving Average model to the
data and computes the distance to the estimated data. Larger distances than
epsilon are then potentially identified as outliers
* ``'jump'`` identifies larger jumps than 'epsilon' in any body part
* ``'uncertain'`` looks for frames with confidence below p_bound
* ``'manual'`` launches a GUI from which the user can choose the frames
- * ``'list'`` looks for user to provide a list of frame numbers to use, 'frames2use'. In this case, ``'extractionalgorithm'`` is forced to be ``'uniform.'``
+ * ``'list'`` looks for user to provide a list of
+ frame numbers to use, 'frames2use'.
+ In this case, ``'extractionalgorithm'`` is forced to be ``'uniform.'``
frames2use: list[str], optional, default=None
If ``'outlieralgorithm'`` is ``'list'``, provide the list of frames here.
@@ -271,7 +300,7 @@ def extract_outlier_frames(
See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html
MAdegree: int, optional, default=1
- For outlieralgorithm ``'fitting'``: MovingAvarage degree of ARIMA model degree.
+ For outlieralgorithm ``'fitting'``: Moving Average degree of ARIMA model degree.
(Note we use SARIMAX without exogeneous and seasonal part)
See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html
@@ -320,6 +349,11 @@ def extract_outlier_frames(
For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
be taken from the config.yaml file if none is given.
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
None
@@ -360,9 +394,7 @@ def extract_outlier_frames(
"""
cfg = auxiliaryfunctions.read_config(config)
- bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts
- )
+ bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
if not len(bodyparts):
raise ValueError("No valid bodyparts were selected.")
@@ -373,9 +405,10 @@ def extract_outlier_frames(
shuffle,
trainFraction=cfg["TrainingFraction"][trainingsetindex],
modelprefix=modelprefix,
+ **kwargs,
)
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if len(Videos) == 0:
print("No suitable videos found in", videos)
@@ -390,11 +423,20 @@ def extract_outlier_frames(
df, dataname, _, _ = auxiliaryfunctions.load_analyzed_data(
videofolder, vname, DLCscorer, track_method=track_method
)
+ metadata = auxiliaryfunctions.load_video_metadata(videofolder, vname, DLCscorer)
nframes = len(df)
startindex = max([int(np.floor(nframes * cfg["start"])), 0])
stopindex = min([int(np.ceil(nframes * cfg["stop"])), nframes])
Index = np.arange(stopindex - startindex) + startindex
+ # offset if the data was cropped
+ # note: When output video is also cropped, the keypoints should be shifted back.
+ out_x1, out_y1 = _read_video_specific_cropping_margins(config, video)
+ if metadata.get("data", {}).get("cropping"):
+ x1, _, y1, _ = metadata["data"]["cropping_parameters"]
+ df.iloc[:, df.columns.get_level_values(level="coords") == "x"] += x1 - out_x1
+ df.iloc[:, df.columns.get_level_values(level="coords") == "y"] += y1 - out_y1
+
df = df.iloc[Index]
mask = df.columns.get_level_values("bodyparts").isin(bodyparts)
df_temp = df.loc[:, mask]
@@ -407,19 +449,14 @@ def extract_outlier_frames(
temp_dt = df_temp.diff(axis=0) ** 2
temp_dt.drop("likelihood", axis=1, level="coords", inplace=True)
sum_ = temp_dt.groupby(level="bodyparts", axis=1).sum()
- ind = df_temp.index[(sum_ > epsilon ** 2).any(axis=1)].tolist()
+ ind = df_temp.index[(sum_ > epsilon**2).any(axis=1)].tolist()
Indices.extend(ind)
elif outlieralgorithm == "fitting":
- d, o = compute_deviations(
- df_temp, dataname, p_bound, alpha, ARdegree, MAdegree
- )
+ d, o = compute_deviations(df_temp, dataname, p_bound, alpha, ARdegree, MAdegree)
# Some heuristics for extracting frames based on distance:
- ind = np.flatnonzero(
- d > epsilon
- ) # time points with at least average difference of epsilon
+ ind = np.flatnonzero(d > epsilon) # time points with at least average difference of epsilon
if (
- len(ind) < cfg["numframes2pick"] * 2
- and len(d) > cfg["numframes2pick"] * 2
+ len(ind) < cfg["numframes2pick"] * 2 and len(d) > cfg["numframes2pick"] * 2
): # if too few points qualify, extract the most distant ones.
ind = np.argsort(d)[::-1][: cfg["numframes2pick"] * 2]
Indices.extend(ind)
@@ -433,9 +470,7 @@ def extract_outlier_frames(
coords=None,
)
if added_video:
- project_video_path = (
- Path(cfg["project_path"]) / "videos" / Path(video).name
- )
+ project_video_path = Path(cfg["project_path"]) / "videos" / Path(video).name
_ = launch_napari([project_video_path, dataname])
return
@@ -443,16 +478,15 @@ def extract_outlier_frames(
if frames2use is not None:
try:
frames2use = np.array(frames2use).astype("int")
- except ValueError() as e:
+ except ValueError():
print(
- "Could not cast frames2use into np array, please check that frames2use is a simply a list of integers!"
+ "Could not cast frames2use into np array, "
+ "please check that frames2use is a simply a list of integers!"
)
raise
Indices.extend(frames2use)
else:
- raise ValueError(
- 'Expected list of frames2use for outlieralgorithm "list"!'
- )
+ raise ValueError('Expected list of frames2use for outlieralgorithm "list"!')
else:
raise ValueError(f"outlieralgorithm {outlieralgorithm} not recognized!")
@@ -488,12 +522,7 @@ def extract_outlier_frames(
else:
askuser = "Ja"
- if (
- askuser == "y"
- or askuser == "yes"
- or askuser == "Ja"
- or askuser == "ha"
- ): # multilanguage support :)
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
# Now extract from those Indices!
ExtractFramesbasedonPreselection(
Indices,
@@ -509,14 +538,13 @@ def extract_outlier_frames(
copy_videos=copy_videos,
)
else:
- print(
- "Nothing extracted, please change the parameters and start again..."
- )
+ print("Nothing extracted, please change the parameters and start again...")
except FileNotFoundError as e:
print(e)
print(
"It seems the video has not been analyzed yet, or the video is not found! "
- "You can only refine the labels after the a video is analyzed. Please run 'analyze_video' first. "
+ "You can only refine the labels after the a video is analyzed. "
+ "Please run 'analyze_video' first. "
"Or, please double check your video file path"
)
@@ -536,11 +564,13 @@ def convertparms2start(pn):
def FitSARIMAXModel(x, p, pcutoff, alpha, ARdegree, MAdegree, nforecast=0, disp=False):
# Seasonal Autoregressive Integrated Moving-Average with eXogenous regressors (SARIMAX)
- # see http://www.statsmodels.org/stable/statespace.html#seasonal-autoregressive-integrated-moving-average-with-exogenous-regressors-sarimax
+ # see
+ # http://www.statsmodels.org/stable/statespace.html#seasonal-autoregressive-integrated-moving-average-with-exogenous-regressors-sarimax
Y = x.copy()
Y[p < pcutoff] = np.nan # Set uncertain estimates to nan (modeled as missing data)
if np.sum(np.isfinite(Y)) > 10:
- # SARIMAX implementation has better prediction models than simple ARIMAX (however we do not use the seasonal etc. parameters!)
+ # SARIMAX implementation has better prediction models than simple ARIMAX
+ # (however we do not use the seasonal etc. parameters!)
mod = sm.tsa.statespace.SARIMAX(
Y.flatten(),
order=(ARdegree, 0, MAdegree),
@@ -551,9 +581,8 @@ def FitSARIMAXModel(x, p, pcutoff, alpha, ARdegree, MAdegree, nforecast=0, disp=
# mod = sm.tsa.ARIMA(Y, order=(ARdegree,0,MAdegree)) #order=(ARdegree,0,MAdegree)
try:
res = mod.fit(disp=disp)
- except (
- ValueError
- ): # https://groups.google.com/forum/#!topic/pystatsmodels/S_Fo53F25Rk (let's update to statsmodels 0.10.0 soon...)
+ # https://groups.google.com/forum/#!topic/pystatsmodels/S_Fo53F25Rk (let's update to statsmodels 0.10.0 soon...)
+ except ValueError:
startvalues = np.array([convertparms2start(pn) for pn in mod.param_names])
res = mod.fit(start_params=startvalues, disp=disp)
except np.linalg.LinAlgError:
@@ -576,11 +605,9 @@ def FitSARIMAXModel(x, p, pcutoff, alpha, ARdegree, MAdegree, nforecast=0, disp=
return np.nan * np.zeros(len(Y)), np.nan * np.zeros((len(Y), 2))
-def compute_deviations(
- Dataframe, dataname, p_bound, alpha, ARdegree, MAdegree, storeoutput=None
-):
- """Fits Seasonal AutoRegressive Integrated Moving Average with eXogenous regressors model to data and computes confidence interval
- as well as mean fit."""
+def compute_deviations(Dataframe, dataname, p_bound, alpha, ARdegree, MAdegree, storeoutput=None):
+ """Fits Seasonal AutoRegressive Integrated Moving Average with eXogenous regressors
+ model to data and computes confidence interval as well as mean fit."""
print("Fitting state-space models with parameters:", ARdegree, MAdegree)
df_x, df_y, df_likelihood = Dataframe.values.reshape((Dataframe.shape[0], -1, 3)).T
@@ -592,29 +619,31 @@ def compute_deviations(
meanx, CIx = FitSARIMAXModel(x, p, p_bound, alpha, ARdegree, MAdegree)
meany, CIy = FitSARIMAXModel(y, p, p_bound, alpha, ARdegree, MAdegree)
distance = np.sqrt((x - meanx) ** 2 + (y - meany) ** 2)
- significant = (
- (x < CIx[:, 0]) + (x > CIx[:, 1]) + (y < CIy[:, 0]) + (y > CIy[:, 1])
- )
+ significant = (x < CIx[:, 0]) + (x > CIx[:, 1]) + (y < CIy[:, 0]) + (y > CIy[:, 1])
preds.append(np.c_[distance, significant, meanx, meany, CIx, CIy])
columns = Dataframe.columns
- prod = []
- for i in range(columns.nlevels - 1):
- prod.append(columns.get_level_values(i).unique())
- prod.append(
- [
- "distance",
- "sig",
- "meanx",
- "meany",
- "lowerCIx",
- "higherCIx",
- "lowerCIy",
- "higherCIy",
- ]
+ # Use the existing valid keypoint combinations, in their original order.
+ # The goal is to extract each stream (e.g. Scorer/ID/Bodypart) as a separate column,
+ # and then build, for each stat, a MultiIndex with the same levels, i.e.
+ # Scorer/ID/Bodypart/stat (see stats below).
+ # Note, this could be built from "y" as well without any difference in the output
+ base_cols = Dataframe.xs("x", axis=1, level="coords", drop_level=True).columns
+ stats = [
+ "distance",
+ "sig",
+ "meanx",
+ "meany",
+ "lowerCIx",
+ "higherCIx",
+ "lowerCIy",
+ "higherCIy",
+ ]
+ pdindex = pd.MultiIndex.from_tuples(
+ [(*col, stat) for col in base_cols for stat in stats],
+ names=[n for n in columns.names if n != "coords"] + ["stats"],
)
- pdindex = pd.MultiIndex.from_product(prod, names=columns.names)
- data = pd.DataFrame(np.concatenate(preds, axis=1), columns=pdindex)
+ data = pd.DataFrame(np.concatenate(preds, axis=1), columns=pdindex) # preds (n_frames, n_stats * n_streams)
# average distance and average # significant differences avg. over comparisonbodyparts
d = data.xs("distance", axis=1, level=-1).mean(axis=1).values
o = data.xs("sig", axis=1, level=-1).mean(axis=1).values
@@ -622,7 +651,7 @@ def compute_deviations(
if storeoutput == "full":
data.to_hdf(
dataname.split(".h5")[0] + "filtered.h5",
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -635,10 +664,9 @@ def attempt_to_add_video(
config: str,
video: str,
copy_videos: bool,
- coords: Optional[List],
+ coords: list | None,
) -> bool:
- """
- Add new videos to the config file at any stage of the project.
+ """Add new videos to the config file at any stage of the project.
Parameters
----------
@@ -649,7 +677,8 @@ def attempt_to_add_video(
Full path of the video to add to the project.
copy_videos : bool, optional
- If this is set to True, the videos will be copied to the project/videos directory. If False, the symlink of the
+ If this is set to True, the videos will be copied to the project/videos directory.
+ If False, the symlink of the
videos will be copied instead. The default is
``False``; if provided it must be either ``True`` or ``False``.
@@ -669,11 +698,11 @@ def attempt_to_add_video(
try:
add.add_new_videos(config, videos, coords=coords, copy_videos=copy_videos)
- except:
+ except Exception:
# can we make a catch here? - in fact we should drop indices from DataCombined
# if they are in CollectedData.. [ideal behavior; currently pretty unlikely]
print(
- f"AUTOMATIC ADDING OF VIDEO TO CONFIG FILE FAILED! You need to "
+ "AUTOMATIC ADDING OF VIDEO TO CONFIG FILE FAILED! You need to "
"do this manually for including it in the config.yaml file!"
)
print("Videopath:", video, "Coordinates for cropping:", coords)
@@ -699,11 +728,9 @@ def ExtractFramesbasedonPreselection(
start = cfg["start"]
stop = cfg["stop"]
numframes2extract = cfg["numframes2pick"]
- bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, "all"
- )
+ bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, "all")
- videofolder = str(Path(video).parents[0])
+ str(Path(video).parents[0])
vname = str(Path(video).stem)
tmpfolder = os.path.join(cfg["project_path"], "labeled-data", vname)
if os.path.isdir(tmpfolder):
@@ -738,9 +765,7 @@ def ExtractFramesbasedonPreselection(
if opencv:
if coords is not None:
vid.set_bbox(*coords)
- frames2pick = frameselectiontools.UniformFramescv2(
- vid, numframes2extract, start, stop, Index
- )
+ frames2pick = frameselectiontools.UniformFramescv2(vid, numframes2extract, start, stop, Index)
else:
if coords is not None:
clip = clip.crop(
@@ -749,9 +774,7 @@ def ExtractFramesbasedonPreselection(
x1=coords[0],
x2=coords[1],
)
- frames2pick = frameselectiontools.UniformFrames(
- clip, numframes2extract, start, stop, Index
- )
+ frames2pick = frameselectiontools.UniformFrames(clip, numframes2extract, start, stop, Index)
elif extractionalgorithm == "kmeans":
if opencv:
if coords is not None:
@@ -784,12 +807,11 @@ def ExtractFramesbasedonPreselection(
)
else:
- print(
- "Please implement this method yourself! Currently the options are 'kmeans', 'jump', 'uniform'."
- )
+ print("Please implement this method yourself! Currently the options are 'kmeans', 'jump', 'uniform'.")
frames2pick = []
- # Extract frames + frames with plotted labels and store them in folder (with name derived from video name) nder labeled-data
+ # Extract frames + frames with plotted labels and store them in folder
+ # (with name derived from video name) nder labeled-data
print("Let's select frames indices:", frames2pick)
colors = visualization.get_cmap(len(bodyparts), cfg["colormap"])
strwidth = int(np.ceil(np.log10(nframes))) # width for strings
@@ -831,7 +853,8 @@ def ExtractFramesbasedonPreselection(
clip.close()
del clip
- # Extract annotations based on DeepLabCut and store in the folder (with name derived from video name) under labeled-data
+ # Extract annotations based on DeepLabCut and store in the folder (with
+ # name derived from video name) under labeled-data
if len(frames2pick) > 0:
added_video = attempt_to_add_video(
config=config,
@@ -843,9 +866,7 @@ def ExtractFramesbasedonPreselection(
pass
if with_annotations:
- machinefile = os.path.join(
- tmpfolder, "machinelabels-iter" + str(cfg["iteration"]) + ".h5"
- )
+ machinefile = os.path.join(tmpfolder, "machinelabels-iter" + str(cfg["iteration"]) + ".h5")
if isinstance(data, pd.DataFrame):
df = data.loc[frames2pick]
df.index = pd.MultiIndex.from_tuples(
@@ -869,9 +890,7 @@ def ExtractFramesbasedonPreselection(
for index in frames2pick
]
)
- filename = os.path.join(
- str(tmpfolder), f"CollectedData_{cfg['scorer']}.h5"
- )
+ filename = os.path.join(str(tmpfolder), f"CollectedData_{cfg['scorer']}.h5")
try:
df_temp = pd.read_hdf(filename, "df_with_missing")
columns = df_temp.columns
@@ -916,9 +935,7 @@ def ExtractFramesbasedonPreselection(
conversioncode.guarantee_multiindex_rows(Data)
DataCombined = pd.concat([Data, df])
# drop duplicate labels:
- DataCombined = DataCombined[
- ~DataCombined.index.duplicated(keep="first")
- ]
+ DataCombined = DataCombined[~DataCombined.index.duplicated(keep="first")]
DataCombined.to_hdf(machinefile, key="df_with_missing", mode="w")
DataCombined.to_csv(
@@ -928,13 +945,8 @@ def ExtractFramesbasedonPreselection(
df.to_hdf(machinefile, key="df_with_missing", mode="w")
df.to_csv(os.path.join(tmpfolder, "machinelabels.csv"))
- print(
- "The outlier frames are extracted. They are stored in the subdirectory labeled-data\%s."
- % vname
- )
- print(
- "Once you extracted frames for all videos, use 'refine_labels' to manually correct the labels."
- )
+ print(rf"The outlier frames are extracted. They are stored in the subdirectory labeled-data\{vname}.")
+ print("Once you extracted frames for all videos, use 'refine_labels' to manually correct the labels.")
else:
print("No frames were extracted.")
@@ -956,13 +968,9 @@ def PlottingSingleFrame(
from skimage import io
imagename1 = os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")
- imagename2 = os.path.join(
- tmpfolder, "img" + str(index).zfill(strwidth) + "labeled.png"
- )
+ imagename2 = os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + "labeled.png")
- if not os.path.isfile(
- os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")
- ):
+ if not os.path.isfile(os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")):
plt.axis("off")
image = img_as_ubyte(clip.get_frame(index * 1.0 / clip.fps))
io.imsave(imagename1, image)
@@ -975,9 +983,7 @@ def PlottingSingleFrame(
bpts = Dataframe.columns.get_level_values("bodyparts")
all_bpts = bpts.values[::3]
- df_x, df_y, df_likelihood = Dataframe.values.reshape(
- (Dataframe.shape[0], -1, 3)
- ).T
+ df_x, df_y, df_likelihood = Dataframe.values.reshape((Dataframe.shape[0], -1, 3)).T
bplist = bpts.unique().to_list()
if Dataframe.columns.nlevels == 3:
map2bp = list(range(len(all_bpts)))
@@ -993,7 +999,7 @@ def PlottingSingleFrame(
plt.scatter(
df_x[ind, index],
df_y[ind, index],
- s=dotsize ** 2,
+ s=dotsize**2,
color=colors(map2bp[i]),
alpha=alphavalue,
)
@@ -1023,13 +1029,9 @@ def PlottingSingleFramecv2(
from skimage import io
imagename1 = os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")
- imagename2 = os.path.join(
- tmpfolder, "img" + str(index).zfill(strwidth) + "labeled.png"
- )
+ imagename2 = os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + "labeled.png")
- if not os.path.isfile(
- os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")
- ):
+ if not os.path.isfile(os.path.join(tmpfolder, "img" + str(index).zfill(strwidth) + ".png")):
plt.axis("off")
cap.set_to_frame(index)
frame = cap.read_frame(crop=True)
@@ -1047,9 +1049,7 @@ def PlottingSingleFramecv2(
bpts = Dataframe.columns.get_level_values("bodyparts")
all_bpts = bpts.values[::3]
- df_x, df_y, df_likelihood = Dataframe.values.reshape(
- (Dataframe.shape[0], -1, 3)
- ).T
+ df_x, df_y, df_likelihood = Dataframe.values.reshape((Dataframe.shape[0], -1, 3)).T
bplist = bpts.unique().to_list()
if Dataframe.columns.nlevels == 3:
map2bp = list(range(len(all_bpts)))
@@ -1065,7 +1065,7 @@ def PlottingSingleFramecv2(
plt.scatter(
df_x[ind, index],
df_y[ind, index],
- s=dotsize ** 2,
+ s=dotsize**2,
color=colors(map2bp[i]),
alpha=alphavalue,
)
@@ -1107,15 +1107,11 @@ def merge_datasets(config, forceiterate=None):
bf = Path(str(config_path / "labeled-data"))
allfolders = [
- os.path.join(bf, fn)
- for fn in os.listdir(bf)
- if "_labeled" not in fn and not fn.startswith(".")
+ os.path.join(bf, fn) for fn in os.listdir(bf) if "_labeled" not in fn and not fn.startswith(".")
] # exclude labeled data folders and temporary files
flagged = False
- for findex, folder in enumerate(allfolders):
- if os.path.isfile(
- os.path.join(folder, "MachineLabelsRefine.h5")
- ): # Folder that was manually refine...
+ for _findex, folder in enumerate(allfolders):
+ if os.path.isfile(os.path.join(folder, "MachineLabelsRefine.h5")): # Folder that was manually refine...
pass
elif os.path.isfile(
os.path.join(folder, "CollectedData_" + cfg["scorer"] + ".h5")
@@ -1136,14 +1132,8 @@ def merge_datasets(config, forceiterate=None):
auxiliaryfunctions.write_config(config, cfg)
- print(
- "Merged data sets and updated refinement iteration to "
- + str(cfg["iteration"])
- + "."
- )
- print(
- "Now you can create a new training set for the expanded annotated images (use create_training_dataset)."
- )
+ print("Merged data sets and updated refinement iteration to " + str(cfg["iteration"]) + ".")
+ print("Now you can create a new training set for the expanded annotated images (use create_training_dataset).")
else:
print("Please label, or remove the un-corrected folders.")
diff --git a/deeplabcut/refine_training_dataset/stitch.py b/deeplabcut/refine_training_dataset/stitch.py
index d760f1efd1..59f792cf3a 100644
--- a/deeplabcut/refine_training_dataset/stitch.py
+++ b/deeplabcut/refine_training_dataset/stitch.py
@@ -8,39 +8,41 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import matplotlib.pyplot as plt
-import networkx as nx
-import numpy as np
import os
-import pandas as pd
import pickle
import re
-import scipy.linalg.interpolative as sli
import shelve
import warnings
from collections import defaultdict
-
-import deeplabcut
-from deeplabcut.utils.auxfun_videos import VideoWriter
+from collections.abc import Sequence
from functools import partial
-from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import (
- calc_iou,
- TRACK_METHODS,
-)
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal
from itertools import combinations, cycle
-from networkx.algorithms.flow import preflow_push
from pathlib import Path
+
+import matplotlib.pyplot as plt
+import networkx as nx
+import numpy as np
+import pandas as pd
+import scipy.linalg.interpolative as sli
+from networkx.algorithms.flow import preflow_push
from scipy.linalg import hankel
from scipy.spatial.distance import directed_hausdorff
from scipy.stats import mode
from tqdm import trange
+import deeplabcut
+from deeplabcut.core.trackingutils import (
+ TRACK_METHODS,
+ calc_iou,
+)
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import VideoWriter, collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
+
class Tracklet:
def __init__(self, data, inds):
- """
- Create a Tracklet object.
+ """Create a Tracklet object.
Parameters
----------
@@ -54,13 +56,11 @@ def __init__(self, data, inds):
raise ValueError("Data must of shape (nframes, nbodyparts, 3 or 4)")
if data.shape[0] != len(inds):
- raise ValueError(
- "Data and corresponding indices must have the same length."
- )
+ raise ValueError("Data and corresponding indices must have the same length.")
self.data = data.astype(np.float64)
self.inds = np.array(inds)
- monotonically_increasing = all(a < b for a, b in zip(inds, inds[1:]))
+ monotonically_increasing = all(a < b for a, b in zip(inds, inds[1:], strict=False))
if not monotonically_increasing:
idx = np.argsort(inds, kind="mergesort") # For stable sort with duplicates
self.inds = self.inds[idx]
@@ -100,10 +100,7 @@ def __contains__(self, other_tracklet):
return np.isin(self.inds, other_tracklet.inds, assume_unique=True).any()
def __repr__(self):
- return (
- f"Tracklet of length {len(self)} from {self.start} to {self.end} "
- f"with reliability {self.likelihood:.3f}"
- )
+ return f"Tracklet of length {len(self)} from {self.start} to {self.end} with reliability {self.likelihood:.3f}"
@property
def xy(self):
@@ -112,18 +109,17 @@ def xy(self):
@property
def centroid(self):
- """
- Return the instantaneous 2D position of the Tracklet centroid.
- For Tracklets longer than 10 frames, the centroid is automatically
- smoothed using an exponential moving average.
- The result is cached for efficiency.
+ """Return the instantaneous 2D position of the Tracklet centroid.
+
+ For Tracklets longer than 10 frames, the centroid is automatically smoothed
+ using an exponential moving average. The result is cached for efficiency.
"""
if self._centroid is None:
self._update_centroid()
return self._centroid
def _update_centroid(self):
- like = self.data[..., 2:3]
+ like = self.data[..., 2:3] + 1e-10 # Avoid division by zero in very uncertain tracklets
self._centroid = np.nansum(self.xy * like, axis=1) / np.nansum(like, axis=1)
@property
@@ -193,8 +189,8 @@ def interpolate(self, max_gap=1):
return self + sum(fills)
def contains_duplicates(self, return_indices=False):
- """
- Evaluate whether the Tracklet contains duplicate time indices.
+ """Evaluate whether the Tracklet contains duplicate time indices.
+
If `return_indices`, also return the indices of the duplicates.
"""
has_duplicates = len(set(self.inds)) != len(self.inds)
@@ -203,39 +199,31 @@ def contains_duplicates(self, return_indices=False):
return has_duplicates, np.flatnonzero(np.diff(self.inds) == 0)
def calc_velocity(self, where="head", norm=True):
- """
- Calculate the linear velocity of either the `head`
- or `tail` of the Tracklet, computed over the last or first
- three frames, respectively. If `norm`, return the absolute
+ """Calculate the linear velocity of either the `head` or `tail` of the Tracklet,
+ computed over the last or first three frames, respectively.
+
+ If `norm`, return the absolute
speed rather than a 2D vector.
"""
if where == "tail":
- vel = (
- np.diff(self.centroid[:3], axis=0)
- / np.diff(self.inds[:3])[:, np.newaxis]
- )
+ vel = np.diff(self.centroid[:3], axis=0) / np.diff(self.inds[:3])[:, np.newaxis]
elif where == "head":
- vel = (
- np.diff(self.centroid[-3:], axis=0)
- / np.diff(self.inds[-3:])[:, np.newaxis]
- )
+ vel = np.diff(self.centroid[-3:], axis=0) / np.diff(self.inds[-3:])[:, np.newaxis]
else:
raise ValueError(f"Unknown where={where}")
if norm:
- return np.sqrt(np.sum(vel ** 2, axis=1)).mean()
+ return np.sqrt(np.sum(vel**2, axis=1)).mean()
return vel.mean(axis=0)
@property
def maximal_velocity(self):
vel = np.diff(self.centroid, axis=0) / np.diff(self.inds)[:, np.newaxis]
- return np.sqrt(np.max(np.sum(vel ** 2, axis=1)))
+ return np.sqrt(np.max(np.sum(vel**2, axis=1)))
def calc_rate_of_turn(self, where="head"):
- """
- Calculate the rate of turn (or angular velocity) of
- either the `head` or `tail` of the Tracklet, computed over
- the last or first three frames, respectively.
- """
+ """Calculate the rate of turn (or angular velocity) of either the `head` or
+ `tail` of the Tracklet, computed over the last or first three frames,
+ respectively."""
if where == "tail":
v = np.diff(self.centroid[:3], axis=0)
else:
@@ -249,58 +237,48 @@ def is_continuous(self):
return self.end - self.start + 1 == len(self)
def immediately_follows(self, other_tracklet, max_gap=1):
- """
- Test whether this Tracklet follows another within
- a tolerance of`max_gap` frames.
- """
+ """Test whether this Tracklet follows another within a tolerance of`max_gap`
+ frames."""
return 0 < self.start - other_tracklet.end <= max_gap
def distance_to(self, other_tracklet):
- """
- Calculate the Euclidean distance between this Tracklet and another.
- If the Tracklets overlap in time, this is the mean distance over
- those frames. Otherwise, it is the distance between the head/tail
- of one to the tail/head of the other.
+ """Calculate the Euclidean distance between this Tracklet and another.
+
+ If the Tracklets overlap in time, this is the mean distance over those frames.
+ Otherwise, it is the distance between the head/tail of one to the tail/head of
+ the other.
"""
if self in other_tracklet:
dist = (
self.centroid[np.isin(self.inds, other_tracklet.inds)]
- other_tracklet.centroid[np.isin(other_tracklet.inds, self.inds)]
)
- return np.sqrt(np.sum(dist ** 2, axis=1)).mean()
+ return np.sqrt(np.sum(dist**2, axis=1)).mean()
elif self < other_tracklet:
- return np.sqrt(
- np.sum((self.centroid[-1] - other_tracklet.centroid[0]) ** 2)
- )
+ return np.sqrt(np.sum((self.centroid[-1] - other_tracklet.centroid[0]) ** 2))
else:
- return np.sqrt(
- np.sum((self.centroid[0] - other_tracklet.centroid[-1]) ** 2)
- )
+ return np.sqrt(np.sum((self.centroid[0] - other_tracklet.centroid[-1]) ** 2))
def motion_affinity_with(self, other_tracklet):
- """
- Evaluate the motion affinity of this Tracklet' with another one.
- This evaluates whether the Tracklets could realistically be reached
- by one another, knowing the time separating them and their velocities.
- Return 0 if the Tracklets overlap.
+ """Evaluate the motion affinity of this Tracklet' with another one.
+
+ This evaluates whether the Tracklets could realistically be reached by one
+ another, knowing the time separating them and their velocities. Return 0 if the
+ Tracklets overlap.
"""
time_gap = self.time_gap_to(other_tracklet)
if time_gap > 0:
if self < other_tracklet:
d1 = self.centroid[-1] + time_gap * self.calc_velocity(norm=False)
- d2 = other_tracklet.centroid[
- 0
- ] - time_gap * other_tracklet.calc_velocity("tail", False)
+ d2 = other_tracklet.centroid[0] - time_gap * other_tracklet.calc_velocity("tail", False)
delta1 = other_tracklet.centroid[0] - d1
delta2 = self.centroid[-1] - d2
else:
- d1 = other_tracklet.centroid[
- -1
- ] + time_gap * other_tracklet.calc_velocity(norm=False)
+ d1 = other_tracklet.centroid[-1] + time_gap * other_tracklet.calc_velocity(norm=False)
d2 = self.centroid[0] - time_gap * self.calc_velocity("tail", False)
delta1 = self.centroid[0] - d1
delta2 = other_tracklet.centroid[-1] - d2
- return (np.sqrt(np.sum(delta1 ** 2)) + np.sqrt(np.sum(delta2 ** 2))) / 2
+ return (np.sqrt(np.sum(delta1**2)) + np.sqrt(np.sum(delta2**2))) / 2
return 0
def time_gap_to(self, other_tracklet):
@@ -367,13 +345,11 @@ def to_hankelet(self):
return self.hankelize(self.centroid)
def dynamic_dissimilarity_with(self, other_tracklet):
- """
- Compute a dissimilarity score between Hankelets.
- This metric efficiently captures the degree of alignment of
- the subspaces spanned by the columns of both matrices.
+ """Compute a dissimilarity score between Hankelets. This metric efficiently
+ captures the degree of alignment of the subspaces spanned by the columns of both
+ matrices.
- See Li et al., 2012.
- Cross-view Activity Recognition using Hankelets.
+ See Li et al., 2012. Cross-view Activity Recognition using Hankelets.
"""
hk1 = self.to_hankelet()
hk1 /= np.linalg.norm(hk1)
@@ -385,12 +361,10 @@ def dynamic_dissimilarity_with(self, other_tracklet):
return 2 - np.linalg.norm(temp1 + temp2)
def dynamic_similarity_with(self, other_tracklet, tol=0.01):
- """
- Evaluate the complexity of the tracklets' underlying dynamics
- from the rank of their Hankel matrices, and assess whether
- they originate from the same track. The idea is that if two
- tracklets are part of the same track, they can be approximated
- by a low order regressor. Conversely, tracklets belonging to
+ """Evaluate the complexity of the tracklets' underlying dynamics from the rank
+ of their Hankel matrices, and assess whether they originate from the same track.
+ The idea is that if two tracklets are part of the same track, they can be
+ approximated by a low order regressor. Conversely, tracklets belonging to
different tracks will require a higher order regressor.
See Dicle et al., 2013.
@@ -404,20 +378,23 @@ def dynamic_similarity_with(self, other_tracklet, tol=0.01):
return (rank1 + rank2) / joint_rank - 1
def estimate_rank(self, tol):
- """
- Estimate the (low) rank of a noisy matrix via
- hard thresholding of singular values.
+ """Estimate the (low) rank of a noisy matrix via hard thresholding of singular
+ values.
- See Gavish & Donoho, 2013.
- The optimal hard threshold for singular values is 4/sqrt(3)
+ See Gavish & Donoho, 2013. The optimal hard threshold for singular values is
+ 4/sqrt(3)
"""
mat = self.to_hankelet()
- # nrows, ncols = mat.shape
- # beta = nrows / ncols
- # omega = 0.56 * beta ** 3 - 0.95 * beta ** 2 + 1.82 * beta + 1.43
- _, s, _ = sli.svd(mat, min(10, min(mat.shape)))
+ if np.any(mat): # check that the matrix contains non-zero entries
+ # nrows, ncols = mat.shape
+ # beta = nrows / ncols
+ # omega = 0.56 * beta ** 3 - 0.95 * beta ** 2 + 1.82 * beta + 1.43
+ _, s, _ = sli.svd(mat, min(10, min(mat.shape)))
+ else:
+ s = np.zeros(min(10, min(mat.shape)))
+
# return np.argmin(s > omega * np.median(s))
- eigen = s ** 2
+ eigen = s**2
diff = np.abs(np.diff(eigen / eigen[0]))
return np.argmin(diff > tol)
@@ -477,7 +454,7 @@ def __init__(
self.residuals.append(t)
if not len(self.tracklets):
- raise IOError("Tracklets are empty.")
+ raise OSError("Tracklets are empty.")
if prestitch_residuals:
self._prestitch_residuals(5) # Hard-coded but found to work very well
@@ -492,13 +469,8 @@ def __init__(
# Map each Tracklet to an entry and output nodes and vice versa,
# which is convenient once the tracklets are stitched.
- self._mapping = {
- tracklet: {"in": f"{i}in", "out": f"{i}out"}
- for i, tracklet in enumerate(self)
- }
- self._mapping_inv = {
- label: k for k, v in self._mapping.items() for label in v.values()
- }
+ self._mapping = {tracklet: {"in": f"{i}in", "out": f"{i}out"} for i, tracklet in enumerate(self)}
+ self._mapping_inv = {label: k for k, v in self._mapping.items() for label in v.values()}
# Store tracklets and corresponding negatives (those that overlap in time)
self._lu_overlap = defaultdict(list)
@@ -524,9 +496,7 @@ def from_pickle(
):
with open(pickle_file, "rb") as file:
tracklets = pickle.load(file)
- class_ = cls.from_dict_of_dict(
- tracklets, n_tracks, min_length, split_tracklets, prestitch_residuals
- )
+ class_ = cls.from_dict_of_dict(tracklets, n_tracks, min_length, split_tracklets, prestitch_residuals)
class_.filename = pickle_file
return class_
@@ -544,7 +514,7 @@ def from_dict_of_dict(
single = None
for k, dict_ in dict_of_dict.items():
try:
- inds, data = zip(*[(cls.get_frame_ind(k), v) for k, v in dict_.items()])
+ inds, data = zip(*[(cls.get_frame_ind(k), v) for k, v in dict_.items()], strict=False)
except ValueError:
continue
inds = np.asarray(inds)
@@ -559,9 +529,7 @@ def from_dict_of_dict(
single = tracklet
else:
tracklets.append(Tracklet(data, inds))
- class_ = cls(
- tracklets, n_tracks, min_length, split_tracklets, prestitch_residuals
- )
+ class_ = cls(tracklets, n_tracks, min_length, split_tracklets, prestitch_residuals)
class_.header = header
class_.single = single
return class_
@@ -584,7 +552,7 @@ def split_tracklet(tracklet, inds):
idx = sorted(set(np.searchsorted(tracklet.inds, inds)))
inds_new = np.split(tracklet.inds, idx)
data_new = np.split(tracklet.data, idx)
- return [Tracklet(data, inds) for data, inds in zip(data_new, inds_new)]
+ return [Tracklet(data, inds) for data, inds in zip(data_new, inds_new, strict=False)]
@property
def n_frames(self):
@@ -606,6 +574,9 @@ def compute_max_gap(tracklets):
return max_gap
def mine(self, n_samples):
+ if not self._lu_overlap:
+ raise ValueError("No overlapping tracklets found.")
+
p = np.asarray([t.likelihood for t in self])
p /= p.sum()
triplets = []
@@ -615,9 +586,7 @@ def mine(self, n_samples):
if not overlapping_tracklets:
continue
# Pick the closest (spatially) overlapping tracklet
- ind_min = np.argmin(
- [tracklet.distance_to(t) for t in overlapping_tracklets]
- )
+ ind_min = np.argmin([tracklet.distance_to(t) for t in overlapping_tracklets])
overlapping_tracklet = overlapping_tracklets[ind_min]
common_inds = set(tracklet.inds).intersection(overlapping_tracklet.inds)
ind_anchor = np.random.choice(list(common_inds))
@@ -646,14 +615,12 @@ def build_graph(
self.G = nx.DiGraph()
self.G.add_node("source", demand=-self.n_tracks)
self.G.add_node("sink", demand=self.n_tracks)
- nodes_in, nodes_out = zip(
- *[v.values() for k, v in self._mapping.items() if k in nodes]
- )
+ nodes_in, nodes_out = zip(*[v.values() for k, v in self._mapping.items() if k in nodes], strict=False)
self.G.add_nodes_from(nodes_in, demand=1)
self.G.add_nodes_from(nodes_out, demand=-1)
- self.G.add_edges_from(zip(nodes_in, nodes_out), capacity=1)
- self.G.add_edges_from(zip(["source"] * n_nodes, nodes_in), capacity=1)
- self.G.add_edges_from(zip(nodes_out, ["sink"] * n_nodes), capacity=1)
+ self.G.add_edges_from(zip(nodes_in, nodes_out, strict=False), capacity=1)
+ self.G.add_edges_from(zip(["source"] * n_nodes, nodes_in, strict=False), capacity=1)
+ self.G.add_edges_from(zip(nodes_out, ["sink"] * n_nodes, strict=False), capacity=1)
if weight_func is None:
weight_func = self.calculate_edge_weight
for i in trange(n_nodes):
@@ -692,46 +659,28 @@ def stitch(self, add_back_residuals=True):
_, self.flow = nx.capacity_scaling(self.G)
self.paths = self.reconstruct_paths()
except nx.exception.NetworkXUnfeasible:
- warnings.warn("No optimal solution found. Employing black magic...")
+ warnings.warn("No optimal solution found. Employing black magic...", stacklevel=2)
# Let us prune the graph by removing all source and sink edges
# but those connecting the `n_tracks` first and last tracklets.
- in_to_keep = [
- self._mapping[first_tracklet]["in"]
- for first_tracklet in self._first_tracklets
- ]
- out_to_keep = [
- self._mapping[last_tracklet]["out"]
- for last_tracklet in self._last_tracklets
- ]
- in_to_remove = set(
- node for _, node in self.G.out_edges("source")
- ).difference(in_to_keep)
- out_to_remove = set(node for node, _ in self.G.in_edges("sink")).difference(
- out_to_keep
- )
- self.G.remove_edges_from(zip(["source"] * len(in_to_remove), in_to_remove))
- self.G.remove_edges_from(zip(out_to_remove, ["sink"] * len(out_to_remove)))
+ in_to_keep = [self._mapping[first_tracklet]["in"] for first_tracklet in self._first_tracklets]
+ out_to_keep = [self._mapping[last_tracklet]["out"] for last_tracklet in self._last_tracklets]
+ in_to_remove = set(node for _, node in self.G.out_edges("source")).difference(in_to_keep)
+ out_to_remove = set(node for node, _ in self.G.in_edges("sink")).difference(out_to_keep)
+ self.G.remove_edges_from(zip(["source"] * len(in_to_remove), in_to_remove, strict=False))
+ self.G.remove_edges_from(zip(out_to_remove, ["sink"] * len(out_to_remove), strict=False))
# Preflow push seems to work slightly better than shortest
# augmentation path..., and is more computationally efficient.
paths = []
- for path in nx.node_disjoint_paths(
- self.G, "source", "sink", preflow_push, self.n_tracks
- ):
+ for path in nx.node_disjoint_paths(self.G, "source", "sink", preflow_push, self.n_tracks):
temp = set()
for node in path[1:-1]:
self.G.remove_node(node)
temp.add(self._mapping_inv[node])
paths.append(list(temp))
incomplete_tracks = self.n_tracks - len(paths)
- remaining_nodes = set(
- self._mapping_inv[node]
- for node in self.G
- if node not in ("source", "sink")
- )
+ remaining_nodes = set(self._mapping_inv[node] for node in self.G if node not in ("source", "sink"))
if len(remaining_nodes) > 0:
- if (
- incomplete_tracks == 1
- ): # All remaining nodes must belong to the same track
+ if incomplete_tracks == 1: # All remaining nodes must belong to the same track
# Verify whether there are overlapping tracklets
for t1, t2 in combinations(remaining_nodes, 2):
if t1 in t2:
@@ -765,13 +714,11 @@ def stitch(self, add_back_residuals=True):
paths += self.reconstruct_paths()
self.paths = paths
if len(self.paths) != self.n_tracks:
- warnings.warn(f"Only {len(self.paths)} tracks could be reconstructed.")
+ warnings.warn(f"Only {len(self.paths)} tracks could be reconstructed.", stacklevel=2)
finally:
if self.paths is None:
- raise ValueError(
- f"Could not reconstruct {self.n_tracks} tracks from the tracklets given."
- )
+ raise ValueError(f"Could not reconstruct {self.n_tracks} tracks from the tracklets given.")
self.tracks = np.asarray([sum(path) for path in self.paths if path])
if add_back_residuals:
@@ -785,9 +732,7 @@ def _finalize_tracks(self):
n_max = len(residuals)
while n_attemps < n_max and residuals:
for res in residuals[::-1]:
- easy_fit = [
- i for i, track in enumerate(self.tracks) if res not in track
- ]
+ easy_fit = [i for i, track in enumerate(self.tracks) if res not in track]
if not easy_fit:
residuals.remove(res)
continue
@@ -819,9 +764,7 @@ def _finalize_tracks(self):
elif right_gap <= 3:
dist = np.linalg.norm(track.centroid[e] - c1[1])
else:
- dist = np.linalg.norm(track.centroid[s] - c1[0]) + np.linalg.norm(
- track.centroid[e] - c1[1]
- )
+ dist = np.linalg.norm(track.centroid[s] - c1[0]) + np.linalg.norm(track.centroid[e] - c1[1])
dists.append((n, dist))
if not dists:
continue
@@ -884,8 +827,11 @@ def concatenate_data(self):
def format_df(self, animal_names=None):
data = self.concatenate_data()
- if not animal_names or len(animal_names) != self.n_tracks:
+ if not animal_names or len(animal_names) < self.n_tracks:
animal_names = [f"ind{i}" for i in range(1, self.n_tracks + 1)]
+ elif len(animal_names) > self.n_tracks:
+ animal_names = animal_names[: self.n_tracks]
+
coords = ["x", "y", "likelihood"]
n_multi_bpts = data.shape[1] // (len(animal_names) * len(coords))
n_unique_bpts = 0 if self.single is None else self.single.data.shape[1]
@@ -910,21 +856,17 @@ def format_df(self, animal_names=None):
[scorer, ["single"], bpts[-n_unique_bpts:], coords],
names=["scorer", "individuals", "bodyparts", "coords"],
)
- df2 = pd.DataFrame(
- self.single.flat_data, columns=columns, index=self.single.inds
- )
+ df2 = pd.DataFrame(self.single.flat_data, columns=columns, index=self.single.inds)
df = df.join(df2, how="outer")
return df
- def write_tracks(
- self, output_name="", suffix="", animal_names=None, save_as_csv=False
- ):
+ def write_tracks(self, output_name="", suffix="", animal_names=None, save_as_csv=False):
df = self.format_df(animal_names)
if not output_name:
if suffix:
suffix = "_" + suffix
output_name = self.filename.replace(".pickle", f"{suffix}.h5")
- df.to_hdf(output_name, "tracks", format="table", mode="w")
+ df.to_hdf(output_name, key="tracks", format="table", mode="w")
if save_as_csv:
df.to_csv(output_name.replace(".h5", ".csv"))
@@ -961,7 +903,7 @@ def plot_paths(self, colormap="Set2"):
for path in self.paths:
length = len(path)
colors = plt.get_cmap(colormap, length)(range(length))
- for tracklet, color in zip(path, colors):
+ for tracklet, color in zip(path, colors, strict=False):
tracklet.plot(color=color, ax=ax)
def plot_tracks(self, colormap="viridis"):
@@ -974,7 +916,7 @@ def plot_tracks(self, colormap="viridis"):
if loc != "bottom":
spine.set_visible(False)
colors = plt.get_cmap(colormap, self.n_tracks)(range(self.n_tracks))
- for track, color in zip(self.tracks, colors):
+ for track, color in zip(self.tracks, colors, strict=False):
track.plot(color=color, ax=ax)
def plot_tracklets(self, colormap="Paired"):
@@ -996,7 +938,7 @@ def plot_tracklets(self, colormap="Paired"):
tracklet2lines[tracklet] = lines
for line in lines:
line2tracklet[line] = tracklet
- for i, (x, y) in zip(tracklet.inds, tracklet.centroid):
+ for i, (x, y) in zip(tracklet.inds, tracklet.centroid, strict=False):
all_points[i][(x, y)] = color
def reconstruct_paths(self):
@@ -1017,13 +959,15 @@ def reconstruct_path(self, source):
return path
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def stitch_tracklets(
config_path,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
n_tracks=None,
+ animal_names: list[str] | None = None,
min_length=10,
split_tracklets=True,
prestitch_residuals=True,
@@ -1035,10 +979,10 @@ def stitch_tracklets(
output_name="",
transformer_checkpoint="",
save_as_csv=False,
+ **kwargs,
):
- """
- Stitch sparse tracklets into full tracks via a graph-based,
- minimum-cost flow optimization problem.
+ """Stitch sparse tracklets into full tracks via a graph-based, minimum-cost flow
+ optimization problem.
Parameters
----------
@@ -1046,17 +990,24 @@ def stitch_tracklets(
Path to the main project config.yaml file.
videos : list
- A list of strings containing the full paths to videos for analysis or a path to the directory, where all the videos with same extension are stored.
-
- videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ A list of strings containing the full paths to videos for analysis or a path to the directory, where all the
+ videos with same extension are stored.
+
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle: int, optional
An integer specifying the shuffle index of the training dataset used for training the network. The default is 1.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+ Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list
+ in config.yaml).
n_tracks : int, optional
Number of tracks to reconstruct. By default, taken as the number
@@ -1064,6 +1015,13 @@ def stitch_tracklets(
passed if the number of animals in the video is different from
the number of animals the model was trained on.
+ animal_names: list, optional
+ If you want the names given to individuals in the labeled data file, you can
+ specify those names as a list here. If given and `n_tracks` is None, `n_tracks`
+ will be set to `len(animal_names)`. If `n_tracks` is not None, then it must be
+ equal to `len(animal_names)`. If it is not given, then `animal_names` will
+ be loaded from the `individuals` in the project config.yaml file.
+
min_length : int, optional
Tracklets less than `min_length` frames of length
are considered to be residuals; i.e., they do not participate
@@ -1100,8 +1058,8 @@ def stitch_tracklets(
tracklets should be stitched together, the lower the returned value.
destfolder: string, optional
- Specifies the destination folder for analysis data (default is the path of the video). Note that for subsequent analysis this
- folder also needs to be passed.
+ Specifies the destination folder for analysis data (default is the path of the
+ video). Note that for subsequent analysis this folder also needs to be passed.
track_method: string, optional
Specifies the tracker used to generate the pose estimation data.
@@ -1116,27 +1074,46 @@ def stitch_tracklets(
save_as_csv: bool, optional
Whether to write the tracks to a CSV file too (False by default).
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
A TrackletStitcher object
"""
- vids = deeplabcut.utils.auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ vids = collect_video_paths(videos, extensions=video_extensions)
if not vids:
print("No video(s) found. Please check your path!")
return
cfg = auxiliaryfunctions.read_config(config_path)
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+ if track_method == "ctd":
+ raise ValueError(
+ "CTD tracking occurs directly during video analysis. No need to call "
+ "`stitch_tracklets` with `track_method=='ctd'`."
+ )
+
+ if animal_names is None:
+ animal_names = cfg["individuals"]
+ elif n_tracks is not None and n_tracks != len(animal_names):
+ raise ValueError(
+ "When setting both `n_tracks` and `animal_names`, `n_tracks` must be equal "
+ f"to len(animal_names)`. Found `n_tracks`={n_tracks} and `animal_names`="
+ f"{animal_names} of length {len(animal_names)}.`"
+ )
- animal_names = cfg["individuals"]
if n_tracks is None:
n_tracks = len(animal_names)
- DLCscorer, _ = deeplabcut.utils.auxiliaryfunctions.GetScorerName(
+ DLCscorer, _ = deeplabcut.utils.auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
cfg["TrainingFraction"][trainingsetindex],
modelprefix=modelprefix,
+ **kwargs,
)
if transformer_checkpoint:
@@ -1164,6 +1141,7 @@ def trans_weight_func(tracklet1, tracklet2, nframe, feature_dict):
return -dist
+ base_weight_func = weight_func
for video in vids:
print("Processing... ", video)
nframe = len(VideoWriter(video))
@@ -1172,19 +1150,15 @@ def trans_weight_func(tracklet1, tracklet2, nframe, feature_dict):
deeplabcut.utils.auxiliaryfunctions.attempt_to_make_folder(dest)
vname = Path(video).stem
- feature_dict_path = os.path.join(
- dest, vname + DLCscorer + "_bpt_features.pickle"
- )
+ feature_dict_path = os.path.join(dest, vname + DLCscorer + "_bpt_features.pickle")
# should only exist one
if transformer_checkpoint:
import dbm
try:
feature_dict = shelve.open(feature_dict_path, flag="r")
- except dbm.error:
- raise FileNotFoundError(
- f"{feature_dict_path} does not exist. Did you run transformer_reID()?"
- )
+ except dbm.error as err:
+ raise FileNotFoundError(f"{feature_dict_path} does not exist. Did you run transformer_reID()?") from err
dataname = os.path.join(dest, vname + DLCscorer + ".h5")
@@ -1194,22 +1168,21 @@ def trans_weight_func(tracklet1, tracklet2, nframe, feature_dict):
stitcher = TrackletStitcher.from_pickle(
pickle_file, n_tracks, min_length, split_tracklets, prestitch_residuals
)
+ current_weight_func = base_weight_func
with_id = any(tracklet.identity != -1 for tracklet in stitcher)
if with_id and weight_func is None:
# Add in identity weighing before building the graph
- def weight_func(t1, t2):
+ def current_weight_func(t1, t2, stitcher=stitcher):
w = 0.01 if t1.identity == t2.identity else 1
return w * stitcher.calculate_edge_weight(t1, t2)
if transformer_checkpoint:
stitcher.build_graph(
max_gap=max_gap,
- weight_func=partial(
- trans_weight_func, nframe=nframe, feature_dict=feature_dict
- ),
+ weight_func=partial(trans_weight_func, nframe=nframe, feature_dict=feature_dict),
)
else:
- stitcher.build_graph(max_gap=max_gap, weight_func=weight_func)
+ stitcher.build_graph(max_gap=max_gap, weight_func=current_weight_func)
stitcher.stitch()
if transformer_checkpoint:
diff --git a/deeplabcut/refine_training_dataset/tracklets.py b/deeplabcut/refine_training_dataset/tracklets.py
index bbdc0de60a..c89e24efb9 100644
--- a/deeplabcut/refine_training_dataset/tracklets.py
+++ b/deeplabcut/refine_training_dataset/tracklets.py
@@ -8,13 +8,15 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
-import pandas as pd
import pickle
import re
+
+import numpy as np
+import pandas as pd
+from tqdm import trange
+
from deeplabcut.post_processing import columnwise_spline_interp
from deeplabcut.utils import auxiliaryfunctions
-from tqdm import trange
class TrackletManager:
@@ -72,22 +74,19 @@ def _load_tracklets(self, tracklets, auto_fill):
header = tracklets.pop("header")
self.scorer = header.get_level_values("scorer").unique().to_list()
bodyparts = header.get_level_values("bodyparts")
- bodyparts_multi = [
- bp for bp in self.cfg["multianimalbodyparts"] if bp in bodyparts
- ]
+ bodyparts_multi = [bp for bp in self.cfg["multianimalbodyparts"] if bp in bodyparts]
bodyparts_single = self.cfg["uniquebodyparts"]
mask_multi = bodyparts.isin(bodyparts_multi)
mask_single = bodyparts.isin(bodyparts_single)
- self.bodyparts = list(bodyparts[mask_multi]) * self.nindividuals + list(
- bodyparts[mask_single]
- )
+ self.bodyparts = list(bodyparts[mask_multi]) * self.nindividuals + list(bodyparts[mask_single])
# Sort tracklets by length to prioritize greater continuity
temp = sorted(tracklets.values(), key=len)
if not len(temp):
- raise IOError("Tracklets are empty.")
+ raise OSError("Tracklets are empty.")
- get_frame_ind = lambda s: int(re.findall(r"\d+", s)[0])
+ def get_frame_ind(s):
+ return int(re.findall(r"\d+", s)[0])
# Drop tracklets that are too short
tracklets_sorted = []
@@ -105,12 +104,10 @@ def _load_tracklets(self, tracklets, auto_fill):
np.nan,
np.float16,
)
- tracklets_single = np.full(
- (self.nframes, len(bodyparts_single) * 3), np.nan, np.float16
- )
+ tracklets_single = np.full((self.nframes, len(bodyparts_single) * 3), np.nan, np.float16)
for _ in trange(len(tracklets_sorted)):
tracklet = tracklets_sorted.pop()
- inds, temp = zip(*[(get_frame_ind(k), v) for k, v in tracklet.items()])
+ inds, temp = zip(*[(get_frame_ind(k), v) for k, v in tracklet.items()], strict=False)
inds = np.asarray(inds)
data = np.asarray(temp, dtype=np.float16)
data_single = data[:, mask_single]
@@ -126,15 +123,11 @@ def _load_tracklets(self, tracklets, auto_fill):
overwrite = has_data & ~is_free
if overwrite.any():
rows, cols = np.nonzero(overwrite)
- more_confident = (
- data_single[overwrite] > tracklets_single[inds[rows], cols]
- )[2::3]
+ more_confident = (data_single[overwrite] > tracklets_single[inds[rows], cols])[2::3]
idx = np.flatnonzero(more_confident)
for i in idx:
sl = slice(i * 3, i * 3 + 3)
- tracklets_single[inds[rows[sl]], cols[sl]] = data_single[
- rows[sl], cols[sl]
- ]
+ tracklets_single[inds[rows[sl]], cols[sl]] = data_single[rows[sl], cols[sl]]
else:
is_free = np.isnan(tracklets_multi[:, inds])
data_multi = data[:, mask_multi]
@@ -149,21 +142,15 @@ def _load_tracklets(self, tracklets, auto_fill):
current_mask = mask[ind]
rows, cols = np.nonzero(current_mask)
if rows.size:
- tracklets_multi[ind, inds[rows], cols] = data_multi[
- current_mask
- ]
+ tracklets_multi[ind, inds[rows], cols] = data_multi[current_mask]
is_free[ind, current_mask] = False
has_data[current_mask] = False
if has_data.any():
# For the remaining data, overwrite where we are least confident
remaining = data_multi[has_data].reshape((-1, 3))
- mask3d = np.broadcast_to(
- has_data, (self.nindividuals,) + has_data.shape
- )
+ mask3d = np.broadcast_to(has_data, (self.nindividuals,) + has_data.shape)
dims, rows, cols = np.nonzero(mask3d)
- temp = tracklets_multi[dims, inds[rows], cols].reshape(
- (self.nindividuals, -1, 3)
- )
+ temp = tracklets_multi[dims, inds[rows], cols].reshape((self.nindividuals, -1, 3))
diff = remaining - temp
# Find keypoints closest to the remaining data
# Use Manhattan distance to avoid overflow
@@ -174,11 +161,9 @@ def _load_tracklets(self, tracklets, auto_fill):
better = np.flatnonzero(prob > 0)
idx = closest[better]
rows, cols = np.nonzero(has_data)
- for i, j in zip(idx, better):
+ for i, j in zip(idx, better, strict=False):
sl = slice(j * 3, j * 3 + 3)
- tracklets_multi[
- i, inds[rows[sl]], cols[sl]
- ] = remaining.flat[sl]
+ tracklets_multi[i, inds[rows[sl]], cols[sl]] = remaining.flat[sl]
else:
rows, cols = np.nonzero(has_data)
n = np.argmin(overwrite_risk)
@@ -207,14 +192,12 @@ def _load_tracklets(self, tracklets, auto_fill):
self.prob = self.data[:, :, 2]
# Map a tracklet # to the animal ID it belongs to or the bodypart # it corresponds to.
- self.individuals = self.cfg["individuals"] + (
- ["single"] if len(self.cfg["uniquebodyparts"]) else []
- )
- self.tracklet2id = [
- i for i in range(0, self.nindividuals) for _ in bodyparts_multi
- ] + [self.nindividuals] * len(bodyparts_single)
+ self.individuals = self.cfg["individuals"] + (["single"] if len(self.cfg["uniquebodyparts"]) else [])
+ self.tracklet2id = [i for i in range(0, self.nindividuals) for _ in bodyparts_multi] + [
+ self.nindividuals
+ ] * len(bodyparts_single)
bps = bodyparts_multi + bodyparts_single
- map_ = dict(zip(bps, range(len(bps))))
+ map_ = dict(zip(bps, range(len(bps)), strict=False))
self.tracklet2bp = [map_[bp] for bp in self.bodyparts[::3]]
self._label_pairs = self.get_label_pairs()
else:
@@ -227,11 +210,7 @@ def _load_tracklets(self, tracklets, auto_fill):
for frame, data in tracklet.items():
i = get_frame_ind(frame)
tracklets_raw[n, i] = data
- self.data = (
- tracklets_raw.swapaxes(0, 1)
- .reshape((self.nframes, -1, 3))
- .swapaxes(0, 1)
- )
+ self.data = tracklets_raw.swapaxes(0, 1).reshape((self.nframes, -1, 3)).swapaxes(0, 1)
self.xy = self.data[:, :, :2]
self.prob = self.data[:, :, 2]
self.tracklet2id = self.tracklet2bp = [0] * self.data.shape[0]
@@ -277,12 +256,10 @@ def load_tracklets_from_hdf(self, filename):
individuals = idx.get_level_values("individuals")
self.individuals = individuals.unique().to_list()
self.tracklet2id = individuals.map(
- dict(zip(self.individuals, range(len(self.individuals))))
+ dict(zip(self.individuals, range(len(self.individuals)), strict=False))
).tolist()[::3]
bodyparts = self.bodyparts.unique()
- self.tracklet2bp = self.bodyparts.map(
- dict(zip(bodyparts, range(len(bodyparts))))
- ).tolist()[::3]
+ self.tracklet2bp = self.bodyparts.map(dict(zip(bodyparts, range(len(bodyparts)), strict=False))).tolist()[::3]
self._label_pairs = list(idx.droplevel(["scorer", "coords"]).unique())
self._xy = self.xy.copy()
@@ -305,12 +282,8 @@ def get_non_nan_elements(self, at):
return data[mask], mask, np.flatnonzero(mask)
def swap_tracklets(self, track1, track2, inds):
- self.xy[np.ix_([track1, track2], inds)] = self.xy[
- np.ix_([track2, track1], inds)
- ]
- self.prob[np.ix_([track1, track2], inds)] = self.prob[
- np.ix_([track2, track1], inds)
- ]
+ self.xy[np.ix_([track1, track2], inds)] = self.xy[np.ix_([track2, track1], inds)]
+ self.prob[np.ix_([track1, track2], inds)] = self.prob[np.ix_([track2, track1], inds)]
self.tracklet2bp[track1], self.tracklet2bp[track2] = (
self.tracklet2bp[track2],
self.tracklet2bp[track1],
@@ -318,12 +291,8 @@ def swap_tracklets(self, track1, track2, inds):
def find_swapping_bodypart_pairs(self, force_find=False):
if not self.swapping_pairs or force_find:
- sub = (
- self.xy[:, np.newaxis] - self.xy
- ) # Broadcasting for efficient subtraction of X and Y coordinates
- with np.errstate(
- invalid="ignore"
- ): # Get rid of annoying warnings when comparing with NaNs
+ sub = self.xy[:, np.newaxis] - self.xy # Broadcasting for efficient subtraction of X and Y coordinates
+ with np.errstate(invalid="ignore"): # Get rid of annoying warnings when comparing with NaNs
pos = sub > 0
neg = sub <= 0
down = neg[:, :, 1:] & pos[:, :, :-1]
@@ -336,7 +305,7 @@ def find_swapping_bodypart_pairs(self, force_find=False):
temp_pairs = np.where(mat)
# Get only those bodypart pairs that belong to different individuals
pairs = []
- for a, b in zip(*temp_pairs):
+ for a, b in zip(*temp_pairs, strict=False):
if self.tracklet2id[a] != self.tracklet2id[b]:
pairs.append((a, b))
self.swapping_pairs = pairs
@@ -349,7 +318,7 @@ def get_nonoverlapping_segments(self, tracklet1, tracklet2):
swap_inds = self.get_swap_indices(tracklet1, tracklet2)
inds = np.insert(swap_inds, [0, len(swap_inds)], [0, self.nframes])
mask = np.ones_like(self.times, dtype=bool)
- for i, j in zip(inds[::2], inds[1::2]):
+ for i, j in zip(inds[::2], inds[1::2], strict=False):
mask[i:j] = False
return mask
@@ -359,7 +328,7 @@ def flatten_data(self):
def format_multiindex(self):
scorer = self.scorer * len(self.bodyparts)
- map_ = dict(zip(range(len(self.individuals)), self.individuals))
+ map_ = dict(zip(range(len(self.individuals)), self.individuals, strict=False))
individuals = [map_[ind] for ind in self.tracklet2id for _ in range(3)]
coords = ["x", "y", "likelihood"] * len(self.tracklet2id)
return pd.MultiIndex.from_arrays(
@@ -382,4 +351,4 @@ def save(self, output_name="", *args):
df = self.format_data()
if not output_name:
output_name = self.filename.replace("pickle", "h5")
- df.to_hdf(output_name, "df_with_missing", format="table", mode="w")
+ df.to_hdf(output_name, key="df_with_missing", format="table", mode="w")
diff --git a/deeplabcut/reid_cfg.yaml b/deeplabcut/reid_cfg.yaml
index 717ff9c670..f7977958da 100644
--- a/deeplabcut/reid_cfg.yaml
+++ b/deeplabcut/reid_cfg.yaml
@@ -51,4 +51,4 @@ log_period: 100
########## Test ##########
# Whether feature is normalized before test, if yes, it is equivalent to cosine distance
-feat_norm: yes
\ No newline at end of file
+feat_norm: yes
diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py
index db7bcbfe63..77cf85217d 100644
--- a/deeplabcut/utils/auxfun_models.py
+++ b/deeplabcut/utils/auxfun_models.py
@@ -19,10 +19,9 @@
"""
import os
-import tensorflow as tf
from pathlib import Path
-from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils import auxiliaryfunctions
# This dictionary maps the model types to the file locations where the models exist.
MODEL_BASE_PATH = Path("pose_estimation_tensorflow") / "models" / "pretrained"
@@ -45,11 +44,14 @@
def check_for_weights(modeltype, parent_path):
- """gets local path to network weights and checks if they are present. If not, downloads them from tensorflow.org"""
+ """Gets local path to network weights and checks if they are present.
+ If not, downloads them from tensorflow.org
+ """
if modeltype not in MODELTYPE_FILEPATH_MAP.keys():
print(
- "Currently ResNet (50, 101, 152), MobilenetV2 (1, 0.75, 0.5 and 0.35) and EfficientNet (b0-b6) are supported, please change 'resnet' entry in config.yaml!"
+ "Currently ResNet (50, 101, 152), MobilenetV2 (1, 0.75, 0.5 and 0.35) and EfficientNet (b0-b6) are"
+ "supported, please change 'resnet' entry in config.yaml!"
)
# Exit the function early if an unknown modeltype is provided.
return parent_path
@@ -74,24 +76,23 @@ def check_for_weights(modeltype, parent_path):
def download_weights(modeltype, model_path):
+ """Downloads the ImageNet pretrained weights for ResNets, MobileNets et al.
+
+ from TensorFlow...
"""
- Downloads the ImageNet pretrained weights for ResNets, MobileNets et al. from TensorFlow...
- """
- import urllib
import tarfile
+ import urllib
from io import BytesIO
target_dir = model_path.parents[0]
- neturls = auxiliaryfunctions.read_plainconfig(
- target_dir / "pretrained_model_urls.yaml"
- )
+ neturls = auxiliaryfunctions.read_plainconfig(target_dir / "pretrained_model_urls.yaml")
try:
if "efficientnet" in modeltype:
url = neturls["efficientnet"]
url = url + modeltype.replace("_", "-") + ".tar.gz"
else:
url = neturls[modeltype]
- print("Downloading a ImageNet-pretrained model from {}....".format(url))
+ print(f"Downloading a ImageNet-pretrained model from {url}....")
response = urllib.request.urlopen(url)
with tarfile.open(fileobj=BytesIO(response.read()), mode="r:gz") as tar:
tar.extractall(path=target_dir)
@@ -101,19 +102,19 @@ def download_weights(modeltype, model_path):
def download_model(modelname, target_dir):
- """
- Downloads a DeepLabCut Model Zoo Project
- """
- import urllib.request
+ """Downloads a DeepLabCut Model Zoo Project."""
import tarfile
+ import urllib.request
+
from tqdm import tqdm
def show_progress(count, block_size, total_size):
pbar.update(block_size)
def tarfilenamecutting(tarf):
- """' auxfun to extract folder path
- ie. /xyz-trainsetxyshufflez/
+ """' auxfun to extract folder path ie.
+
+ /xyz-trainsetxyshufflez/
"""
for memberid, member in enumerate(tarf.getmembers()):
if memberid == 0:
@@ -136,11 +137,7 @@ def tarfilenamecutting(tarf):
if modelname in neturls.keys():
url = neturls[modelname]
response = urllib.request.urlopen(url)
- print(
- "Downloading the model from the DeepLabCut server @Harvard -> Go Crimson!!! {}....".format(
- url
- )
- )
+ print(f"Downloading the model from the DeepLabCut server @Harvard -> Go Crimson!!! {url}....")
total_size = int(response.getheader("Content-Length"))
pbar = tqdm(unit="B", total=total_size, position=0)
filename, _ = urllib.request.urlretrieve(url, reporthook=show_progress)
@@ -148,22 +145,21 @@ def tarfilenamecutting(tarf):
tar.extractall(target_dir, members=tarfilenamecutting(tar))
else:
models = [
- fn
- for fn in neturls.keys()
- if "resnet_" not in fn
- and "efficientnet" not in fn
- and "mobilenet_" not in fn
+ fn for fn in neturls.keys() if "resnet_" not in fn and "efficientnet" not in fn and "mobilenet_" not in fn
]
print("Model does not exist: ", modelname)
print("Pick one of the following: ", models)
def set_visible_devices(gputouse: int):
+ import tensorflow as tf
+
physical_devices = tf.config.list_physical_devices("GPU")
n_devices = len(physical_devices)
if gputouse >= n_devices:
raise ValueError(
- f"There are {n_devices} available GPUs: {physical_devices}\nPlease choose `gputouse` in {list(range(n_devices))}."
+ f"There are {n_devices} available GPUs: {physical_devices}\nPlease choose `gputouse` in"
+ f"{list(range(n_devices))}."
)
tf.config.set_visible_devices(physical_devices[gputouse], "GPU")
@@ -175,14 +171,15 @@ def smart_restore(restorer, sess, checkpoint_path, net_type):
except ValueError as e: # The path may be wrong, or the weights no longer exist
dlcparent_path = auxiliaryfunctions.get_deeplabcut_path()
correct_model_path = os.path.join(
- dlcparent_path, MODELTYPE_FILEPATH_MAP[net_type],
+ dlcparent_path,
+ MODELTYPE_FILEPATH_MAP[net_type],
)
if checkpoint_path == correct_model_path:
# The path is right, hence the weights are missing; we'll download them again.
_ = check_for_weights(net_type, Path(dlcparent_path))
restorer.restore(sess, checkpoint_path)
else:
- raise ValueError(e)
+ raise ValueError(e) from e
# Aliases for backwards-compatibility
diff --git a/deeplabcut/utils/auxfun_multianimal.py b/deeplabcut/utils/auxfun_multianimal.py
index fa337444fa..f398a7c597 100644
--- a/deeplabcut/utils/auxfun_multianimal.py
+++ b/deeplabcut/utils/auxfun_multianimal.py
@@ -31,14 +31,13 @@
import numpy as np
import pandas as pd
-from deeplabcut.utils import auxiliaryfunctions, conversioncode
+from deeplabcut.core.trackingutils import TRACK_METHODS
from deeplabcut.generate_training_dataset import trainingsetmanipulation
-from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS
+from deeplabcut.utils import auxiliaryfunctions, conversioncode
def reorder_individuals_in_df(df: pd.DataFrame, order: list) -> pd.DataFrame:
- """
- Reorders data of df to match the order given in a list
+ """Reorders data of df to match the order given in a list.
Parameters:
----------
@@ -73,21 +72,18 @@ def get_track_method(cfg, track_method=""):
if track_method != "":
# check if it exists:
if track_method not in TRACK_METHODS:
- raise ValueError(
- f"Invalid tracking method. Only {', '.join(TRACK_METHODS)} are currently supported."
- )
+ raise ValueError(f"Invalid tracking method. Only {', '.join(TRACK_METHODS)} are currently supported.")
return track_method
else: # default
track_method = cfg.get("default_track_method", "")
if not track_method:
warnings.warn(
- "default_track_method` is undefined in the config.yaml file and will be set to `ellipse`."
+ "default_track_method` is undefined in the config.yaml file and will be set to `ellipse`.",
+ stacklevel=2,
)
track_method = "ellipse"
cfg["default_track_method"] = track_method
- auxiliaryfunctions.write_config(
- str(Path(cfg["project_path"]) / "config.yaml"), cfg
- )
+ auxiliaryfunctions.write_config(str(Path(cfg["project_path"]) / "config.yaml"), cfg)
return track_method
else: # no tracker for single-animal projects
@@ -95,7 +91,8 @@ def get_track_method(cfg, track_method=""):
def IntersectionofIndividualsandOnesGivenbyUser(cfg, individuals):
- """Returns all individuals when set to 'all', otherwise all bpts that are in the intersection of comparisonbodyparts and the actual bodyparts"""
+ """Returns all individuals when set to 'all', otherwise all bpts that are in the
+ intersection of comparisonbodyparts and the actual bodyparts."""
if "individuals" not in cfg: # Not a multi-animal project...
return [""]
all_indivs = extractindividualsandbodyparts(cfg)[0]
@@ -121,17 +118,16 @@ def validate_paf_graph(cfg, paf_graph):
unconnected = set(range(len(multianimalbodyparts))).difference(connected)
if unconnected and len(multianimalbodyparts) > 1: # for single bpt not important!
raise ValueError(
- f'Unconnected {", ".join(multianimalbodyparts[i] for i in unconnected)}. '
+ f"Unconnected {', '.join(multianimalbodyparts[i] for i in unconnected)}. "
f"For multi-animal projects, all multianimalbodyparts should be connected. "
- f"Ideally there should be at least one (multinode) path from each multianimalbodyparts to each other multianimalbodyparts. "
+ f"Ideally there should be at least one (multinode) path from each multianimalbodyparts to each other"
+ f"multianimalbodyparts."
)
def prune_paf_graph(list_of_edges, desired_n_edges=None, average_degree=None):
if not (desired_n_edges or average_degree):
- raise ValueError(
- "Either `desired_n_edges` or `average_degree` must be specified."
- )
+ raise ValueError("Either `desired_n_edges` or `average_degree` must be specified.")
G = nx.Graph(list_of_edges)
n_edges = len(G.edges)
@@ -147,7 +143,7 @@ def prune_paf_graph(list_of_edges, desired_n_edges=None, average_degree=None):
)
while True:
- g = nx.Graph(random.sample(G.edges, desired_n_edges))
+ g = nx.Graph(random.sample(list(G.edges), desired_n_edges))
if len(g.nodes) == n_nodes and nx.is_connected(g):
print("Valid subgraph found...")
break
@@ -155,14 +151,13 @@ def prune_paf_graph(list_of_edges, desired_n_edges=None, average_degree=None):
def getpafgraph(cfg, printnames=True):
- """Auxiliary function that turns skeleton (list of connected bodypart pairs)
- into a list of corresponding indices (with regard to the stacked multianimal/uniquebodyparts)
+ """Auxiliary function that turns skeleton (list of connected bodypart pairs) into a
+ list of corresponding indices (with regard to the stacked
+ multianimal/uniquebodyparts)
Convention: multianimalbodyparts go first!
"""
- individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(
- cfg
- )
+ individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(cfg)
# Attention this order has to be consistent (for training set creation, training, inference etc.)
bodypartnames = multianimalbodyparts + uniquebodyparts
@@ -190,16 +185,15 @@ def getpafgraph(cfg, printnames=True):
def graph2names(cfg, partaffinityfield_graph):
- individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(
- cfg
- )
+ individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(cfg)
bodypartnames = multianimalbodyparts + uniquebodyparts
for pair in partaffinityfield_graph:
print(pair, bodypartnames[pair[0]], bodypartnames[pair[1]])
def SaveFullMultiAnimalData(data, metadata, dataname, suffix="_full"):
- """Save predicted data as h5 file and metadata as pickle file; created by predict_videos.py"""
+ """Save predicted data as h5 file and metadata as pickle file; created by
+ predict_videos.py."""
data_path = dataname.split(".h5")[0] + suffix + ".pickle"
metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
@@ -211,7 +205,8 @@ def SaveFullMultiAnimalData(data, metadata, dataname, suffix="_full"):
def LoadFullMultiAnimalData(dataname):
- """Save predicted data as h5 file and metadata as pickle file; created by predict_videos.py"""
+ """Save predicted data as h5 file and metadata as pickle file; created by
+ predict_videos.py."""
data_file = dataname.split(".h5")[0] + "_full.pickle"
try:
with open(data_file, "rb") as handle:
@@ -232,9 +227,7 @@ def returnlabelingdata(config):
for folder in folders:
print("Do you want to get the data for folder:", folder, "?")
askuser = input("yes/no")
- if (
- askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha"
- ): # multilanguage support :)
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
Data = pd.read_hdf(fn)
return Data
@@ -251,8 +244,10 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
Full path of the config.yaml file as a string.
userfeedback: bool, optional
- If this is set to false during automatic mode then frames for all videos are extracted. The user can set this to true, which will result in a dialog,
- where the user is asked for each video if (additional/any) frames from this video should be extracted. Use this, e.g. if you have already labeled
+ If this is set to false during automatic mode then frames for all videos are extracted. The user can set
+ this to true, which will result in a dialog,
+ where the user is asked for each video if (additional/any) frames from this video should be extracted. Use
+ this, e.g. if you have already labeled
some folders and want to extract data for new videos.
forceindividual: None default
@@ -274,9 +269,7 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
video_names = [trainingsetmanipulation._robust_path_split(i)[1] for i in videos]
folders = [Path(config).parent / "labeled-data" / Path(i) for i in video_names]
- individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(
- cfg
- )
+ individuals, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(cfg)
if forceindividual is None:
if len(individuals) == 0:
@@ -288,21 +281,17 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
if forceindividual == "single": # no specific individual ()
if len(multianimalbodyparts) > 0: # there should be an individual name...
- print(
- "At least one individual should exist beyond 'single', as there are multianimalbodyparts..."
- )
+ print("At least one individual should exist beyond 'single', as there are multianimalbodyparts...")
folders = []
for folder in folders:
- if userfeedback == True:
+ if userfeedback:
print("Do you want to convert the annotation file in folder:", folder, "?")
askuser = input("yes/no")
else:
askuser = "yes"
- if (
- askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha"
- ): # multilanguage support :)
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"])
Data = pd.read_hdf(fn + ".h5")
conversioncode.guarantee_multiindex_rows(Data)
@@ -313,16 +302,12 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
# -> adding (single,bpt) for uniquebodyparts
for j, bpt in enumerate(uniquebodyparts):
index = pd.MultiIndex.from_arrays(
- np.array(
- [2 * [cfg["scorer"]], 2 * ["single"], 2 * [bpt], ["x", "y"]]
- ),
+ np.array([2 * [cfg["scorer"]], 2 * ["single"], 2 * [bpt], ["x", "y"]]),
names=["scorer", "individuals", "bodyparts", "coords"],
)
if bpt in Data[cfg["scorer"]].keys():
- frame = pd.DataFrame(
- Data[cfg["scorer"]][bpt].values, columns=index, index=imindex
- )
+ frame = pd.DataFrame(Data[cfg["scorer"]][bpt].values, columns=index, index=imindex)
else:
frame = pd.DataFrame(
np.ones((len(imindex), 2)) * np.nan,
@@ -353,9 +338,7 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
)
if bpt in Data[cfg["scorer"]].keys():
- frame = pd.DataFrame(
- Data[cfg["scorer"]][bpt].values, columns=index, index=imindex
- )
+ frame = pd.DataFrame(Data[cfg["scorer"]][bpt].values, columns=index, index=imindex)
else:
frame = pd.DataFrame(
np.ones((len(imindex), 2)) * np.nan,
@@ -370,42 +353,39 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
Data.to_hdf(
fn + "singleanimal.h5",
- "df_with_missing",
+ key="df_with_missing",
)
Data.to_csv(fn + "singleanimal.csv")
- dataFrame.to_hdf(fn + ".h5", "df_with_missing")
+ dataFrame.to_hdf(fn + ".h5", key="df_with_missing")
dataFrame.to_csv(fn + ".csv")
def convert_single2multiplelegacyAM(config, userfeedback=True, target=None):
- """Convert multi animal to single animal code and vice versa. Note that by providing target='single'/'multi' this will be target!"""
+ """Convert multi animal to single animal code and vice versa.
+
+ Note that by providing target='single'/'multi' this will be target!
+ """
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
video_names = [Path(i).stem for i in videos]
folders = [Path(config).parent / "labeled-data" / Path(i) for i in video_names]
- prefixes, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(
- cfg
- )
+ prefixes, uniquebodyparts, multianimalbodyparts = extractindividualsandbodyparts(cfg)
for folder in folders:
- if userfeedback == True:
+ if userfeedback:
print("Do you want to convert the annotation file in folder:", folder, "?")
askuser = input("yes/no")
else:
askuser = "yes"
- if (
- askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha"
- ): # multilanguage support :)
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"])
Data = pd.read_hdf(fn + ".h5")
conversioncode.guarantee_multiindex_rows(Data)
imindex = Data.index
- if "individuals" in Data.columns.names and (
- target is None or target == "single"
- ):
+ if "individuals" in Data.columns.names and (target is None or target == "single"):
print("This is a multianimal data set, converting to single...", folder)
for prfxindex, prefix in enumerate(prefixes):
if prefix == "single":
@@ -445,19 +425,17 @@ def convert_single2multiplelegacyAM(config, userfeedback=True, target=None):
Data.to_hdf(
fn + "multianimal.h5",
- "df_with_missing",
+ key="df_with_missing",
)
Data.to_csv(fn + "multianimal.csv")
DataFrame.to_hdf(
fn + ".h5",
- "df_with_missing",
+ key="df_with_missing",
)
DataFrame.to_csv(fn + ".csv")
elif target is None or target == "multi":
- print(
- "This is a single animal data set, converting to multi...", folder
- )
+ print("This is a single animal data set, converting to multi...", folder)
for prfxindex, prefix in enumerate(prefixes):
if prefix == "single":
if cfg["uniquebodyparts"] != [None]:
@@ -534,13 +512,13 @@ def convert_single2multiplelegacyAM(config, userfeedback=True, target=None):
Data.to_hdf(
fn + "singleanimal.h5",
- "df_with_missing",
+ key="df_with_missing",
)
Data.to_csv(fn + "singleanimal.csv")
DataFrame.to_hdf(
fn + ".h5",
- "df_with_missing",
+ key="df_with_missing",
)
DataFrame.to_csv(fn + ".csv")
@@ -551,9 +529,7 @@ def form_default_inferencecfg(cfg):
os.path.join(auxiliaryfunctions.get_deeplabcut_path(), "inference_cfg.yaml")
)
# set project specific parameters:
- inferencecfg["minimalnumberofconnections"] = (
- len(cfg["multianimalbodyparts"]) / 2
- ) # reasonable default
+ inferencecfg["minimalnumberofconnections"] = len(cfg["multianimalbodyparts"]) / 2 # reasonable default
inferencecfg["topktoretain"] = len(cfg["individuals"])
return inferencecfg
@@ -562,7 +538,7 @@ def check_inferencecfg_sanity(cfg, inferencecfg):
template = form_default_inferencecfg(cfg)
missing = [key for key in template if key not in inferencecfg]
if missing:
- raise KeyError(f'Keys {", ".join(missing)} are missing in the inferencecfg.')
+ raise KeyError(f"Keys {', '.join(missing)} are missing in the inferencecfg.")
def read_inferencecfg(path_inference_config, cfg):
@@ -571,7 +547,5 @@ def read_inferencecfg(path_inference_config, cfg):
inferencecfg = auxiliaryfunctions.read_plainconfig(str(path_inference_config))
except FileNotFoundError:
inferencecfg = form_default_inferencecfg(cfg)
- auxiliaryfunctions.write_plainconfig(
- str(path_inference_config), dict(inferencecfg)
- )
+ auxiliaryfunctions.write_plainconfig(str(path_inference_config), dict(inferencecfg))
return inferencecfg
diff --git a/deeplabcut/utils/auxfun_videos.py b/deeplabcut/utils/auxfun_videos.py
index c6fd1bd47b..5eae5df4ef 100644
--- a/deeplabcut/utils/auxfun_videos.py
+++ b/deeplabcut/utils/auxfun_videos.py
@@ -19,19 +19,25 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import skimage.color
-from skimage import io
-from skimage.util import img_as_ubyte
-import cv2
import datetime
-import numpy as np
import os
+import random
import subprocess
import warnings
+from collections.abc import Sequence
+from pathlib import Path
+import cv2
+import numpy as np
+import skimage.color
+from skimage import io
+from skimage.util import img_as_ubyte
+
+from deeplabcut.utils.deprecation import DLCDeprecationWarning
# more videos are in principle covered, as OpenCV is used and allows many formats.
SUPPORTED_VIDEOS = "avi", "mp4", "mov", "mpeg", "mpg", "mpv", "mkv", "flv", "qt", "yuv"
+DEFAULT_EXCLUDE_PATTERNS: tuple[str, ...] = "*_labeled.*", "*_full.*"
class VideoReader:
@@ -41,7 +47,7 @@ def __init__(self, video_path):
self.video_path = video_path
self.video = cv2.VideoCapture(video_path)
if not self.video.isOpened():
- raise IOError("Video could not be opened; it may be corrupted.")
+ raise OSError("Video could not be opened; it may be corrupted.")
self.parse_metadata()
self._bbox = 0, 1, 0, 1
self._n_frames_robust = None
@@ -58,7 +64,7 @@ def check_integrity(self):
command = f'ffmpeg -v error -i "{self.video_path}" -f null - 2>"{dest}"'
subprocess.call(command, shell=True)
if os.path.getsize(dest) != 0:
- warnings.warn(f'Video contains errors. See "{dest}" for a detailed report.')
+ warnings.warn(f'Video contains errors. See "{dest}" for a detailed report.', stacklevel=2)
def check_integrity_robust(self):
numframes = self.video.get(cv2.CAP_PROP_FRAME_COUNT)
@@ -66,9 +72,7 @@ def check_integrity_robust(self):
while fr < numframes:
success, frame = self.video.read()
if not success or frame is None:
- warnings.warn(
- f"Opencv failed to load frame {fr}. Use ffmpeg to re-encode video file"
- )
+ warnings.warn(f"Opencv failed to load frame {fr}. Use ffmpeg to re-encode video file", stacklevel=2)
fr += 1
@property
@@ -85,9 +89,7 @@ def directory(self):
@property
def metadata(self):
- return dict(
- n_frames=len(self), fps=self.fps, width=self.width, height=self.height
- )
+ return dict(n_frames=len(self), fps=self.fps, width=self.width, height=self.height)
def get_n_frames(self, robust=False):
if not robust:
@@ -98,21 +100,14 @@ def get_n_frames(self, robust=False):
f"-select_streams v:0 -show_entries stream=nb_read_frames "
f"-of default=nokey=1:noprint_wrappers=1"
)
- output = subprocess.check_output(
- command, shell=True, stderr=subprocess.STDOUT
- )
+ output = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT)
self._n_frames_robust = int(output)
return self._n_frames_robust
def calc_duration(self, robust=False):
if robust:
- command = (
- f'ffprobe -i "{self.video_path}" -show_entries '
- f'format=duration -v quiet -of csv="p=0"'
- )
- output = subprocess.check_output(
- command, shell=True, stderr=subprocess.STDOUT
- )
+ command = f'ffprobe -i "{self.video_path}" -show_entries format=duration -v quiet -of csv="p=0"'
+ output = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT)
return float(output)
return len(self) / self.fps
@@ -121,10 +116,7 @@ def set_to_frame(self, ind):
raise ValueError("Index must be a positive integer.")
last_frame = len(self) - 1
if ind > last_frame:
- warnings.warn(
- "Index exceeds the total number of frames. "
- "Setting to last frame instead."
- )
+ warnings.warn("Index exceeds the total number of frames. Setting to last frame instead.", stacklevel=2)
ind = last_frame
self.video.set(cv2.CAP_PROP_POS_FRAMES, ind)
@@ -159,6 +151,20 @@ def get_bbox(self, relative=False):
y2 = int(self._height * y2)
return x1, x2, y1, y2
+ def set_bbox(self, x1, x2, y1, y2, relative=False):
+ if x2 <= x1 or y2 <= y1:
+ raise ValueError(f"Coordinates look wrong... Ensure {x1} < {x2} and {y1} < {y2}.")
+ if not relative:
+ x1 /= self._width
+ x2 /= self._width
+ y1 /= self._height
+ y2 /= self._height
+ bbox = x1, x2, y1, y2
+ if any(coord > 1 for coord in bbox):
+ warnings.warn("Bounding box larger than the video... Clipping to video dimensions.", stacklevel=2)
+ bbox = tuple(map(lambda x: min(x, 1), bbox))
+ self._bbox = bbox
+
@property
def fps(self):
return self._fps
@@ -186,9 +192,7 @@ def dimensions(self):
def parse_metadata(self):
self._n_frames = int(self.video.get(cv2.CAP_PROP_FRAME_COUNT))
if self._n_frames >= 1e9:
- warnings.warn(
- "The video has more than 10^9 frames, we recommend chopping it up."
- )
+ warnings.warn("The video has more than 10^9 frames, we recommend chopping it up.", stacklevel=2)
self._width = int(self.video.get(cv2.CAP_PROP_FRAME_WIDTH))
self._height = int(self.video.get(cv2.CAP_PROP_FRAME_HEIGHT))
self._fps = round(self.video.get(cv2.CAP_PROP_FPS), 2)
@@ -199,35 +203,14 @@ def close(self):
class VideoWriter(VideoReader):
def __init__(self, video_path, codec="h264", dpi=100, fps=None):
- super(VideoWriter, self).__init__(video_path)
+ super().__init__(video_path)
self.codec = codec
self.dpi = dpi
if fps:
self.fps = fps
- def set_bbox(self, x1, x2, y1, y2, relative=False):
- if x2 <= x1 or y2 <= y1:
- raise ValueError(
- f"Coordinates look wrong... " f"Ensure {x1} < {x2} and {y1} < {y2}."
- )
- if not relative:
- x1 /= self._width
- x2 /= self._width
- y1 /= self._height
- y2 /= self._height
- bbox = x1, x2, y1, y2
- if any(coord > 1 for coord in bbox):
- warnings.warn(
- "Bounding box larger than the video... " "Clipping to video dimensions."
- )
- bbox = tuple(map(lambda x: min(x, 1), bbox))
- self._bbox = bbox
-
- def shorten(
- self, start, end, suffix="short", dest_folder=None, validate_inputs=True
- ):
- """
- Shorten the video from start to end.
+ def shorten(self, start, end, suffix="short", dest_folder=None, validate_inputs=True):
+ """Shorten the video from start to end.
Parameter
----------
@@ -251,10 +234,7 @@ def shorten(
def validate_timestamp(stamp):
if not isinstance(stamp, str):
- raise ValueError(
- "Timestamp should be a string formatted "
- "as hours:minutes:seconds."
- )
+ raise ValueError("Timestamp should be a string formatted as hours:minutes:seconds.")
time = datetime.datetime.strptime(stamp, "%H:%M:%S").time()
# The above already raises a ValueError if formatting is wrong
seconds = (time.hour * 60 + time.minute) * 60 + time.second
@@ -266,16 +246,12 @@ def validate_timestamp(stamp):
validate_timestamp(stamp)
output_path = self.make_output_path(suffix, dest_folder)
- command = (
- f'ffmpeg -n -i "{self.video_path}" -ss {start} -to {end} '
- f'-c:a copy "{output_path}"'
- )
+ command = f'ffmpeg -n -i "{self.video_path}" -ss {start} -to {end} -c:a copy "{output_path}"'
subprocess.call(command, shell=True)
return output_path
def split(self, n_splits, suffix="split", dest_folder=None):
- """
- Split a video into several shorter ones of equal duration.
+ """Split a video into several shorter ones of equal duration.
Parameters
----------
@@ -297,9 +273,12 @@ def split(self, n_splits, suffix="split", dest_folder=None):
raise ValueError("The video should at least be split in half.")
chunk_dur = self.calc_duration() / n_splits
splits = np.arange(n_splits + 1) * chunk_dur
- time_formatter = lambda val: str(datetime.timedelta(seconds=val))
+
+ def time_formatter(val):
+ return str(datetime.timedelta(seconds=val))
+
clips = []
- for n, (start, end) in enumerate(zip(splits, splits[1:]), start=1):
+ for n, (start, end) in enumerate(zip(splits, splits[1:], strict=False), start=1):
clips.append(
self.shorten(
time_formatter(start),
@@ -322,6 +301,21 @@ def crop(self, suffix="crop", dest_folder=None):
subprocess.call(command, shell=True)
return output_path
+ def rotate(self, angle, rotatecw="Arbitrary", suffix="rotated", dest_folder=None):
+ output_path = self.make_output_path(suffix, dest_folder)
+ command = f'ffmpeg -n -i "{self.video_path}" -vf '
+ if rotatecw == "Arbitrary":
+ angle = np.deg2rad(angle)
+ command += f"rotate={angle} "
+ elif rotatecw == "Yes":
+ command += "transpose=1 "
+ else:
+ raise ValueError("Unknown rotation direction.")
+
+ command += f'-c:a copy "{output_path}"'
+ subprocess.call(command, shell=True)
+ return output_path
+
def rescale(
self,
width,
@@ -332,17 +326,14 @@ def rescale(
dest_folder=None,
):
output_path = self.make_output_path(suffix, dest_folder)
- command = (
- f'ffmpeg -n -i "{self.video_path}" -filter:v '
- f'"scale={width}:{height}{{}}" -c:a copy "{output_path}"'
- )
+ command = f'ffmpeg -n -i "{self.video_path}" -filter:v "scale={width}:{height}{{}}" -c:a copy "{output_path}"'
# Rotate, see: https://stackoverflow.com/questions/3937387/rotating-videos-with-ffmpeg
# interesting option to just update metadata.
if rotatecw == "Arbitrary":
angle = np.deg2rad(angle)
command = command.format(f", rotate={angle}")
elif rotatecw == "Yes":
- command = command.format(f", transpose=1")
+ command = command.format(", transpose=1")
else:
command = command.format("")
subprocess.call(command, shell=True)
@@ -366,7 +357,9 @@ def check_video_integrity(video_path):
def imread(image_path, mode="skimage"):
"""Read image either with skimage or cv2.
- Returns frame in uint with 3 color channels."""
+
+ Returns frame in uint with 3 color channels.
+ """
if mode == "skimage":
image = io.imread(image_path)
if image.ndim == 2 or image.shape[-1] == 1:
@@ -377,9 +370,7 @@ def imread(image_path, mode="skimage"):
return img_as_ubyte(image)
elif mode == "cv2":
- return cv2.imread(image_path, cv2.IMREAD_UNCHANGED)[
- ..., ::-1
- ] # ~10% faster than using cv2.cvtColor
+ return cv2.imread(image_path, cv2.IMREAD_UNCHANGED)[..., ::-1] # ~10% faster than using cv2.cvtColor
# https://docs.opencv.org/3.4.0/da/d54/group__imgproc__transform.html#ga5bb5a1fea74ea38e1a5445ca803ff121
@@ -392,12 +383,9 @@ def imresize(img, size=1.0, interpolationmethod=cv2.INTER_AREA):
return img
-def ShortenVideo(
- vname, start="00:00:01", stop="00:01:00", outsuffix="short", outpath=None
-):
- """
- Auxiliary function to shorten video and output with outsuffix appended.
- to the same folder from start (hours:minutes:seconds) to stop (hours:minutes:seconds).
+def ShortenVideo(vname, start="00:00:01", stop="00:01:00", outsuffix="short", outpath=None):
+ """Auxiliary function to shorten video and output with outsuffix appended. to the
+ same folder from start (hours:minutes:seconds) to stop (hours:minutes:seconds).
Returns the full path to the shortened video!
@@ -427,9 +415,11 @@ def ShortenVideo(
Extracts (sub)video from 1st second to 1st minutes (default values) and saves it in /data/videos as mouse1short.avi
Windows:
- >>> deeplabcut.ShortenVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi', start='00:17:00',stop='00:22:00',outsuffix='brief')
+ >>> deeplabcut.ShortenVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi',
+ ... start='00:17:00',stop='00:22:00',outsuffix='brief')
- Extracts (sub)video from minute 17 to 22 and and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1brief.avi
+ Extracts (sub)video from minute 17 to 22 and and saves it in
+ C:\\yourusername\\rig-95\\Videos as reachingvideo1brief.avi
"""
writer = VideoWriter(vname)
return writer.shorten(start, stop, outsuffix, outpath)
@@ -445,9 +435,8 @@ def CropVideo(
outpath=None,
useGUI=False,
):
- """
- Auxiliary function to crop a video and output it to the same folder with "outsuffix" appended in its name.
- Width and height will control the new dimensions.
+ """Auxiliary function to crop a video and output it to the same folder with
+ "outsuffix" appended in its name. Width and height will control the new dimensions.
Returns the full path to the downsampled video!
@@ -482,16 +471,16 @@ def CropVideo(
Crops the video using default values and saves it in /data/videos as mouse1cropped.avi
Windows:
- >>> =deeplabcut.CropVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi', width=220,height=320,outsuffix='cropped')
+ >>> =deeplabcut.CropVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi',
+ ... width=220,height=320,outsuffix='cropped')
- Crops the video to a width of 220 and height of 320 starting at the origin (top left) and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1cropped.avi
+ Crops the video to a width of 220 and height of 320 starting at the origin (top left)
+ and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1cropped.avi
"""
writer = VideoWriter(vname)
if useGUI:
- print(
- "Please, select your coordinates (draw from top left to bottom right ...)"
- )
+ print("Please, select your coordinates (draw from top left to bottom right ...)")
coords = draw_bbox(vname)
if not coords:
@@ -513,10 +502,10 @@ def DownSampleVideo(
rotatecw="No",
angle=0.0,
):
- """
- Auxiliary function to downsample a video and output it to the same folder with "outsuffix" appended in its name.
- Width and height will control the new dimensions. You can also pass only height or width and set the other one to -1,
- this will keep the aspect ratio identical.
+ """Auxiliary function to downsample a video and output it to the same folder with
+ "outsuffix" appended in its name. Width and height will control the new dimensions.
+ You can also pass only height or width and set the other one to -1, this will keep
+ the aspect ratio identical.
Returns the full path to the downsampled video!
@@ -552,17 +541,61 @@ def DownSampleVideo(
Downsamples the video using default values and saves it in /data/videos as mouse1cropped.avi
Windows:
- >>> shortenedvideoname=deeplabcut.DownSampleVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi', width=220,height=320,outsuffix='cropped')
+ >>> shortenedvideoname=deeplabcut.DownSampleVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi',
+ ... width=220,height=320,outsuffix='cropped')
- Downsamples the video to a width of 220 and height of 320 and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1cropped.avi
+ Downsamples the video to a width of 220 and height of 320 and
+ saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1cropped.avi
"""
writer = VideoWriter(vname)
return writer.rescale(width, height, rotatecw, angle, outsuffix, outpath)
+def rotate_video(vname, angle, rotatecw="Arbitrary", outsuffix="rotated", outpath=None):
+ """Auxiliary function to rotate a video and output it to the same folder with
+ "outsuffix" appended in its name. Angle is in degrees.
+
+ Returns the full path to the rotated video!
+
+ Parameter
+ ----------
+ vname : string
+ A string containing the full path of the video.
+
+ angle: float
+ Angle to rotate by in degrees. Negative values rotate counter-clockwise.
+
+ rotatecw: str
+ Default "Arbitrary", rotates clockwise if "Yes", "Arbitrary" for arbitrary rotation by specified angle.
+
+ outsuffix: str
+ Suffix for output videoname (see example).
+
+ outpath: str
+ Output path for saving video to (by default will be the same folder as the video)
+
+ Examples
+ ----------
+
+ Linux/MacOs
+ >>> deeplabcut.rotate_video('/data/videos/mouse1.avi',angle=90)
+
+ Rotates the video by 90 degrees and saves it in /data/videos as mouse1rotated.avi
+
+ Windows:
+ >>> shortenedvideoname=deeplabcut.rotate_video('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi',
+ ... angle=180,rotatecw='Yes')
+
+ Rotates the video by 180 degrees and
+ saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1rotated.avi
+ """
+ writer = VideoWriter(vname)
+ return writer.rotate(angle, rotatecw, outsuffix, outpath)
+
+
def draw_bbox(video):
import matplotlib.pyplot as plt
- from matplotlib.widgets import RectangleSelector, Button
+ from matplotlib.widgets import Button, RectangleSelector
clip = VideoWriter(video)
frame = None
@@ -581,7 +614,10 @@ def validate_crop(*args):
def display_help(*args):
print(
- "1. Use left click to select the region of interest. A red box will be drawn around the selected region. \n\n2. Use the corner points to expand the box and center to move the box around the image. \n\n3. Click "
+ "1. Use left click to select the region of interest. "
+ "A red box will be drawn around the selected region. \n\n"
+ "2. Use the corner points to expand the box and center to move the box around the image. \n\n"
+ "3. Click "
)
fig = plt.figure()
@@ -594,7 +630,7 @@ def display_help(*args):
help_button = Button(ax_help, "Help")
help_button.on_clicked(display_help)
- rs = RectangleSelector(
+ RectangleSelector(
ax,
line_select_callback,
minspanx=5,
@@ -613,3 +649,108 @@ def display_help(*args):
plt.close(fig)
return bbox
+
+
+def collect_video_paths(
+ data_path: str | Path | list[str | Path],
+ extensions: str | Sequence[str] | None = None,
+ shuffle: bool = False,
+ exclude_patterns: Sequence[str] = DEFAULT_EXCLUDE_PATTERNS,
+) -> list[Path]:
+ """
+ Collects video paths from a given set of data paths: directories, files, or a mix
+ of both. Directories are scanned one level deep (non-recursively).
+
+ Files and directories are treated differently with respect to extension filtering:
+ - File paths are accepted as-is when ``extensions`` is ``None``; only filtered when
+ ``extensions`` is explicitly set.
+ - Directory contents are always filtered by extension: by ``SUPPORTED_VIDEOS`` when
+ ``extensions`` is ``None``, or by the given value(s) otherwise.
+ - ``exclude_patterns`` are always applied to both files and directory contents.
+
+ Args:
+ data_path: Path or list of paths to folders containing videos, or individual
+ video files. Can be a mix of directories and files.
+ extensions: Controls extension filtering for collected video files.
+ - ``None`` (default): file paths are accepted without extension filtering;
+ directories are scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered to only include files
+ matching the given extension(s).
+ - Empty ``str`` ``""`` is treated as ``None`` (deprecated, keep for backwards
+ compatibility).
+ shuffle: Whether to shuffle the order of videos. If ``False``, videos are
+ returned in sorted order for deterministic behavior.
+ exclude_patterns: Patterns to exclude from the collection. Defaults to
+ ``DEFAULT_EXCLUDE_PATTERNS``. Set to ``[]`` to disable pattern exclusion.
+
+ Returns:
+ The paths of videos to analyze. Duplicate paths are removed.
+
+ Raises:
+ FileNotFoundError: If any path in ``data_path`` does not exist.
+ ValueError: If ``extensions`` is an empty sequence.
+ """
+ if isinstance(data_path, (str, Path)):
+ data_path = [data_path]
+
+ def _coerce_extensions(extensions: str | Sequence[str] | None) -> set[str] | None:
+ """Coerce the extensions argument to a set of dot-prefixed suffixes, or None."""
+ if extensions is None:
+ return None
+
+ if extensions in ["", ("",), [""], {""}]:
+ warnings.warn(
+ "Passing an empty string for filtering video type extensions is deprecated; pass None instead.",
+ DLCDeprecationWarning,
+ stacklevel=3,
+ )
+ return None
+
+ if isinstance(extensions, str):
+ return {f".{extensions.lstrip('.').lower()}"}
+
+ if not isinstance(extensions, Sequence):
+ raise TypeError(f"extensions must be a string, a sequence or None, got {type(extensions)}")
+
+ if len(extensions) == 0:
+ raise ValueError("Video type extensions filter needs to be a non-empty sequence.")
+ return {f".{e.lstrip('.').lower()}" for e in extensions}
+
+ explicit_suffixes = _coerce_extensions(extensions)
+ implicit_suffixes = {f".{ext.lower()}" for ext in SUPPORTED_VIDEOS}
+
+ videos: list[Path] = []
+ for path in map(Path, data_path):
+ if not path.exists():
+ raise FileNotFoundError(f"Could not find: {path}. Check access rights.")
+
+ if path.is_dir():
+ # Discriminate videos from other files; skip excluded patterns (e.g. prior DLC outputs).
+ allowed = explicit_suffixes if explicit_suffixes else implicit_suffixes
+ videos.extend(
+ f
+ for f in path.iterdir()
+ if f.is_file()
+ and f.suffix.lower() in allowed
+ and not any(f.match(pattern) for pattern in exclude_patterns)
+ )
+ elif path.is_file():
+ # Accept all caller-supplied files; ONLY filter extensions if set. ALWAYS filter exclude patterns.
+ if explicit_suffixes is None or path.suffix.lower() in explicit_suffixes:
+ if not any(path.match(pattern) for pattern in exclude_patterns):
+ videos.append(path)
+
+ # Resolve video paths and remove duplicates
+ unique_videos = list(dict.fromkeys(v.resolve() for v in videos))
+ if shuffle:
+ random.shuffle(unique_videos)
+ else:
+ unique_videos.sort()
+
+ if any(fn.suffix.lower().lstrip(".") not in SUPPORTED_VIDEOS for fn in unique_videos if fn.suffix):
+ warnings.warn(
+ f"Some videos have unsupported extensions: {unique_videos} \nSupported extensions are: {SUPPORTED_VIDEOS}",
+ stacklevel=2,
+ )
+ return unique_videos
diff --git a/deeplabcut/utils/auxiliaryfunctions.py b/deeplabcut/utils/auxiliaryfunctions.py
index e71f8a8114..d726905dd4 100644
--- a/deeplabcut/utils/auxiliaryfunctions.py
+++ b/deeplabcut/utils/auxiliaryfunctions.py
@@ -18,125 +18,147 @@
Licensed under GNU Lesser General Public License v3.0
"""
+from __future__ import annotations
+
import os
-import typing
import pickle
import warnings
+from collections.abc import Sequence
from pathlib import Path
-import numpy as np
+
import pandas as pd
import ruamel.yaml.representer
import yaml
from ruamel.yaml import YAML
-from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS
-from deeplabcut.utils import auxfun_videos
+
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.trackingutils import TRACK_METHODS
+from deeplabcut.utils import auxfun_multianimal
+from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS, collect_video_paths
+from deeplabcut.utils.deprecation import deprecated
def create_config_template(multianimal=False):
- """
- Creates a template for config.yaml file. This specific order is preserved while saving as yaml file.
+ """Creates a template for config.yaml file.
+
+ This specific order is preserved while saving as yaml file.
"""
if multianimal:
yaml_str = """\
- # Project definitions (do not edit)
- Task:
- scorer:
- date:
- multianimalproject:
- identity:
- \n
- # Project path (change when moving around)
- project_path:
- \n
- # Annotation data set configuration (and individual video cropping parameters)
- video_sets:
- individuals:
- uniquebodyparts:
- multianimalbodyparts:
- bodyparts:
- \n
- # Fraction of video to start/stop when extracting frames for labeling/refinement
- start:
- stop:
- numframes2pick:
- \n
- # Plotting configuration
- skeleton:
- skeleton_color:
- pcutoff:
- dotsize:
- alphavalue:
- colormap:
- \n
- # Training,Evaluation and Analysis configuration
- TrainingFraction:
- iteration:
- default_net_type:
- default_augmenter:
- default_track_method:
- snapshotindex:
- batch_size:
- \n
- # Cropping Parameters (for analysis and outlier frame detection)
- cropping:
- #if cropping is true for analysis, then set the values here:
- x1:
- x2:
- y1:
- y2:
- \n
- # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
- corner2move2:
- move2corner:
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+\n
+# Project path (change when moving around)
+project_path:
+\n
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+\n
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+individuals:
+uniquebodyparts:
+multianimalbodyparts:
+bodyparts:
+\n
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+\n
+# Plotting configuration
+skeleton:
+skeleton_color:
+pcutoff:
+dotsize:
+alphavalue:
+colormap:
+\n
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+default_track_method:
+snapshotindex:
+detector_snapshotindex:
+batch_size:
+\n
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+\n
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+\n
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
"""
else:
yaml_str = """\
- # Project definitions (do not edit)
- Task:
- scorer:
- date:
- multianimalproject:
- identity:
- \n
- # Project path (change when moving around)
- project_path:
- \n
- # Annotation data set configuration (and individual video cropping parameters)
- video_sets:
- bodyparts:
- \n
- # Fraction of video to start/stop when extracting frames for labeling/refinement
- start:
- stop:
- numframes2pick:
- \n
- # Plotting configuration
- skeleton:
- skeleton_color:
- pcutoff:
- dotsize:
- alphavalue:
- colormap:
- \n
- # Training,Evaluation and Analysis configuration
- TrainingFraction:
- iteration:
- default_net_type:
- default_augmenter:
- snapshotindex:
- batch_size:
- \n
- # Cropping Parameters (for analysis and outlier frame detection)
- cropping:
- #if cropping is true for analysis, then set the values here:
- x1:
- x2:
- y1:
- y2:
- \n
- # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
- corner2move2:
- move2corner:
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+\n
+# Project path (change when moving around)
+project_path:
+\n
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+\n
+# Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+\n
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+\n
+# Plotting configuration
+skeleton:
+skeleton_color:
+pcutoff:
+dotsize:
+alphavalue:
+colormap:
+\n
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+detector_snapshotindex:
+batch_size:
+detector_batch_size:
+\n
+# Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+#if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+\n
+# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+\n
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
"""
ruamelFile = YAML()
@@ -145,32 +167,33 @@ def create_config_template(multianimal=False):
def create_config_template_3d():
- """
- Creates a template for config.yaml file for 3d project. This specific order is preserved while saving as yaml file.
+ """Creates a template for config.yaml file for 3d project.
+
+ This specific order is preserved while saving as yaml file.
"""
yaml_str = """\
# Project definitions (do not edit)
- Task:
- scorer:
- date:
- \n
+Task:
+scorer:
+date:
+\n
# Project path (change when moving around)
- project_path:
- \n
+project_path:
+\n
# Plotting configuration
- skeleton: # Note that the pairs must be defined, as you want them linked!
- skeleton_color:
- pcutoff:
- colormap:
- dotsize:
- alphaValue:
- markerType:
- markerColor:
- \n
+skeleton: # Note that the pairs must be defined, as you want them linked!
+skeleton_color:
+pcutoff:
+colormap:
+dotsize:
+alphaValue:
+markerType:
+markerColor:
+\n
# Number of cameras, camera names, path of the config files, shuffle index and trainingsetindex used to analyze videos:
- num_cameras:
- camera_names:
- scorername_3d: # Enter the scorer name for the 3D output
+num_cameras:
+camera_names:
+scorername_3d: # Enter the scorer name for the 3D output
"""
ruamelFile_3d = YAML()
cfg_file_3d = ruamelFile_3d.load(yaml_str)
@@ -178,26 +201,32 @@ def create_config_template_3d():
def read_config(configname):
- """
- Reads structured config file defining a project.
- """
+ """Reads structured config file defining a project."""
ruamelFile = YAML()
path = Path(configname)
if os.path.exists(path):
try:
- with open(path, "r") as f:
+ with open(path) as f:
cfg = ruamelFile.load(f)
- curr_dir = os.path.dirname(configname)
+ curr_dir = str(Path(configname).parent.resolve())
+
+ if cfg.get("engine") is None:
+ cfg["engine"] = Engine.TF.aliases[0]
+ write_config(configname, cfg)
+
+ if cfg.get("detector_snapshotindex") is None:
+ cfg["detector_snapshotindex"] = -1
+
+ if cfg.get("detector_batch_size") is None:
+ cfg["detector_batch_size"] = 1
+
if cfg["project_path"] != curr_dir:
cfg["project_path"] = curr_dir
write_config(configname, cfg)
except Exception as err:
if len(err.args) > 2:
- if (
- err.args[2]
- == "could not determine a constructor for the tag '!!python/tuple'"
- ):
- with open(path, "r") as ymlfile:
+ if err.args[2] == "could not determine a constructor for the tag '!!python/tuple'":
+ with open(path) as ymlfile:
cfg = yaml.load(ymlfile, Loader=yaml.SafeLoader)
write_config(configname, cfg)
else:
@@ -205,32 +234,33 @@ def read_config(configname):
else:
raise FileNotFoundError(
- "Config file is not found. Please make sure that the file exists and/or that you passed the path of the config file correctly!"
+ f"Config file at {path} not found. Please make sure that the file exists and/or that you passed the path of"
+ f"the config file correctly!"
)
return cfg
def write_config(configname, cfg):
- """
- Write structured config file.
- """
+ """Write structured config file."""
with open(configname, "w") as cf:
- cfg_file, ruamelFile = create_config_template(
- cfg.get("multianimalproject", False)
- )
+ cfg_file, ruamelFile = create_config_template(cfg.get("multianimalproject", False))
for key in cfg.keys():
cfg_file[key] = cfg[key]
# Adding default value for variable skeleton and skeleton_color for backward compatibility.
- if not "skeleton" in cfg.keys():
+ if "skeleton" not in cfg.keys():
cfg_file["skeleton"] = []
cfg_file["skeleton_color"] = "black"
+ # Use a very large width so long strings (e.g., file paths or keys with spaces)
+ # are kept on a single line instead of being wrapped, which can otherwise cause
+ # them to be emitted as complex keys. See also:
+ # https://stackoverflow.com/questions/31197268/pyyaml-yaml-dump-produces-complex-key-for-string-key-122-chars/31199123#31199123
+ ruamelFile.width = 1_000_000
ruamelFile.dump(cfg_file, cf)
def edit_config(configname, edits, output_name=""):
- """
- Convenience function to edit and save a config file from a dictionary.
+ """Convenience function to edit and save a config file from a dictionary.
Parameters
----------
@@ -260,20 +290,51 @@ def edit_config(configname, edits, output_name=""):
try:
write_plainconfig(output_name, cfg)
except ruamel.yaml.representer.RepresenterError:
- warnings.warn(
- "Some edits could not be written. "
- "The configuration file will be left unchanged."
- )
+ warnings.warn("Some edits could not be written. The configuration file will be left unchanged.", stacklevel=2)
for key in edits:
cfg.pop(key)
write_plainconfig(output_name, cfg)
return cfg
-def write_config_3d(configname, cfg):
+def get_bodyparts(cfg: dict) -> list[str]:
"""
- Write structured 3D config file.
+ Args:
+ cfg: a project configuration file
+
+ Returns: bodyparts listed in the project (does not include the unique_bodyparts entry)
"""
+ if cfg.get("multianimalproject", False):
+ (
+ _,
+ _,
+ multianimal_bodyparts,
+ ) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
+ return multianimal_bodyparts
+
+ return cfg["bodyparts"]
+
+
+def get_unique_bodyparts(cfg: dict) -> list[str]:
+ """
+ Args:
+ cfg: a project configuration file
+
+ Returns: all unique bodyparts listed in the project
+ """
+ if cfg.get("multianimalproject", False):
+ (
+ _,
+ unique_bodyparts,
+ _,
+ ) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
+ return unique_bodyparts
+
+ return []
+
+
+def write_config_3d(configname, cfg):
+ """Write structured 3D config file."""
with open(configname, "w") as cf:
cfg_file, ruamelFile = create_config_template_3d()
for key in cfg.keys():
@@ -288,9 +349,7 @@ def write_config_3d_template(projconfigfile, cfg_file_3d, ruamelFile_3d):
def read_plainconfig(configname):
if not os.path.exists(configname):
- raise FileNotFoundError(
- f"Config {configname} is not found. Please make sure that the file exists."
- )
+ raise FileNotFoundError(f"Config {configname} is not found. Please make sure that the file exists.")
with open(configname) as file:
return YAML().load(file)
@@ -301,13 +360,14 @@ def write_plainconfig(configname, cfg):
def attempt_to_make_folder(foldername, recursive=False):
- """Attempts to create a folder with specified name. Does nothing if it already exists."""
+ """Attempts to create a folder with specified name.
+
+ Does nothing if it already exists.
+ """
try:
os.path.isdir(foldername)
except TypeError: # https://www.python.org/dev/peps/pep-0519/
- foldername = os.fspath(
- foldername
- ) # https://github.com/DeepLabCut/DeepLabCut/issues/105 (windows)
+ foldername = os.fspath(foldername) # https://github.com/DeepLabCut/DeepLabCut/issues/105 (windows)
if os.path.isdir(foldername):
pass
@@ -319,87 +379,39 @@ def attempt_to_make_folder(foldername, recursive=False):
def read_pickle(filename):
- """Read the pickle file"""
+ """Read the pickle file."""
with open(filename, "rb") as handle:
return pickle.load(handle)
def write_pickle(filename, data):
- """Write the pickle file"""
+ """Write the pickle file."""
with open(filename, "wb") as handle:
pickle.dump(data, handle, protocol=pickle.HIGHEST_PROTOCOL)
+@deprecated(replacement="deeplabcut.collect_video_paths", since="3.0.0")
def get_list_of_videos(
- videos: typing.Union[typing.List[str], str],
- videotype: typing.Union[typing.List[str], str] = "",
+ videos: list[str] | str,
+ videotype: str | Sequence[str] | None = SUPPORTED_VIDEOS,
in_random_order: bool = True,
-) -> typing.List[str]:
- """Returns list of videos of videotype "videotype" in
- folder videos or for list of videos.
-
- NOTE: excludes keyword videos of the form:
-
- *_labeled.videotype
- *_full.videotype
-
- Args:
- videos (list[str], str): List of video paths or a single path string. If string (or len() == 1 list of strings) is a directory,
- finds all videos whose extension matches ``videotype`` in the directory
-
- videotype (list[str], str): File extension used to filter videos. Optional if ``videos`` is a list of video files,
- and filters with common video extensions if a directory is passed in.
-
- in_random_order (bool): Whether or not to return a shuffled list of videos.
- """
- if isinstance(videos, str):
- videos = [videos]
-
- if [os.path.isdir(i) for i in videos] == [True]: # checks if input is a directory
- """
- Returns all the videos in the directory.
- """
- if not videotype:
- videotype = auxfun_videos.SUPPORTED_VIDEOS
-
- print("Analyzing all the videos in the directory...")
- videofolder = videos[0]
-
- # make list of full paths
- videos = [os.path.join(videofolder, fn) for fn in os.listdir(videofolder)]
-
- if in_random_order:
- from random import shuffle
-
- shuffle(
- videos
- ) # this is useful so multiple nets can be used to analyze simultaneously
- else:
- videos.sort()
-
- if isinstance(videotype, str):
- videotype = [videotype]
-
- # filter list of videos
- videos = [
- v
- for v in videos
- if os.path.isfile(v)
- and any(v.endswith(ext) for ext in videotype)
- and "_labeled." not in v
- and "_full." not in v
- ]
-
- return videos
+) -> list[str]:
+ video_paths = collect_video_paths(
+ data_path=videos,
+ extensions=videotype,
+ shuffle=in_random_order,
+ )
+ return [str(path) for path in video_paths]
def save_data(PredicteData, metadata, dataname, pdindex, imagenames, save_as_csv):
- """Save predicted data as h5 file and metadata as pickle file; created by predict_videos.py"""
+ """Save predicted data as h5 file and metadata as pickle file; created by
+ predict_videos.py."""
DataMachine = pd.DataFrame(PredicteData, columns=pdindex, index=imagenames)
if save_as_csv:
print("Saving csv poses!")
DataMachine.to_csv(dataname.split(".h5")[0] + ".csv")
- DataMachine.to_hdf(dataname, "df_with_missing", format="table", mode="w")
+ DataMachine.to_hdf(dataname, key="df_with_missing", format="table", mode="w")
with open(dataname.split(".h5")[0] + "_meta.pickle", "wb") as f:
# Pickle the 'data' dictionary using the highest protocol available.
pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
@@ -408,9 +420,7 @@ def save_data(PredicteData, metadata, dataname, pdindex, imagenames, save_as_csv
def save_metadata(metadatafilename, data, trainIndices, testIndices, trainFraction):
with open(metadatafilename, "wb") as f:
# Pickle the 'labeled-data' dictionary using the highest protocol available.
- pickle.dump(
- [data, trainIndices, testIndices, trainFraction], f, pickle.HIGHEST_PROTOCOL
- )
+ pickle.dump([data, trainIndices, testIndices, trainFraction], f, pickle.HIGHEST_PROTOCOL)
def load_metadata(metadatafile):
@@ -425,12 +435,12 @@ def load_metadata(metadatafile):
def get_immediate_subdirectories(a_dir):
- """Get list of immediate subdirectories"""
- return [
- name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name))
- ]
+ """Get list of immediate subdirectories."""
+ return [name for name in os.listdir(a_dir) if os.path.isdir(os.path.join(a_dir, name))]
+# TODO: @deruyter92 2026-05-20: this function could be updated to match the
+# signature of collect_video_paths, allowing for multiple extensions.
def grab_files_in_folder(folder, ext="", relative=True):
"""Return the paths of files with extension *ext* present in *folder*."""
for file in os.listdir(folder):
@@ -438,8 +448,50 @@ def grab_files_in_folder(folder, ext="", relative=True):
yield file if relative else os.path.join(folder, file)
+def filter_files_by_patterns(
+ folder: str | Path,
+ start_patterns: set[str] | None = None,
+ contain_patterns: set[str] | None = None,
+ end_patterns: set[str] | None = None,
+) -> list[Path]:
+ """Filters files in a folder based on start, contain, and end patterns.
+
+ Args:
+ folder (str | Path): The folder to search for files.
+
+ start_patterns (Set[str] | None): Patterns the filenames should start with.
+ If None or empty, this pattern is not taken into account.
+
+ contain_patterns (set[str]): Patterns the filenames should contain.
+ If None or empty, this pattern is not taken into account.
+
+ end_patterns (set[str]): Patterns the filenames should end with.
+ If None or empty, this pattern is not taken into account.
+
+ Returns:
+ List[Path]: List of files that match the criteria.
+ """
+ folder = Path(folder) # Ensure the folder is a Path object
+ if not folder.is_dir():
+ raise ValueError(f"{folder} is not a valid directory.")
+
+ # Filter files based on the given patterns
+ matching_files = [
+ file
+ for file in folder.iterdir()
+ if file.is_file()
+ and (not start_patterns or any(file.name.startswith(start) for start in start_patterns))
+ and (not contain_patterns or any(contain in file.name for contain in contain_patterns))
+ and (not end_patterns or any(file.name.endswith(end) for end in end_patterns))
+ ]
+
+ return matching_files
+
+
+@deprecated(replacement="deeplabcut.collect_video_paths", since="3.0.0")
def get_video_list(filename, videopath, videtype):
- """Get list of videos in a path (if filetype == all), otherwise just a specific file."""
+ """Get list of videos in a path (if filetype == all), otherwise just a specific
+ file."""
videos = list(grab_files_in_folder(videopath, videtype))
if filename == "all":
return videos
@@ -453,14 +505,12 @@ def get_video_list(filename, videopath, videtype):
## Various functions to get filenames, foldernames etc. based on configuration parameters.
-def get_training_set_folder(cfg):
- """Training Set folder for config file based on parameters"""
+def get_training_set_folder(cfg: dict) -> Path:
+ """Training Set folder for config file based on parameters."""
Task = cfg["Task"]
date = cfg["date"]
iterate = "iteration-" + str(cfg["iteration"])
- return Path(
- os.path.join("training-datasets", iterate, "UnaugmentedDataSet_" + Task + date)
- )
+ return Path(os.path.join("training-datasets", iterate, "UnaugmentedDataSet_" + Task + date))
def get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, cfg):
@@ -477,64 +527,119 @@ def get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, c
)
datafn = os.path.join(
str(trainingsetfolder),
- cfg["Task"]
- + "_"
- + cfg["scorer"]
- + str(int(100 * trainFraction))
- + "shuffle"
- + str(shuffle)
- + ".mat",
+ cfg["Task"] + "_" + cfg["scorer"] + str(int(100 * trainFraction)) + "shuffle" + str(shuffle) + ".mat",
)
+
return datafn, metadatafn
-def get_model_folder(trainFraction, shuffle, cfg, modelprefix=""):
- Task = cfg["Task"]
- date = cfg["date"]
- iterate = "iteration-" + str(cfg["iteration"])
+def get_model_folder(
+ trainFraction: float,
+ shuffle: int,
+ cfg: dict,
+ modelprefix: str = "",
+ engine: Engine = Engine.TF,
+) -> Path:
+ """
+ Args:
+ trainFraction: the training fraction (as defined in the project configuration)
+ for which to get the model folder
+ shuffle: the index of the shuffle for which to get the model folder
+ cfg: the project configuration
+ modelprefix: The name of the folder
+ engine: The engine for which we want the model folder. Defaults to `tensorflow`
+ for backwards compatibility with DeepLabCut 2.X
+
+ Returns:
+ the relative path from the project root to the folder containing the model files
+ for a shuffle (configuration files, snapshots, training logs, ...)
+ """
+ proj_id = f"{cfg['Task']}{cfg['date']}"
return Path(
modelprefix,
- "dlc-models",
- iterate,
- Task
- + date
- + "-trainset"
- + str(int(trainFraction * 100))
- + "shuffle"
- + str(shuffle),
+ engine.model_folder_name,
+ f"iteration-{cfg['iteration']}",
+ f"{proj_id}-trainset{int(trainFraction * 100)}shuffle{shuffle}",
)
-def get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=""):
+def get_evaluation_folder(
+ trainFraction: float,
+ shuffle: int,
+ cfg: dict,
+ engine: Engine | None = None,
+ modelprefix: str = "",
+) -> Path:
+ """
+ Args:
+ trainFraction: the training fraction (as defined in the project configuration)
+ for which to get the evaluation folder
+ shuffle: the index of the shuffle for which to get the evaluation folder
+ cfg: the project configuration
+ engine: The engine for which we want the model folder. Defaults to None,
+ which automatically gets the engine for the shuffle from the training
+ dataset metadata file.
+ modelprefix: The name of the folder
+
+ Returns:
+ the relative path from the project root to the folder containing the model files
+ for a shuffle (configuration files, snapshots, training logs, ...)
+ """
+ if engine is None:
+ from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
+
+ engine = get_shuffle_engine(
+ cfg=cfg,
+ trainingsetindex=cfg["TrainingFraction"].index(trainFraction),
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
Task = cfg["Task"]
date = cfg["date"]
iterate = "iteration-" + str(cfg["iteration"])
if "eval_prefix" in cfg:
eval_prefix = cfg["eval_prefix"]
else:
- eval_prefix = "evaluation-results"
+ eval_prefix = engine.results_folder_name
return Path(
modelprefix,
eval_prefix,
iterate,
- Task
- + date
- + "-trainset"
- + str(int(trainFraction * 100))
- + "shuffle"
- + str(shuffle),
+ Task + date + "-trainset" + str(int(trainFraction * 100)) + "shuffle" + str(shuffle),
)
+def get_snapshots_from_folder(train_folder: Path) -> list[str]:
+ """Returns an ordered list of existing snapshot names in the train folder, sorted by
+ increasing training iterations.
+
+ Raises:
+ FileNotFoundError: if no snapshot_names are found in the train_folder.
+ """
+ snapshot_names = [file.stem for file in train_folder.iterdir() if "index" in file.name]
+
+ if len(snapshot_names) == 0:
+ raise FileNotFoundError(
+ f"No snapshots were found in {train_folder}! Please ensure the network has "
+ f"been trained and verify the iteration, shuffle and trainFraction are "
+ f"correct."
+ )
+
+ # sort in ascending order of iteration number
+ return sorted(snapshot_names, key=lambda name: int(name.split("-")[1]))
+
+
def get_deeplabcut_path():
- """Get path of where deeplabcut is currently running"""
+ """Get path of where deeplabcut is currently running."""
import importlib.util
return os.path.split(importlib.util.find_spec("deeplabcut").origin)[0]
def intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts):
- """Returns all body parts when comparisonbodyparts=='all', otherwise all bpts that are in the intersection of comparisonbodyparts and the actual bodyparts"""
+ """Returns all body parts when comparisonbodyparts=='all', otherwise all bpts that
+ are in the intersection of comparisonbodyparts and the actual bodyparts."""
# if "MULTI!" in allbpts:
if cfg["multianimalproject"]:
allbpts = cfg["multianimalbodyparts"] + cfg["uniquebodyparts"]
@@ -564,44 +669,65 @@ def form_data_containers(df, bodyparts):
def get_scorer_name(
- cfg, shuffle, trainFraction, trainingsiterations="unknown", modelprefix=""
+ cfg: dict,
+ shuffle: int,
+ trainFraction: float,
+ trainingsiterations: str | int = "unknown",
+ modelprefix: str = "",
+ engine: Engine | None = None,
+ **kwargs,
):
"""Extract the scorer/network name for a particular shuffle, training fraction, etc.
+ If the engine is not specified, determines which to use from
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns tuple of DLCscorer, DLCscorerlegacy (old naming convention)
"""
+ if engine is None:
+ from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
+
+ engine = get_shuffle_engine(
+ cfg=cfg,
+ trainingsetindex=cfg["TrainingFraction"].index(trainFraction),
+ shuffle=shuffle,
+ modelprefix=modelprefix,
+ )
+
+ if engine == Engine.PYTORCH:
+ from deeplabcut.pose_estimation_pytorch.apis.utils import get_scorer_name
+
+ snapshot_index = kwargs.get("snapshot_index", None)
+ detector_snapshot_index = kwargs.get("detector_snapshot_index", None)
+ dlc3_scorer = get_scorer_name(
+ cfg=cfg,
+ shuffle=shuffle,
+ train_fraction=trainFraction,
+ snapshot_index=snapshot_index,
+ detector_index=detector_snapshot_index,
+ modelprefix=modelprefix,
+ )
+ return dlc3_scorer, dlc3_scorer
Task = cfg["Task"]
date = cfg["date"]
if trainingsiterations == "unknown":
- snapshotindex = cfg["snapshotindex"]
- if cfg["snapshotindex"] == "all":
- print(
- "Changing snapshotindext to the last one -- plotting, videomaking, etc. should not be performed for all indices. For more selectivity enter the ordinal number of the snapshot you want (ie. 4 for the fifth) in the config file."
- )
- snapshotindex = -1
- else:
- snapshotindex = cfg["snapshotindex"]
-
- modelfolder = os.path.join(
- cfg["project_path"],
- str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
- "train",
- )
- Snapshots = np.array(
- [fn.split(".")[0] for fn in os.listdir(modelfolder) if "index" in fn]
- )
- increasing_indices = np.argsort([int(m.split("-")[1]) for m in Snapshots])
- Snapshots = Snapshots[increasing_indices]
- SNP = Snapshots[snapshotindex]
- trainingsiterations = (SNP.split(os.sep)[-1]).split("-")[-1]
+ snapshotindex = get_snapshot_index_for_scorer("snapshotindex", cfg["snapshotindex"])
+ model_folder = get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)
+ train_folder = Path(cfg["project_path"]) / model_folder / "train"
+ snapshot_names = get_snapshots_from_folder(train_folder)
+ snapshot_name = snapshot_names[snapshotindex]
+ trainingsiterations = (snapshot_name.split(os.sep)[-1]).split("-")[-1]
dlc_cfg = read_plainconfig(
os.path.join(
cfg["project_path"],
- str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
+ str(get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)),
"train",
- "pose_cfg.yaml",
+ engine.pose_cfg_name,
)
)
# ABBREVIATE NETWORK NAMES -- esp. for mobilenet!
@@ -614,29 +740,24 @@ def get_scorer_name(
netname = "mobnet_" + str(int(float(dlc_cfg["net_type"].split("_")[-1]) * 100))
elif "efficientnet" in dlc_cfg["net_type"]:
netname = "effnet_" + dlc_cfg["net_type"].split("-")[1]
+ else:
+ raise ValueError(f"Failed to abbreviate network name: {dlc_cfg['net_type']}")
- scorer = (
- "DLC_"
- + netname
- + "_"
- + Task
- + str(date)
- + "shuffle"
- + str(shuffle)
- + "_"
- + str(trainingsiterations)
- )
- # legacy scorername until DLC 2.1. (cfg['resnet'] is deprecated / which is why we get the resnet_xyz name from dlc_cfg!
- # scorer_legacy = 'DeepCut' + "_resnet" + str(cfg['resnet']) + "_" + Task + str(date) + 'shuffle' + str(shuffle) + '_' + str(trainingsiterations)
+ scorer = "DLC_" + netname + "_" + Task + str(date) + "shuffle" + str(shuffle) + "_" + str(trainingsiterations)
+ # legacy scorername until DLC 2.1. (cfg['resnet'] is deprecated / which is why we get the resnet_xyz name from
+ # dlc_cfg!
+ # scorer_legacy = 'DeepCut' + "_resnet" + str(cfg['resnet']) + "_" + Task + str(date) + 'shuffle' + str(shuffle) +
+ # '_' + str(trainingsiterations)
scorer_legacy = scorer.replace("DLC", "DeepCut")
return scorer, scorer_legacy
-def check_if_post_processing(
- folder, vname, DLCscorer, DLCscorerlegacy, suffix="filtered"
-):
- """Checks if filtered/bone lengths were already calculated. If not, figures
- out if data was already analyzed (either with legacy scorer name or new one!)"""
+def check_if_post_processing(folder, vname, DLCscorer, DLCscorerlegacy, suffix="filtered"):
+ """Checks if filtered/bone lengths were already calculated.
+
+ If not, figures out if data was already analyzed (either with legacy scorer name or
+ new one!)
+ """
outdataname = os.path.join(folder, vname + DLCscorer + suffix + ".h5")
sourcedataname = os.path.join(folder, vname + DLCscorer + ".h5")
if os.path.isfile(outdataname): # was data already processed?
@@ -708,32 +829,45 @@ def check_if_not_evaluated(folder, DLCscorer, DLCscorerlegacy, snapshot):
return True, dataname, DLCscorer
-def find_video_metadata(folder, videoname, scorer):
- """For backward compatibility, let us search the substring 'meta'"""
+def find_video_full_data(folder, videoname, scorer):
scorer_legacy = scorer.replace("DLC", "DeepCut")
- meta = [
- file
- for file in grab_files_in_folder(folder, "pickle")
- if "meta" in file
- and (
- file.startswith(videoname + scorer)
- or file.startswith(videoname + scorer_legacy)
- )
- ]
- if not len(meta):
- raise FileNotFoundError(
- f"No metadata found in {folder} "
- f"for video {videoname} and scorer {scorer}."
- )
- return os.path.join(folder, meta[0])
+ full_files = filter_files_by_patterns(
+ folder=folder,
+ start_patterns={videoname + scorer, videoname + scorer_legacy},
+ contain_patterns={"full"},
+ end_patterns={"pickle"},
+ )
+ if not full_files:
+ raise FileNotFoundError(f"No full data found in {folder} for video {videoname} and scorer {scorer}.")
+ return full_files[0]
+
+
+def find_video_metadata(folder, videoname: str, scorer: str):
+ """For backward compatibility, let us search the substring 'meta'."""
+
+ scorer_legacy = scorer.replace("DLC", "DeepCut")
+ meta_files = filter_files_by_patterns(
+ folder=folder,
+ start_patterns={videoname + scorer, videoname + scorer_legacy},
+ contain_patterns={"meta"},
+ end_patterns={"pickle"},
+ )
+ if not meta_files:
+ raise FileNotFoundError(f"No metadata found in {folder} for video {videoname} and scorer {scorer}.")
+ return meta_files[0]
def load_video_metadata(folder, videoname, scorer):
return read_pickle(find_video_metadata(folder, videoname, scorer))
-def find_analyzed_data(folder, videoname, scorer, filtered=False, track_method=""):
+def load_video_full_data(folder, videoname, scorer):
+ return read_pickle(find_video_full_data(folder, videoname, scorer))
+
+
+def find_analyzed_data(folder, videoname: str, scorer: str, filtered=False, track_method=""):
"""Find potential data files from the hints given to the function."""
+
scorer_legacy = scorer.replace("DLC", "DeepCut")
suffix = "_filtered" if filtered else ""
tracker = TRACK_METHODS.get(track_method, "")
@@ -741,9 +875,7 @@ def find_analyzed_data(folder, videoname, scorer, filtered=False, track_method="
candidates = []
for file in grab_files_in_folder(folder, "h5"):
stem = Path(file).stem.replace("_filtered", "")
- starts_by_scorer = file.startswith(videoname + scorer) or file.startswith(
- videoname + scorer_legacy
- )
+ starts_by_scorer = file.startswith(videoname + scorer) or file.startswith(videoname + scorer_legacy)
if tracker:
matches_tracker = stem.endswith(tracker)
else:
@@ -753,15 +885,14 @@ def find_analyzed_data(folder, videoname, scorer, filtered=False, track_method="
starts_by_scorer,
"skeleton" not in file,
matches_tracker,
- (filtered and "filtered" in file)
- or (not filtered and "filtered" not in file),
+ (filtered and "filtered" in file) or (not filtered and "filtered" not in file),
)
):
candidates.append(file)
if not len(candidates):
msg = (
- f'No {"un" if not filtered else ""}filtered data file found in {folder} '
+ f"No {'un' if not filtered else ''}filtered data file found in {folder} "
f"for video {videoname} and scorer {scorer}"
)
if track_method:
@@ -771,19 +902,14 @@ def find_analyzed_data(folder, videoname, scorer, filtered=False, track_method="
n_candidates = len(candidates)
if n_candidates > 1: # This should not be happening anyway...
- print(
- f"{n_candidates} possible data files were found: {candidates}.\n"
- f"Picking the first by default..."
- )
- filepath = os.path.join(folder, candidates[0])
+ print(f"{n_candidates} possible data files were found: {candidates}.\nPicking the first by default...")
+ filepath = str(Path(folder) / candidates[0])
scorer = scorer if scorer in filepath else scorer_legacy
return filepath, scorer, suffix
def load_analyzed_data(folder, videoname, scorer, filtered=False, track_method=""):
- filepath, scorer, suffix = find_analyzed_data(
- folder, videoname, scorer, filtered, track_method
- )
+ filepath, scorer, suffix = find_analyzed_data(folder, videoname, scorer, filtered, track_method)
df = pd.read_hdf(filepath)
return df, filepath, scorer, suffix
@@ -803,8 +929,7 @@ def load_detection_data(video, scorer, track_method):
filepath = os.path.splitext(video)[0] + scorer + f"_{tracker}.pickle"
if not os.path.isfile(filepath):
raise FileNotFoundError(
- f"No detection data found in {folder} for video {videoname}, "
- f"scorer {scorer}, and tracker {track_method}"
+ f"No detection data found in {folder} for video {videoname}, scorer {scorer}, and tracker {track_method}"
)
return read_pickle(filepath)
@@ -834,6 +959,18 @@ def find_next_unlabeled_folder(config_path, verbose=False):
return next_folder
+def get_snapshot_index_for_scorer(name: str, index: int | str) -> int:
+ if index == "all":
+ print(
+ f"Changing {name} to the last one -- plotting, videomaking, etc. should "
+ "not be performed for all indices. For more selectivity enter the ordinal "
+ "number of the snapshot you want (ie. 4 for the fifth) in the config file."
+ )
+ return -1
+
+ return index
+
+
# aliases for backwards-compatibility.
SaveData = save_data
SaveMetadata = save_metadata
@@ -841,10 +978,10 @@ def find_next_unlabeled_folder(config_path, verbose=False):
GetVideoList = get_video_list
GetTrainingSetFolder = get_training_set_folder
GetDataandMetaDataFilenames = get_data_and_metadata_filenames
-IntersectionofBodyPartsandOnesGivenbyUser = (
- intersection_of_body_parts_and_ones_given_by_user
-)
+IntersectionofBodyPartsandOnesGivenbyUser = intersection_of_body_parts_and_ones_given_by_user
GetScorerName = get_scorer_name
CheckifPostProcessing = check_if_post_processing
CheckifNotAnalyzed = check_if_not_analyzed
CheckifNotEvaluated = check_if_not_evaluated
+GetEvaluationFolder = get_evaluation_folder
+GetModelFolder = get_model_folder
diff --git a/deeplabcut/utils/auxiliaryfunctions_3d.py b/deeplabcut/utils/auxiliaryfunctions_3d.py
index 22d31b4555..5d2c223004 100644
--- a/deeplabcut/utils/auxiliaryfunctions_3d.py
+++ b/deeplabcut/utils/auxiliaryfunctions_3d.py
@@ -30,15 +30,13 @@
def Foldernames3Dproject(cfg_3d):
- """Definitions of subfolders in 3D projects"""
+ """Definitions of subfolders in 3D projects."""
img_path = os.path.join(cfg_3d["project_path"], "calibration_images")
path_corners = os.path.join(cfg_3d["project_path"], "corners")
path_camera_matrix = os.path.join(cfg_3d["project_path"], "camera_matrix")
path_undistort = os.path.join(cfg_3d["project_path"], "undistortion")
- path_removed_images = os.path.join(
- cfg_3d["project_path"], "removed_calibration_images"
- )
+ path_removed_images = os.path.join(cfg_3d["project_path"], "removed_calibration_images")
return (
img_path,
@@ -76,9 +74,7 @@ def create_empty_df(dataframe, scorer, flag):
def compute_triangulation_calibration_images(
stereo_matrix, projectedPoints1, projectedPoints2, path_undistort, cfg_3d, plot=True
):
- """
- Performs triangulation of the calibration images.
- """
+ """Performs triangulation of the calibration images."""
triangulate = []
P1 = stereo_matrix["P1"]
P2 = stereo_matrix["P2"]
@@ -93,7 +89,7 @@ def compute_triangulation_calibration_images(
triangulate = np.asanyarray(triangulate)
# Plotting
- if plot == True:
+ if plot:
col = colormap(np.linspace(0, 1, triangulate.shape[0]))
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")
@@ -115,12 +111,15 @@ def triangulatePoints(P1, P2, x1, x2):
return X / X[3]
+# TODO: @deruyter92 2026-05-20: the function signature could be updated to match
+# other API (i.e. videotype: str -> video_extensions: str | Sequence[str] | None)
+# see `collect_video_paths` for reference.
def get_camerawise_videos(path, cam_names, videotype):
- """
- This function returns the list of videos corresponding to the camera names specified in the cam_names.
- e.g. if cam_names = ['camera-1','camera-2']
+ """This function returns the list of videos corresponding to the camera names
+ specified in the cam_names. e.g. if cam_names = ['camera-1','camera-2']
- then it will return [['somename-camera-1-othername.avi', 'somename-camera-2-othername.avi']]
+ then it will return [['somename-camera-1-othername.avi', 'somename-
+ camera-2-othername.avi']]
"""
import glob
from pathlib import Path
@@ -128,10 +127,7 @@ def get_camerawise_videos(path, cam_names, videotype):
vid = []
# Find videos only specific to the cam names
- videos = [
- glob.glob(os.path.join(path, str("*" + cam_names[i] + "*" + videotype)))
- for i in range(len(cam_names))
- ]
+ videos = [glob.glob(os.path.join(path, str("*" + cam_names[i] + "*" + videotype))) for i in range(len(cam_names))]
videos = [y for x in videos for y in x]
# Exclude the labeled video files
@@ -139,14 +135,11 @@ def get_camerawise_videos(path, cam_names, videotype):
file_to_exclude = str("labeled" + videotype)
else:
file_to_exclude = str("labeled." + videotype)
- videos = [v for v in videos if os.path.isfile(v) and not (file_to_exclude in v)]
+ videos = [v for v in videos if os.path.isfile(v) and file_to_exclude not in v]
video_list = []
cam = cam_names[0] # camera1
vid.append(
- [
- name
- for name in glob.glob(os.path.join(path, str("*" + cam + "*" + videotype)))
- ]
+ [name for name in glob.glob(os.path.join(path, str("*" + cam + "*" + videotype)))]
) # all videos with cam
# print("here is what I found",vid)
for k in range(len(vid[0])):
@@ -163,24 +156,16 @@ def get_camerawise_videos(path, cam_names, videotype):
if suf == "":
putativecam2name = os.path.join(path, pref + cam_names[1] + ending)
else:
- putativecam2name = os.path.join(
- path, pref + cam_names[1] + suf + ending
- )
+ putativecam2name = os.path.join(path, pref + cam_names[1] + suf + ending)
# print([os.path.join(path,pref+cam+suf+ending),putativecam2name])
if os.path.isfile(putativecam2name):
# found a pair!!!
- video_list.append(
- [os.path.join(path, pref + cam + suf + ending), putativecam2name]
- )
+ video_list.append([os.path.join(path, pref + cam + suf + ending), putativecam2name])
return video_list
-def Get_list_of_triangulated_and_videoFiles(
- filepath, videotype, scorer_3d, cam_names, videofolder
-):
- """
- Returns the list of triangulated h5 and the corresponding video files.
- """
+def Get_list_of_triangulated_and_videoFiles(filepath, videotype, scorer_3d, cam_names, videofolder):
+ """Returns the list of triangulated h5 and the corresponding video files."""
prefix = []
suffix = []
@@ -189,26 +174,18 @@ def Get_list_of_triangulated_and_videoFiles(
# Checks if filepath is a directory
if [os.path.isdir(i) for i in filepath] == [True]:
- """
- Analyzes all the videos in the directory.
- """
+ """Analyzes all the videos in the directory."""
print("Analyzing all the videos in the directory")
videofolder = filepath[0]
cwd = os.getcwd()
os.chdir(videofolder)
- triangulated_file_list = [
- fn for fn in os.listdir(os.curdir) if (string_to_search in fn)
- ]
+ triangulated_file_list = [fn for fn in os.listdir(os.curdir) if (string_to_search in fn)]
video_list = get_camerawise_videos(videofolder, cam_names, videotype)
os.chdir(cwd)
triangulated_folder = videofolder
else:
- triangulated_file_list = [
- str(Path(fn).name) for fn in filepath if (string_to_search in fn)
- ]
- triangulated_folder = [
- str(Path(fn).parents[0]) for fn in filepath if (string_to_search in fn)
- ]
+ triangulated_file_list = [str(Path(fn).name) for fn in filepath if (string_to_search in fn)]
+ triangulated_folder = [str(Path(fn).parents[0]) for fn in filepath if (string_to_search in fn)]
triangulated_folder = triangulated_folder[0]
if videofolder is None:
@@ -223,7 +200,8 @@ def Get_list_of_triangulated_and_videoFiles(
if filename[i][0] == "_" or filename[i][0] == "-":
filename[i] = filename[i][1:]
- # Get the suffix and prefix of the video filenames so that they can be used for matching the triangulated file names.
+ # Get the suffix and prefix of the video filenames so that they can be
+ # used for matching the triangulated file names.
for i in range(len(video_list)):
pre = [
str(Path(video_list[i][0]).stem).split(cam_names[0])[0],
@@ -245,7 +223,8 @@ def Get_list_of_triangulated_and_videoFiles(
suffix.append(suf)
prefix.append(pre)
- # Match the suffix and prefix with the triangulated file name and return the list with triangulated file and corresponding video files.
+ # Match the suffix and prefix with the triangulated file name and return
+ # the list with triangulated file and corresponding video files.
for k in range(len(filename)):
for j in range(len(prefix)):
if (prefix[j][0] in filename[k] and prefix[j][1] in filename[k]) and (
@@ -258,9 +237,7 @@ def Get_list_of_triangulated_and_videoFiles(
)
)
vfiles = get_camerawise_videos(videofolder, cam_names, videotype)
- vfiles = [
- z for z in vfiles if prefix[j][0] in z[0] and suffix[j][0] in z[1]
- ][0]
+ vfiles = [z for z in vfiles if prefix[j][0] in z[0] and suffix[j][0] in z[1]][0]
file_list.append(triangulated_file + vfiles)
return file_list
@@ -297,9 +274,8 @@ def _reconstruct_tracks_as_tracklets(df):
def _associate_paired_view_tracks(tracklets1, tracklets2, F):
- """
- Computes the optimal matching between tracks in two cameras
- using the xFx'=0 epipolar constraint equation.
+ """Computes the optimal matching between tracks in two cameras using the xFx'=0
+ epipolar constraint equation.
Parameters:
-----------
@@ -322,24 +298,27 @@ def _associate_paired_view_tracks(tracklets1, tracklets2, F):
_t1 = np.c_[_t1, np.ones((*_t1.shape[:2], 1))]
_t2 = np.c_[_t2, np.ones((*_t2.shape[:2], 1))]
- # cost for any point in time of t1 being the same
- # any point in time of t2
- cost = np.abs(np.nansum(np.matmul(_t1, F) * _t2, axis=2))
+ try:
+ # cost for any point in time of t1 being the same
+ # any point in time of t2
+ cost = np.abs(np.nansum(np.matmul(_t1, F) * _t2, axis=2))
+
+ # Get average cost of the entire track
+ cost = cost.mean()
+ except Exception:
+ # typically when dim 2 differs, with uniquebodyparts
+ cost = 100000.0
- # Get average cost of the entire track
- cost = cost.mean()
costs[i, j] = cost
match_inds = linear_sum_assignment(np.abs(costs))
- voting = dict(zip(*match_inds))
+ voting = dict(zip(*match_inds, strict=False))
return costs, voting
def cross_view_match_dataframes(df1, df2, F):
- """
- Computes the costs and matched voting for tracks between
- a camera pair
+ """Computes the costs and matched voting for tracks between a camera pair.
df: Data read from .h5 track file
F: fundamental matrix from OpenCV
diff --git a/deeplabcut/utils/conversioncode.py b/deeplabcut/utils/conversioncode.py
index 4263015441..6ff756b245 100644
--- a/deeplabcut/utils/conversioncode.py
+++ b/deeplabcut/utils/conversioncode.py
@@ -8,21 +8,17 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
-
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
+
import os
-import pandas as pd
-from deeplabcut.utils import auxiliaryfunctions
from itertools import islice
from pathlib import Path
+import numpy as np
+import pandas as pd
+from tqdm import tqdm
+
+import deeplabcut as dlc
+from deeplabcut.utils import auxiliaryfunctions
SUPPORTED_FILETYPES = "csv", "nwb"
@@ -30,17 +26,20 @@
def convertcsv2h5(config, userfeedback=True, scorer=None):
"""
Convert (image) annotation files in folder labeled-data from csv to h5.
- This function allows the user to manually edit the csv (e.g. to correct the scorer name and then convert it into hdf format).
+ This function allows the user to manually edit the csv
+ (e.g. to correct the scorer name and then convert it into hdf format).
WARNING: conversion might corrupt the data.
config : string
Full path of the config.yaml file as a string.
userfeedback: bool, optional
- If true the user will be asked specifically for each folder in labeled-data if the containing csv shall be converted to hdf format.
+ If true the user will be asked specifically
+ for each folder in labeled-data if the containing csv shall be converted to hdf format.
scorer: string, optional
- If a string is given, then the scorer/annotator in all csv and hdf files that are changed, will be overwritten with this name.
+ If a string is given, then the scorer/annotator
+ in all csv and hdf files that are changed, will be overwritten with this name.
Examples
--------
@@ -48,7 +47,8 @@ def convertcsv2h5(config, userfeedback=True, scorer=None):
>>> deeplabcut.convertcsv2h5('/analysis/project/reaching-task/config.yaml')
--------
- Convert csv annotation files for reaching-task project into hdf while changing the scorer/annotator in all annotation files to Albert!
+ Convert csv annotation files for reaching-task project into hdf
+ while changing the scorer/annotator in all annotation files to Albert!
>>> deeplabcut.convertcsv2h5('/analysis/project/reaching-task/config.yaml',scorer='Albert')
--------
"""
@@ -68,9 +68,7 @@ def convertcsv2h5(config, userfeedback=True, scorer=None):
askuser = "yes"
if askuser in ("y", "yes", "Ja", "ha", "oui"): # multilanguage support :)
- fn = os.path.join(
- str(folder), "CollectedData_" + cfg["scorer"] + ".csv"
- )
+ fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".csv")
# Determine whether the data are single- or multi-animal without loading into memory
# simply by checking whether 'individuals' is in the second line of the CSV.
with open(fn) as datafile:
@@ -92,12 +90,139 @@ def convertcsv2h5(config, userfeedback=True, scorer=None):
print("Attention:", folder, "does not appear to have labeled data!")
-def analyze_videos_converth5_to_csv(video_folder, videotype=".mp4", listofvideos=False):
+def adapt_labeled_data_to_new_project(config_path, remove_old_bodyparts=False, other_scorer=False, userfeedback=False):
+ """Given the config.yaml file, this function will convert the labels of an ancient
+ project to a new project. For this, the labeled data must be in the project folder,
+ under the labeled-data folder and with the same configuration as all deeplabcut
+ projects.
+
+ Parameters
+ ----------
+ config_path : str
+ The path to the config.yaml file.
+ remove_old_bodyparts : bool (default = False)
+ If True, the old bodyparts that are not in the new project will be removed from the dataframe.
+ other_scorer : bool (default = False)
+ If True, the labels will be converted to the new scorer.
+ userfeedback : bool (default = True)
+ If true the user will be asked specifically
+ for each folder in labeled-data if the containing csv
+ shall be converted to hdf format.
"""
- By default the output poses (when running analyze_videos) are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position \n
- in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) \n
- in the same directory, where the video is stored. This functions converts hdf (h5) files to the comma-separated values format (.csv),
- which in turn can be imported in many programs, such as MATLAB, R, Prism, etc.
+
+ # Load the config file
+ cfg = dlc.auxiliaryfunctions.read_config(config_path)
+
+ # Get the Project path
+ project_path = cfg["project_path"]
+
+ # Get the bodyparts
+ bodyparts = cfg["multianimalbodyparts"]
+ print("New Bodyparts:", bodyparts)
+
+ # Iterate over each labeled data video
+
+ # Use tqdm for a progress bar
+ for video in tqdm.tqdm(cfg["video_sets"]):
+ print("Video:", video)
+
+ video_name = video.split("\\")[-1]
+ # discard the file extension
+ video_name = video_name.split(".")[0]
+ # Load the csv file
+ label_path = os.path.join(project_path, "labeled-data", video_name)
+ csv_files = [file for file in os.listdir(label_path) if file.endswith(".csv")]
+ if not csv_files:
+ print("No csv file in the folder:", label_path)
+ else:
+ csv_path = os.path.join(label_path, csv_files[0])
+ df = pd.read_csv(csv_path, header=None)
+
+ # get the scorer
+ if other_scorer:
+ scorer = cfg["scorer"]
+ # Change the scorer in the dataframe
+ df.iloc[0, 3:] = pd.Series([scorer] * len(df.columns[3:]))
+
+ else:
+ scorer = df.iloc[0, 3]
+
+ # Get the individuals
+ individuals = np.unique(df.iloc[1, 3:])
+
+ # Get the old bodyparts
+ old_bodyparts = np.unique(df.iloc[2, 3:])
+ print("Old bodyparts:", old_bodyparts)
+
+ # Get the unmber of old bodyparts
+ num_of_old_bodyparts = len(old_bodyparts)
+
+ # Bodyparts to add
+ print("Bodyparts to add:", set(bodyparts) - set(old_bodyparts))
+
+ # If a bodypart is missing, add it to the dataframe
+ for index, bodypart in enumerate(bodyparts):
+ if bodypart not in old_bodyparts:
+ num_of_old_bodyparts += 1
+ for i, individual in enumerate(individuals):
+ # create the columns for the bodypart, concatenate, the individual, the bodypart, and nan values
+ x_column = pd.concat(
+ [
+ pd.Series(scorer),
+ pd.Series(individual),
+ pd.Series(bodypart),
+ pd.Series("x"),
+ pd.Series(np.nan, index=df.index),
+ ],
+ axis=0,
+ ignore_index=True,
+ )
+ y_column = pd.concat(
+ [
+ pd.Series(scorer),
+ pd.Series(individual),
+ pd.Series(bodypart),
+ pd.Series("y"),
+ pd.Series(np.nan, index=df.index),
+ ],
+ axis=0,
+ ignore_index=True,
+ )
+ # Insert the columns in the dataframe
+ df.insert(
+ i * 2 * num_of_old_bodyparts + index * 2 + 3,
+ "insert_" + bodypart + "_x" + individual,
+ x_column,
+ )
+ df.insert(
+ i * 2 * num_of_old_bodyparts + index * 2 + 4,
+ "insert" + bodypart + "_y" + individual,
+ y_column,
+ )
+
+ # If the old bodyparts are not in the new project, remove them
+ if remove_old_bodyparts:
+ for bodypart in old_bodyparts:
+ if bodypart not in bodyparts:
+ df = df.drop(df.columns[df.iloc[2, :] == bodypart], axis=1)
+
+ # Save the dataframe
+ df.to_csv(csv_path, index=False, header=False)
+
+ # Create/Update the h5 file
+ convertcsv2h5(config_path, userfeedback=userfeedback)
+
+
+# TODO: @deruyter92 2026-05-20: this function still uses grab_files_in_folder instead
+# of collect_video_paths and videotype instead of video_extensions.
+def analyze_videos_converth5_to_csv(video_folder, videotype=".mp4", listofvideos=False):
+ """By default the output poses (when running analyze_videos) are stored as
+ MultiIndex Pandas Array, which contains the name of the network, body part name, (x,
+ y) label position \n in pixels, and the likelihood for each frame per body part.
+ These arrays are stored in an efficient Hierarchical Data Format (HDF) \n in the
+ same directory, where the video is stored. This functions converts hdf (h5) files to
+ the comma-separated values format (.csv), which in turn can be imported in many
+ programs, such as MATLAB, R, Prism, etc.
Parameters
----------
@@ -111,40 +236,33 @@ def analyze_videos_converth5_to_csv(video_folder, videotype=".mp4", listofvideos
Examples
--------
- Converts all pose-output files belonging to mp4 videos in the folder '/media/alex/experimentaldata/cheetahvideos' to csv files.
+ Converts all pose-output files belonging to mp4 videos
+ in the folder '/media/alex/experimentaldata/cheetahvideos' to csv files.
deeplabcut.analyze_videos_converth5_to_csv('/media/alex/experimentaldata/cheetahvideos','.mp4')
-
"""
if listofvideos: # can also be called with a list of videos (from GUI)
videos = video_folder # GUI gives a list of videos
if len(videos) > 0:
- h5_files = list(
- auxiliaryfunctions.grab_files_in_folder(
- Path(videos[0]).parent, "h5", relative=False
- )
- )
+ h5_files = list(auxiliaryfunctions.grab_files_in_folder(Path(videos[0]).parent, "h5", relative=False))
else:
h5_files = []
else:
- h5_files = list(
- auxiliaryfunctions.grab_files_in_folder(video_folder, "h5", relative=False)
- )
- videos = auxiliaryfunctions.grab_files_in_folder(
- video_folder, videotype, relative=False
- )
+ h5_files = list(auxiliaryfunctions.grab_files_in_folder(video_folder, "h5", relative=False))
+ videos = auxiliaryfunctions.grab_files_in_folder(video_folder, videotype, relative=False)
_convert_h5_files_to("csv", None, h5_files, videos)
+# TODO: @deruyter92 2026-05-20: this function still uses grab_files_in_folder instead
+# of collect_video_paths and videotype instead of video_extensions.
def analyze_videos_converth5_to_nwb(
config,
video_folder,
videotype=".mp4",
listofvideos=False,
):
- """
- Convert all h5 output data files in `video_folder` to NWB format.
+ """Convert all h5 output data files in `video_folder` to NWB format.
Parameters
----------
@@ -160,27 +278,19 @@ def analyze_videos_converth5_to_nwb(
Examples
--------
- Converts all pose-output files belonging to mp4 videos in the folder '/media/alex/experimentaldata/cheetahvideos' to csv files.
+ Converts all pose-output files belonging to mp4 videos in the folder
+ '/media/alex/experimentaldata/cheetahvideos' to csv files.
deeplabcut.analyze_videos_converth5_to_csv('/media/alex/experimentaldata/cheetahvideos','.mp4')
-
"""
if listofvideos: # can also be called with a list of videos (from GUI)
videos = video_folder # GUI gives a list of videos
if len(videos) > 0:
- h5_files = list(
- auxiliaryfunctions.grab_files_in_folder(
- Path(videos[0]).parent, "h5", relative=False
- )
- )
+ h5_files = list(auxiliaryfunctions.grab_files_in_folder(Path(videos[0]).parent, "h5", relative=False))
else:
h5_files = []
else:
- h5_files = list(
- auxiliaryfunctions.grab_files_in_folder(video_folder, "h5", relative=False)
- )
- videos = auxiliaryfunctions.grab_files_in_folder(
- video_folder, videotype, relative=False
- )
+ h5_files = list(auxiliaryfunctions.grab_files_in_folder(video_folder, "h5", relative=False))
+ videos = auxiliaryfunctions.grab_files_in_folder(video_folder, videotype, relative=False)
_convert_h5_files_to("nwb", config, h5_files, videos)
@@ -196,10 +306,8 @@ def _convert_h5_files_to(filetype, config, h5_files, videos):
if filetype == "nwb":
try:
from dlc2nwb.utils import convert_h5_to_nwb
- except ImportError:
- raise ImportError(
- "The package `dlc2nwb` is missing. Please run `pip install dlc2nwb`."
- )
+ except ImportError as e:
+ raise ImportError("The package `dlc2nwb` is missing. Please run `pip install dlc2nwb`.") from e
for video in videos:
if "_labeled" in video:
@@ -221,9 +329,11 @@ def _convert_h5_files_to(filetype, config, h5_files, videos):
def merge_windowsannotationdataONlinuxsystem(cfg):
- """If a project was created on Windows (and labeled there,) but ran on unix then the data folders
- corresponding in the keys in cfg['video_sets'] are not found. This function gets them directly by
- looping over all folders in labeled-data"""
+ """If a project was created on Windows (and labeled there,) but ran on unix then the
+ data folders corresponding in the keys in cfg['video_sets'] are not found.
+
+ This function gets them directly by looping over all folders in labeled-data
+ """
AnnotationData = []
data_path = Path(cfg["project_path"], "labeled-data")
diff --git a/deeplabcut/utils/deprecation.py b/deeplabcut/utils/deprecation.py
new file mode 100644
index 0000000000..ed0bd0c7d1
--- /dev/null
+++ b/deeplabcut/utils/deprecation.py
@@ -0,0 +1,203 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import functools
+import inspect
+import warnings
+from collections.abc import Callable
+from typing import Literal, ParamSpec, TypeVar
+
+from packaging.version import InvalidVersion, Version
+from pydantic import BaseModel, ConfigDict, field_validator, model_validator
+
+P = ParamSpec("P")
+R = TypeVar("R")
+
+
+class DLCDeprecationWarning(DeprecationWarning):
+ """Project-specific deprecation warning. Helps with filtering."""
+
+
+class DeprecationInfo(BaseModel):
+ model_config = ConfigDict(
+ frozen=True,
+ arbitrary_types_allowed=True,
+ )
+
+ kind: Literal["callable", "parameter"]
+ target: str
+ replacement: str | None = None
+
+ since: Version | None = None
+ removed_in: Version | None = None
+
+ old_parameter: str | None = None
+ new_parameter: str | None = None
+
+ @field_validator("since", "removed_in", mode="before")
+ @classmethod
+ def _parse_version(cls, value):
+ if value is None or isinstance(value, Version):
+ return value
+ try:
+ return Version(value)
+ except InvalidVersion as e:
+ raise ValueError(f"Invalid version: {value!r}") from e
+
+ @model_validator(mode="after")
+ def _validate_version_order(self) -> DeprecationInfo:
+ if self.since and self.removed_in and self.removed_in <= self.since:
+ raise ValueError(f"'removed_in' ({self.removed_in}) must be greater than 'since' ({self.since}).")
+ return self
+
+ def format_message(self) -> str:
+ if self.kind == "callable":
+ parts = [f"{self.target} is deprecated"]
+ if self.since:
+ parts[0] += f" since {self.since}"
+ if self.replacement:
+ parts.append(f"Use {self.replacement} instead.")
+ if self.removed_in:
+ parts.append(f"It will be removed in {self.removed_in}.")
+ return " ".join(parts)
+
+ if self.kind == "parameter":
+ return (
+ f"Parameter '{self.old_parameter}' of {self.target} is deprecated"
+ + (f" since {self.since}" if self.since else "")
+ + f"; use '{self.new_parameter}' instead."
+ )
+
+ raise ValueError(f"Unknown deprecation kind: {self.kind}")
+
+
+def deprecated(
+ *,
+ replacement: str | None = None,
+ since: str | None = None,
+ removed_in: str | None = None,
+) -> Callable[[Callable[P, R]], Callable[P, R]]:
+ """Mark a function as deprecated.
+
+ Args:
+ replacement: Fully-qualified name of the replacement callable, e.g.
+ ``"deeplabcut.utils.auxfun_videos.list_videos_in_folder"``.
+ since: Version in which the function was deprecated.
+ removed_in: Version in which the function will be removed.
+ """
+
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
+ info = DeprecationInfo(
+ kind="callable",
+ target=fn.__qualname__,
+ replacement=replacement,
+ since=since,
+ removed_in=removed_in,
+ )
+ message = info.format_message()
+
+ @functools.wraps(fn)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ warnings.warn(message, DLCDeprecationWarning, stacklevel=2)
+ return fn(*args, **kwargs)
+
+ wrapper.__doc__ = f"Deprecated. {message}\n\n" + (fn.__doc__ or "")
+ wrapper.__deprecated_info__ = info
+ return wrapper
+
+ return decorator
+
+
+def renamed_parameter(
+ *,
+ old: str,
+ new: str,
+ since: str | None = None,
+) -> Callable[[Callable[P, R]], Callable[P, R]]:
+ """Support a renamed keyword argument while warning callers to update.
+
+ Args:
+ old: The old parameter name that callers may still pass.
+ new: The current parameter name the function actually accepts.
+ since: Version when the rename happened.
+
+ Rules:
+ - ``new`` must be the name used in the function signature and all
+ internal call-sites. ``old`` must **not** appear in the signature.
+ - Do **not** chain renames. If ``A`` was renamed to ``B`` and ``B``
+ is later renamed to ``C``, replace the ``A→B`` decorator with
+ ``A→C`` directly rather than stacking a second decorator.
+ Example:
+ @renamed_parameter(old="A", new="C", since="12.4.0")
+ @renamed_parameter(old="B", new="C", since="13.0.0")
+ def func(*, C: int):
+ print(f"C={C}")
+ - Multiple independent renames on the same function (e.g.
+ ``batchsize→batch_size`` *and* ``videotype→video_extensions``) are fine
+ as long as they do not form a chain.
+ - This decorator only intercepts **keyword** arguments. Positional
+ arguments are passed through unchanged; renaming a parameter that
+ callers commonly pass positionally will not be caught.
+ """
+
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
+ sig = inspect.signature(fn)
+
+ # Guard: disallow chaining renames (A→B stacked on top of B→C).
+ existing = getattr(fn, "__deprecated_params__", ())
+ for prev in existing:
+ if prev.old_parameter == new:
+ raise ValueError(
+ f"@renamed_parameter: chaining renames is not allowed. "
+ f"'{old}' → '{new}' would chain with the existing "
+ f"'{prev.old_parameter}' → '{prev.new_parameter}' rename "
+ f"on {fn.__qualname__}. "
+ f"Use '{old}' → '{prev.new_parameter}' directly instead."
+ )
+
+ # Guard: 'new' must actually exist in the function's signature.
+ if new not in sig.parameters:
+ raise ValueError(
+ f"@renamed_parameter: '{new}' is not a parameter of "
+ f"{fn.__qualname__}. "
+ f"Available parameters: {list(sig.parameters)}"
+ )
+
+ # Guard: 'old' must NOT exist in the signature.
+ if old in sig.parameters:
+ raise ValueError(
+ f"@renamed_parameter: '{old}' is still a parameter of "
+ f"{fn.__qualname__}. Use either old name or new name: '{new}'."
+ )
+
+ info = DeprecationInfo(
+ kind="parameter",
+ target=fn.__qualname__,
+ since=since,
+ old_parameter=old,
+ new_parameter=new,
+ )
+ message = info.format_message()
+
+ @functools.wraps(fn)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ if old in kwargs:
+ if new in kwargs:
+ raise TypeError(f"{fn.__qualname__} received both '{old}' and '{new}'. Use only '{new}'.")
+ warnings.warn(message, DLCDeprecationWarning, stacklevel=2)
+ kwargs[new] = kwargs.pop(old)
+ return fn(*args, **kwargs)
+
+ wrapper.__deprecated_params__ = (*existing, info)
+ return wrapper
+
+ return decorator
diff --git a/deeplabcut/utils/frameselectiontools.py b/deeplabcut/utils/frameselectiontools.py
index dd2201e40f..ada9175d85 100644
--- a/deeplabcut/utils/frameselectiontools.py
+++ b/deeplabcut/utils/frameselectiontools.py
@@ -18,7 +18,6 @@
Licensed under GNU Lesser General Public License v3.0
"""
-
import math
import cv2
@@ -29,9 +28,9 @@
def UniformFrames(clip, numframes2pick, start, stop, Index=None):
- """Temporally uniformly sampling frames in interval (start,stop).
- Visual information of video is irrelevant for this method. This code is fast and sufficient (to extract distinct frames),
- when behavioral videos naturally covers many states.
+ """Temporally uniformly sampling frames in interval (start,stop). Visual information
+ of video is irrelevant for this method. This code is fast and sufficient (to extract
+ distinct frames), when behavioral videos naturally covers many states.
The variable Index allows to pass on a subindex for the frames.
"""
@@ -72,9 +71,9 @@ def UniformFrames(clip, numframes2pick, start, stop, Index=None):
# uses openCV
def UniformFramescv2(cap, numframes2pick, start, stop, Index=None):
- """Temporally uniformly sampling frames in interval (start,stop).
- Visual information of video is irrelevant for this method. This code is fast and sufficient (to extract distinct frames),
- when behavioral videos naturally covers many states.
+ """Temporally uniformly sampling frames in interval (start,stop). Visual information
+ of video is irrelevant for this method. This code is fast and sufficient (to extract
+ distinct frames), when behavioral videos naturally covers many states.
The variable Index allows to pass on a subindex for the frames.
"""
@@ -89,9 +88,7 @@ def UniformFramescv2(cap, numframes2pick, start, stop, Index=None):
if Index is None:
if start == 0:
- frames2pick = np.random.choice(
- math.ceil(nframes * stop), size=numframes2pick, replace=False
- )
+ frames2pick = np.random.choice(math.ceil(nframes * stop), size=numframes2pick, replace=False)
else:
frames2pick = np.random.choice(
range(math.floor(nframes * start), math.ceil(nframes * stop)),
@@ -124,13 +121,17 @@ def KmeansbasedFrameselection(
):
"""This code downsamples the video to a width of resizewidth.
- The video is extracted as a numpy array, which is then clustered with kmeans, whereby each frames is treated as a vector.
- Frames from different clusters are then selected for labeling. This procedure makes sure that the frames "look different",
+ The video is extracted as a numpy array, which is then clustered with kmeans, whereby each frames is treated as a
+ vector.
+ Frames from different clusters are then selected for labeling. This procedure makes sure that the frames "look
+ different",
i.e. different postures etc. On large videos this code is slow.
- Consider not extracting the frames from the whole video but rather set start and stop to a period around interesting behavior.
+ Consider not extracting the frames from the whole video but rather set start and stop to a period around interesting
+ behavior.
- Note: this method can return fewer images than numframes2pick."""
+ Note: this method can return fewer images than numframes2pick.
+ """
print(
"Kmeans-quantization based extracting of frames from",
@@ -165,25 +166,18 @@ def KmeansbasedFrameselection(
if color and ncolors > 1:
DATA = np.zeros((nframes, nx * 3, ny))
for counter, index in tqdm(enumerate(Index)):
- image = img_as_ubyte(
- clipresized.get_frame(index * 1.0 / clipresized.fps)
- )
- DATA[counter, :, :] = np.vstack(
- [image[:, :, 0], image[:, :, 1], image[:, :, 2]]
- )
+ image = img_as_ubyte(clipresized.get_frame(index * 1.0 / clipresized.fps))
+ DATA[counter, :, :] = np.vstack([image[:, :, 0], image[:, :, 1], image[:, :, 2]])
else:
DATA = np.zeros((nframes, nx, ny))
for counter, index in tqdm(enumerate(Index)):
if ncolors == 1:
- DATA[counter, :, :] = img_as_ubyte(
- clipresized.get_frame(index * 1.0 / clipresized.fps)
- )
- else: # attention: averages over color channels to keep size small / perhaps you want to use color information?
+ DATA[counter, :, :] = img_as_ubyte(clipresized.get_frame(index * 1.0 / clipresized.fps))
+ else: # attention: averages over color channels to keep size small
+ # / perhaps you want to use color information?
DATA[counter, :, :] = img_as_ubyte(
np.array(
- np.mean(
- clipresized.get_frame(index * 1.0 / clipresized.fps), 2
- ),
+ np.mean(clipresized.get_frame(index * 1.0 / clipresized.fps), 2),
dtype=np.uint8,
)
)
@@ -192,9 +186,7 @@ def KmeansbasedFrameselection(
data = DATA - DATA.mean(axis=0)
data = data.reshape(nframes, -1) # stacking
- kmeans = MiniBatchKMeans(
- n_clusters=numframes2pick, tol=1e-3, batch_size=batchsize, max_iter=max_iter
- )
+ kmeans = MiniBatchKMeans(n_clusters=numframes2pick, tol=1e-3, batch_size=batchsize, max_iter=max_iter)
kmeans.fit(data)
frames2pick = []
for clusterid in range(numframes2pick): # pick one frame per cluster
@@ -202,9 +194,7 @@ def KmeansbasedFrameselection(
numimagesofcluster = len(clusterids)
if numimagesofcluster > 0:
- frames2pick.append(
- Index[clusterids[np.random.randint(numimagesofcluster)]]
- )
+ frames2pick.append(Index[clusterids[np.random.randint(numimagesofcluster)]])
clipresized.close()
del clipresized
@@ -225,16 +215,19 @@ def KmeansbasedFrameselectioncv2(
max_iter=50,
color=False,
):
- """This code downsamples the video to a width of resizewidth.
- The video is extracted as a numpy array, which is then clustered with kmeans, whereby each frames is treated as a vector.
- Frames from different clusters are then selected for labeling. This procedure makes sure that the frames "look different",
- i.e. different postures etc. On large videos this code is slow.
+ """This code downsamples the video to a width of resizewidth. The video is extracted
+ as a numpy array, which is then clustered with kmeans, whereby each frames is
+ treated as a vector. Frames from different clusters are then selected for labeling.
+ This procedure makes sure that the frames "look different", i.e. different postures
+ etc. On large videos this code is slow.
- Consider not extracting the frames from the whole video but rather set start and stop to a period around interesting behavior.
+ Consider not extracting the frames from the whole video but rather set start and stop to a period around interesting
+ behavior.
Note: this method can return fewer images than numframes2pick.
- Attention: the flow of commands was not optimized for readability, but rather speed. This is why it might appear tedious and repetitive.
+ Attention: the flow of commands was not optimized for readability, but rather speed. This is why it might appear
+ tedious and repetitive.
"""
nframes = len(cap)
nx, ny = cap.dimensions
@@ -262,7 +255,9 @@ def KmeansbasedFrameselectioncv2(
if batchsize > nframes:
batchsize = nframes // 2
- allocated = False
+ ny_ = np.round(ny * ratio).astype(int)
+ nx_ = np.round(nx * ratio).astype(int)
+ DATA = np.empty((nframes, ny_, nx_ * 3 if color else nx_))
if len(Index) >= numframes2pick:
if (
np.mean(np.diff(Index)) > 1
@@ -282,16 +277,7 @@ def KmeansbasedFrameselectioncv2(
interpolation=cv2.INTER_NEAREST,
)
) # color trafo not necessary; lack thereof improves speed.
- if (
- not allocated
- ): #'DATA' not in locals(): #allocate memory in first pass
- DATA = np.empty(
- (nframes, np.shape(image)[0], np.shape(image)[1] * 3)
- )
- allocated = True
- DATA[counter, :, :] = np.hstack(
- [image[:, :, 0], image[:, :, 1], image[:, :, 2]]
- )
+ DATA[counter, :, :] = np.hstack([image[:, :, 0], image[:, :, 1], image[:, :, 2]])
else:
for counter, index in tqdm(enumerate(Index)):
cap.set_to_frame(index) # extract a particular frame
@@ -306,13 +292,6 @@ def KmeansbasedFrameselectioncv2(
interpolation=cv2.INTER_NEAREST,
)
) # color trafo not necessary; lack thereof improves speed.
- if (
- not allocated
- ): #'DATA' not in locals(): #allocate memory in first pass
- DATA = np.empty(
- (nframes, np.shape(image)[0], np.shape(image)[1])
- )
- allocated = True
DATA[counter, :, :] = np.mean(image, 2)
else:
print("Extracting and downsampling...", nframes, " frames from the video.")
@@ -329,16 +308,7 @@ def KmeansbasedFrameselectioncv2(
interpolation=cv2.INTER_NEAREST,
)
) # color trafo not necessary; lack thereof improves speed.
- if (
- not allocated
- ): #'DATA' not in locals(): #allocate memory in first pass
- DATA = np.empty(
- (nframes, np.shape(image)[0], np.shape(image)[1] * 3)
- )
- allocated = True
- DATA[counter, :, :] = np.hstack(
- [image[:, :, 0], image[:, :, 1], image[:, :, 2]]
- )
+ DATA[counter, :, :] = np.hstack([image[:, :, 0], image[:, :, 1], image[:, :, 2]])
else:
for counter, index in tqdm(enumerate(Index)):
frame = cap.read_frame(crop=True)
@@ -352,22 +322,13 @@ def KmeansbasedFrameselectioncv2(
interpolation=cv2.INTER_NEAREST,
)
) # color trafo not necessary; lack thereof improves speed.
- if (
- not allocated
- ): #'DATA' not in locals(): #allocate memory in first pass
- DATA = np.empty(
- (nframes, np.shape(image)[0], np.shape(image)[1])
- )
- allocated = True
DATA[counter, :, :] = np.mean(image, 2)
print("Kmeans clustering ... (this might take a while)")
data = DATA - DATA.mean(axis=0)
data = data.reshape(nframes, -1) # stacking
- kmeans = MiniBatchKMeans(
- n_clusters=numframes2pick, tol=1e-3, batch_size=batchsize, max_iter=max_iter
- )
+ kmeans = MiniBatchKMeans(n_clusters=numframes2pick, tol=1e-3, batch_size=batchsize, max_iter=max_iter)
kmeans.fit(data)
frames2pick = []
for clusterid in range(numframes2pick): # pick one frame per cluster
@@ -375,9 +336,7 @@ def KmeansbasedFrameselectioncv2(
numimagesofcluster = len(clusterids)
if numimagesofcluster > 0:
- frames2pick.append(
- Index[clusterids[np.random.randint(numimagesofcluster)]]
- )
+ frames2pick.append(Index[clusterids[np.random.randint(numimagesofcluster)]])
# cap.release() >> still used in frame_extraction!
return list(np.array(frames2pick))
else:
diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py
index 238fd4e3ef..959050908d 100644
--- a/deeplabcut/utils/make_labeled_video.py
+++ b/deeplabcut/utils/make_labeled_video.py
@@ -21,6 +21,8 @@
You can find the directory for your ffmpeg bindings by: "find / | grep ffmpeg" and then setting it.
"""
+from __future__ import annotations
+
import argparse
import os
@@ -28,27 +30,29 @@
# Dependencies
####################################################
import os.path
-from pathlib import Path
+from collections.abc import Callable, Iterable, Sequence
from functools import partial
from multiprocessing import Pool, get_start_method
-from typing import Iterable, Callable, Optional, Union
+from pathlib import Path
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
+from matplotlib import patches
from matplotlib.animation import FFMpegWriter
from matplotlib.collections import LineCollection
-from skimage.draw import disk, line_aa, set_color
+from skimage.draw import disk, line_aa, rectangle_perimeter, set_color
from skimage.util import img_as_ubyte
from tqdm import trange
-from deeplabcut.modelzoo.utils import parse_available_supermodels
-from deeplabcut.pose_estimation_tensorflow.config import load_config
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, visualization
+
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization
+from deeplabcut.utils.auxfun_videos import VideoWriter, collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
from deeplabcut.utils.video_processor import (
VideoProcessorCV as vp,
) # used to CreateVideo
-from deeplabcut.utils.auxfun_videos import VideoWriter
def get_segment_indices(bodyparts2connect, all_bpts):
@@ -60,7 +64,8 @@ def get_segment_indices(bodyparts2connect, all_bpts):
*(
np.flatnonzero(all_bpts == bpt1),
np.flatnonzero(all_bpts == bpt2),
- )
+ ),
+ strict=False,
)
)
return bpts2connect
@@ -85,14 +90,16 @@ def CreateVideo(
displaycropped,
color_by,
confidence_to_alpha=None,
+ plot_bboxes=True,
+ bboxes_list=None,
+ bboxes_pcutoff=0.6,
+ bboxes_color: tuple | None = None,
):
- """Creating individual frames with labeled body parts and making a video"""
+ """Creating individual frames with labeled body parts and making a video."""
bpts = Dataframe.columns.get_level_values("bodyparts")
all_bpts = bpts.values[::3]
if draw_skeleton:
- color_for_skeleton = (
- np.array(mcolors.to_rgba(skeleton_color))[:3] * 255
- ).astype(np.uint8)
+ color_for_skeleton = (np.array(mcolors.to_rgba(skeleton_color))[:3] * 255).astype(np.uint8)
# recode the bodyparts2connect into indices for df_x and df_y for speed
bpts2connect = get_segment_indices(bodyparts2connect, all_bpts)
@@ -108,16 +115,8 @@ def CreateVideo(
nframes = clip.nframes
duration = nframes / fps
- print(
- "Duration of video [s]: {}, recorded with {} fps!".format(
- round(duration, 2), round(fps, 2)
- )
- )
- print(
- "Overall # of frames: {} with cropped frame dimensions: {} {}".format(
- nframes, nx, ny
- )
- )
+ print(f"Duration of video [s]: {round(duration, 2)}, recorded with {round(fps, 2)} fps!")
+ print(f"Overall # of frames: {nframes} with cropped frame dimensions: {nx} {ny}")
print("Generating frames and creating video.")
df_x, df_y, df_likelihood = Dataframe.values.reshape((len(Dataframe), -1, 3)).T
@@ -136,9 +135,7 @@ def CreateVideo(
else:
nindividuals = len(Dataframe.columns.get_level_values("individuals").unique())
map2bp = [bplist.index(bp) for bp in all_bpts]
- nbpts_per_ind = (
- Dataframe.groupby(level="individuals", axis=1).size().values // 3
- )
+ nbpts_per_ind = Dataframe.groupby(level="individuals", axis=1).size().values // 3
map2id = []
for i, j in enumerate(nbpts_per_ind):
map2id.extend([i] * j)
@@ -151,19 +148,42 @@ def CreateVideo(
C = colorclass.to_rgba(np.linspace(0, 1, nindividuals))
colors = (C[:, :3] * 255).astype(np.uint8)
+ if bboxes_color is None:
+ bboxes_color = (255, 0, 0)
+
with np.errstate(invalid="ignore"):
for index in trange(min(nframes, len(Dataframe))):
image = clip.load_frame()
if displaycropped:
image = image[y1:y2, x1:x2]
+ # Draw bounding boxes if required and present
+ if plot_bboxes and bboxes_list:
+ bboxes = bboxes_list[index]["bboxes"]
+ bbox_scores = bboxes_list[index].get("bbox_scores")
+ n_bboxes = len(bboxes)
+ for i in range(n_bboxes):
+ bbox = bboxes[i]
+ x, y = bbox[0], bbox[1]
+ x += x1
+ y += y1
+ w, h = bbox[2], bbox[3]
+ if bbox_scores is not None and bbox_scores[i] < bboxes_pcutoff:
+ continue
+ rect_coords = rectangle_perimeter(start=(y, x), extent=(h, w))
+
+ set_color(
+ image,
+ rect_coords,
+ bboxes_color,
+ )
+
# Draw the skeleton for specific bodyparts to be connected as
# specified in the config file
if draw_skeleton:
for bpt1, bpt2 in bpts2connect:
if np.all(df_likelihood[[bpt1, bpt2], index] > pcutoff) and not (
- np.any(np.isnan(df_x[[bpt1, bpt2], index]))
- or np.any(np.isnan(df_y[[bpt1, bpt2], index]))
+ np.any(np.isnan(df_x[[bpt1, bpt2], index])) or np.any(np.isnan(df_y[[bpt1, bpt2], index]))
):
rr, cc, val = line_aa(
int(np.clip(df_y[bpt1, index], 0, ny - 1)),
@@ -187,9 +207,7 @@ def CreateVideo(
shape=(ny, nx),
)
image[rr, cc] = color
- rr, cc = disk(
- (df_y[ind, index], df_x[ind, index]), dotsize, shape=(ny, nx)
- )
+ rr, cc = disk((df_y[ind, index], df_x[ind, index]), dotsize, shape=(ny, nx))
alpha = 1
if confidence_to_alpha is not None:
alpha = confidence_to_alpha(df_likelihood[ind, index])
@@ -224,10 +242,12 @@ def CreateVideoSlow(
draw_skeleton,
displaycropped,
color_by,
+ plot_bboxes=True,
+ bboxes_list=None,
+ bboxes_pcutoff=0.6,
+ bboxes_color: str | None = None,
):
- """Creating individual frames with labeled body parts and making a video"""
- # scorer=np.unique(Dataframe.columns.get_level_values(0))[0]
- # bodyparts2plot = list(np.unique(Dataframe.columns.get_level_values(1)))
+ """Creating individual frames with labeled body parts and making a video."""
if displaycropped:
ny, nx = y2 - y1, x2 - x1
@@ -241,16 +261,8 @@ def CreateVideoSlow(
nframes = clip.nframes
duration = nframes / fps
- print(
- "Duration of video [s]: {}, recorded with {} fps!".format(
- round(duration, 2), round(fps, 2)
- )
- )
- print(
- "Overall # of frames: {} with cropped frame dimensions: {} {}".format(
- nframes, nx, ny
- )
- )
+ print(f"Duration of video [s]: {round(duration, 2)}, recorded with {round(fps, 2)} fps!")
+ print(f"Overall # of frames: {nframes} with cropped frame dimensions: {nx} {ny}")
print("Generating frames and creating video.")
df_x, df_y, df_likelihood = Dataframe.values.reshape((len(Dataframe), -1, 3)).T
if cropping and not displaycropped:
@@ -271,9 +283,7 @@ def CreateVideoSlow(
else:
nindividuals = len(Dataframe.columns.get_level_values("individuals").unique())
map2bp = [bplist.index(bp) for bp in all_bpts]
- nbpts_per_ind = (
- Dataframe.groupby(level="individuals", axis=1).size().values // 3
- )
+ nbpts_per_ind = Dataframe.groupby(level="individuals", axis=1).size().values // 3
map2id = []
for i, j in enumerate(nbpts_per_ind):
map2id.extend([i] * j)
@@ -284,11 +294,12 @@ def CreateVideoSlow(
else:
colors = visualization.get_cmap(nbodyparts, name=colormap)
+ if bboxes_color is None:
+ bboxes_color = "red"
+
nframes_digits = int(np.ceil(np.log10(nframes)))
if nframes_digits > 9:
- raise Exception(
- "Your video has more than 10**9 frames, we recommend chopping it up."
- )
+ raise Exception("Your video has more than 10**9 frames, we recommend chopping it up.")
if Frames2plot is None:
Index = set(range(nframes))
@@ -305,13 +316,35 @@ def CreateVideoSlow(
writer = FFMpegWriter(fps=outputframerate, codec="h264")
with writer.saving(fig, videooutname, dpi=dpi), np.errstate(invalid="ignore"):
for index in trange(min(nframes, len(Dataframe))):
- imagename = tmpfolder + "/file" + str(index).zfill(nframes_digits) + ".png"
+ imagename = Path(tmpfolder) / f"file{index:0{nframes_digits}d}.png"
image = img_as_ubyte(clip.load_frame())
if index in Index: # then extract the frame!
if cropping and displaycropped:
image = image[y1:y2, x1:x2]
ax.imshow(image)
+ # Draw bounding boxes of required and present
+ if plot_bboxes and bboxes_list:
+ bboxes = bboxes_list[index]["bboxes"]
+ bbox_scores = bboxes_list[index].get("bbox_scores")
+ n_bboxes = len(bboxes)
+ for i in range(n_bboxes):
+ bbox = bboxes[i]
+ bbox_origin = (bbox[0], bbox[1])
+ (bbox_width, bbox_height) = (bbox[2], bbox[3])
+ if bbox_scores is not None and bbox_scores[i] < bboxes_pcutoff:
+ continue
+ rectangle = patches.Rectangle(
+ bbox_origin,
+ bbox_width,
+ bbox_height,
+ linewidth=1,
+ edgecolor=bboxes_color,
+ facecolor="none",
+ )
+ ax.add_patch(rectangle)
+
+ # Draw skeleton
if draw_skeleton:
for bpt1, bpt2 in bpts2connect:
if np.all(df_likelihood[[bpt1, bpt2], index] > pcutoff):
@@ -322,6 +355,7 @@ def CreateVideoSlow(
alpha=alphavalue,
)
+ # Draw bodyparts
for ind, num_bp, num_ind in bpts2color:
if df_likelihood[ind, index] > pcutoff:
if color_by == "bodypart":
@@ -332,14 +366,14 @@ def CreateVideoSlow(
ax.scatter(
df_x[ind][max(0, index - trailpoints) : index],
df_y[ind][max(0, index - trailpoints) : index],
- s=dotsize ** 2,
+ s=dotsize**2,
color=color,
alpha=alphavalue * 0.75,
)
ax.scatter(
df_x[ind, index],
df_y[ind, index],
- s=dotsize ** 2,
+ s=dotsize**2,
color=color,
alpha=alphavalue,
)
@@ -347,50 +381,53 @@ def CreateVideoSlow(
ax.set_ylim(0, ny)
ax.axis("off")
ax.invert_yaxis()
- fig.subplots_adjust(
- left=0, bottom=0, right=1, top=1, wspace=0, hspace=0
- )
+ fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
if save_frames:
fig.savefig(imagename)
writer.grab_frame()
ax.clear()
- print("Labeled video {} successfully created.".format(videooutname))
+ print(f"Labeled video {videooutname} successfully created.")
plt.switch_backend(prev_backend)
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_labeled_video(
- config,
- videos,
- videotype="",
- shuffle=1,
- trainingsetindex=0,
- filtered=False,
- fastmode=True,
- save_frames=False,
- keypoints_only=False,
- Frames2plot=None,
- displayedbodyparts="all",
- displayedindividuals="all",
- codec="mp4v",
- outputframerate=None,
- destfolder=None,
- draw_skeleton=False,
- trailpoints=0,
- displaycropped=False,
- color_by="bodypart",
- modelprefix="",
- init_weights="",
- track_method="",
- superanimal_name="",
- pcutoff=0.6,
- skeleton=[],
- skeleton_color="white",
- dotsize=8,
- colormap="rainbow",
- alphavalue=0.5,
- overwrite=False,
- confidence_to_alpha: Union[bool, Callable[[float], float]] = False,
+ config: str,
+ videos: list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ filtered: bool = False,
+ fastmode: bool = True,
+ save_frames: bool = False,
+ keypoints_only: bool = False,
+ Frames2plot: list[int] | None = None,
+ displayedbodyparts: list[str] | str = "all",
+ displayedindividuals: list[str] | str = "all",
+ codec: str = "mp4v",
+ outputframerate: int | None = None,
+ destfolder: Path | str | None = None,
+ draw_skeleton: bool = False,
+ trailpoints: int = 0,
+ displaycropped: bool = False,
+ color_by: str = "bodypart",
+ modelprefix: str = "",
+ init_weights: str = "",
+ track_method: str = "",
+ superanimal_name: str = "",
+ pcutoff: float | None = None,
+ skeleton: list = None,
+ skeleton_color: str = "white",
+ dotsize: int = 8,
+ colormap: str = "rainbow",
+ alphavalue: float = 0.5,
+ overwrite: bool = False,
+ confidence_to_alpha: bool | Callable[[float], float] = False,
+ plot_bboxes: bool = True,
+ bboxes_pcutoff: float | None = None,
+ max_workers: int | None = None,
+ **kwargs,
):
"""Labels the bodyparts in a video.
@@ -406,11 +443,14 @@ def create_labeled_video(
A list of strings containing the full paths to videos for analysis or a path
to the directory, where all the videos with same extension are stored.
- videotype: str, optional, default=""
- Checks for the extension of the video in case the input to the video is a
- directory. Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions
- ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle : int, optional, default=1
Number of shuffles of training dataset.
@@ -455,7 +495,7 @@ def create_labeled_video(
displayedindividuals: list[str] or str, optional, default="all"
Individuals plotted in the video.
- By default, all individuals present in the config will be showed.
+ By default, all individuals present in the config will be shown.
codec: str, optional, default="mp4v"
Codec for labeled video. For available options, see
@@ -467,7 +507,7 @@ def create_labeled_video(
mode with saving frames.) If ``None``, which results in the original video
rate.
- destfolder: string or None, optional, default=None
+ destfolder: Path, string or None, optional, default=None
Specifies the destination folder that was used for storing analysis data. If
``None``, the path of the video file is used.
@@ -502,6 +542,25 @@ def create_labeled_video(
For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
be taken from the config.yaml file if none is given.
+ superanimal_name: str, optional, default=""
+ Name of the superanimal model.
+
+ pcutoff: float, optional, default=None
+ Overrides the pcutoff set in the project configuration to plot the trajectories.
+
+ skeleton: list, optional, default=[],
+
+ skeleton_color: string, optional, default="white",
+ Color for the skeleton
+
+ dotsize, int, optional, default=8,
+ Size of label dots tu use
+
+ colormap: str, optional, default="rainbow",
+ Colormap to use for the labels
+
+ alphavalue: float, optional, default=0.5,
+
overwrite: bool, optional, default=False
If ``True`` overwrites existing labeled videos.
@@ -511,6 +570,25 @@ def create_labeled_video(
keypoint will be set as a function of its score: alpha = f(score). The default
function used when True is f(x) = max(0, (x - pcutoff)/(1 - pcutoff)).
+ plot_bboxes: bool, optional, default=True
+ If using Pytorch and in Top-Down mode,
+ setting this to true will also plot the bounding boxes
+
+ bboxes_pcutoff, float, optional, default=None:
+ If plotting bounding boxes, this overrides the bboxes_pcutoff
+ set in the model configuration.
+
+ max_workers (int | None):
+ Maximum number of processes to use for multiprocessing.
+ Set this parameter to limit the total RAM-usage of simultaneous processes.
+ Default: no maximum (i.e. number of spawned processes is based on the number of
+ cores and the number of input videos).
+
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
results : list[bool]
@@ -557,21 +635,57 @@ def create_labeled_video(
>>> deeplabcut.create_labeled_video(
'/analysis/project/reaching-task/config.yaml',
['/analysis/project/videos/'],
- videotype='mp4',
+ video_extensions='mp4',
)
"""
+ if skeleton is None:
+ skeleton = []
if config == "":
- pass
+ if pcutoff is None:
+ pcutoff = 0.6
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = 0.6
+
+ individuals = [""]
+ uniquebodyparts = []
else:
cfg = auxiliaryfunctions.read_config(config)
- trainFraction = cfg["TrainingFraction"][trainingsetindex]
- track_method = auxfun_multianimal.get_track_method(
- cfg, track_method=track_method
+ train_fraction = cfg["TrainingFraction"][trainingsetindex]
+ track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+ if pcutoff is None:
+ pcutoff = cfg["pcutoff"]
+
+ # Get individuals from the config
+ individuals = cfg.get("individuals", [""])
+ uniquebodyparts = cfg.get("uniquebodyparts", [])
+
+ # Only for PyTorch engine - check if the shuffle was fine-tuned from a
+ # SuperAnimal model with memory replay -> SuperAnimal bodyparts must be used
+ model_folder = auxiliaryfunctions.get_model_folder(
+ train_fraction,
+ shuffle,
+ cfg,
+ modelprefix,
+ engine=Engine.PYTORCH,
)
+ model_config_path = Path(config).parent / model_folder / "train" / Engine.PYTORCH.pose_cfg_name
+ if model_config_path.exists():
+ model_config = auxiliaryfunctions.read_plainconfig(str(model_config_path))
+ if model_config["train_settings"].get("weight_init", {}).get("memory_replay", False):
+ superanimal_name = model_config["train_settings"]["weight_init"]["dataset"]
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = model_config.get("detector", {}).get("model", {}).get("box_score_thresh", 0.6)
+ else:
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = 0.6
if init_weights == "":
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
+ cfg,
+ shuffle,
+ train_fraction,
+ modelprefix=modelprefix,
+ **kwargs,
) # automatically loads corresponding model (even training iteration based on snapshot index)
else:
DLCscorer = "DLC_" + Path(init_weights).stem
@@ -587,17 +701,16 @@ def create_labeled_video(
if superanimal_name != "":
dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
- supermodels = parse_available_supermodels()
- test_cfg = load_config(
+ test_cfg = auxiliaryfunctions.read_plainconfig(
os.path.join(
dlc_root_path,
- "pose_estimation_tensorflow",
- "superanimal_configs",
- supermodels[superanimal_name],
+ "modelzoo",
+ "project_configs",
+ f"{superanimal_name}.yaml",
)
)
- bodyparts = test_cfg["all_joints_names"]
+ bodyparts = test_cfg["bodyparts"]
cfg = {
"skeleton": skeleton,
"skeleton_color": skeleton_color,
@@ -605,24 +718,19 @@ def create_labeled_video(
"dotsize": dotsize,
"alphavalue": alphavalue,
"colormap": colormap,
+ "bodyparts": bodyparts,
+ "multianimalbodyparts": bodyparts,
+ "individuals": individuals,
+ "uniquebodyparts": uniquebodyparts,
}
else:
- bodyparts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, displayedbodyparts
- )
- )
+ bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, displayedbodyparts)
- individuals = auxfun_multianimal.IntersectionofIndividualsandOnesGivenbyUser(
- cfg, displayedindividuals
- )
if draw_skeleton:
bodyparts2connect = cfg["skeleton"]
if displayedbodyparts != "all":
bodyparts2connect = [
- pair
- for pair in bodyparts2connect
- if all(element in displayedbodyparts for element in pair)
+ pair for pair in bodyparts2connect if all(element in displayedbodyparts for element in pair)
]
skeleton_color = cfg["skeleton_color"]
else:
@@ -630,7 +738,7 @@ def create_labeled_video(
skeleton_color = None
start_path = os.getcwd()
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if not Videos:
return []
@@ -644,7 +752,7 @@ def create_labeled_video(
DLCscorerlegacy,
track_method,
cfg,
- individuals,
+ displayedindividuals,
color_by,
bodyparts,
codec,
@@ -660,11 +768,15 @@ def create_labeled_video(
keypoints_only,
overwrite,
init_weights=init_weights,
+ pcutoff=pcutoff,
confidence_to_alpha=confidence_to_alpha,
+ plot_bboxes=plot_bboxes,
+ bboxes_pcutoff=bboxes_pcutoff,
)
if get_start_method() == "fork":
- with Pool(min(os.cpu_count(), len(Videos))) as pool:
+ n_workers = max_workers or min(os.cpu_count(), len(Videos))
+ with Pool(n_workers) as pool:
results = pool.map(func, Videos)
else:
results = []
@@ -700,9 +812,12 @@ def proc_video(
overwrite,
video,
init_weights="",
- confidence_to_alpha: Optional[Callable[[float], float]] = None,
+ pcutoff: float | None = None,
+ confidence_to_alpha: Callable[[float], float] | None = None,
+ plot_bboxes: bool = True,
+ bboxes_pcutoff: float = 0.6,
):
- """Helper function for create_videos
+ """Helper function for create_videos.
Parameters
----------
@@ -713,14 +828,19 @@ def proc_video(
result : bool
``True`` if a video is successfully created.
"""
- videofolder = Path(video).parents[0]
+ videofolder = Path(video).parent
if destfolder is None:
destfolder = videofolder # where your folder with videos is.
+ else:
+ destfolder = Path(destfolder)
+
+ if pcutoff is None:
+ pcutoff = cfg["pcutoff"]
auxiliaryfunctions.attempt_to_make_folder(destfolder)
os.chdir(destfolder) # THE VIDEO IS STILL IN THE VIDEO FOLDER
- print("Starting to process video: {}".format(video))
+ print(f"Starting to process video: {video}")
vname = str(Path(video).stem)
if init_weights != "":
@@ -728,44 +848,57 @@ def proc_video(
DLCscorerlegacy = "DLC_" + Path(init_weights).stem
if filtered:
- videooutname1 = os.path.join(vname + DLCscorer + "filtered_labeled.mp4")
- videooutname2 = os.path.join(vname + DLCscorerlegacy + "filtered_labeled.mp4")
+ videooutname1 = destfolder / f"{vname}{DLCscorer}filtered_labeled.mp4"
+ videooutname2 = destfolder / f"{vname}{DLCscorerlegacy}filtered_labeled.mp4"
else:
- videooutname1 = os.path.join(vname + DLCscorer + "_labeled.mp4")
- videooutname2 = os.path.join(vname + DLCscorerlegacy + "_labeled.mp4")
+ videooutname1 = destfolder / f"{vname}{DLCscorer}_labeled.mp4"
+ videooutname2 = destfolder / f"{vname}{DLCscorerlegacy}_labeled.mp4"
- if (
- os.path.isfile(videooutname1) or os.path.isfile(videooutname2)
- ) and not overwrite:
- print("Labeled video {} already created.".format(vname))
+ if (videooutname1.is_file() or videooutname2.is_file()) and not overwrite:
+ print(f"Labeled video {vname} already created.")
return True
else:
- print("Loading {} and data.".format(video))
+ print(f"Loading {video} and data.")
try:
df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(
destfolder, vname, DLCscorer, filtered, track_method
)
- metadata = auxiliaryfunctions.load_video_metadata(
- destfolder, vname, DLCscorer
- )
+ metadata = auxiliaryfunctions.load_video_metadata(destfolder, vname, DLCscorer)
if cfg.get("multianimalproject", False):
s = "_id" if color_by == "individual" else "_bp"
else:
s = ""
- videooutname = filepath.replace(".h5", f"{s}_labeled.mp4")
+
+ videooutname = filepath.replace(".h5", f"{s}_p{int(100 * pcutoff)}_labeled.mp4")
if os.path.isfile(videooutname) and not overwrite:
print("Labeled video already created. Skipping...")
return
- if all(individuals):
- df = df.loc(axis=1)[:, individuals]
+ if individuals != "all":
+ if isinstance(individuals, str):
+ individuals = [individuals]
+
+ if all(individuals) and "individuals" in df.columns.names:
+ mask = df.columns.get_level_values("individuals").isin(individuals)
+ df = df.loc[:, mask]
+
cropping = metadata["data"]["cropping"]
[x1, x2, y1, y2] = metadata["data"]["cropping_parameters"]
- labeled_bpts = [
- bp
- for bp in df.columns.get_level_values("bodyparts").unique()
- if bp in bodyparts
- ]
+ labeled_bpts = [bp for bp in df.columns.get_level_values("bodyparts").unique() if bp in bodyparts]
+
+ # The full data file is not created for single-animal TensorFlow models
+ try:
+ full_data = auxiliaryfunctions.load_video_full_data(destfolder, vname, DLCscorer)
+ frames_dict = {
+ int(key.replace("frame", "")): value
+ for key, value in full_data.items()
+ if key.startswith("frame") and key[5:].isdigit()
+ }
+ bboxes_list = None
+ if "bboxes" in frames_dict.get(min(frames_dict.keys()), {}):
+ bboxes_list = [frames_dict[key] for key in sorted(frames_dict.keys())]
+ except FileNotFoundError:
+ bboxes_list = None
if keypoints_only:
# Mask rather than drop unwanted bodyparts to ensure consistent coloring
@@ -780,7 +913,7 @@ def proc_video(
df,
videooutname,
inds,
- cfg["pcutoff"],
+ pcutoff,
cfg["dotsize"],
cfg["alphavalue"],
skeleton_color=skeleton_color,
@@ -802,7 +935,7 @@ def proc_video(
cfg["dotsize"],
cfg["colormap"],
cfg["alphavalue"],
- cfg["pcutoff"],
+ pcutoff,
trailpoints,
cropping,
x1,
@@ -818,10 +951,13 @@ def proc_video(
draw_skeleton,
displaycropped,
color_by,
+ plot_bboxes=plot_bboxes,
+ bboxes_list=bboxes_list,
+ bboxes_pcutoff=bboxes_pcutoff,
)
clip.close()
else:
- _create_labeled_video(
+ create_video(
video,
filepath,
keypoints2show=labeled_bpts,
@@ -829,7 +965,7 @@ def proc_video(
bbox=(x1, x2, y1, y2),
codec=codec,
output_path=videooutname,
- pcutoff=cfg["pcutoff"],
+ pcutoff=pcutoff,
dotsize=cfg["dotsize"],
cmap=cfg["colormap"],
color_by=color_by,
@@ -839,7 +975,11 @@ def proc_video(
fps=outputframerate,
display_cropped=displaycropped,
confidence_to_alpha=confidence_to_alpha,
+ plot_bboxes=plot_bboxes,
+ bboxes_list=bboxes_list,
+ bboxes_pcutoff=bboxes_pcutoff,
)
+
return True
except FileNotFoundError as e:
@@ -847,15 +987,15 @@ def proc_video(
return False
-def _create_labeled_video(
+def create_video(
video,
h5file,
keypoints2show="all",
animals2show="all",
skeleton_edges=None,
pcutoff=0.6,
- dotsize=8,
- cmap="cool",
+ dotsize=6,
+ cmap="rainbow",
color_by="bodypart",
skeleton_color="k",
trailpoints=0,
@@ -865,6 +1005,10 @@ def _create_labeled_video(
fps=None,
output_path="",
confidence_to_alpha=None,
+ plot_bboxes=True,
+ bboxes_list=None,
+ bboxes_pcutoff=0.6,
+ bboxes_color: tuple | None = None,
):
if color_by not in ("bodypart", "individual"):
raise ValueError("`color_by` should be either 'bodypart' or 'individual'.")
@@ -873,23 +1017,21 @@ def _create_labeled_video(
s = "_id" if color_by == "individual" else "_bp"
output_path = h5file.replace(".h5", f"{s}_labeled.mp4")
- x1, x2, y1, y2 = bbox
- if display_cropped:
- sw = x2 - x1
- sh = y2 - y1
- else:
- sw = sh = ""
-
clip = vp(
fname=video,
- sname=output_path,
+ sname=str(output_path),
codec=codec,
- sw=sw,
- sh=sh,
+ sw=bbox[1] - bbox[0] if display_cropped else "",
+ sh=bbox[3] - bbox[2] if display_cropped else "",
fps=fps,
)
+
cropping = bbox != (0, clip.w, 0, clip.h)
+
+ x1, x2, y1, y2 = bbox if bbox is not None else (0, clip.w, 0, clip.h)
+
df = pd.read_hdf(h5file)
+
try:
animals = df.columns.get_level_values("individuals").unique().to_list()
if animals2show != "all" and isinstance(animals, Iterable):
@@ -897,9 +1039,12 @@ def _create_labeled_video(
df = df.loc(axis=1)[:, animals]
except KeyError:
pass
+
kpts = df.columns.get_level_values("bodyparts").unique().to_list()
+
if keypoints2show != "all" and isinstance(keypoints2show, Iterable):
kpts = [kpt for kpt in kpts if kpt in keypoints2show]
+
CreateVideo(
clip,
df,
@@ -919,9 +1064,17 @@ def _create_labeled_video(
display_cropped,
color_by,
confidence_to_alpha=confidence_to_alpha,
+ plot_bboxes=plot_bboxes,
+ bboxes_list=bboxes_list,
+ bboxes_pcutoff=bboxes_pcutoff,
+ bboxes_color=bboxes_color,
)
+# for backwards compatibility
+_create_labeled_video = create_video
+
+
def create_video_with_keypoints_only(
df,
output_name,
@@ -947,19 +1100,17 @@ def create_video_with_keypoints_only(
xyp = df.values.reshape((n_frames, -1, 3))
if color_by == "bodypart":
- map_ = bodyparts.map(dict(zip(bodypart_names, range(n_bodyparts))))
+ map_ = bodyparts.map(dict(zip(bodypart_names, range(n_bodyparts), strict=False)))
cmap = plt.get_cmap(colormap, n_bodyparts)
elif color_by == "individual":
try:
individuals = df.columns.get_level_values("individuals")[::3]
individual_names = individuals.unique().to_list()
n_individuals = len(individual_names)
- map_ = individuals.map(dict(zip(individual_names, range(n_individuals))))
+ map_ = individuals.map(dict(zip(individual_names, range(n_individuals), strict=False)))
cmap = plt.get_cmap(colormap, n_individuals)
except KeyError as e:
- raise Exception(
- "Coloring by individuals is only valid for multi-animal data"
- ) from e
+ raise Exception("Coloring by individuals is only valid for multi-animal data") from e
else:
raise ValueError(f"Invalid color_by={color_by}")
@@ -967,23 +1118,19 @@ def create_video_with_keypoints_only(
plt.switch_backend("agg")
fig = plt.figure(frameon=False, figsize=(nx / dpi, ny / dpi))
ax = fig.add_subplot(111)
- scat = ax.scatter([], [], s=dotsize ** 2, alpha=alpha)
+ scat = ax.scatter([], [], s=dotsize**2, alpha=alpha)
coords = xyp[0, :, :2]
coords[xyp[0, :, 2] < pcutoff] = np.nan
scat.set_offsets(coords)
colors = cmap(map_)
scat.set_color(colors)
- segs = coords[tuple(zip(*tuple(ind_links))), :].swapaxes(0, 1) if ind_links else []
+ segs = coords[tuple(zip(*tuple(ind_links), strict=False)), :].swapaxes(0, 1) if ind_links else []
coll = LineCollection(segs, colors=skeleton_color, alpha=alpha)
ax.add_collection(coll)
ax.set_xlim(0, nx)
ax.set_ylim(0, ny)
ax.axis("off")
- ax.add_patch(
- plt.Rectangle(
- (0, 0), 1, 1, facecolor=background_color, transform=ax.transAxes, zorder=-1
- )
- )
+ ax.add_patch(plt.Rectangle((0, 0), 1, 1, facecolor=background_color, transform=ax.transAxes, zorder=-1))
ax.invert_yaxis()
plt.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
@@ -995,26 +1142,29 @@ def create_video_with_keypoints_only(
coords[xyp[index, :, 2] < pcutoff] = np.nan
scat.set_offsets(coords)
if ind_links:
- segs = coords[tuple(zip(*tuple(ind_links))), :].swapaxes(0, 1)
+ segs = coords[tuple(zip(*tuple(ind_links), strict=False)), :].swapaxes(0, 1)
coll.set_segments(segs)
writer.grab_frame()
plt.close(fig)
plt.switch_backend(prev_backend)
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def create_video_with_all_detections(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
displayedbodyparts="all",
+ cropping: list[int] | None = None,
destfolder=None,
modelprefix="",
- confidence_to_alpha: Union[bool, Callable[[float], float]] = False,
+ confidence_to_alpha: bool | Callable[[float], float] = False,
+ plot_bboxes: bool = True,
+ **kwargs,
):
- """
- Create a video labeled with all the detections stored in a '*_full.pickle' file.
+ """Create a video labeled with all the detections stored in a '*_full.pickle' file.
Parameters
----------
@@ -1025,40 +1175,67 @@ def create_video_with_all_detections(
A list of strings containing the full paths to videos for analysis or a path to the directory,
where all the videos with same extension are stored.
- videotype: string, optional
- Checks for the extension of the video in case the input to the video is a directory.\n Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle : int, optional
Number of shuffles of training dataset. Default is set to 1.
trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+ Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
displayedbodyparts: list of strings, optional
- This selects the body parts that are plotted in the video. Either ``all``, then all body parts
- from config.yaml are used orr a list of strings that are a subset of the full list.
- E.g. ['hand','Joystick'] for the demo Reaching-Mackenzie-2018-08-30/config.yaml to select only these two body parts.
+ This selects the body parts that are plotted in the video.
+ Either ``all``, then all body parts from config.yaml are used or
+ a list of strings that are a subset of the full list.
+ E.g. ['hand','Joystick'] for the demo Reaching-Mackenzie-2018-08-30/config.yaml
+ to select only these two body parts.
+
+ cropping: list[int], optional (default=None)
+ If passed in, the [x1, x2, y1, y2] crop coordinates are used to shift detections appropriately.
destfolder: string, optional
- Specifies the destination folder that was used for storing analysis data (default is the path of the video).
+ Specifies the destination folder that was used for storing analysis data
+ (default is the path of the video).
confidence_to_alpha: Union[bool, Callable[[float], float], default=False
If False, all keypoints will be plot with alpha=1. Otherwise, this can be
defined as a function f: [0, 1] -> [0, 1] such that the alpha value for a
keypoint will be set as a function of its score: alpha = f(score). The default
function used when True is f(x) = x.
+
+ plot_bboxes: bool, optional (default=True)
+ If detections were produced using a Pytorch Top-Down model,
+ setting this parameter to True will also plot
+ the bounding boxes generated by the detector.
+
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
"""
- from deeplabcut.pose_estimation_tensorflow.lib.inferenceutils import Assembler
import re
+ from deeplabcut.core.inferenceutils import Assembler
+
cfg = auxiliaryfunctions.read_config(config)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
DLCscorername, _ = auxiliaryfunctions.get_scorer_name(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
+ cfg,
+ shuffle,
+ trainFraction,
+ modelprefix=modelprefix,
+ **kwargs,
)
- videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ videos = collect_video_paths(videos, extensions=video_extensions)
if not videos:
return
@@ -1069,24 +1246,25 @@ def create_video_with_all_detections(
videofolder = os.path.splitext(video)[0]
if destfolder is None:
- outputname = "{}_full.mp4".format(videofolder + DLCscorername)
+ outputname = f"{videofolder + DLCscorername}_full.mp4"
full_pickle = os.path.join(videofolder + DLCscorername + "_full.pickle")
else:
auxiliaryfunctions.attempt_to_make_folder(destfolder)
- outputname = os.path.join(
- destfolder, str(Path(video).stem) + DLCscorername + "_full.mp4"
- )
- full_pickle = os.path.join(
- destfolder, str(Path(video).stem) + DLCscorername + "_full.pickle"
- )
+ outputname = os.path.join(destfolder, str(Path(video).stem) + DLCscorername + "_full.mp4")
+ full_pickle = os.path.join(destfolder, str(Path(video).stem) + DLCscorername + "_full.pickle")
if not (os.path.isfile(outputname)):
- print("Creating labeled video for ", str(Path(video).stem))
+ video_name = str(Path(video).stem)
+ print("Creating labeled video for ", video_name)
h5file = full_pickle.replace("_full.pickle", ".h5")
- data, _ = auxfun_multianimal.LoadFullMultiAnimalData(h5file)
- data = dict(
- data
- ) # Cast to dict (making a copy) so items can safely be popped
+ data, metadata = auxfun_multianimal.LoadFullMultiAnimalData(h5file)
+ data = dict(data) # Cast to dict (making a copy) so items can safely be popped
+
+ x1, y1 = 0, 0
+ if cropping is not None:
+ x1, _, y1, _ = cropping
+ elif metadata.get("data", {}).get("cropping"):
+ x1, _, y1, _ = metadata["data"]["cropping_parameters"]
header = data.pop("metadata")
all_jointnames = header["all_joints_names"]
@@ -1111,17 +1289,52 @@ def create_video_with_all_detections(
clip = vp(fname=video, sname=outputname, codec="mp4v")
ny, nx = clip.height(), clip.width()
+ bboxes_pcutoff = (
+ metadata.get("data", {})
+ .get("pytorch-config", {})
+ .get("detector", {})
+ .get("model", {})
+ .get("box_score_thresh", 0.6)
+ )
+ bboxes_color = (255, 0, 0)
+
for n in trange(clip.nframes):
frame = clip.load_frame()
if frame is None:
continue
try:
ind = frames.index(n)
+
+ # Draw bounding boxes of required and present
+ if plot_bboxes and "bboxes" in data[frame_names[ind]] and "bbox_scores" in data[frame_names[ind]]:
+ bboxes = data[frame_names[ind]]["bboxes"]
+ bbox_scores = data[frame_names[ind]]["bbox_scores"]
+ n_bboxes = bboxes.shape[0]
+ for i in range(n_bboxes):
+ bbox = bboxes[i, :]
+ x, y = bbox[0], bbox[1]
+ x += x1
+ y += y1
+ w, h = bbox[2], bbox[3]
+ confidence = bbox_scores[i]
+ if confidence < bboxes_pcutoff:
+ continue
+ rect_coords = rectangle_perimeter(start=(y, x), extent=(h, w))
+
+ set_color(
+ frame,
+ rect_coords,
+ bboxes_color,
+ )
+
+ # Draw detected bodyparts
dets = Assembler._flatten_detections(data[frame_names[ind]])
for det in dets:
if det.label not in bpts or det.confidence < pcutoff:
continue
x, y = det.pos
+ x += x1
+ y += y1
rr, cc = disk((y, x), dotsize, shape=(ny, nx))
alpha = 1
if confidence_to_alpha is not None:
@@ -1138,7 +1351,7 @@ def create_video_with_all_detections(
pass
try:
clip.save_frame(frame)
- except:
+ except Exception:
print(n, "frame writing error.")
pass
clip.close()
@@ -1148,6 +1361,7 @@ def create_video_with_all_detections(
def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, scale=1):
import subprocess
+
from tqdm import tqdm
if not os.path.isdir(destfolder):
@@ -1176,7 +1390,7 @@ def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, s
im.set_data(frame[:, X1:X2])
for n, trackid in enumerate(trackids):
if imname in tracks[trackid]:
- x, y, p = tracks[trackid][imname].reshape((-1, 3)).T
+ x, y, p = tracks[trackid][imname][:, :3].reshape((-1, 3)).T
markers[n].set_data(x[p > pcutoff], y[p > pcutoff])
else:
markers[n].set_data([], [])
@@ -1198,11 +1412,11 @@ def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, s
output_name,
]
)
+ # remove frames used for video creation
+ [os.remove(image) for image in os.listdir(destfolder) if "frame" in image]
-def create_video_from_pickled_tracks(
- video, pickle_file, destfolder="", output_name="", pcutoff=0.6
-):
+def create_video_from_pickled_tracks(video, pickle_file, destfolder="", output_name="", pcutoff=0.6):
if not destfolder:
destfolder = os.path.splitext(video)[0]
if not output_name:
@@ -1215,8 +1429,8 @@ def create_video_from_pickled_tracks(
def _get_default_conf_to_alpha(
confidence_to_alpha: bool,
pcutoff: float,
-) -> Optional[Callable[[float], float]]:
- """Creates the default confidence_to_alpha function"""
+) -> Callable[[float], float] | None:
+ """Creates the default confidence_to_alpha function."""
if not confidence_to_alpha:
return None
diff --git a/deeplabcut/utils/multiprocessing.py b/deeplabcut/utils/multiprocessing.py
new file mode 100644
index 0000000000..d7d88d7201
--- /dev/null
+++ b/deeplabcut/utils/multiprocessing.py
@@ -0,0 +1,50 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""
+DeepLabCut2.2 Toolbox (deeplabcut.org)
+© A. & M. Mathis Labs
+https://github.com/DeepLabCut/DeepLabCut
+Please see AUTHORS for contributors.
+
+https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+Licensed under GNU Lesser General Public License v3.0
+"""
+
+import multiprocessing
+
+
+def _wrapper(func, queue, *args, **kwargs):
+ try:
+ result = func(*args, **kwargs)
+ queue.put(result) # Pass the result back via the queue
+ except Exception as e:
+ queue.put(e) # Pass any exception back via the queue
+
+
+# NOTE: @C-Achard 2026-03-10 deprecated, as this is not used for the update check anymore
+def call_with_timeout(func, timeout, *args, **kwargs):
+ queue = multiprocessing.Queue()
+ process = multiprocessing.Process(target=_wrapper, args=(func, queue, *args), kwargs=kwargs)
+ process.start()
+ process.join(timeout)
+
+ if process.is_alive():
+ process.terminate() # Forcefully terminate the process
+ process.join()
+ raise TimeoutError(f"Function {func.__name__} did not complete within {timeout} seconds.")
+
+ if not queue.empty():
+ result = queue.get()
+ if isinstance(result, Exception):
+ raise result # Re-raise the exception if it occurred in the function
+ return result
+ else:
+ raise TimeoutError(f"Function {func.__name__} completed but did not return a result.")
diff --git a/deeplabcut/utils/plotting.py b/deeplabcut/utils/plotting.py
index 1320791a34..e5745092d3 100644
--- a/deeplabcut/utils/plotting.py
+++ b/deeplabcut/utils/plotting.py
@@ -18,22 +18,27 @@
Licensed under GNU Lesser General Public License v3.0
"""
+from __future__ import annotations
+
import argparse
import os
-import pickle
-import pandas as pd
####################################################
# Dependencies
####################################################
import os.path
+import pickle
+from collections.abc import Sequence
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
+import pandas as pd
-from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, visualization
+from deeplabcut.core import crossvalutils
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+from deeplabcut.utils.deprecation import renamed_parameter
def Histogram(vector, color, bins, ax=None, linewidth=1.0):
@@ -56,13 +61,17 @@ def PlottingResults(
resolution=100,
linewidth=1.0,
):
- """Plots poses vs time; pose x vs pose y; histogram of differences and likelihoods."""
+ """Plots poses vs time; pose x vs pose y; histogram of differences and
+ likelihoods."""
pcutoff = cfg["pcutoff"]
colors = visualization.get_cmap(len(bodyparts2plot), name=cfg["colormap"])
alphavalue = cfg["alphavalue"]
if individuals2plot:
Dataframe = Dataframe.loc(axis=1)[:, individuals2plot]
animal_bpts = Dataframe.columns.get_level_values("bodyparts")
+ # Close previous figures before plotting
+ plt.close("all")
+
# Pose X vs pose Y
fig1 = plt.figure(figsize=(8, 6))
ax1 = fig1.add_subplot(111)
@@ -91,12 +100,8 @@ def PlottingResults(
with np.errstate(invalid="ignore"):
for bpindex, bp in enumerate(bodyparts2plot):
- if (
- bp in animal_bpts
- ): # Avoid 'unique' bodyparts only present in the 'single' animal
- prob = Dataframe.xs(
- (bp, "likelihood"), level=(-2, -1), axis=1
- ).values.squeeze()
+ if bp in animal_bpts: # Avoid 'unique' bodyparts only present in the 'single' animal
+ prob = Dataframe.xs((bp, "likelihood"), level=(-2, -1), axis=1).values.squeeze()
mask = prob < pcutoff
temp_x = np.ma.array(
Dataframe.xs((bp, "x"), level=(-2, -1), axis=1).values.squeeze(),
@@ -148,21 +153,15 @@ def PlottingResults(
bbox_inches="tight",
dpi=resolution,
)
- fig2.savefig(
- os.path.join(tmpfolder, "plot" + suffix), bbox_inches="tight", dpi=resolution
- )
+ fig2.savefig(os.path.join(tmpfolder, "plot" + suffix), bbox_inches="tight", dpi=resolution)
fig3.savefig(
os.path.join(tmpfolder, "plot-likelihood" + suffix),
bbox_inches="tight",
dpi=resolution,
)
- fig4.savefig(
- os.path.join(tmpfolder, "hist" + suffix), bbox_inches="tight", dpi=resolution
- )
+ fig4.savefig(os.path.join(tmpfolder, "hist" + suffix), bbox_inches="tight", dpi=resolution)
- if not showfigures:
- plt.close("all")
- else:
+ if showfigures:
plt.show()
@@ -171,10 +170,11 @@ def PlottingResults(
##################################################
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def plot_trajectories(
config,
videos,
- videotype="",
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtered=False,
@@ -187,6 +187,8 @@ def plot_trajectories(
resolution=100,
linewidth=1.0,
track_method="",
+ pcutoff: float | None = None,
+ **kwargs,
):
"""Plots the trajectories of various bodyparts across the video.
@@ -199,11 +201,14 @@ def plot_trajectories(
Full paths to videos for analysis or a path to the directory, where all the
videos with same extension are stored.
- videotype: str, optional, default=""
- Checks for the extension of the video in case the input to the video is a
- directory. Only videos with this extension are analyzed.
- If left unspecified, videos with common extensions
- ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ video_extensions : str | Sequence[str] | None, optional, default=None
+ Controls how ``videos`` are filtered, based on file extension.
+ File paths and directory contents are treated differently:
+ - ``None`` (default): file paths are accepted as-is; directories are
+ scanned for files with a recognized video extension.
+ - ``str`` or ``Sequence[str]`` (e.g. ``"mp4"`` or ``["mp4", "avi"]``):
+ both file paths and directory contents are filtered by the given
+ extension(s).
shuffle: int, optional, default=1
Integer specifying the shuffle index of the training dataset.
@@ -251,6 +256,14 @@ def plot_trajectories(
For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
be taken from the config.yaml file if none is given.
+ pcutoff: string, optional, default=None
+ Overrides the pcutoff set in the project configuration to plot the trajectories.
+
+ kwargs: additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
Returns
-------
None
@@ -266,23 +279,25 @@ def plot_trajectories(
)
"""
cfg = auxiliaryfunctions.read_config(config)
+
+ if pcutoff is None:
+ pcutoff = cfg["pcutoff"]
+
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
+ cfg,
+ shuffle,
+ trainFraction,
+ modelprefix=modelprefix,
+ **kwargs,
) # automatically loads corresponding model (even training iteration based on snapshot index)
- bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, displayedbodyparts
- )
- individuals = auxfun_multianimal.IntersectionofIndividualsandOnesGivenbyUser(
- cfg, displayedindividuals
- )
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ bodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, displayedbodyparts)
+ individuals = auxfun_multianimal.IntersectionofIndividualsandOnesGivenbyUser(cfg, displayedindividuals)
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if not len(Videos):
- print(
- "No videos found. Make sure you passed a list of videos and that *videotype* is right."
- )
+ print("No videos found. Make sure you passed a list of videos and that the video_extensions filter is right.")
return
failures, multianimal_errors = [], []
@@ -308,7 +323,7 @@ def plot_trajectories(
linewidth,
cfg["colormap"],
cfg["alphavalue"],
- cfg["pcutoff"],
+ pcutoff,
suffix,
imagetype,
tmpfolder,
@@ -319,9 +334,7 @@ def plot_trajectories(
if track_method != "":
# In a multi animal scenario, show more verbose errors.
try:
- _ = auxiliaryfunctions.load_detection_data(
- video, DLCscorer, track_method
- )
+ _ = auxiliaryfunctions.load_detection_data(video, DLCscorer, track_method)
error_message = 'Call "deeplabcut.stitch_tracklets() prior to plotting the trajectories.'
except FileNotFoundError as e:
print(e)
@@ -333,7 +346,7 @@ def plot_trajectories(
multianimal_errors.append(error_message)
if len(failures) > 0:
- # Some vidoes were not evaluated.
+ # Some videos were not evaluated.
failed_videos = ",".join(failures)
if len(multianimal_errors) > 0:
verbose_error = ": " + " ".join(multianimal_errors)
@@ -341,13 +354,10 @@ def plot_trajectories(
verbose_error = "."
print(
f"Plots could not be created for {failed_videos}. "
- f"Videos were not evaluated with the current scorer {DLCscorer}"
- + verbose_error
+ f"Videos were not evaluated with the current scorer {DLCscorer}" + verbose_error
)
else:
- print(
- 'Plots created! Please check the directory "plot-poses" within the video directory'
- )
+ print('Plots created! Please check the directory "plot-poses" within the video directory')
def _plot_trajectories(
@@ -378,11 +388,7 @@ def _plot_trajectories(
dest_folder = os.path.join(vid_folder, "plot-poses", vname)
auxiliaryfunctions.attempt_to_make_folder(dest_folder, recursive=True)
# Keep only the individuals and bodyparts that were labeled
- labeled_bpts = [
- bp
- for bp in df.columns.get_level_values("bodyparts").unique()
- if bp in bodyparts
- ]
+ labeled_bpts = [bp for bp in df.columns.get_level_values("bodyparts").unique() if bp in bodyparts]
# Either display the animals defined in the config if they are found
# in the dataframe, or all the trajectories regardless of their names
try:
@@ -424,9 +430,7 @@ def _plot_paf_performance(
if ax is None:
fig, ax = plt.subplots(tight_layout=True, figsize=(3, 3))
sns.histplot(within, kde=kde, ax=ax, stat="probability", color=colors[0], bins=bins)
- sns.histplot(
- between, kde=kde, ax=ax, stat="probability", color=colors[1], bins=bins
- )
+ sns.histplot(between, kde=kde, ax=ax, stat="probability", color=colors[1], bins=bins)
return ax
@@ -436,8 +440,7 @@ def plot_edge_affinity_distributions(
output_name="",
figsize=(10, 7),
):
- """
- Display the distribution of affinity costs of within- and between-animal edges.
+ """Display the distribution of affinity costs of within- and between-animal edges.
Parameters
----------
@@ -454,7 +457,6 @@ def plot_edge_affinity_distributions(
figsize: tuple
Figure size in inches.
-
"""
with open(eval_pickle_file, "rb") as file:
diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py
new file mode 100644
index 0000000000..77139a172b
--- /dev/null
+++ b/deeplabcut/utils/pseudo_label.py
@@ -0,0 +1,488 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import glob
+import json
+import os
+from collections import defaultdict
+from pathlib import Path
+
+import cv2
+import matplotlib.pyplot as plt
+import numpy as np
+from scipy.optimize import linear_sum_assignment
+from scipy.spatial import distance
+from scipy.spatial.distance import cdist
+
+import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.modelzoo.generalized_data_converter.datasets import (
+ MaDLCDataFrame,
+ SingleDLCDataFrame,
+)
+from deeplabcut.pose_estimation_pytorch.apis.utils import get_inference_runners
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ select_device,
+ update_config,
+)
+
+
+class NumpyEncoder(json.JSONEncoder):
+ def default(self, obj):
+ if isinstance(obj, np.ndarray):
+ return obj.tolist() # Convert ndarray to list
+ return super().default(obj)
+
+
+def xywh2xyxy(bbox):
+ temp_bbox = np.copy(bbox)
+ temp_bbox[2:] = temp_bbox[:2] + temp_bbox[2:]
+ return temp_bbox
+
+
+def optimal_match(gts_list, preds_list):
+ num_gts = len(gts_list)
+ num_preds = len(preds_list)
+ cost_matrix = np.zeros((num_gts, num_preds))
+
+ for i in range(num_gts):
+ for j in range(num_preds):
+ cost_matrix[i, j] = distance.euclidean(gts_list[i][..., :2].flatten(), preds_list[j][..., :2].flatten())
+ row_ind, col_ind = linear_sum_assignment(cost_matrix)
+
+ return col_ind
+
+
+def calculate_iou(box1, box2):
+ # Unpack the coordinates
+ x1_1, y1_1, x2_1, y2_1 = box1
+ x1_2, y1_2, x2_2, y2_2 = box2
+
+ # Calculate the coordinates of the intersection rectangle
+ inter_x1 = max(x1_1, x1_2)
+ inter_y1 = max(y1_1, y1_2)
+ inter_x2 = min(x2_1, x2_2)
+ inter_y2 = min(y2_1, y2_2)
+
+ # Calculate the width and height of the intersection rectangle
+ inter_width = max(0, inter_x2 - inter_x1)
+ inter_height = max(0, inter_y2 - inter_y1)
+
+ # Calculate the area of the intersection rectangle
+ inter_area = inter_width * inter_height
+
+ # Calculate the area of each bounding box
+ area_1 = (x2_1 - x1_1) * (y2_1 - y1_1)
+ area_2 = (x2_2 - x1_2) * (y2_2 - y1_2)
+
+ # Calculate the area of the union of the two bounding boxes
+ union_area = area_1 + area_2 - inter_area
+
+ # Calculate the IoU
+ iou = inter_area / union_area
+
+ return iou
+
+
+def video_to_frames(input_video, output_folder, cropping: list[int] | None = None):
+ # Create the output folder if it doesn't exist
+ video = cv2.VideoCapture(str(input_video))
+ # Get the frames per second (fps) of the video
+ int(video.get(cv2.CAP_PROP_FPS))
+ # Initialize a frame counter
+ frame_count = 0
+ while True:
+ # Read a frame from the video
+ ret, frame = video.read()
+ # Break the loop if we have reached the end of the video
+ if not ret:
+ break
+ # Crop the frame if desired
+ if cropping is not None:
+ x1, x2, y1, y2 = cropping
+ frame = frame[y1:y2, x1:x2]
+
+ # Save the frame as an image file.
+ frame_str = str(frame_count).zfill(5)
+ frame_file = os.path.join(output_folder, "images", f"frame_{frame_str}.png")
+ cv2.imwrite(frame_file, frame)
+ # Increment the frame counter
+ frame_count += 1
+ # Release the video object and close the window (if open)
+ video.release()
+ # cv2.destroyAllWindows()
+
+
+def plot_cost_matrix(matrix, gt_keypoint_names, pred_keypoint_names, conversion_plot_out_path):
+
+ matrix /= np.max(matrix)
+ fig, ax = plt.subplots()
+ ax.pcolor(matrix, cmap=plt.cm.Blues, vmin=0, vmax=1)
+ ax.set_xticks(np.arange(matrix.shape[1]) + 0.5, minor=False)
+ ax.set_yticks(np.arange(matrix.shape[0]) + 0.5, minor=False)
+ ax.set_xlim(0, int(matrix.shape[1]))
+ ax.set_ylim(0, int(matrix.shape[0]))
+ ax.set_yticklabels(pred_keypoint_names, minor=False)
+ ax.set_xticklabels(gt_keypoint_names, minor=False)
+ ax.set_title("cost matrix")
+ plt.xticks(rotation=90)
+ fig = plt.gcf()
+ fig.tight_layout()
+
+ plt.savefig(conversion_plot_out_path, dpi=300)
+
+
+def keypoint_matching(
+ config_path: str | Path,
+ superanimal_name: str,
+ model_name: str,
+ detector_name: str,
+ copy_images: bool = False,
+ device: str | None = None,
+ train_file: str = "train.json",
+):
+ """Runs the keypoint matching algorithm for a DeepLabCut project.
+
+ Matches project keypoints to SuperAnimal keypoints automatically, by running
+ SuperAnimal inference on all images in the dataset
+
+ Args:
+ config_path: The path of the DeepLabCut project configuration file.
+ superanimal_name: SuperAnimal dataset with which to run keypoint matching.
+ model_name: Name of the SuperAnimal pose model architecture with which to run
+ keypoint matching
+ detector_name: Name of the SuperAnimal detector architecture with which to run
+ keypoint matching
+ copy_images: When False, symlinks are created for the dataset used for keypoint
+ matching. Otherwise, images are copied from the `labeled-data` folder to the
+ folder used for keypoint matching.
+ device: The device on which to run keypoint matching.
+ train_file: The name of the file containing the labels to output.
+ """
+ config_path = Path(config_path)
+ cfg = af.read_config(str(config_path))
+ dlc_proj_root = config_path.parent
+
+ if "individuals" in cfg:
+ temp_dataset = MaDLCDataFrame(str(dlc_proj_root), "temp_dataset")
+ max_individuals = len(cfg["individuals"])
+ else:
+ temp_dataset = SingleDLCDataFrame(str(dlc_proj_root), "temp_dataset")
+ max_individuals = 1
+
+ memory_replay_folder = dlc_proj_root / "memory_replay"
+ temp_dataset.materialize(str(memory_replay_folder), framework="coco", deepcopy=copy_images)
+
+ # run inference on the train set
+ config = modelzoo.load_super_animal_config(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=detector_name,
+ )
+ if device is None:
+ device = select_device()
+
+ # get the SuperAnimal detector and pose model snapshot paths
+ pose_model_path = modelzoo.get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=model_name,
+ )
+ detector_path = modelzoo.get_super_animal_snapshot_path(
+ dataset=superanimal_name,
+ model_name=detector_name,
+ )
+
+ config = update_config(config, max_individuals, device)
+ individuals = [f"animal{i}" for i in range(max_individuals)]
+ config["metadata"]["individuals"] = individuals
+ train_file_path = os.path.join(memory_replay_folder, "annotations", train_file)
+
+ pose_runner, detector_runner = get_inference_runners(
+ config,
+ snapshot_path=pose_model_path,
+ max_individuals=max_individuals,
+ num_bodyparts=len(config["metadata"]["bodyparts"]),
+ num_unique_bodyparts=0,
+ detector_path=detector_path,
+ )
+
+ with open(train_file_path) as f:
+ train_obj = json.load(f)
+
+ images = train_obj["images"]
+ annotations = train_obj["annotations"]
+ categories = train_obj["categories"]
+ image_name_to_id = {}
+ image_id_to_name = {}
+
+ image_name_to_gt = defaultdict(list)
+ image_name_to_bbox = defaultdict(list)
+ image_id_to_annotations = defaultdict(list)
+
+ for image in images:
+ # this only works with relative path as the testing image can be at a different folder
+ name = image["file_name"].split(os.sep)[-1]
+ image_name_to_id[name] = image["id"]
+ image_id_to_name[image["id"]] = name
+
+ for anno in annotations:
+ name = image_id_to_name[anno["image_id"]]
+ image_name_to_gt[name].append(anno)
+ image_name_to_bbox[name].append(anno["bbox"])
+
+ image_ids = set(image_name_to_id.values())
+ for anno in annotations:
+ image_id = anno["image_id"]
+ if anno["image_id"] in image_ids:
+ image_id_to_annotations[image_id].append(anno)
+
+ # need to support more image types
+ image_extensions = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.tiff"]
+ images_in_folder = []
+ for ext in image_extensions:
+ images_in_folder.extend(glob.glob(os.path.join(memory_replay_folder, "images", ext)))
+
+ corresponded_images = []
+ for image in images_in_folder:
+ image_path = image
+ name = image.split(os.sep)[-1]
+ if name in image_name_to_id:
+ corresponded_images.append(image_path)
+
+ images = corresponded_images
+ bbox_gts = [{"bboxes": np.array(image_name_to_bbox[image.split(os.sep)[-1]])} for image in images]
+
+ pose_inputs = list(zip(images, bbox_gts, strict=False))
+
+ # pose inference should return meta data for pseudo labeling
+ predictions = pose_runner.inference(pose_inputs)
+
+ with open(str(memory_replay_folder / "pseudo_predictions.json"), "w") as f:
+ json.dump(pose_inputs, f, cls=NumpyEncoder)
+
+ assert len(images) == len(predictions)
+
+ image_name_to_pred = {}
+ for image_path, prediction in zip(images, predictions, strict=False):
+ name = image_path.split(os.sep)[-1]
+ image_name_to_pred[name] = prediction
+
+ pred_keypoint_names = config["metadata"]["bodyparts"]
+ num_pred_keypoints = len(pred_keypoint_names)
+ gt_keypoint_names = categories[0]["keypoints"]
+ num_gt_keypoints = len(gt_keypoint_names)
+
+ match_matrix = np.zeros((num_pred_keypoints, num_gt_keypoints))
+ match_dict = defaultdict(lambda: defaultdict(int))
+
+ for name, gts in image_name_to_gt.items():
+ bbox_gts = [np.array(gt["bbox"]) for gt in gts]
+ bbox_gts = [xywh2xyxy(e) for e in bbox_gts]
+ prediction = image_name_to_pred[name]
+ bbox_preds = [xywh2xyxy(pred) for pred in prediction["bboxes"]]
+ optimal_pred_indices = optimal_match(bbox_gts, bbox_preds)
+
+ for idx in range(len(bbox_gts)):
+ if idx == len(optimal_pred_indices):
+ break
+
+ optimal_index = optimal_pred_indices[idx]
+ matched_gt = np.array(gts[idx]["keypoints"])
+ matched_pred = prediction["bodyparts"][optimal_index]
+ matched_gt = matched_gt.reshape(num_gt_keypoints, -1)
+ matched_pred = matched_pred.reshape(num_pred_keypoints, -1)
+
+ pair_distance = cdist(matched_pred, matched_gt)
+ row_ind, column_ind = linear_sum_assignment(pair_distance)
+ for row, column in zip(row_ind, column_ind, strict=False):
+ pred_kpt_name = pred_keypoint_names[row]
+ anno_kpt_name = gt_keypoint_names[column]
+ match_matrix[row][column] += 1
+ match_dict[pred_kpt_name][anno_kpt_name] += 1
+
+ row_ind, column_ind = linear_sum_assignment(match_matrix * -1)
+ keypoint_mapping_list = []
+
+ conversion_matrix_out_path = os.path.join(memory_replay_folder, "confusion_matrix.png")
+
+ plot_cost_matrix(match_matrix, gt_keypoint_names, pred_keypoint_names, conversion_matrix_out_path)
+
+ for row, column in zip(row_ind, column_ind, strict=False):
+ pred_kpt_name = pred_keypoint_names[row]
+ anno_kpt_name = gt_keypoint_names[column]
+ count = match_dict[pred_kpt_name][anno_kpt_name]
+ keypoint_mapping_list.append((pred_kpt_name, anno_kpt_name, count))
+
+ keypoint_mapping_list = sorted(keypoint_mapping_list, key=lambda x: x[2], reverse=True)
+
+ names = [e[:2] for e in keypoint_mapping_list]
+ conversion_table = {}
+ for pred, anno in names:
+ conversion_table[pred] = anno
+
+ conversion_table_out_path = os.path.join(memory_replay_folder, "conversion_table.csv")
+ with open(conversion_table_out_path, "w") as f:
+ out = "gt, MasterName\n"
+ for name in pred_keypoint_names:
+ target = name
+ source = conversion_table.get(target, "")
+ out += f"{source}, {target}\n"
+ f.write(out)
+
+
+# this is to generate a coco project as an intermediate data
+def dlc3predictions_2_annotation_from_video(
+ predictions,
+ dest_proj_folder,
+ bodyparts,
+ superanimal_name,
+ pose_threshold=0.0,
+ bbox_threshold=0.0,
+):
+ """
+ For video adaptation, we also need to create a coco project
+ dlc3 predictions:
+
+ list of dictionary
+ [{
+ bodyparts:[] # (n_individuals, n_kpts, 3)
+ bboxes: [] # (n_individuals, 4) -> x,y,w,h
+ }]
+
+ coco result is a list of dictionary
+ # i might get a minimal version that works with my script
+
+ category_id:
+ image_id: []
+ image_path: []
+ keypoints: []
+ score: []
+ bbox: []
+
+ """
+
+ category_id = 1 # the default for superanimal. But it might be changed
+
+ images = []
+ annotations = []
+ categories = []
+ annotation_id = 0
+ image_folder = os.path.join(dest_proj_folder, "images")
+
+ # video_to_frames function by default outputs png or jpg
+ image_paths = sorted(glob.glob(os.path.join(image_folder, "*.png")))
+
+ # Ensure predictions and image_paths have the same length before subsampling
+ if len(predictions) != len(image_paths):
+ print(f"Warning: predictions length ({len(predictions)}) != image_paths length ({len(image_paths)})")
+ # Take the minimum length to avoid index errors
+ min_length = min(len(predictions), len(image_paths))
+ predictions = predictions[:min_length]
+ image_paths = image_paths[:min_length]
+ print(f"Truncated both arrays to length {min_length}")
+
+ # skipping every 10 frames should speed up and not impact the performance
+ predictions, image_paths = predictions[::10], image_paths[::10]
+
+ # Since the inference API does not return the image path, I assume the
+ # predictions are provided in the same order as the frames in the video.
+ assert len(image_paths) == len(predictions), (
+ f"number of images must be equal to number of predictions. image_paths: {len(image_paths)} , predictions:"
+ f"{len(predictions)}"
+ )
+
+ len(bodyparts)
+
+ if not superanimal_name.startswith("superanimal_"):
+ raise ValueError("not supporting non superanimal model video adaptation yet")
+
+ category_name = superanimal_name[len("superanimal_") :]
+ categories = [
+ {
+ "name": category_name,
+ "id": 1,
+ "supercategory": "animal",
+ "keypoints": bodyparts,
+ }
+ ]
+
+ assert len(predictions) == len(image_paths)
+ imageid2annotations = defaultdict(list)
+ for image_id, (prediction, image_path) in enumerate(zip(predictions, image_paths, strict=False)):
+ image_obj = cv2.imread(image_path)
+ height, width, channels = image_obj.shape
+ imagename = image_path.split(os.sep)[-1]
+ image = {
+ "id": image_id,
+ "file_name": imagename,
+ "width": width,
+ "height": height,
+ }
+
+ # iterate through individuals if there are many
+
+ assert len(prediction["bodyparts"]) == len(prediction["bboxes"]) == len(prediction["bbox_scores"])
+ for pose, bbox, bbox_score in zip(
+ prediction["bodyparts"], prediction["bboxes"], prediction["bbox_scores"], strict=False
+ ):
+ if np.all(np.array(pose) <= 0) or len(bbox) == 0 or bbox_score < bbox_threshold:
+ continue
+ imageid2annotations[image_id].append(pose)
+ pose = np.array(pose)
+ bbox = np.array(bbox)
+
+ mask = pose[:, -1] < pose_threshold
+
+ pose[mask] = 0
+
+ # by default all visible
+ pose[:, -1] = 2
+ bbox[-1]
+
+ keypoints = list(pose.reshape(-1))
+ keypoints = [float(num) for num in keypoints]
+ # bbox here is x,y,w,h from dlc3
+ bbox = [float(num) for num in bbox][:4]
+
+ anno = {
+ "category_id": int(category_id),
+ "keypoints": keypoints,
+ "num_keypoints": len(keypoints) // 3,
+ "image_id": int(image_id),
+ "bbox": bbox,
+ "area": float(bbox[-2] * bbox[-3]),
+ "iscrowd": 0,
+ "id": int(annotation_id),
+ }
+
+ annotation_id += 1
+ annotations.append(anno)
+
+ # this is to prevent images that do not have annotations
+ if len(imageid2annotations[image_id]) > 0:
+ images.append(image)
+
+ train_obj = {"images": images, "annotations": annotations, "categories": categories}
+
+ # just use the first 10 image annotations for test
+ test_obj = {
+ "images": images[:10],
+ "annotations": annotations[:10],
+ "categories": categories,
+ }
+
+ # there is no 'test' split of video adaptation. This is essentially train.json
+ with open(os.path.join(dest_proj_folder, "annotations", "test.json"), "w") as f:
+ json.dump(test_obj, f, indent=4)
+
+ with open(os.path.join(dest_proj_folder, "annotations", "train.json"), "w") as f:
+ json.dump(train_obj, f, indent=4)
diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py
index fab48dfb01..e4e7da8eb3 100644
--- a/deeplabcut/utils/skeleton.py
+++ b/deeplabcut/utils/skeleton.py
@@ -26,25 +26,28 @@
import numpy as np
import pandas as pd
from matplotlib.collections import LineCollection
-from matplotlib.path import Path
from matplotlib.widgets import Button, LassoSelector
from ruamel.yaml import YAML
-from scipy.spatial import cKDTree as KDTree
+from scipy.spatial import KDTree
from skimage import io
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns
+
+# NOTE @C-Achard 2026-03-26 duplicate config read/write functions
+# should be addressed in config refactor
def read_config(configname):
if not os.path.exists(configname):
- raise FileNotFoundError(
- f"Config {configname} is not found. Please make sure that the file exists."
- )
- with open(configname) as file:
- return YAML().load(file)
+ raise FileNotFoundError(f"Config {configname} is not found. Please make sure that the file exists.")
+ yaml = YAML(typ="rt")
+ with open(configname, encoding="utf-8") as file:
+ return yaml.load(file)
def write_config(configname, cfg):
- with open(configname, "w") as file:
- YAML().dump(cfg, file)
+ yaml = YAML(typ="rt")
+ with open(configname, "w", encoding="utf-8") as file:
+ yaml.dump(cfg, file)
class SkeletonBuilder:
@@ -57,12 +60,9 @@ def __init__(self, config_path):
root = os.path.join(self.cfg["project_path"], "labeled-data")
for dir_ in os.listdir(root):
folder = os.path.join(root, dir_)
- if os.path.isdir(folder) and not any(
- folder.endswith(s) for s in ("cropped", "labeled")
- ):
- self.df = pd.read_hdf(
- os.path.join(folder, f'CollectedData_{self.cfg["scorer"]}.h5')
- )
+ if os.path.isdir(folder) and not any(folder.endswith(s) for s in ("cropped", "labeled")):
+ self.df = pd.read_hdf(os.path.join(folder, f"CollectedData_{self.cfg['scorer']}.h5"))
+ self.df = drop_likelihood_columns(self.df)
row, col = self.pick_labeled_frame()
if "individuals" in self.df.columns.names:
self.df = self.df.xs(col, axis=1, level="individuals")
@@ -72,13 +72,14 @@ def __init__(self, config_path):
found = True
break
if self.df is None:
- raise IOError("No labeled data were found.")
+ raise OSError("No labeled data were found.")
self.bpts = self.df.columns.get_level_values("bodyparts").unique()
if not found:
warnings.warn(
f"A fully labeled animal could not be found. "
- f"{', '.join(self.bpts[missing])} will need to be manually connected in the config.yaml."
+ f"{', '.join(self.bpts[missing])} will need to be manually connected in the config.yaml.",
+ stacklevel=2,
)
self.tree = KDTree(self.xy)
# Handle image previously annotated on a different platform
@@ -97,29 +98,12 @@ def __init__(self, config_path):
pair_sorted = tuple(sorted(pair))
self.inds.add(pair_sorted)
self.segs.add(tuple(map(tuple, self.xy[pair_sorted, :])))
- self.lines = LineCollection(
- self.segs, colors=mcolors.to_rgba(self.cfg["skeleton_color"])
- )
+ self.lines = LineCollection(self.segs, colors=mcolors.to_rgba(self.cfg["skeleton_color"]))
self.lines.set_picker(True)
- self.show()
-
- def pick_labeled_frame(self):
- # Find the most 'complete' animal
- try:
- count = self.df.groupby(level="individuals", axis=1).count()
- if "single" in count:
- count.drop("single", axis=1, inplace=True)
- except KeyError:
- count = self.df.count(axis=1).to_frame()
- mask = count.where(count == count.values.max())
- kept = mask.stack().index.to_list()
- np.random.shuffle(kept)
- picked = kept.pop()
- row = picked[:-1]
- col = picked[-1]
- return row, col
+ self.build_ui()
+ self.display()
- def show(self):
+ def build_ui(self):
self.fig = plt.figure()
ax = self.fig.add_subplot(111)
ax.axis("off")
@@ -145,38 +129,70 @@ def show(self):
self.export_button = Button(ax_export, "Export")
self.export_button.on_clicked(self.export)
self.fig.canvas.mpl_connect("pick_event", self.on_pick)
+
+ def display(self):
plt.show()
+ def pick_labeled_frame(self):
+ # Find the most 'complete' animal
+ if "individuals" in self.df.columns.names:
+ count = self.df.T.groupby(level="individuals").count().T
+ if "single" in count.columns:
+ count = count.drop(columns="single")
+ else:
+ count = self.df.count(axis=1).to_frame()
+ mask = count.where(count == count.to_numpy().max())
+ kept = mask.stack().index.to_list()
+ np.random.shuffle(kept)
+ picked = kept.pop()
+ row = picked[:-1]
+ col = picked[-1]
+ return row, col
+
def clear(self, *args):
self.inds.clear()
self.segs.clear()
- self.lines.set_segments(self.segs)
+ self.lines.set_segments([])
+ self.fig.canvas.draw_idle()
+
+ def read_config(self, config_path):
+ return read_config(config_path)
+
+ def write_config(self, config_path, cfg):
+ write_config(config_path, cfg)
def export(self, *args):
inds_flat = set(ind for pair in self.inds for ind in pair)
unconnected = [i for i in range(len(self.xy)) if i not in inds_flat]
if len(unconnected):
warnings.warn(
- f"You didn't connect all the bodyparts (which is fine!). This is just a note to let you know."
+ "You didn't connect all the bodyparts (which is fine!). This is just a note to let you know.",
+ stacklevel=2,
)
- self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in self.inds]
- write_config(self.config_path, self.cfg)
+ # sort to ensure consistent order in config.yaml
+ self.cfg["skeleton"] = [tuple(self.bpts[list(pair)]) for pair in sorted(self.inds)]
+ self.write_config(self.config_path, self.cfg)
def on_pick(self, event):
if event.mouseevent.button == 3:
- removed = event.artist.get_segments().pop(event.ind[0])
- self.segs.remove(tuple(map(tuple, removed)))
- self.inds.remove(tuple(self.tree.query(removed)[1]))
+ seg = tuple(map(tuple, event.artist.get_segments()[event.ind[0]]))
+ self.segs.discard(seg)
+
+ pair = tuple(sorted(self.tree.query(np.asarray(seg))[1]))
+ self.inds.discard(pair)
+
+ self.lines.set_segments(list(self.segs))
+ self.fig.canvas.draw_idle()
def on_select(self, verts):
- self.path = Path(verts)
- self.verts = verts
+ # self.path = Path(verts)
+ # self.verts = verts
inds = self.tree.query_ball_point(verts, 5)
inds_unique = []
for lst in inds:
if len(lst) and lst[0] not in inds_unique:
inds_unique.append(lst[0])
- for pair in zip(inds_unique, inds_unique[1:]):
+ for pair in zip(inds_unique, inds_unique[1:], strict=False):
pair_sorted = tuple(sorted(pair))
self.inds.add(pair_sorted)
self.segs.add(tuple(map(tuple, self.xy[pair_sorted, :])))
diff --git a/deeplabcut/utils/video_processor.py b/deeplabcut/utils/video_processor.py
index 5da9f7c54f..5ce7bee90c 100644
--- a/deeplabcut/utils/video_processor.py
+++ b/deeplabcut/utils/video_processor.py
@@ -25,16 +25,14 @@
import numpy as np
-class VideoProcessor(object):
- """
- Base class for a video processing unit, implementation is required for video loading and saving
+class VideoProcessor:
+ """Base class for a video processing unit, implementation is required for video
+ loading and saving.
sh and sw are the output height and width respectively.
"""
- def __init__(
- self, fname="", sname="", nframes=-1, fps=None, codec="X264", sh="", sw=""
- ):
+ def __init__(self, fname="", sname="", nframes=-1, fps=None, codec="X264", sh="", sw=""):
self.fname = fname
self.sname = sname
self.nframes = nframes
@@ -87,50 +85,35 @@ def frame_count(self):
return self.nframes
def get_video(self):
- """
- implement your own
- """
+ """Implement your own."""
pass
def get_info(self):
- """
- implement your own
- """
+ """Implement your own."""
pass
def create_video(self):
- """
- implement your own
- """
+ """Implement your own."""
pass
def _read_frame(self):
- """
- implement your own
- """
+ """Implement your own."""
pass
def save_frame(self, frame):
- """
- implement your own
- """
+ """Implement your own."""
pass
def close(self):
- """
- implement your own
- """
+ """Implement your own."""
pass
class VideoProcessorCV(VideoProcessor):
- """
- OpenCV implementation of VideoProcessor
- requires opencv-python==3.4.0.12
- """
+ """OpenCV implementation of VideoProcessor requires opencv-python==3.4.0.12."""
def __init__(self, *args, **kwargs):
- super(VideoProcessorCV, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
def get_video(self):
return cv2.VideoCapture(self.fname)
diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py
index 407ae65dd7..e0bbff0b5c 100644
--- a/deeplabcut/utils/visualization.py
+++ b/deeplabcut/utils/visualization.py
@@ -18,21 +18,33 @@
Licensed under GNU Lesser General Public License v3.0
"""
+from __future__ import annotations
+
import os
from pathlib import Path
+import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
+import pandas as pd
from matplotlib.collections import LineCollection
-from skimage import io, color
+from matplotlib.colors import Colormap
+from skimage import color, io
from tqdm import trange
-from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils import auxfun_videos, auxiliaryfunctions
+
+def get_cmap(n: int, name: str = "hsv") -> Colormap:
+ """
+ Args:
+ n: number of distinct colors
+ name: name of matplotlib colormap
-def get_cmap(n, name="hsv"):
- """Returns a function that maps each index in 0, 1, ..., n-1 to a distinct
- RGB color; the keyword argument name must be a standard mpl colormap name."""
+ Returns:
+ A function that maps each index in 0, 1, ..., n-1 to a distinct
+ RGB color; the keyword argument name must be a standard mpl colormap name.
+ """
return plt.cm.get_cmap(name, n)
@@ -45,12 +57,15 @@ def make_labeled_image(
bodyparts,
colors,
cfg,
- labels=["+", ".", "x"],
+ labels=None,
scaling=1,
ax=None,
):
- """Creating a labeled image with the original human labels, as well as the DeepLabCut's!"""
+ """Creating a labeled image with the original human labels, as well as the
+ DeepLabCut's!"""
+ if labels is None:
+ labels = ["+", ".", "x"]
alphavalue = cfg["alphavalue"] # .5
dotsize = cfg["dotsize"] # =15
@@ -61,18 +76,17 @@ def make_labeled_image(
h, w = np.shape(frame)
_, ax = prepare_figure_axes(w, h, scaling)
ax.imshow(frame, "gray")
- for scorerindex, loopscorer in enumerate(Scorers):
+ for _scorerindex, loopscorer in enumerate(Scorers):
for bpindex, bp in enumerate(bodyparts):
if np.isfinite(
- DataCombined[loopscorer][bp]["y"][imagenr]
- + DataCombined[loopscorer][bp]["x"][imagenr]
+ DataCombined[loopscorer][bp]["y"].iloc[imagenr] + DataCombined[loopscorer][bp]["x"].iloc[imagenr]
):
y, x = (
- int(DataCombined[loopscorer][bp]["y"][imagenr]),
- int(DataCombined[loopscorer][bp]["x"][imagenr]),
+ int(DataCombined[loopscorer][bp]["y"].iloc[imagenr]),
+ int(DataCombined[loopscorer][bp]["x"].iloc[imagenr]),
)
if cfg["scorer"] not in loopscorer:
- p = DataCombined[loopscorer][bp]["likelihood"][imagenr]
+ p = DataCombined[loopscorer][bp]["likelihood"].iloc[imagenr]
if p > pcutoff:
ax.plot(
x,
@@ -104,22 +118,75 @@ def make_labeled_image(
def make_multianimal_labeled_image(
- frame,
- coords_truth,
- coords_pred,
- probs_pred,
- colors,
- dotsize=12,
- alphavalue=0.7,
- pcutoff=0.6,
- labels=["+", ".", "x"],
- ax=None,
-):
+ frame: np.ndarray,
+ coords_truth: np.ndarray | list,
+ coords_pred: np.ndarray | list,
+ probs_pred: np.ndarray | list,
+ colors: Colormap,
+ dotsize: float | int = 12,
+ alphavalue: float = 0.7,
+ pcutoff: float = 0.6,
+ labels: list = None,
+ ax: plt.Axes | None = None,
+ bounding_boxes: tuple[np.ndarray, np.ndarray] | None = None,
+ bboxes_cutoff: float = 0.6,
+ bboxes_color: Colormap | str | None = None,
+) -> plt.Axes:
+ """Plots groundtruth labels and predictions onto the matplotlib's axes, with the
+ specified graphical parameters.
+
+ Args:
+ frame: image
+ coords_truth: groundtruth labels
+ coords_pred: predictions
+ probs_pred: prediction probabilities
+ colors: colors for poses
+ dotsize: size of dot
+ alphavalue: transparency for the keypoints
+ pcutoff: cut-off confidence value
+ labels: labels to use for ground truth, reliable predictions, and not reliable predictions (confidence below
+ cut-off value)
+ ax: matplotlib plot's axes object
+ bounding_boxes: bounding boxes (top-left corner, size) and their respective confidence levels,
+ bboxes_cutoff: bounding boxes confidence cutoff threshold.
+ bboxes_color: color(s) for the bounding boxes.
+ If Colormap is passed -> each bounding box will be colored into its own color from the colormap.
+ If string is passed -> all bboxes will be of string's defined color.
+ If None -> all bboxes will be colored into a default color.
+
+ Returns:
+ matplotlib Axes object with plotted labels and predictions.
+ """
+
+ if labels is None:
+ labels = ["+", ".", "x"]
if ax is None:
h, w, _ = np.shape(frame)
_, ax = prepare_figure_axes(w, h)
ax.imshow(frame, "gray")
- for n, data in enumerate(zip(coords_truth, coords_pred, probs_pred)):
+
+ if bounding_boxes is not None:
+ for i, (bbox, bbox_score) in enumerate(zip(bounding_boxes[0], bounding_boxes[1], strict=False)):
+ bbox_origin = (bbox[0], bbox[1])
+ (bbox_width, bbox_height) = (bbox[2], bbox[3])
+ if isinstance(bboxes_color, Colormap):
+ bbox_color = bboxes_color(i)
+ elif bboxes_color is None:
+ bbox_color = "red"
+ else:
+ bbox_color = bboxes_color
+ rectangle = patches.Rectangle(
+ bbox_origin,
+ bbox_width,
+ bbox_height,
+ linewidth=1,
+ edgecolor=bbox_color,
+ facecolor="none",
+ linestyle="--" if bbox_score < bboxes_cutoff else "-",
+ )
+ ax.add_patch(rectangle)
+
+ for n, data in enumerate(zip(coords_truth, coords_pred, probs_pred, strict=False)):
color = colors(n)
coord_gt, coord_pred, prob_pred = data
@@ -159,7 +226,10 @@ def plot_and_save_labeled_frame(
ax,
scaling=1,
):
- image_path = os.path.join(cfg["project_path"], *DataCombined.index[ind])
+ if isinstance(DataCombined.index[ind], tuple):
+ image_path = os.path.join(cfg["project_path"], *DataCombined.index[ind])
+ else:
+ image_path = os.path.join(cfg["project_path"], DataCombined.index[ind])
frame = io.imread(image_path)
if np.ndim(frame) > 2: # color image!
h, w, numcolors = np.shape(frame)
@@ -217,9 +287,7 @@ def erase_artists(ax):
def prepare_figure_axes(width, height, scale=1.0, dpi=100):
- fig = plt.figure(
- frameon=False, figsize=(width * scale / dpi, height * scale / dpi), dpi=dpi
- )
+ fig = plt.figure(frameon=False, figsize=(width * scale / dpi, height * scale / dpi), dpi=dpi)
ax = fig.add_subplot(111)
ax.axis("off")
ax.set_xlim(0, width)
@@ -238,8 +306,8 @@ def make_labeled_images_from_dataframe(
draw_skeleton=True,
color_by="bodypart",
):
- """
- Write labeled frames to disk from a DataFrame.
+ """Write labeled frames to disk from a DataFrame.
+
Parameters
----------
df : pd.DataFrame
@@ -269,12 +337,10 @@ def make_labeled_images_from_dataframe(
bodypart_names = bodyparts.unique()
nbodyparts = len(bodypart_names)
bodyparts = bodyparts[::2]
- draw_skeleton = (
- draw_skeleton and cfg["skeleton"]
- ) # Only draw if a skeleton is defined
+ draw_skeleton = draw_skeleton and cfg["skeleton"] # Only draw if a skeleton is defined
if color_by == "bodypart":
- map_ = bodyparts.map(dict(zip(bodypart_names, range(nbodyparts))))
+ map_ = bodyparts.map(dict(zip(bodypart_names, range(nbodyparts), strict=False)))
cmap = get_cmap(nbodyparts, cfg["colormap"])
colors = cmap(map_)
elif color_by == "individual":
@@ -283,13 +349,11 @@ def make_labeled_images_from_dataframe(
individual_names = individuals.unique().to_list()
nindividuals = len(individual_names)
individuals = individuals[::2]
- map_ = individuals.map(dict(zip(individual_names, range(nindividuals))))
+ map_ = individuals.map(dict(zip(individual_names, range(nindividuals), strict=False)))
cmap = get_cmap(nindividuals, cfg["colormap"])
colors = cmap(map_)
except KeyError as e:
- raise Exception(
- "Coloring by individuals is only valid for multi-animal data"
- ) from e
+ raise Exception("Coloring by individuals is only valid for multi-animal data") from e
else:
raise ValueError("`color_by` must be either `bodypart` or `individual`.")
@@ -302,12 +366,10 @@ def make_labeled_images_from_dataframe(
match1.append(j)
elif bp == bp2:
match2.append(j)
- bones.extend(zip(match1, match2))
- ind_bones = tuple(zip(*bones))
+ bones.extend(zip(match1, match2, strict=False))
+ ind_bones = tuple(zip(*bones, strict=False))
- images_list = [
- os.path.join(cfg["project_path"], *tuple_) for tuple_ in df.index.tolist()
- ]
+ images_list = [os.path.join(cfg["project_path"], *tuple_) for tuple_ in df.index.tolist()]
if not destfolder:
destfolder = os.path.dirname(images_list[0])
tmpfolder = destfolder + "_labeled"
@@ -340,8 +402,8 @@ def make_labeled_images_from_dataframe(
if img.ndim == 2 or img.shape[-1] == 1:
img = color.gray2rgb(ic[i])
im.set_data(img)
- for pt, coord in zip(pts, coords):
- pt.set_data(*coord)
+ for pt, coord in zip(pts, coords, strict=False):
+ pt.set_data(*np.expand_dims(coord, axis=1))
if ind_bones:
coll.set_segments(segs[ind])
imagename = os.path.basename(filename)
@@ -361,12 +423,10 @@ def make_labeled_images_from_dataframe(
h, w = image.shape[:2]
fig, ax = prepare_figure_axes(w, h, scale, dpi)
ax.imshow(image)
- for coord, c in zip(coords, colors):
+ for coord, c in zip(coords, colors, strict=False):
ax.plot(*coord, keypoint, ms=s, alpha=alpha, color=c)
if ind_bones:
- coll = LineCollection(
- segs[ind], colors=cfg["skeleton_color"], alpha=alpha
- )
+ coll = LineCollection(segs[ind], colors=cfg["skeleton_color"], alpha=alpha)
ax.add_collection(coll)
imagename = os.path.basename(filename)
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
@@ -375,3 +435,172 @@ def make_labeled_images_from_dataframe(
dpi=dpi,
)
plt.close(fig)
+
+
+def plot_evaluation_results(
+ df_combined: pd.DataFrame,
+ project_root: str,
+ scorer: str,
+ model_name: str,
+ output_folder: str,
+ in_train_set: bool,
+ plot_unique_bodyparts: bool = False,
+ mode: str = "bodypart",
+ colormap: str = "rainbow",
+ dot_size: int = 12,
+ alpha_value: float = 0.7,
+ p_cutoff: float = 0.6,
+ bounding_boxes: dict | None = None,
+ bboxes_cutoff: float = 0.6,
+ bounding_boxes_color: str = "auto",
+) -> None:
+ """Creates labeled images using the results of inference, and saves them to an
+ output folder.
+
+ Args:
+ df_combined: dataframe with multiindex rows ("labeled-data", video_name,
+ image_name) and columns ("scorer", "individuals", "bodyparts", "coords").
+ There should be two scorers: scorer (for ground truth data) and model_name
+ (for prediction data)
+ project_root: the project root path
+ scorer: the name of the scorer for ground truth data in df_combined
+ model_name: the name of the model for predictions in df_combined
+ output_folder: the name of the folder where images should be saved
+ in_train_set: whether df_combined is for train set images
+ plot_unique_bodyparts: whether we should plot unique bodyparts
+ mode: one of {"bodypart", "individual"}. Determines the keypoint color grouping
+ colormap: the colormap to use for keypoints
+ dot_size: the dot size to use for keypoints
+ alpha_value: the alpha value to use for keypoints
+ p_cutoff: the p-cutoff for "confident" keypoints
+ bounding_boxes: dictionary with df_combined rows as keys and bounding boxes
+ (np array for coordinates and np array for confidence).
+ None corresponds to no bounding boxes.
+ bboxes_cutoff: bounding boxes confidence cutoff threshold.
+ bounding_boxes_color: If plotting bounding boxes, this is the color that will be used for bounding boxes.
+ If set to "auto" (default value):
+ - if mode is "bodypart", the bbox color will be a default color
+ - if mode is "individual", each individual's color will be used for its bounding box
+ """
+ if bounding_boxes is None:
+ bounding_boxes = {}
+
+ for row_index, row in df_combined.iterrows():
+ if isinstance(row_index, str):
+ image_rel_path = Path(row_index)
+ data_folder = image_rel_path.parent.parent.name
+ video = image_rel_path.parent.name
+ image = image_rel_path.name
+ else:
+ data_folder, video, image = row_index
+
+ image_path = Path(project_root) / data_folder / video / image
+ frame = auxfun_videos.imread(str(image_path), mode="skimage")
+
+ row_multi = row.loc[(slice(None), row.index.get_level_values("individuals") != "single")]
+ individuals = len(row_multi.index.get_level_values("individuals").unique())
+ bodyparts = len(row_multi.index.get_level_values("bodyparts").unique())
+ df_gt = row_multi[scorer]
+ df_predictions = row_multi[model_name]
+
+ # Shape (num_individuals, num_bodyparts, xy)
+ try:
+ ground_truth = df_gt.to_numpy().reshape((individuals, bodyparts, 2))
+ predictions = df_predictions.to_numpy().reshape((individuals, bodyparts, 3))
+ except ValueError:
+ # Handle cases where the actual data size doesn't match expected shape
+ actual_size_gt = df_gt.size
+ actual_size_pred = df_predictions.size
+ expected_size_gt = individuals * bodyparts * 2
+ expected_size_pred = individuals * bodyparts * 3
+
+ print(f"Warning: DataFrame reshape failed for {image}")
+ print(f" Expected: {individuals} individuals, {bodyparts} bodyparts")
+ print(f" Ground truth: {actual_size_gt} elements (expected {expected_size_gt})")
+ print(f" Predictions: {actual_size_pred} elements (expected {expected_size_pred})")
+ print(" Skipping visualization for this image")
+ continue
+
+ bboxes = bounding_boxes.get(row_index)
+
+ if plot_unique_bodyparts:
+ row_unique = row.loc[(slice(None), row.index.get_level_values("individuals") == "single")]
+ unique_individuals = 1
+ unique_bodyparts = len(row_unique.index.get_level_values("bodyparts").unique())
+ try:
+ unique_ground_truth = row_unique[scorer].to_numpy().reshape((unique_individuals, unique_bodyparts, 2))
+ unique_predictions = (
+ row_unique[model_name].to_numpy().reshape((unique_individuals, unique_bodyparts, 3))
+ )
+ except ValueError:
+ # Handle cases where unique bodyparts reshape fails
+ print(f"Warning: Unique bodyparts reshape failed for {image}, skipping unique bodyparts")
+ plot_unique_bodyparts = False
+
+ fig, ax = create_minimal_figure()
+ h, w, _ = np.shape(frame)
+ fig.set_size_inches(w / 100, h / 100)
+ ax.set_xlim(0, w)
+ ax.set_ylim(0, h)
+ ax.invert_yaxis()
+
+ if mode == "bodypart":
+ num_colors = bodyparts
+ if plot_unique_bodyparts:
+ num_colors += unique_bodyparts
+
+ colors = get_cmap(num_colors, name=colormap)
+ predictions = predictions.swapaxes(0, 1)
+ ground_truth = ground_truth.swapaxes(0, 1)
+ elif mode == "individual":
+ colors = get_cmap(individuals + 1, name=colormap)
+ else:
+ colors = []
+
+ if bounding_boxes_color == "auto":
+ if mode == "bodypart":
+ bboxes_color = None
+ elif mode == "individual":
+ bboxes_color = get_cmap(individuals + 1, name=colormap)
+ else:
+ raise ValueError(f"Invalid mode: {mode}")
+ else:
+ bboxes_color = bounding_boxes_color
+
+ ax = make_multianimal_labeled_image(
+ frame=frame,
+ coords_truth=ground_truth,
+ coords_pred=predictions[:, :, :2],
+ probs_pred=predictions[:, :, 2:],
+ colors=colors,
+ dotsize=dot_size,
+ alphavalue=alpha_value,
+ pcutoff=p_cutoff,
+ ax=ax,
+ bounding_boxes=bboxes,
+ bboxes_cutoff=bboxes_cutoff,
+ bboxes_color=bboxes_color,
+ )
+ if plot_unique_bodyparts:
+ unique_predictions = unique_predictions.swapaxes(0, 1)
+ unique_ground_truth = unique_ground_truth.swapaxes(0, 1)
+ ax = make_multianimal_labeled_image(
+ frame=frame,
+ coords_truth=unique_ground_truth,
+ coords_pred=unique_predictions[:, :, :2],
+ probs_pred=unique_predictions[:, :, 2:],
+ colors=colors,
+ dotsize=dot_size,
+ alphavalue=alpha_value,
+ pcutoff=p_cutoff,
+ ax=ax,
+ )
+
+ save_labeled_frame(
+ fig,
+ str(image_path),
+ output_folder,
+ belongs_to_train=in_train_set,
+ )
+ erase_artists(ax)
+ plt.close()
diff --git a/deeplabcut/version.py b/deeplabcut/version.py
index 6fd013311e..08022ce8dc 100644
--- a/deeplabcut/version.py
+++ b/deeplabcut/version.py
@@ -9,5 +9,5 @@
# Licensed under GNU Lesser General Public License v3.0
#
-__version__ = "2.3.9"
+__version__ = "3.0.0"
VERSION = __version__
diff --git a/dlc.py b/dlc.py
index 6ec999032b..811227ad3a 100644
--- a/dlc.py
+++ b/dlc.py
@@ -7,6 +7,7 @@
https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
Licensed under GNU Lesser General Public License v3.0
"""
+
from deeplabcut import cli
diff --git a/docker/.dockerignore b/docker/.dockerignore
new file mode 100644
index 0000000000..50710c2a0c
--- /dev/null
+++ b/docker/.dockerignore
@@ -0,0 +1,5 @@
+examples/
+package/
+README.md
+docker-bake.hcl
+.gitignore
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000000..9119099bb8
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,59 @@
+# syntax=docker/dockerfile:1
+
+ARG PYTORCH_VERSION=2.5.1
+ARG CUDA_VERSION=12.4
+ARG CUDNN_VERSION=9
+ARG DEEPLABCUT_VERSION=3.0.0rc14
+
+# ── core ──────────────────────────────────────────────────────────────────────
+FROM pytorch/pytorch:${PYTORCH_VERSION}-cuda${CUDA_VERSION}-cudnn${CUDNN_VERSION}-runtime AS core
+
+ARG DEEPLABCUT_VERSION
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -yy && \
+ apt-get install -yy --no-install-recommends \
+ libgl1 \
+ libglib2.0-0 \
+ build-essential \
+ git \
+ ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN pip install --upgrade pip && \
+ pip install "deeplabcut[modelzoo,wandb]==${DEEPLABCUT_VERSION}"
+
+# Create pretrained weights directory and make it writable
+# (same default as HuggingFaceWeightsMixin in backbones/base.py: Path(__file__).parent / "pretrained_weights")
+RUN PRETRAINED=$(python -c "from pathlib import Path; import deeplabcut.pose_estimation_pytorch.models.backbones.base as b; print(Path(b.__file__).parent / 'pretrained_weights')") && \
+ mkdir -p "$PRETRAINED" && \
+ chmod a+rwx -R "$PRETRAINED"
+
+RUN mkdir -p /app && chmod a+rwx /app
+
+COPY motd.sh /home/motd.sh
+RUN echo "source /home/motd.sh" >> /etc/profile
+
+ENV CUDA_VERSION=${CUDA_VERSION}
+ENV PYTORCH_VERSION=${PYTORCH_VERSION}
+ENV DEEPLABCUT_VERSION=${DEEPLABCUT_VERSION}
+
+WORKDIR /app
+
+# ── jupyter ───────────────────────────────────────────────────────────────────
+# (runs as root intentionally; deeplabcut-docker adds a host-matched user at runtime)
+FROM core AS jupyter
+
+ENV NOTEBOOK_TOKEN=deeplabcut
+
+RUN pip install "notebook<7"
+EXPOSE 8888
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8888/api/', timeout=3).read()" || exit 1
+ENTRYPOINT ["jupyter", "notebook", \
+ "--no-browser", "--NotebookApp.token=${NOTEBOOK_TOKEN}", "--ip", "0.0.0.0"]
+
+# ── test (CI only, not published to Hub) ─────────────────────────────────────
+FROM core AS test
+
+RUN pip install --no-cache-dir pytest
diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base
deleted file mode 100644
index a71f5c4701..0000000000
--- a/docker/Dockerfile.base
+++ /dev/null
@@ -1,34 +0,0 @@
-ARG CUDA_VERSION
-FROM nvidia/cuda:${CUDA_VERSION}
-
-ARG DEEPLABCUT_VERSION
-ENV DEBIAN_FRONTEND=noninteractive
-
-ARG PYTHON_VERSION=3.9
-RUN apt-get update -yy && \
- apt-get install -yy --no-install-recommends python${PYTHON_VERSION} python3-pip ffmpeg libsm6 libxext6 && \
- ln -s -f /usr/bin/python${PYTHON_VERSION} /usr/bin/python3 && \
- ln -s -f /usr/bin/python${PYTHON_VERSION} /usr/bin/python && \
- ln -s -f /usr/bin/pip3 /usr/bin/pip && \
- rm -rf /var/lib/apt/lists/* && \
- apt-get clean
-
-RUN pip3 install --upgrade \
- deeplabcut==${DEEPLABCUT_VERSION} \
- numpy==1.24.0 \
- decorator==4.4.2 \
- tensorflow==2.10 \
- torch==1.12 \
- && pip3 list
-
-# The installed tensorflow version will not work with the latest protocol buffer version,
-# hence we are fixing the version to 3.20.
-# See https://developers.google.com/protocol-buffers/docs/news/2022-05-06#python-updates
-# for details on why this is needed. (re: Aug 21, 2023: retested, still required)
-RUN pip3 install protobuf==3.20.1
-
-# TODO required to fix permission errors when running the container with limited permission.
-RUN chmod a+rwx -R /usr/local/lib/python${PYTHON_VERSION}/dist-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained
-
-ENV CUDA_VERSION=${CUDA_VERSION}
-ENV DEEPLABCUT_VERSION=${DEEPLABCUT_VERSION}
diff --git a/docker/Dockerfile.core b/docker/Dockerfile.core
deleted file mode 100644
index a5e8602768..0000000000
--- a/docker/Dockerfile.core
+++ /dev/null
@@ -1,8 +0,0 @@
-ARG CUDA_VERSION
-ARG DEEPLABCUT_VERSION
-FROM deeplabcut/deeplabcut:${DEEPLABCUT_VERSION}-base-cuda${CUDA_VERSION}-latest
-
-ENV DLClight True
-
-COPY motd.sh /home/motd.sh
-RUN echo "source /home/motd.sh" >> /etc/profile
diff --git a/docker/Dockerfile.gui b/docker/Dockerfile.gui
deleted file mode 100644
index 3c3016e58e..0000000000
--- a/docker/Dockerfile.gui
+++ /dev/null
@@ -1,19 +0,0 @@
-# NOTE: This dockerfile is currently not included in the build process
-# It is still left for reference, but currently untested.
-ARG CUDA_VERSION
-ARG DEEPLABCUT_VERSION
-
-FROM deeplabcut/deeplabcut:${DEEPLABCUT_VERSION}-base-cuda${CUDA_VERSION}-latest
-
-RUN DEBIAN_FRONTEND=noninteractive apt-get update -yy \
- && apt-get install -yy --no-install-recommends libgtk-3-dev python3-wxgtk4.0 locales \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/* \
- && locale-gen en_US.UTF-8 en_GB.UTF-8
-
-ARG DEEPLABCUT_VERSION
-RUN pip3 install --no-cache-dir --upgrade deeplabcut[gui]==${DEEPLABCUT_VERSION} \
- && pip3 list
-
-ENV DLClight=False
-CMD ["python3", "-m", "deeplabcut"]
diff --git a/docker/Dockerfile.jupyter b/docker/Dockerfile.jupyter
deleted file mode 100644
index efa998d4db..0000000000
--- a/docker/Dockerfile.jupyter
+++ /dev/null
@@ -1,23 +0,0 @@
-ARG CUDA_VERSION
-ARG DEEPLABCUT_VERSION
-FROM deeplabcut/deeplabcut:${DEEPLABCUT_VERSION}-core-cuda${CUDA_VERSION}-latest
-
-RUN pip3 install --no-cache-dir \
- notebook==6.4.12 \
- && pip3 list
-
-ENV PYTHONPATH "${PYTHONPATH}:/usr/lib/python3"
-
-ARG USER=docker_user
-RUN useradd -m ${USER} \
- && cp /root/.bashrc /home/${USER}/ \
- && mkdir /app /data /codebase \
- && chown -R --from=root ${USER} /home/${SUSER} \
- /app /data /codebase
-ENV HOME /home/${USER}
-WORKDIR ${HOME}
-USER ${USER}
-
-RUN jupyter notebook --generate-config
-EXPOSE 8888
-ENTRYPOINT ["jupyter", "notebook", "--no-browser", "--ip", "0.0.0.0"]
diff --git a/docker/Dockerfile.test b/docker/Dockerfile.test
deleted file mode 100644
index 95f61dab9f..0000000000
--- a/docker/Dockerfile.test
+++ /dev/null
@@ -1,14 +0,0 @@
-ARG CUDA_VERSION
-ARG DEEPLABCUT_VERSION
-FROM deeplabcut/deeplabcut:${DEEPLABCUT_VERSION}-core-cuda${CUDA_VERSION}-latest
-
-RUN mkdir test/
-WORKDIR test
-RUN apt-get update && apt-get install -yy git
-RUN git config --global advice.detachedHead false
-RUN git clone --depth 1 --branch v${DEEPLABCUT_VERSION} \
- https://github.com/DeepLabCut/DeepLabCut.git /test
-
-RUN pip3 install --no-cache-dir pytest
-RUN chmod a+rwx -R /test
-
diff --git a/docker/MANIFEST.in b/docker/MANIFEST.in
deleted file mode 100644
index 644ae86674..0000000000
--- a/docker/MANIFEST.in
+++ /dev/null
@@ -1,4 +0,0 @@
-include pyproject.toml
-include PYPI_README.md
-include LICENSE
-include deeplabcut_docker.sh
diff --git a/docker/README.md b/docker/README.md
index b8a5584465..6e03a281ae 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,36 +1,32 @@
# DeepLabCut Dockerfiles
-**Note that this README is mainly intended for DeepLabCut developers. The main documentation contains its own user documentation on the provided docker images.**
+**Note that this README is mainly intended for DeepLabCut developers. The main
+documentation contains its own user documentation on the provided docker images.**
This repo contains build routines for the following official DeepLabCut docker images:
-- `deeplabcut/deeplabcut:base`: Base image with TF2.5, cuDNN8 and DLC
-- `deeplabcut/deeplabcut:latest-core`: DLC in light mode
-- `deeplabcut/deeplabcut:latest-gui`: DLC in GUI mode
-- `deeplabcut/deeplabcut:latest-gui-jupyter`: DLC in GUI mode, with jupyter installed
+- `deeplabcut/deeplabcut:latest` — default runtime image (same as the former “core” image)
+- `deeplabcut/deeplabcut:latest-jupyter` — Jupyter Notebook server
+- `deeplabcut/deeplabcut:${DLC_VERSION}-core-cuda${CUDA_VERSION}` and `...-jupyter-cuda...` — versioned tags
-All images are based on Python 3.8.
+All images come with Python 3.11 installed.
The images are synced to DockerHub: https://hub.docker.com/r/deeplabcut/deeplabcut
## Quickstart
-You can use the images fully standalone, without the need of cloning the DeepLabCut repo.
-A helper package called `deeplabcut-docker` is available on PyPI and can be installed by running
+### `deeplabcut-docker`
-``` bash
+You can use the images fully standalone, without the need of cloning the DeepLabCut
+repo. A helper package called `deeplabcut-docker` is available on PyPI and can be
+installed by running:
+
+```bash
pip install deeplabcut-docker
```
-*Note: Advanced users can also directly download and use the `deeplabcut-docker.sh` script if this is preferred over a python helper script.*
-
-We provide docker containers for three different use cases outlined below.
+We provide docker containers for two different use cases outlined below. In both cases,
+your current directory will be mounted in the container, and the container will be
+started with your current username and group.
-In all cases, your current directory will be mounted in the container, and the container
-will be started with your current username and group.
-
-- To launch the DLC GUI directly, run
- ```bash
- deeplabcut-docker gui
- ```
- Interactive console with DLC in light mode
```bash
deeplabcut-docker bash
@@ -40,46 +36,163 @@ will be started with your current username and group.
deeplabcut-docker notebook
```
-## For developers
+You can pass `docker run` arguments to `deeplabcut-docker` directly. So if you have GPUs
+and want them to be available in your Docker container, call:
+
+```bash
+deeplabcut-docker bash --gpus all
+```
+
+If you want to mount other volumes to your container, you can do so with the [`-v`
+](https://docs.docker.com/reference/cli/docker/container/run/#volume) flag, as you would
+when calling `docker run`:
+
+```bash
+deeplabcut-docker bash --gpus all -v /home/john:/home/john
+```
+
+Use `DLC_VERSION` and `CUDA_VERSION` to select the Hub tag (unset `DLC_VERSION` uses
+`latest` / `latest-jupyter`):
+
+```bash
+DLC_VERSION=3.0.0rc14 CUDA_VERSION=12.4 deeplabcut-docker bash --gpus all
+```
+
+To use a specific image instead of the default tags, pass `--image repo:tag`.
+Make sure that the image supports jupyter notebooks when passing `notebook`.
+
+### Jupyter Notebooks Running on Remote Servers
+
+Sometimes, we want to run Jupyter Notebooks on remote servers but connect to them
+through the browser on our local machine. To do so, port forwarding needs to be used.
+This is straightforward, and there are many resources you can explore on how to do so (
+such as [this StackOverflow post](https://stackoverflow.com/a/69244262) or the [Jupyter
+Notebook docs](https://jupyter-notebook.readthedocs.io/en/4.x/public_server.html)).
+
+```{warning}
+The Jupyter image uses a fixed default access token (deeplabcut) that is publicly
+known. Anyone who can reach port 8888 on your machine can execute arbitrary
+code in the container. Do not expose port 8888 to the internet (e.g. via
+a cloud VM's firewall or a public 0.0.0.0 binding without a reverse proxy).
+For local use, bind the port to localhost only (e.g. -p 127.0.0.1:8888:8888)
+and use SSH port forwarding to access the server remotely, as described below.
+To use a custom token, pass -e NOTEBOOK_TOKEN= to docker run
+```
-Make sure your docker daemon is running and navigate to the repository root directory.
-You can build the images by running
+This can easily be done with `deeplabcut-docker`. To run a DeepLabCut notebook on a
+remote server:
+```bash
+# The Jupyter Server is running on port 8888 in the docker container
+# You forward your server's port XXXX to the container's port 8888
+# You forward port your laptop's port YYYY to port XXXX on the server
+ssh -L localhost:YYYY:localhost:XXXX john@123.456.78.987
+DLC_NOTEBOOK_PORT=XXXX deeplabcut-docker notebook --gpus all
+
+# Example with XXXX=8889, YYYY=8890
+# 1. Connect to your server, using port forwarding
+ssh -L localhost:8890:localhost:8889 john@123.456.78.987
+
+# 2. On the remote server, use deeplabcut-docker to launch the container
+DLC_NOTEBOOK_PORT=8889 deeplabcut-docker notebook --gpus all
+
+# 3. Connect to the server running on your machine at http://127.0.0.1:8890!
+```
+
+### Using Docker without `deeplabcut-docker`
+
+Docker images can also be run without the `deeplabcut-docker` package, for more expert
+users. This is not the recommended, as many of the nice features (such as starting
+the container with the current user instead of root) won't be there.
+
+The `core` image can simply be run by pulling the image and using `docker run`:
+
+```bash
+docker pull deeplabcut/deeplabcut:latest
+docker run -it --rm --gpus all deeplabcut/deeplabcut:latest
```
-docker/build.sh build
+
+The `jupyter` image cannot be run in the same way. Notebook servers cannot be run as
+the root user (which can be dangerous) without passing the `--allow-root` option, so
+running `docker run deeplabcut/deeplabcut:latest-jupyter` will lead to an
+error (`Running as root is not recommended. Use --allow-root to bypass`). What you can
+do (and we do in the `deeplabcut-docker` package) is to build a docker image with the
+`jupyter` image as a base. We would recommend doing this for the `core` images as well.
+You can create the `Dockerfile`:
+
+```dockerfile
+FROM deeplabcut/deeplabcut:latest-jupyter
+ARG UID
+ARG GID
+ARG UNAME
+ARG GNAME
+
+# Create same user as on the host system
+RUN mkdir -p /home/${UNAME}
+RUN mkdir -p /app
+RUN groupadd -g ${GID} ${GNAME} || groupmod -o -g ${GID} ${GNAME}
+RUN useradd -d /home/${UNAME} -s /bin/bash -u ${UID} -g ${GID} ${UNAME}
+RUN chown -R ${UNAME}:${GNAME} /home/${UNAME}
+RUN chown -R ${UNAME}:${GNAME} /app
+WORKDIR /app
+
+# Switch to the local user from now on
+USER ${UNAME}
+```
+
+And then build and run:
+
+```bash
+docker build \
+ --build-arg UID=$(id -u) \
+ --build-arg GID=$(id -g) \
+ --build-arg UNAME=$(id -un) \
+ --build-arg GNAME=$(id -gn) \
+ -t my-dlc-image \
+ .
+docker run -p 127.0.0.1:8889:8888 -it --rm --gpus all my-dlc-image
```
-Note that this assumes that you have rights to execute `docker build` and `docker run` commands which requires either `sudo` access or membership in the `docker` group on your local machine. If you are not in the `docker` group, run the script with the environment variable `DOCKER="sudo docker"` set to override the default docker command.
+## For developers
-Images can be verified by running
+Make sure your Docker daemon is running. From the `docker/` directory, build with
+Buildx bake (see `docker-bake.hcl`):
+```bash
+cd docker
+docker buildx bake
```
-docker/build.sh test
-```
-Built images can be pushed to DockerHub by running
+Set `MARK_LATEST=true` when building the primary CUDA variant if you want `latest` /
+`latest-jupyter` tags included. Push to Docker Hub (after `docker login`):
+```bash
+docker buildx bake --push
```
-docker/build.sh push
-```
+
+Note that this assumes that you have rights to execute `docker build` and `docker run`
+commands which requires either `sudo` access or membership in the `docker` group on
+your local machine. If you are not in the `docker` group, run `sudo docker buildx bake`
+(and `sudo docker buildx bake --push` when pushing) or add your user to the `docker`
+group.
## Prerequisites (if you don't have Docker installed already)
**(1)** Install Docker. See https://docs.docker.com/install/ & for Ubuntu: https://docs.docker.com/install/linux/docker-ce/ubuntu/
-Test docker:
+Test docker:
$ sudo docker run hello-world
-
+
The output should be: ``Hello from Docker! This message shows that your installation appears to be working correctly.``
-*if you get the error ``docker: Error response from daemon: Unknown runtime specified nvidia.`` just simply restart docker:
-
+*if you get the error ``docker: Error response from daemon: Unknown runtime specified nvidia.`` just simply restart docker:
+
$ sudo systemctl daemon-reload
$ sudo systemctl restart docker
-
+
**(2)** Add your user to the docker group (https://docs.docker.com/install/linux/linux-postinstall/#manage-docker-as-a-non-root-user)
-Quick guide to create the docker group and add your user:
+Quick guide to create the docker group and add your user:
Create the docker group.
$ sudo groupadd docker
@@ -99,12 +212,12 @@ Ascii art in the MOTD is adapted from https://ascii.co.uk/art/mice and https://p
'.__/o o\__.'
`{= ^ =}´
> u <
- ____________________.""`-------`"".______________________
+ ____________________.""`-------`"".______________________
\ ___ __ __ _____ __ /
/ / _ \ ___ ___ ___ / / ___ _ / / / ___/__ __ / /_ \
\ / // // -_)/ -_)/ _ \ / /__/ _ `// _ \/ /__ / // // __/ /
//____/ \__/ \__// .__//____/\_,_//_.__/\___/ \_,_/ \__/ \
\_________________________________________________________/
- ___)( )(___ `-.___.
+ ___)( )(___ `-.___.
(((__) (__))) ~`
```
diff --git a/docker/build.sh b/docker/build.sh
deleted file mode 100755
index e3dce032ce..0000000000
--- a/docker/build.sh
+++ /dev/null
@@ -1,156 +0,0 @@
-#!/bin/bash
-# Build script for deeplabcut docker images.
-# > docker/build.sh [build|test|push]
-
-set -e
-
-# Set default Docker binary
-export DOCKER=${DOCKER:-'docker'}
-export DOCKER_BUILD="$DOCKER build"
-export BASENAME=deeplabcut/deeplabcut
-export DOCKERDIR=docker
-
-# Check if script is being run from the correct directory
-if [[ ! -d ./${DOCKERDIR} ]]; then
- echo >&2 Run from repository root. Current pwd is
- pwd >&2
- exit 1
-fi
-
-# List Docker images related to DeepLabCut
-list_images() {
- $DOCKER images |
- grep '^deeplabcut ' |
- sed -s 's/\s\s\+/\t/g' |
- cut -f1,2 -d$'\t' --output-delimiter ':' |
- grep core
-}
-
-# Run tests inside Docker containers
-run_test() {
- kwargs=(
- -u $(id -u) --tmpfs /.local --tmpfs /.cache
- --tmpfs /test/.pytest_cache
- --env DLClight=True -t
- $1
- )
-
- # Unit tests
- $DOCKER run ${kwargs[@]} python3 -m pytest -v tests || return 255
-
- # Functional tests
- $DOCKER run ${kwargs[@]} python3 testscript_cli.py || return 255
-
- return 0
-}
-export -f run_test
-
-# Iterate through build matrix and perform actions
-iterate_build_matrix() {
- ## TODO(stes): Consider adding legacy versions for CUDA
- ## if there is demand from users:
-
- #[add other dlc versions to build here]
- dlc_versions=(
- "2.3.5"
- #"2.3.2"
- )
-
- #[add other cuda versions to build here]
- cuda_versions=(
- #"11.4.3-cudnn8-runtime-ubuntu20.04"
- "11.7.1-cudnn8-runtime-ubuntu20.04"
- )
-
- docker_types=(
- "base"
- "test"
- "core"
- #"gui"
- )
-
- mode=${1:-build}
- for cuda_version in \
- ${cuda_versions[@]}; do
- for deeplabcut_version in \
- ${dlc_versions[@]}; do
- for stage in \
- ${docker_types[@]}; do
- tag=${deeplabcut_version}-${stage}-cuda${cuda_version}-latest
- case "$mode" in
- build)
- echo \
- --build-arg=CUDA_VERSION=${cuda_version} \
- --build-arg=DEEPLABCUT_VERSION=${deeplabcut_version} \
- "--tag=${BASENAME}:$tag" \
- -f "Dockerfile.${stage}" \.
- # --no-cache \
- ;;
- push | clean | test)
- echo ${BASENAME}:${tag}
- ;;
- esac
- done
- done
- done
-}
-
-# Get Git hash
-githash() {
- git log -1 --pretty=format:"%h"
-}
-
-# Create logs directory and set log file name
-mkdir -p logs
-logfile=logs/$(date +%y%m%d-%H%M%S)-$(githash)
-echo "Logging to $logdir.*"
-
-if [ $# -eq 0 ]; then
- echo "Help: Provide arguments to this script."
- echo "Usage: $0 [build|test|push]"
- exit 1
-fi
-
-# Iterate through command line arguments
-for arg in "$@"; do
- case $1 in
- clean)
- iterate_build_matrix clean |
- tr '\n' '\0' |
- xargs -I@ -0 bash -c "docker image rm @ |& grep -v 'No such image'"
- ;;
- build)
- echo "DeepLabCut docker build:: $(git log -1 --oneline)"
- cp -r examples ${DOCKERDIR}
- (
- cd ${DOCKERDIR}
- iterate_build_matrix |
- tr '\n' '\0' |
- xargs -I@ -0 bash -c "echo Building @; $DOCKER build @ || exit 255"
- echo Successful build.
- ) |& tee ${logfile}.build
- ;;
- test)
- (
- echo "DeepLabCut docker build:: $(git log -1 --oneline)"
- iterate_build_matrix test |
- grep '\-test\-' |
- tr '\n' '\0' |
- xargs -0 -I@ bash -c "run_test @ || exit 255"
- echo Successful test.
- ) |& tee ${logfile}.test
- ;;
- push)
- iterate_build_matrix push |
- grep -v '\-test\-' |
- tr '\n' '\0' |
- xargs -I@ -0 bash -c "echo docker push @; \
- docker push @; \
- docker image rm @ |& grep -v 'No such image'"
- ;;
- *)
- echo "Usage: $0 [build|test|push]"
- exit 1
- ;;
- esac
-done
diff --git a/docker/deeplabcut_docker.py b/docker/deeplabcut_docker.py
deleted file mode 100644
index e3820120f6..0000000000
--- a/docker/deeplabcut_docker.py
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-DeepLabCut2.0-2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-
-import argparse
-import os
-import pty
-import sys
-
-__version__ = "0.0.10-alpha"
-
-_MOTD = r"""
- .--, .--,
- ( ( \.---./ ) )
- '.__/o o\__.'
- `{= ^ =}´
- > u <
- ____________________.""`-------`"".______________________
-\ ___ __ __ _____ __ /
-/ / _ \ ___ ___ ___ / / ___ _ / / / ___/__ __ / /_ \
-\ / // // -_)/ -_)/ _ \ / /__/ _ `// _ \/ /__ / // // __/ /
-//____/ \__/ \__// .__//____/\_,_//_.__/\___/ \_,_/ \__/ \
-\_________________________________________________________/
- ___)( )(___ `-.___.
- (((__) (__))) ~`
-
-Welcome to DeepLabCut docker!
-"""
-
-
-def _parse_args():
- parser = argparse.ArgumentParser(
- "deeplabcut-docker",
- description=(
- "Utility tool for launching DeepLabCut docker containers. "
- "Only a single argument is given to specify the container type. "
- "By default, the current directory is mounted into the container "
- "and used as the current working directory. You can additionally "
- "specify any additional docker argument specified in "
- "https://docs.docker.com/engine/reference/commandline/cli/."
- ),
- )
- parser.add_argument(
- "container",
- type=str,
- choices=["gui", "notebook", "bash"],
- help=(
- "The container to launch. A list of all containers is available on "
- "https://hub.docker.com/r/deeplabcut/deeplabcut/tags. By default, the "
- "latest DLC version will be selected and automatically updated, if "
- "possible. All containers are currently launched in interactive mode "
- "by default, meaning you can use Ctrl+C in your terminal session to "
- "terminate a command."
- ),
- )
- return parser.parse_known_args()
-
-
-def main():
- """Main entry point. Parse arguments and launch container."""
- launch_args, docker_arguments = _parse_args()
- argv = ["deeplabcut_docker.sh", launch_args.container, *docker_arguments]
- print(_MOTD, file=sys.stderr)
- pty.spawn(argv)
- print("Container stopped.", file=sys.stderr)
-
-
-if __name__ == "__main__":
- main()
diff --git a/docker/deeplabcut_docker.sh b/docker/deeplabcut_docker.sh
deleted file mode 100755
index c5ea91269d..0000000000
--- a/docker/deeplabcut_docker.sh
+++ /dev/null
@@ -1,197 +0,0 @@
-#!/bin/bash
-#
-# Helper script for launching deeplabcut docker UI containers
-# Usage:
-# $ ./deeplabcut-docker.sh [gui|notebook|bash]
-
-DOCKER=${DOCKER:-docker}
-DLC_VERSION=${DLC_VERSION:-"latest"}
-DLC_NOTEBOOK_PORT=${DLC_NOTEBOOK_PORT:-8888}
-
-# Check if the current users has privileges to start
-# a docker container.
-check_system() {
- if [[ $(uname -s) == Linux ]]; then
- if [ $(groups | grep -c docker) -eq 0 ]; then
- if [[ "$DOCKER" == "sudo docker" ]]; then
- return 0
- fi
- err "The current user $(id -u) is not "
- err "part of the \"docker\" group. "
- err "Please either: "
- err " 1) Launch this script with the DOCKER environment "
- err " variable set to DOCKER=\"sudo docker\" (use this "
- err " with care)! "
- err " 2) Add your user to the docker group. You might need "
- err " to log in and out again to see the effect of the "
- err " change. "
- exit 1
- fi
- elif [[ $(uname -s) == Darwin ]]; then
- err "Please note that macOSX support is currently experimental"
- err "If you encounter errors, please open an issue on"
- err "https://github.com/DeepLabCut/DeepLabCut/issues"
- err "Thanks for testing the package!"
- fi
-}
-
-# Select docker parameters based on the system.
-# Display variable and bind paths slightly differ
-# between macOS and Linux. Further systems should
-# be added here.
-get_x11_args() {
- if [[ $(uname -s) == Linux ]]; then
- err "Using Linux config"
- args=(
- "-e DISPLAY=unix$DISPLAY"
- "-v /tmp/.X11-unix:/tmp/.X11-unix"
- "-v $XAUTHORITY:/home/developer/.Xauthority"
- )
- elif [[ $(uname -s) == Darwin ]]; then
- err "Using OSX config"
- # TODO(stes) This is most likely not robust for all users;
- # We need to replace "en0" by some dynamic way
- # of figuring out the active network interface.
- # Even better would be to use 127.0.0.1, if this
- # is possible with the correct external config
- ip=$(ifconfig en0 | grep inet | awk '$1=="inet" {print $2}')
- display_id=$(echo $DISPLAY | sed -e 's/.*\(:[0-9]\)/\1/')
- args=(
- "-e DISPLAY=${ip}${display_id}"
- )
- else
- err "Unknown operating system:"
- err "$(uname -s)"
- err "Please open an issue on "
- err "https://github.com/DeepLabCut/DeepLabCut/issues"
- err "And attach your full console output."
- exit 1
- fi
- echo "${args[@]}"
-}
-
-get_mount_args() {
- args=(
- "-v $(pwd):/app -w /app"
- )
- echo "${args[@]}"
-}
-
-get_container_name() {
- echo deeplabcut/deeplabcut:${DLC_VERSION}-$1
-}
-
-get_local_container_name() {
- echo deeplabcut-${DLC_VERSION}-$1
-}
-
-### Start of helper functions ###
-
-# Print error messages to stderr
-# Ref. https://google.github.io/styleguide/shellguide.html#stdout-vs-stderr
-err() {
- echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
-}
-
-# Update the docker container
-update() {
- $DOCKER pull $(get_container_name $1)
-}
-
-# Build the docker container
-# Usage: build [core|gui|gui-jupyter]
-build() {
- tag=$1
- _build $(get_container_name $tag) $(get_local_container_name $tag) || exit 1
-}
-
-_build() {
- remote_name=$1
- local_name=$2
-
- uname=$(id -un)
- uid=$(id -u)
- gname=$(id -gn)
- gid=$(id -g)
-
- err "Configuring a local container for user $uname ($uid) in group $gname ($gid)"
- $DOCKER build -q -t ${local_name} - < u <
- ____________________.""`-------`"".______________________
+ ____________________.""`-------`"".______________________
\ ___ __ __ _____ __ /
/ / _ \ ___ ___ ___ / / ___ _ / / / ___/__ __ / /_ \
\ / // // -_)/ -_)/ _ \ / /__/ _ `// _ \/ /__ / // // __/ /
//____/ \__/ \__// .__//____/\_,_//_.__/\___/ \_,_/ \__/ \
\_________________________________________________________/
- ___)( )(___ `-.___.
+ ___)( )(___ `-.___.
(((__) (__))) ~`
EOF
diff --git a/docker/LICENSE b/docker/package/LICENSE
similarity index 99%
rename from docker/LICENSE
rename to docker/package/LICENSE
index 341c30bda4..65c5ca88a6 100644
--- a/docker/LICENSE
+++ b/docker/package/LICENSE
@@ -163,4 +163,3 @@ whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
-
diff --git a/docker/Makefile b/docker/package/Makefile
similarity index 79%
rename from docker/Makefile
rename to docker/package/Makefile
index 39015360d7..570d996440 100644
--- a/docker/Makefile
+++ b/docker/package/Makefile
@@ -4,9 +4,9 @@ clean:
prepare_build:
python3 -m pip install --upgrade twine build
- cp ../docs/docker.md PYPI_README.md
+ cp ../../docs/docker.md PYPI_README.md
-build: clean prepare_build
+build: clean prepare_build
python3 -m build
upload_test: prepare_build
diff --git a/docker/package/deeplabcut_docker.py b/docker/package/deeplabcut_docker.py
new file mode 100644
index 0000000000..ffdd94dbbd
--- /dev/null
+++ b/docker/package/deeplabcut_docker.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Helper CLI to run DeepLabCut Docker images (LGPL-3.0)."""
+
+import argparse
+import grp
+import os
+import platform
+import pwd
+import shlex
+import subprocess
+import sys
+from datetime import datetime, timezone
+
+__version__ = "0.0.12-alpha"
+
+_IMAGE = "deeplabcut/deeplabcut"
+_DEFAULT_CUDA = "12.4"
+
+
+def _docker() -> list[str]:
+ """Return the docker CLI argv prefix (from DOCKER env or `docker`)."""
+ return shlex.split(os.environ.get("DOCKER", "docker"))
+
+
+def _log(msg: str) -> None:
+ """Log a timestamped message to stderr."""
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z")
+ print(f"[{ts}]: {msg}", file=sys.stderr)
+
+
+def _check_system() -> None:
+ """Verify docker group membership on Linux; warn on macOS."""
+ if platform.system() == "Linux":
+ if os.environ.get("DOCKER", "docker").strip() == "sudo docker":
+ return
+ if os.geteuid() == 0:
+ return
+ try:
+ docker_gid = grp.getgrnam("docker").gr_gid
+ except KeyError:
+ return
+ if docker_gid not in os.getgroups():
+ _log(f'The current user {os.getuid()} is not in the "docker" group.')
+ _log('Use DOCKER="sudo docker" (with care) or add your user to "docker".')
+ sys.exit(1)
+ elif platform.system() == "Darwin":
+ _log("macOS support is experimental; report issues at")
+ _log("https://github.com/DeepLabCut/DeepLabCut/issues")
+
+
+def _remote_tag(mode: str) -> str:
+ """Get the DockerHub image tag from DLC_VERSION and CUDA_VERSION env vars."""
+ cuda = os.environ.get("CUDA_VERSION", _DEFAULT_CUDA)
+ ver = os.environ.get("DLC_VERSION", "").strip()
+ if mode == "notebook":
+ if ver:
+ return f"{_IMAGE}:{ver}-jupyter-cuda{cuda}"
+ return f"{_IMAGE}:latest-jupyter"
+ if ver:
+ return f"{_IMAGE}:{ver}-core-cuda{cuda}"
+ return f"{_IMAGE}:latest"
+
+
+def _warn_if_not_jupyter_image(ref: str) -> None:
+ """Warn if the image does not appear to have a Jupyter entrypoint."""
+ r = subprocess.run(
+ _docker()
+ + [
+ "image",
+ "inspect",
+ ref,
+ "--format",
+ "{{json .Config.Entrypoint}} {{json .Config.Cmd}}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if r.returncode != 0:
+ sys.exit(f"Could not inspect image {ref!r} after pull.\n{r.stderr.strip()}")
+ blob = (r.stdout or "").lower()
+ if "jupyter" not in blob:
+ _log(
+ f"Warning: image {ref!r} does not appear to have a Jupyter entrypoint. "
+ "Proceeding anyway — if the server fails to start, ensure the image "
+ "exposes a Jupyter-compatible entrypoint on port 8888."
+ )
+
+
+def _build_user_image(remote: str, local: str) -> None:
+ """Build a small local image on top of remote with the current UID/GID user."""
+ try:
+ uid, gid = os.getuid(), os.getgid()
+ except AttributeError:
+ sys.exit("deeplabcut-docker requires a POSIX system (Linux or macOS).")
+ user = pwd.getpwuid(uid).pw_name
+ group = grp.getgrgid(gid).gr_name
+ _log(f"Configuring a local image for user {user} ({uid}) in group {group} ({gid})")
+ dockerfile = (
+ "\n".join(
+ (
+ f"FROM {remote}",
+ f"RUN mkdir -p /home/{user} /app",
+ f"RUN groupadd -g {gid} {group} || groupmod -o -g {gid} {group}",
+ f"RUN useradd -d /home/{user} -s /bin/bash -u {uid} -g {gid} {user}",
+ f"RUN chown -R {user}:{group} /home/{user} /app",
+ f"USER {user}",
+ )
+ )
+ + "\n"
+ )
+ subprocess.run(
+ _docker() + ["build", "-q", "-t", local, "-"],
+ input=dockerfile.encode(),
+ check=True,
+ )
+ _log("Build succeeded")
+
+
+def _parse_args() -> tuple[argparse.Namespace, list[str]]:
+ """Parse CLI args and return (namespace, extra args for docker run)."""
+ parser = argparse.ArgumentParser(
+ prog="deeplabcut-docker",
+ description=(
+ "Launch DeepLabCut Docker containers. The current directory is mounted "
+ "at /app and used as the working directory. Additional arguments are "
+ "passed through to `docker run` (see "
+ "https://docs.docker.com/engine/reference/commandline/cli/)."
+ ),
+ )
+ parser.add_argument(
+ "container",
+ choices=("notebook", "bash"),
+ help=(
+ "notebook: Jupyter server; bash: interactive shell. "
+ "Image tags: https://hub.docker.com/r/deeplabcut/deeplabcut/tags — "
+ "use DLC_VERSION and CUDA_VERSION to select a versioned tag; unset "
+ "DLC_VERSION uses latest / latest-jupyter."
+ ),
+ )
+ parser.add_argument(
+ "--image",
+ metavar="REF",
+ help=(
+ "Use this image (name:tag or digest) instead of the default from "
+ "DLC_VERSION / CUDA_VERSION. For notebook, the image is checked for "
+ "a Jupyter Notebook entrypoint after pull."
+ ),
+ )
+ return parser.parse_known_args()
+
+
+def main() -> None:
+ """Entry point: pull, user-layer build, and run the container."""
+ _check_system()
+ args, docker_run_args = _parse_args()
+ mode = args.container
+
+ remote = args.image or _remote_tag(mode)
+ local = f"deeplabcut-local-{mode}"
+ subprocess.run(_docker() + ["pull", remote], check=True)
+ if mode == "notebook" and args.image:
+ _warn_if_not_jupyter_image(remote)
+ _build_user_image(remote, local)
+
+ run = _docker() + ["run", "-it", "--rm", "-v", f"{os.getcwd()}:/app", "-w", "/app"]
+ if mode == "notebook":
+ port = os.environ.get("DLC_NOTEBOOK_PORT", "8888")
+ _log("Starting the notebook server.")
+ _log(f"Open your browser at http://127.0.0.1:{port}")
+ _log("If prompted for a token, enter 'deeplabcut' (default).")
+ _log("To use a custom token: add -e NOTEBOOK_TOKEN= to your arguments.")
+ run += ["-p", f"127.0.0.1:{port}:8888"]
+ run += docker_run_args + [local] + ([] if mode == "notebook" else ["bash"])
+ sys.exit(subprocess.run(run).returncode)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docker/package/pyproject.toml b/docker/package/pyproject.toml
new file mode 100644
index 0000000000..c46f5d3678
--- /dev/null
+++ b/docker/package/pyproject.toml
@@ -0,0 +1,32 @@
+[build-system]
+build-backend = "setuptools.build_meta"
+requires = [ "setuptools>=77", "wheel" ]
+
+[project]
+name = "deeplabcut-docker"
+description = "A helper package to launch DeepLabCut docker images"
+readme = { file = "PYPI_README.md", content-type = "text/markdown" }
+license = "LGPL-3.0-or-later"
+license-files = [ "LICENSE" ]
+authors = [
+ { name = "M-Lab of Adaptive Intelligence", email = "mackenzie@deeplabcut.org" },
+ { name = "Mathis Group for Computational Neuroscience and AI", email = "alexander@deeplabcut.org" },
+]
+requires-python = ">=3.10,<3.13"
+classifiers = [
+ "Operating System :: MacOS",
+ "Operating System :: POSIX :: Linux",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Utilities",
+]
+dynamic = [ "version" ]
+urls."Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut/issues"
+urls.Homepage = "https://github.com/DeepLabCut/DeepLabCut/tree/main/docker"
+scripts.deeplabcut-docker = "deeplabcut_docker:main"
+
+[tool.setuptools]
+py-modules = [ "deeplabcut_docker" ]
+dynamic.version = { attr = "deeplabcut_docker.__version__" }
diff --git a/docker/pyproject.toml b/docker/pyproject.toml
deleted file mode 100644
index 07de284aa5..0000000000
--- a/docker/pyproject.toml
+++ /dev/null
@@ -1,3 +0,0 @@
-[build-system]
-requires = ["setuptools", "wheel"]
-build-backend = "setuptools.build_meta"
\ No newline at end of file
diff --git a/docker/setup.cfg b/docker/setup.cfg
deleted file mode 100644
index edda4512de..0000000000
--- a/docker/setup.cfg
+++ /dev/null
@@ -1,41 +0,0 @@
-[metadata]
-name = deeplabcut-docker
-version = attr: deeplabcut_docker.__version__
-author = A. & M. Mathis Labs
-author_email = alexander@deeplabcut.org
-maintainer = Steffen Schneider
-maintainer_email = stes@hey.com
-description = A helper package to launch DeepLabCut docker images
-url = https://github.com/DeepLabCut/DeepLabCut/tree/main/docker
-project_urls =
- Bug Tracker = https://github.com/DeepLabCut/DeepLabCut/issues
-classifiers =
- Programming Language :: Python :: 3
- License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)
- Operating System :: MacOS
- Operating System :: POSIX :: Linux
- Topic :: Utilities
-license = LGPLv3
-long_description = file: PYPI_README.md
-long_description_content_type = text/markdown
-platform = any
-
-[options]
-package_dir =
- = .
-py_modules = deeplabcut_docker
-python_requires = >=3.6
-include_package_data = True
-
-[options.entry_points]
-console_scripts =
- deeplabcut-docker = deeplabcut_docker:main
-
-[options.packages.find]
-where = .
-
-[options.data_files]
-bin = deeplabcut_docker.sh
-
-[bdist_wheel]
-universal = 1
diff --git a/docs/Governance.md b/docs/Governance.md
index 4fe068f0db..2ee2b1b172 100644
--- a/docs/Governance.md
+++ b/docs/Governance.md
@@ -1,6 +1,12 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(governance-model)=
# Governance Model of DeepLabCut
-(adapted from https://napari.org/docs/_sources/developers/GOVERNANCE.md.txt)
+(adapted from https://napari.org/stable/community/governance.html)
## Abstract
@@ -30,7 +36,7 @@ project in concrete ways, such as:
[GitHub pull request](https://github.com/DeepLabCut/DeepLabCut/pulls);
- reporting issues on our
[GitHub issues page](https://github.com/DeepLabCut/DeepLabCut/issues);
-- proposing a change to the documentation (http://docs.deeplabcut.org) via a
+- proposing a change to the [documentation](https://deeplabcut.github.io/DeepLabCut/README.html) via a
GitHub pull request;
- discussing the design of the `DeepLabCut` or its tutorials on in existing
[issues](https://github.com/DeepLabCut/DeepLabCut/issues) and
@@ -43,7 +49,7 @@ among other possibilities. Any community member can become a contributor, and
all are encouraged to do so. By contributing to the project, community members
can directly help to shape its future.
-Contributors are encouraged to read the [contributing guide](https://github.com/DeepLabCut/DeepLabCut/CONTRIBUTING.md).
+Contributors are encouraged to read the [contributing guide](https://github.com/DeepLabCut/DeepLabCut/blob/main/CONTRIBUTING.md).
### Core developers
@@ -74,7 +80,7 @@ developer community (including the SC members) fails to reach such a consensus
in a reasonable timeframe, the SC is the entity that resolves the issue.
Members of the steering council also have the "owner" role within the [DeepLabCut GitHub organization](https://github.com/DeepLabCut/)
-and are ultimately responsible for managing the DeepLabCut GitHub account, the [@DeepLabCut](https://twitter.com/DeepLabCut)
+and are ultimately responsible for managing the DeepLabCut GitHub account, the [@DeepLabCut](https://x.com/DeepLabCut)
twitter account, the [DeepLabCut website](http://www.DeepLabCut.org), and other similar DeepLabCut owned resources.
The current steering council of DeepLabCut consists of the original developers:
diff --git a/docs/HelperFunctions.md b/docs/HelperFunctions.md
index 05889c24a4..aa90f91975 100644
--- a/docs/HelperFunctions.md
+++ b/docs/HelperFunctions.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(helper-functions)=
# Helper & Advanced Optional Function Documentation
@@ -19,7 +25,7 @@ Or perhaps you sort of know the name of the function, but not fully, then you ca
Now, for any of these functions, you type ``deeplabcut.analyze_videos_converth5_to_csv?`` you get:
-```python
+```text
Signature: deeplabcut.analyze_videos_converth5_to_csv(videopath, videotype='.avi')
Docstring:
By default the output poses (when running analyze_videos) are stored as MultiIndex Pandas Array, which contains the name of the network, body part name, (x, y) label position in pixels, and the likelihood for each frame per body part. These arrays are stored in an efficient Hierarchical Data Format (HDF) in the same directory, where the video is stored. If the flag save_as_csv is set to True, the data is also exported as comma-separated value file. However, if the flag was *not* set, then this function allows the conversion of all h5 files to csv files (without having to analyze the videos again)!
@@ -40,7 +46,7 @@ Only videos with this extension are analyzed. The default is ``.avi``
-----------
Converts all pose-output files belonging to mp4 videos in the folder '/media/alex/experimentaldata/cheetahvideos' to csv files.
- deeplabcut.analyze_videos_converth5_to_csv('/media/alex/experimentaldata/cheetahvideos','.mp4')
+ deeplabcut.analyze_videos_converth5_to_csv('/media/alex/experimentaldata/cheetahvideos','.mp4')
```
While some of the names are ridiculously long, we wanted them to be "self-explanatory." Here is a list
diff --git a/docs/MISSION_AND_VALUES.md b/docs/MISSION_AND_VALUES.md
index 4677f07345..bc6623a6af 100644
--- a/docs/MISSION_AND_VALUES.md
+++ b/docs/MISSION_AND_VALUES.md
@@ -1,16 +1,24 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(mission-and-values)=
# Mission and Values of DeepLabCut
This document is meant to help guide decisions about the future of `DeepLabCut`, be it in terms of
whether to accept new functionality, changes to the styling of the code or graphical user interfaces (GUI),
-or whether to take on new dependencies, when to break into other repos, among other things. It serves as a point of reference for core developers actively working on the project, and an introduction for
+or whether to take on new dependencies, when to break into other repos, among other things. It serves as a point of
+reference for core developers actively working on the project, and an introduction for
newcomers who want to learn a little more about where the project is going and what the team's
-values are. You can also learn more about how the project is managed by looking at our [governance model](governance-model).
+values are. You can also learn more about how the project is managed by looking at our
+[governance model](governance-model).
## Our founding principles
-The founding DeepLabCut team came together around a shared vision for building the first open-source animal pose estimation framework
-that is:
+The founding DeepLabCut team came together around a shared vision for building the first open-source animal pose
+estimation framework that is:
- user defined pose estimation - i.e. species or object agnostic.
- access to SOTA deep learning models that can be swiftly re-trained for customized applications
@@ -18,39 +26,57 @@ that is:
- scalable (project focused for ease of portability and sharability)
-As the project has grown we've turned these original principles into the mission statement and set of values that we described below.
+As the project has grown we've turned these original principles into the mission statement and set of values that we
+described below.
## Our mission
-DeepLabCut aims to be **the animal pose software package for Python** and to **provide access to deep learning-based pose estimation for people to use in their daily work** without the need to be able to program in a deep learning framework.
-We hope to accomplish this by:
+DeepLabCut aims to be **the animal pose software package for Python** and to **provide access to deep learning-based
+pose estimation for people to use in their daily work** without the need to be able to program in a deep learning
+framework. We hope to accomplish this by:
-- being **easy to use and install**. We are careful in taking on new dependencies, sometimes making them optional, and aim support a fully (Python) packaged installation that works cross-platform.
+- being **easy to use and install**. We are careful in taking on new dependencies, sometimes making them optional, and
+aim support a fully (Python) packaged installation that works cross-platform.
-- being **well-documented** with **comprehensive tutorials and examples**. All functions in our API have thorough docstrings clarifying expected inputs and outputs, and we maintain a separate [tutorials and information website](http://deeplabcut.org).
+- being **well-documented** with **comprehensive tutorials and examples**. All functions in our API have thorough
+docstrings clarifying expected inputs and outputs, and we maintain a separate
+[tutorials and information website](http://deeplabcut.org).
- providing **GUI access** to all critical functionality so DeepLabCut can be used by people without coding experience.
- being **interactive** and **highly performant** in order to support large data pipelines.
-- providing a **consistent and stable API** to enable plugin developers to build on top of DeepLabCut without their code constantly breaking and to enable advanced users to build out sophisticated Python workflows, if needed.
+- providing a **consistent and stable API** to enable plugin developers to build on top of DeepLabCut without their
+code constantly breaking and to enable advanced users to build out sophisticated Python workflows, if needed.
-- **ensuring correctness**. We strive for complete test coverage of both the code and GUI, with all code reviewed by a core developer before being included in the repository.
+- **ensuring correctness**. We strive for complete test coverage of both the code and GUI, with all code reviewed by a
+core developer before being included in the repository.
## Our values
-- We are **inclusive**. We welcome newcomers who are making their first contribution and strive to grow our most dedicated contributors into [core developers](https://github.com/orgs/DeepLabCut/teams/core-developers). We have a [Code of Conduct](https://github.com/DeepLabCut/DeepLabCut/CODE_OF_CONDUCT.md) to make DeepLabCut
+- We are **inclusive**. We welcome newcomers who are making their first contribution and strive to grow our most
+dedicated contributors into [core developers](https://github.com/orgs/DeepLabCut/teams/core-developers).
+We have a [Code of Conduct](https://github.com/DeepLabCut/DeepLabCut/blob/main/CODE_OF_CONDUCT.md) to make DeepLabCut
a welcoming place for all.
-- We are **community-engaged**. We respond to feature requests and proposals on our [issue tracker](https://github.com/DeepLabCut/DeepLabCut/issues).
+- We are **community-engaged**. We respond to feature requests and proposals on our
+- [issue tracker](https://github.com/DeepLabCut/DeepLabCut/issues).
-- We serve **scientific applications** primarily, over “consumer or commercial” pose estimation tools. This often means prioritizing core functionality support, and rejecting implementations of “flashy” features that have little scientific value.
+- We serve **scientific applications** primarily, over “consumer or commercial” pose estimation tools. This often means
+prioritizing core functionality support, and rejecting implementations of “flashy” features that have little
+scientific value.
-- We are **domain agnostic** within the sciences. Functionality that is highly specific to particular scientific domains belongs in plugins, whereas functionality that cuts across many domains and is likely to be widely used belongs inside DeepLabCut.
+- We are **domain agnostic** within the sciences. Functionality that is highly specific to particular scientific
+domains belongs in plugins, whereas functionality that cuts across many domains and is likely to be widely used belongs
+inside DeepLabCut.
-- We value **education and documentation**. All functions should have docstrings, preferably with examples, and major functionality should be explained in our [tutorials](http://deeplabcut.org). Core developers can take an active role in finishing documentation examples.
+- We value **education and documentation**. All functions should have docstrings, preferably with examples, and major
+functionality should be explained in our [tutorials](http://deeplabcut.org). Core developers can take an active role
+in finishing documentation examples.
## Acknowledgements
-We share a lot of our mission and values with [`napari`](https://napari.org/docs/developers/MISSION_AND_VALUES.html) and [`scikit-image`](https://scikit-image.org/docs/dev/values.html) and acknowledge the influence of their mission and values statements on this document.
+We share a lot of our mission and values with [`napari`](https://napari.org/stable/community/mission_and_values.html)
+and [`scikit-image`](https://scikit-image.org/docs/stable/about/values.html) and acknowledge the influence of their
+mission and values statements on this document.
diff --git a/docs/ModelZoo.md b/docs/ModelZoo.md
index 0ef8864d1e..9d37486afc 100644
--- a/docs/ModelZoo.md
+++ b/docs/ModelZoo.md
@@ -1,104 +1,219 @@
-# The DeepLabCut Model Zoo!
+---
+deeplabcut:
+ last_content_updated: '2025-07-06'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(file:model-zoo)=
+# The DeepLabCut Model Zoo!
+
+
-🦒 🐈 🐕🦺 🐀 🐁 🦡 🦦 🐏 🐫 🐆 🦓 🐖 🐄 🐂 🦖 🐿 🦍 🦥
## 🏠 [Home page](http://modelzoo.deeplabcut.org/)
-Started in 2020 and expanded in 2022, the model zoo is four things:
-- (1) a collection of models that are trained on diverse data across (typically) large datasets, which means you do not need to train models yourself
-- (2) a contribution website for community crowd sourcing of expertly labeled keypoints to improve models in part 1!
-- (3) a no-install DeepLabCut that you can use on ♾[Google Colab](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_DLC_ModelZoo.ipynb),
-test our models in 🕸[the browser](https://contrib.deeplabcut.org/), or on our 🤗[HuggingFace](https://huggingface.co/spaces/DeepLabCut/MegaDetector_DeepLabCut) app!
-- (4) new methods to make SuperAnimal Models that combine data across different labs/datasets, keypoints, animals/species, and use on your data!
+Started in 2020, expanded in 2022 with PhD student [Shaokai Ye et al.](https://arxiv.org/abs/2203.07436v1), and the
+first proper [SuperAnimal Foundation Models](#about-the-superanimal-models) published in 2024 🔥, the Model Zoo is four things:
+
+- (1) a collection of models that are trained on diverse data across (typically) large datasets, which means you do not need to train models yourself, rather you can use them in your research applications.
+- (2) a contribution website for community crowd sourcing of expertly labeled keypoints to improve models! You can get involved here: [contrib.deeplabcut.org](https://contrib.deeplabcut.org/).
+- (3) a no-install DeepLabCut that you can use on ♾[Google Colab](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb),
+test our models in 🕸[the browser](https://contrib.deeplabcut.org/), or on our 🤗[HuggingFace](https://huggingface.co/spaces/DeepLabCut/DeepLabCutModelZoo-SuperAnimals) app!
+- (4) new methods to make SuperAnimal Foundation Models that combine data across different labs/datasets, keypoints, animals/species, and use on your data!
## Quick Start:
```
-pip install deeplabcut[tf,gui,modelzoo]
+pip install deeplabcut[gui,modelzoo]
```
## About the SuperAnimal Models
-Animal pose estimation is critical in applications ranging from neuroscience to veterinary medicine. However, reliable inference of animal poses currently requires domain knowledge and labeling effort. To ease access to high-performance animal pose estimation models across diverse environments and species, we present a new paradigm for pre-training and fine-tuning that provides excellent zero-shot (no training required) performance on two major classes of animal pose data: quadrupeds and lab mice.
+Animal pose estimation is critical in applications ranging from neuroscience to veterinary medicine. However, reliable inference of animal poses currently requires domain knowledge and labeling effort. To ease access to high-performance animal pose estimation models across diverse environments and species, we present a new paradigm for pre-training and fine-tuning that provides excellent zero-shot (no training required) performance on two major classes of animal pose data: quadrupeds and lab mice.
To provide the community with easy access to such high performance models across diverse environments and species, we present a new paradigm for building pre-trained animal pose models -- which we call SuperAnimal models -- and the ability to use them for transfer learning (e.g., fine-tune them if needed).
-### We now introduce two SuperAnimal members, namely, `superanimal_quadruped` and `superanimal_topviewmouse`.
+## SuperAnimal members:
+- Models are based on what they are trained on, for example `superanimal_quadruped_x` is trained on [SuperAnimal-Quadruped-80K](https://zenodo.org/records/10619173). Each model class is described below:
+
+
-#### `superanimal_quadruped` model aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). Here are example images of what the model is trained on:
+### SuperAnimal-Quadruped:
+
+- `superanimal_quadruped_x` models aim to work across a large range of quadruped animals, from horses, dogs, sheep, rodents, to elephants. The camera perspective is orthogonal to the animal ("side view"), and most of the data includes the animals face (thus the front and side of the animal). You will note we have several variants that differ in speed vs. performance, so please do test them out on your data to see which is best suited for your application. Also note we have a "video adaptation" feature, which lets you adapt your data to the model in a self-supervised way. No labeling needed!
+- [Please see the full datasheet here](https://zenodo.org/records/10619173)
+- [More details on the models (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-Quadruped)
+- We provide several models:
+ - `superanimal_quadruped_hrnetw32` (pytorch engine)
+ - `superanimal_quadruped_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html).
+ - `superanimal_quadruped_dlcrnet` (tensorflow engine)
+ - `superanimal_quadruped_dlcrnet` is a bottom-up model that predicts all keypoints, then groups them into individuals. This can be faster, but more error prone.
+ - `superanimal_quadruped` -> This is the same as `superanimal_quadruped_dlcrnet`, this was the old naming and being depreciated.
+ - For all models, they are automatically downloaded to modelzoo/checkpoints when used.
+
+- Here are example images of what the model is trained on:

-#### `superanimal_topviewmouse` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice. Here are example images of what the model is trained on:
+
+### SuperAnimal-TopViewMouse:
+
+
+- `superanimal_topviewmouse_x` aims to work across lab mice in different lab settings from a top-view perspective; this is very polar in many behavioral assays in freely moving mice.
+- [Please see the full datasheet here](https://zenodo.org/records/10618947)
+- [More details on the models (detector, pose estimators)](https://huggingface.co/mwmathis/DeepLabCutModelZoo-SuperAnimal-TopViewMouse)
+- We provide several models:
+ - `superanimal_topviewmouse_hrnetw32` (pytorch engine)
+ - `superanimal_topviewmouse_hrnetw32` is a top-down model that is paired with a detector. That means it takes a cropped image from an object detector and predicts the keypoints. The object detector is currently a trained [ResNet50-based Faster-RCNN](https://pytorch.org/vision/stable/models/faster_rcnn.html).
+ - `superanimal_topviewmouse_dlcrnet` (tensorflow engine)
+ - `superanimal_topviewmouse_dlcrnet` is a bottom-up model that predicts all keypoints then groups them into individuals. This can be faster, but more error prone.
+ - `superanimal_topviewmouse` -> This is the same as `superanimal_topviewmouse_dlcrnet`, this was the old naming and being depreciated.
+ - For all models, they are automatically downloaded to modelzoo/checkpoints when used.
+
+- Here are example images of what the model is trained on:

+### SuperAnimal-Human:
-IMPORTANT: we currently only support single animal scenarios
+- `superanimal_humanbody` models aim to work across human body pose estimation from various camera perspectives and environments. The models are designed to handle different human poses, activities, and lighting conditions commonly found in human motion analysis, sports analysis, and behavioral studies.
+ - `superanimal_humanbody_rtmpose_x` (pytorch engine)
+ - `superanimal_humanbody_rtmpose_x` is a top-down model that is paired with a detector pretrained from `torchvision`. That means it takes a cropped image from an object detector and predicts the keypoints. This model uses 17 body parts in the COCO body7 format.
-### Our perspective.
-Via DeepLabCut Model Zoo, we aim to provide plug and play models that do not need any labeling and will just work decently on novel videos. If the predictions are not great enough due to failure modes described below, please give us feedback! We are rapidly improving our models and adaptation methods.
+### Practical example: Using SuperAnimal models for inference without training.
+You can simply call the model and run video inference.
-### To use our models in DeepLabCut (versions 2.3+), please use the following API
+To note, a good step is typically to use our self-supervised video adaptation method to reduce jitter. In the `deeplabcut.video_inference_superanimal` simply function set the `video_adapt` option to __True__. Be aware, that enabling this option will (minimally) extend the processing time.
+```python
+import deeplabcut
+video_path = "demo-video.mp4"
+superanimal_name = "superanimal_quadruped"
+
+deeplabcut.video_inference_superanimal([video_path],
+ superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_adapt = False)
```
-pip install deeplabcut[tf,modelzoo]
+
+### Practical example: FMPose3D monocular 3D inference
+
+For FMPose3D models, use `model_name="fmpose3d_animals"` or
+`model_name="fmpose3d_humans"`. Model selection is still determined by
+`model_name`, but to stay aligned with SuperAnimal naming conventions use:
+`superanimal_name="superanimal_quadruped"` for `fmpose3d_animals`, and
+`superanimal_name="superanimal_humanbody"` for `fmpose3d_humans`.
+
+Like the 2D superanimal models, this inference branch writes
+intermediate 2D predictions to `_DLC_fmpose3d_*.h5` and `_DLC_fmpose3d_*.json`. In addition, 3D predictions are saved to
+`_DLC_fmpose3d_*_3d.h5` and `_DLC_fmpose3d_*_3d.json`. Set `fmpose_return_3d=True` to also return
+the in-memory 3D dataframe (`df_3d`) in the function output.
+
+```python
+import deeplabcut
+
+video_path = "demo-video.mp4"
+result = deeplabcut.video_inference_superanimal(
+ videos=[video_path],
+ superanimal_name="superanimal_quadruped",
+ model_name="fmpose3d_animals",
+ batch_size=8,
+ fmpose_return_3d=True, # include 3D dataframe in returned payload
+)
+df_3d = result[video_path]["df_3d"]
```
-#### Practical example: Using SuperAnimal models for inference without training.
-In the `deeplabcut.video_inference_superanimal` function, if the output video appears to be jittery, consider setting the `video_adapt` option to __True__. Be aware, that enabling this option might extend the processing time.
+
+### Practical example: Using SuperAnimal model bottom up, considering video/animal size.
+
+In our work we introduced a spatial-pyramid for smartly rescaling images. Imagine if you frames are much larger than what we trained on, it would be hard for the model to find the animal! Here, you can simply guide the model with the `scale_list`:
```python
-video_path = 'demo-video.mp4'
-superanimal_name = 'superanimal_quadruped'
+import deeplabcut
+video_path = "demo-video.mp4"
+superanimal_name = "superanimal_quadruped"
# The purpose of the scale list is to aggregate predictions from various image sizes. We anticipate the appearance size of the animal in the images to be approximately 400 pixels.
scale_list = range(200, 600, 50)
-deeplabcut.video_inference_superanimal([video_path], superanimal_name, scale_list=scale_list, video_adapt = False)
+deeplabcut.video_inference_superanimal([video_path],
+ superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ scale_list=scale_list,
+ video_adapt = False)
```
-#### Practical example: Using transfer learning with superanimal weights.
-In the `deeplabcut.train_network` function, the `superanimal_transfer_learning` option plays a pivotal role. If it's set to __True__, it uses a new decoding layer and allows you to use superanimal weights in any project, no matter the number of keypoints. However, if it's set to __False__, you are doing fine-tuning. So, make sure your dataset has the right number of keypoints.
- Specifically:
-* `superquadruped` uses 39 keypoints and,
-* `supertopview` uses 27 keypoints
+### Practical example: Using transfer learning with superanimal weights.
+In the `deeplabcut.train_network` function, the `superanimal_transfer_learning` option plays a pivotal role. If it's set to __True__, it uses a new decoding layer and allows you to use superanimal weights in any project, no matter the number of keypoints. However, if it's set to __False__, you are doing fine-tuning. So, make sure your dataset has the right number of keypoints.
+
+Specifically:
+ * `superanimal_quadruped_x` uses 39 keypoints
+ * `superanimal_topviewmouse_x` uses 27 keypoints
+ * `superanimal_humanbody_x` uses 17 keypoints
```python
+import os
+import deeplabcut
+from deeplabcut.modelzoo import build_weight_init
+
superanimal_name = "superanimal_topviewmouse"
+
config_path = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30", "config.yaml")
-deeplabcut.create_training_dataset(config_path, superanimal_name = superanimal_name)
+weight_init = build_weight_init(
+ cfg=config_path,
+ super_animal=superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ with_decoder=False,
+)
+
+deeplabcut.create_training_dataset(config_path, weight_init = weight_init)
deeplabcut.train_network(config_path,
- maxiters=10,
+ epochs=10,
superanimal_name = superanimal_name,
superanimal_transfer_learning = True)
```
+### Potential failure modes for SuperAnimal Models and how to fix it.
+Spatial domain shift: typical DNN models suffer from the spatial resolution shift between training datasets and test
+videos. To help find the proper resolution for our model, please try a range of `scale_list` in the API (details in the
+API docs). For `superanimal_quadruped`, we empirically observe that if your video is larger than 1500 pixels, it is
+better to pass `scale_list` in the range within 1000.
-### To see the list of available models, check out the [Home page](http://modelzoo.deeplabcut.org/).
+Pixel statistics domain shift: The brightness of your video might look very different from our training datasets.
+This might either result in jittering predictions in the video or fail modes for lab mice videos (if the brightness of
+the mice is unusual compared to our training dataset). You can use our "video adaptation" model to counter this.
-**Coming soon:** The DeepLabCut Project Manager GUI will allow you to use the SuperAnimal Models. You can run the model and do ``active learning" to improve performance on your data.
-Specifically, we have *new* video adaptation methods to make your tracking extra smooth and robust!
-### Potential failure modes for SuperAnimal Models and how to fix it.
-Spatial domain shift: typical DNN models suffer from the spatial resolution shift between training datasets and test videos. To help find the proper resolution for our model, please try a range of `scale_list` in the API (details in the API docs). For `superanimal_quadruped`, we empirically observe that if your video is larger than 1500 pixels, it is better to pass `scale_list` in the range within 1000.
+### Our longer term perspective ...
+
+Via DeepLabCut Model Zoo, we aim to provide plug and play models that do not need any labeling and will just work
+decently on novel videos. If the predictions are not great enough due to failure modes described below, please give us
+feedback! We are rapidly improving our models and adaptation methods. We will also continue to expand this project to
+new model/data classes. Please do get in touch is you have data or ideas: modelzoo@deeplabcut.org
+
+## Publication:
+
+To see the first preprint on the work, click [here](https://arxiv.org/abs/2203.07436v1).
-Pixel statistics domain shift: The brightness of your video might look very different from our training datasets. This might either result in jittering predictions in the video or fail modes for lab mice videos (if the brightness of the mice is unusual compared to our training dataset). You can use our "video adaptation" model (released soon) to counter this.
-### To see our first preprint on the work, check out [our paper](https://arxiv.org/abs/2203.07436v1):
+Our first [publication](https://www.nature.com/articles/s41467-024-48792-2) on this project is now published at Nature
+Communications:
```{hint}
Here is the citation:
-@article{Ye2022PanopticAP,
- title={Panoptic animal pose estimators are zero-shot performers},
- author={Shaokai Ye and Alexander Mathis and Mackenzie W. Mathis},
- journal={ArXiv},
- year={2022},
- volume={abs/2203.07436}
+@article{Ye2024,
+ title={SuperAnimal pretrained pose estimation models for behavioral analysis},
+ author={Shaokai Ye and Anastasiia Filippova and Jessy Lauer and Steffen Schneider and Maxime Vidal and Tian Qiu and Alexander Mathis and Mackenzie Weygandt Mathis},
+ journal={Nature Communications},
+ year={2024},
+ preprint={abs/2203.07436}
}
```
diff --git a/docs/Overviewof3D.md b/docs/Overviewof3D.md
index 42d18989d5..07c9384a90 100644
--- a/docs/Overviewof3D.md
+++ b/docs/Overviewof3D.md
@@ -1,14 +1,27 @@
+---
+deeplabcut:
+ last_content_updated: '2025-10-14'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(3D-overview)=
# 3D DeepLabCut
-In this repo we directly support 2-camera based 3D pose estimation. If you want n camera support, plus nicer optimization methods, please see our new work that was published at [ICRA 2021 on strong baseline 3D models (and a 3D dataset)](https://github.com/African-Robotics-Unit/AcinoSet). In the link you will find how we optimize 6+ camera DLC output data for cheetahs (and see more below).
+In this repo we directly support 2-camera based 3D pose estimation. If you want n camera support, plus nicer
+optimization methods, please see our work that was published at
+[ICRA 2021 on strong baseline 3D models (and a 3D dataset)](https://github.com/African-Robotics-Unit/AcinoSet). In the
+link you will find how we optimize 6+ camera DLC output data for cheetahs (and see more below).
## **ATTENTION: Our code base in this repo assumes you:**
-A. You have 2D videos and a DeepLabCut network to analyze them as described in the [main documentation](overview). This can be with multiple separate networks for each camera (less recommended), or one network trained on all views - recommended! (See [Nath*, Mathis* et al., 2019](https://www.biorxiv.org/content/10.1101/476531v1)). We also support multi-animal 3D with this code (please see [Lauer et al. 2022](https://doi.org/10.1038/s41592-022-01443-0)).
+A. You have 2D videos and a DeepLabCut network to analyze them as described in the
+[main documentation](overview). This can be with multiple
+separate networks for each camera (less recommended), or one network trained on all views - recommended! (See
+[Nath*, Mathis* et al., 2019](https://www.biorxiv.org/content/10.1101/476531v1)). We also support multi-animal 3D with this code (please see
+[Lauer et al. 2022](https://doi.org/10.1038/s41592-022-01443-0)).
B. You are using 2 cameras, in a [stereo configuration](https://github.com/DeepLabCut/DeepLabCut/blob/5ac4c8cb6bcf2314a3abfcf979b8dd170608e094/deeplabcut/pose_estimation_3d/camera_calibration.py#L223), for 3D*.
@@ -20,12 +33,15 @@ Here are other excellent options for you to use that extend DeepLabCut:
-- **[AcinoSet](https://github.com/African-Robotics-Unit/AcinoSet)**; **n**-camera support with triangulation, extended Kalman filtering, and trajectory optimization code (see video to the right for a min demo, courtesy of Prof. Patel), plus a GUI to visualize 3D data. It is built to work directly with DeepLabCut (but currently tailored to cheetah's, thus some coding skills are required at this time).
+- **[AcinoSet](https://github.com/African-Robotics-Unit/AcinoSet)**; **n**-camera support with triangulation, extended Kalman filtering, and trajectory optimization
+code (see video to the right for a min demo, courtesy of Prof. Patel), plus a GUI to visualize 3D data. It is built to
+work directly with DeepLabCut (but currently tailored to cheetah's, thus some coding skills are required at this time).
-- **[anipose.org](https://anipose.readthedocs.io/en/latest/)**; a wrapper for 3D deeplabcut that provides >3 camera support and is built to work directly with DeepLabCut. You can `pip install anipose` into your DLC conda environment.
+- **[anipose.org](https://anipose.readthedocs.io/en/latest/)**; a wrapper for 3D deeplabcut that provides >3 camera support and is built to work directly with
+DeepLabCut. You can `pip install anipose` into your DLC conda environment.
-- **Argus, easywand or DLTdv** w/DeepLabCut see https://github.com/haliaetus13/DLCconverterDLT; this can be used with the the highly popular Argus or DLTdv tools for wand calibration.
+- **Argus, easywand or DLTdv** w/DeepLabCut see https://github.com/backyardbiomech/DLCconverterDLT; this can be used with the the highly popular Argus or DLTdv tools for wand calibration. As of Summer, 2025, [Argus](https://github.com/kilmoretrout/argus_gui) now supports direct import and export of DeepLabCut output files in the GUI with new [workflow documentation](https://github.com/kilmoretrout/argus_gui/blob/master/docs/deeplabcut.md)
## Jump in with direct DeepLabCut 2-camera support:
@@ -39,83 +55,118 @@ Here are other excellent options for you to use that extend DeepLabCut:
### (1) Create a New 3D Project:
-Watch a [DEMO VIDEO](https://youtu.be/Eh6oIGE4dwI) on how to use this code, and check out the Notebook [here](https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb)!
+Watch a [DEMO VIDEO](https://youtu.be/Eh6oIGE4dwI) on how to use this code, and check out the Notebook [here](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb)!
-You will run this function **one** time per project; a project is defined as a given set of cameras and calibration images. You can always analyze new videos within this project.
+You will run this function **one** time per project; a project is defined as a given set of cameras and calibration
+images. You can always analyze new videos within this project.
-The function **create\_new\_project\_3d** creates a new project directory specifically for converting the 2D pose to 3D pose, required subdirectories, and a basic 3D project configuration file. Each project is identified by the name of the project (e.g. Task1), name of the experimenter (e.g. YourName), as well as the date at creation.
+The function **create\_new\_project\_3d** creates a new project directory specifically for converting the 2D pose to 3D
+pose, required subdirectories, and a basic 3D project configuration file. Each project is identified by the name of the
+project (e.g. Task1), name of the experimenter (e.g. YourName), as well as the date at creation.
-Thus, this function requires the user to input the enter the name of the project, the name of the experimenter and number of cameras to be used. Currently, DeepLabCut supports triangulation using 2 cameras, but will expand to more than 2 cameras in a future version.
+Thus, this function requires the user to enter the name of the project, the name of the experimenter and number of
+cameras to be used. Currently, DeepLabCut supports triangulation using 2 cameras, but will expand to more than 2 cameras
+in a future version.
To start a 3D project type the following in ipython:
```python
-deeplabcut.create_new_project_3d('ProjectName','NameofLabeler',num_cameras = 2)
+deeplabcut.create_new_project_3d("ProjectName", "NameofLabeler", num_cameras=2)
```
-TIP 1: you can also pass ``working_directory=`Full path of the working directory'`` if you want to place this folder somewhere beside the current directory you are working in. If the optional argument ``working_directory`` is unspecified, the project directory is created in the current working directory.
+TIP 1: you can also pass `working_directory="Full path of the working directory"` if you want to place this folder
+somewhere beside the current directory you are working in. If the optional argument `working_directory` is unspecified,
+the project directory is created in the current working directory.
-TIP 2: you can also place ``config_path3d`` in front of ``deeplabcut.create_new_project_3d`` to create a variable that holds the path to the config.yaml file, i.e. ``config_path3d=deeplabcut.create_new_project_3d(...`` Or, set this variable for easy use. Please note that ``config_path3d='Full path of the 3D project configuration file'``.
+TIP 2: you can also place `config_path3d` in front of `deeplabcut.create_new_project_3d` to create a variable that holds
+the path to the config.yaml file, i.e. `config_path3d=deeplabcut.create_new_project_3d(...` Or, set this variable for
+easy use. Please note that `config_path3d='Full path of the 3D project configuration file'`.
- This function will create a project directory with the name **Name of the project+name of the experimenter+date of creation of the project+3d** in the **Working directory**. The project directory will have subdirectories: **calibration_images**, **camera_matrix**, **corners**, and **undistortion**. All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be curated in separation from other projects.
+This function will create a project directory with the name **Name of the project+name of the experimenter+date of
+creation of the project+3d** in the **Working directory**. The project directory will have subdirectories:
+**calibration_images**, **camera_matrix**, **corners**, and **undistortion**. All the outputs generated during the
+course of a project will be stored in one of these subdirectories, thus allowing each project to be curated in
+separation from other projects.
- The purpose of the subdirectories is as follows:
+The purpose of the subdirectories is as follows:
- **calibration_images:** This directory will contain a set of calibration images acquired from the two cameras. A calibration image can be acquired using a printed checkerboard and its pair wise images are taken from both the cameras to consider as a set of calibration images. These pair of images are saved as ``.jpg`` with camera names as the prefix. e.g. ``camera-1-01.jpg`` and ``camera-2-01.jpg`` for the first pair of images. While taking the images:
-- Keep the orientation of the chessboard same and do not rotate more than 30 degrees. Rotating the chessboard circular will change the origin across the frames and may result in incorrect order of detected corners.
-- Cover several distances, and within each distance, cover all parts of the image view (all corners and center).
-Use a chessboard as big as possible, ideally a chessboard with of at least 8x6 squares.
-- Aim for taking at least 70 pair of images as after corner detection, some of the images might need to be discarded due to either incorrect corner detection or incorrect order of detected corners.
+**calibration_images:** This directory will contain a set of calibration images acquired from the two cameras. A
+calibration image can be acquired using a printed checkerboard and its pair wise images are taken from both the cameras
+to consider as a set of calibration images.
- **camera_matrix:** This directory will store the parameter for both the cameras as a pickle file. Specifically, these pickle files contain the intrinsic and extrinsic camera parameters. While the intrinsic parameters represent a transformation from 3-D camera's coordinates into the image coordinates, the extrinsic parameters represent a rigid transformation from world coordinate system to the 3-D camera's coordinate system.
+**camera_matrix:** This directory will store the parameter for both the cameras as a pickle file. Specifically, these
+pickle files contain the intrinsic and extrinsic camera parameters. While the intrinsic parameters represent a
+transformation from 3-D camera's coordinates into the image coordinates, the extrinsic parameters represent a rigid
+transformation from world coordinate system to the 3-D camera's coordinate system.
- **corners:** As a part of camera calibration, the checkerboard pattern is detected in the calibration images and these patterns will be stored in this directory. Each row of the checkerboard grid is marked with a unique color.
+**corners:** As a part of camera calibration, the checkerboard pattern is detected in the calibration images and these
+patterns will be stored in this directory. Each row of the checkerboard grid is marked with a unique color.
- **undistortion:** In order to check for calibration, the calibration images and the corresponding corner points are undistorted. These undistorted images are overlaid with undistorted points and will be stored in this directory.
+**undistortion:** In order to check for calibration, the calibration images and the corresponding corner points are
+undistorted. These undistorted images are overlaid with undistorted points and will be stored in this directory.
- Here is an overview of the calibration and triangulation workflow that follows:
+Here is an overview of the calibration and triangulation workflow that follows:
-
+
### (2) Take and Process Camera Calibration Images:
- (**CRITICAL!**) You must take images of a checkerboard to calibrate your images. Here are example boards you could print and use (mount it on a flat, hard surface!): https://markhedleyjones.com/projects/calibration-checkerboard-collection.
+(**CRITICAL!**) You must take images of a checkerboard to calibrate your images. Here are example boards you could
+print and use (mount it on a flat, hard surface!):
+https://markhedleyjones.com/projects/calibration-checkerboard-collection.
- You must save the image pairs as .jpg files.
-- They should be named with the **camera-#** as the prefix, i.e. **camera-1-01.jpg** and **camera-2-01.jpg** for the first pair of images. Please note, this cannot be changed after the project is created.
+- They should be named with the **camera-#** as the prefix, i.e. **camera-1-01.jpg** and **camera-2-01.jpg** for the
+first pair of images. Please note, this cannot be changed after the project is created.
-**TIP:** If you want to take a short video (vs. snapping pairs of frames) while you move the checkerboard around, you can use this command inside your conda environment (but outside of ipython!) to convert the video to **.jpg** frames (this will take the first 20 frames (set with ``-vframes``) and name them camera-1-001.jpg, etc; edit appropriately):
+**TIP:** If you want to take a short video (vs. snapping pairs of frames) while you move the checkerboard around, you
+can use this command inside your conda environment (but outside of ipython!) to convert the video to **.jpg** frames
+(this will take the first 20 frames (set with `-vframes`) and name them camera-1-001.jpg, etc; edit appropriately):
```python
ffmpeg -i videoname.mp4 -vframes 20 camera-1-%03d.jpg
```
- While taking the images:
- - Keep the orientation of the checkerboard the same and do not rotate it more than 30 degrees. Rotating the checkerboard circular will change the origin across the frames and may result in incorrect order of detected corners.
+ - Keep the orientation of the checkerboard the same and do not rotate it more than 30 degrees. Rotating the
+ checkerboard circular will change the origin across the frames and may result in incorrect order of detected corners.
- - Cover several distances, and within each distance, cover all parts of the image view (all corners and center).
+ - Cover several distances, and within each distance, cover all parts of the image view (all corners and center).
- - Use a checkerboard as big as possible, ideally with at least 8x6 squares.
+ - Use a checkerboard as big as possible, ideally with at least 8x6 squares.
- - Aim for taking at least 30-70 pair of images, as after corner detection, some of the images might need to be discarded due to either incorrect corner detection or incorrect order of detected corners.
+ - Aim for taking at least 30-70 pair of images, as after corner detection, some of the images might need to be
+ discarded due to either incorrect corner detection or incorrect order of detected corners.
- - You can take the images as a series of .jpg images, or a video where you post-hoc pair sync'd frames (see tip above).
+ - You can take the images as a series of .jpg images, or a video where you post-hoc pair sync'd frames (see tip
+ above).
-The camera calibration is an **iterative process**, where the user needs to select a set of calibration images where the grid pattern is correctly detected. The function:``deeplabcut.calibrate_cameras(config_path)``
-extracts the grid pattern from the calibration images and store them under the `corners` directory. The grid pattern could be 8x8 or 5x5 etc. We use a pattern of the 8x6 grid to find the internal corners of the checkerboard.
+The camera calibration is an **iterative process**, where the user needs to select a set of calibration images where the
+grid pattern is correctly detected. The function `deeplabcut.calibrate_cameras(config_path)`
+extracts the grid pattern from the calibration images and store them under the `corners` directory. The grid pattern
+could be 8x8 or 5x5 etc. We use a pattern of the 8x6 grid to find the internal corners of the checkerboard.
-In some cases, it may happen that the corners are not detected correctly or the order of corners detected in the camera-1 image and camera-2 image is incorrect. You need to remove these pair of images from the **calibration_images** folder as they will reduce the calibration accuracy.
+In some cases, it may happen that the corners are not detected correctly or the order of corners detected in the
+camera-1 image and camera-2 image is incorrect. You need to remove these pair of images from the **calibration_images**
+folder as they will reduce the calibration accuracy.
To begin, please place your images into the **calibration_images** directory.
- (**CRITICAL!**) Edit the **config.yaml** file to set the camera names; note that once this is set, **do not change the names!**
+(**CRITICAL!**) Edit the **config.yaml** file to set the camera names; note that once this is set, **do not change the
+names!**
Then, run:
```python
deeplabcut.calibrate_cameras(config_path3d, cbrow=8, cbcol=6, calibrate=False, alpha=0.9)
```
-NOTE: you need to specify how many rows (``cbrow``) and columns (``cbcol``) your checkerboard has. Also, first set the variable ``calibrate`` to **False**, so you can remove any faulty images. You need to visually inspect the output to check for the detected corners and select those pair of images where the corners are correctly detected. Please note, If the scaling parameter ``alpha=0``, it returns undistorted image with minimum unwanted pixels. So it may even remove some pixels at image corners. If ``alpha=1``, all pixels are retained with some extra black images.
+
+NOTE: you need to specify how many rows (`cbrow`) and columns (`cbcol`) your checkerboard has (beware, we count
+edges between squares and not squares themselves, so for a 8 x 8 squares checkerboard set `cbrow=7` and `cbcol=7`).
+Also, first set the variable `calibrate` to **False**, so you can remove any faulty images. You need to visually
+inspect the output to check for the detected corners and select those pair of images where the corners are correctly
+detected. Please note, If the scaling parameter `alpha=0`, it returns undistorted image with minimum unwanted pixels.
+So it may even remove some pixels at image corners. If `alpha=1`, all pixels are retained with some extra black images.
Here is what they might look like:
@@ -125,55 +176,85 @@ Here is what they might look like:
-Once all the set of images are selected (namely, delete from the folder any bad pairs!) where the corners and their orders are detected correctly, then the two cameras can be calibrated using:
+Once all the set of images has been selected (namely, delete from the folder any bad pairs!) where the corners and their
+orders are detected correctly, then the two cameras can be calibrated using:
```python
deeplabcut.calibrate_cameras(config_path3d, cbrow=8, cbcol=6, calibrate=True, alpha=0.9)
```
-This computes the intrinsic and extrinsic parameters for each camera. A re-projection error is also computed using the intrinsic and extrinsic parameters which provide an estimate of how good the parameters are. The transformation between the two cameras are estimated and the cameras are stereo calibrated. Furthermore, the above function brings both the camera image plane to the same plane by computing the stereo rectification. These parameters are stored as a pickle file named as `stereo_params.pickle` under the directory `camera_matrix`.
+This computes the intrinsic and extrinsic parameters for each camera. A re-projection error is also computed using the
+intrinsic and extrinsic parameters which provide an estimate of how good the parameters are. The transformation between
+the two cameras is estimated and the cameras are stereo calibrated. Furthermore, the above function brings both the
+camera image plane to the same plane by computing the stereo rectification. These parameters are stored as a pickle file
+named as `stereo_params.pickle` under the directory `camera_matrix`.
-Once you have run this for the project, you do not need to do so again (unless you want to re-calibrate your cameras); be advised, if you do re-calibrate, you may want to clearly mark which videos are analyzed with "old" vs. "new" calibration images.
+Once you have run this for the project, you do not need to do so again (unless you want to re-calibrate your cameras);
+be advised, if you do re-calibrate, you may want to clearly mark which videos are analyzed with "old" vs. "new"
+calibration images.
### (3) Check for Undistortion:
-In order to check how well the stereo calibration is, it is recommended to undistort the calibration images and the corner points using camera matrices and project these undistorted points on the undistorted images to check if they align correctly. This can be done in deeplabcut as:
+In order to check how well the stereo calibration is, it is recommended to undistort the calibration images and the
+corner points using camera matrices and project these undistorted points on the undistorted images to check if they
+align correctly. This can be done in deeplabcut as:
```python
deeplabcut.check_undistortion(config_path3d, cbrow=8, cbcol=6)
```
-Each calibration image is undistorted and saved under the directory ``undistortion``. A plot with a pair of undistorted camera images with its undistorted corner points overlaid is also stored. Please visually inspect this image. All the undistorted corner points from all the calibration images are triangulated and plotted for the user to visualize for any undistortion related errors. If they are not correct, go check and revise the calibration images (then repeat the calibration and this step)!
+Each calibration image is undistorted and saved under the directory `undistortion`. A plot with a pair of undistorted
+camera images with its undistorted corner points overlaid is also stored. Please visually inspect this image. All the
+undistorted corner points from all the calibration images are triangulated and plotted for the user to visualize for any
+undistortion related errors. If they are not correct, go check and revise the calibration images (then repeat the
+calibration and this step)!
### (4) Triangulation --> Take your 2D to 3D!
-If there are no errors in the undistortion, then the pose from the 2 cameras can be triangulated to get the 3D DeepLabCut coordinates!
+If there are no errors in the undistortion, then the pose from the 2 cameras can be triangulated to get the 3D
+DeepLabCut coordinates!
- (**CRITICAL!**) Name the video files in such a way that the file name **contains the name of the cameras** as specified in the ``config file``. e.g. if the cameras as named as ``camera-1`` and ``camera-2`` (or ``cam-1``, ``cam-2`` etc.) then the video filename must contain this naming, i.e. this could be named as ``rig-1-mouse-day1-camera-1.avi`` and ``rig-1-mouse-day1-camera-2.avi`` or could be ``rig-1-mouse-day1-camera-1-date.avi`` and ``rig-1-mouse-day1-camera-2-date.avi``.
+(**CRITICAL!**) Name the video files in such a way that the file name **contains the name of the cameras** as specified
+in the `config file`. e.g. if the cameras as named as `camera-1` and `camera-2` (or `cam-1`, `cam-2` etc.) then the
+video filename must contain this naming, i.e. this could be named as `rig-1-mouse-day1-camera-1.avi` and
+`rig-1-mouse-day1-camera-2.avi` or could be `rig-1-mouse-day1-camera-1-date.avi` and
+`rig-1-mouse-day1-camera-2-date.avi`.
- **Note** that to correctly pair the videos, the file names otherwise need to be the same!
-- If helpful, [here is the software we use to record videos](https://github.com/AdaptiveMotorControlLab/Camera_Control).
-- **Note** that the videos do not need to be the same pixel size, but be sure they are similar in size to the calibration images (and they must be the same cameras used for calibration).
+- If helpful, [here is the software we use to record videos](https://github.com/AdaptiveMotorControlLab/Camera_Control).
- (**CRITICAL!**) You must also edit the **3D project config.yaml** file to denote which DeepLabCut projects have the information for the 2D views.
+(**CRITICAL!**) You must also edit the **3D project config.yaml** file to denote which DeepLabCut projects have the
+information for the 2D views.
- - Of critical importance is that you need to input the **same** body part names as in the config.yaml file of the 2D project.
-- You must set the snapshot to use inside the 2D config file (default is -1, namely the last training snapshot of the network).
+- Of critical importance is that you need to input the **same** body part names as in the config.yaml file of the 2D
+project.
+- You must set the snapshot to use inside the 2D config file (default is -1, namely the last training snapshot of the
+network).
- You need to set a "scorer 3D" name; this will point to the project file and be set in future 3D output file names.
-- You should define a "skeleton" here as well (note, this is not rigid, it just connects the points in the plotting step). Not every point needs to be "skeletonized", i.e. these points can be a subset of the full body parts list. The other points will just be plotted into the 3D space. Here is how the config.yaml looks with some example inputs:
+- You should define a "skeleton" here as well (note, this is not rigid, it just connects the points in the plotting
+step). Not every point needs to be "skeletonized", i.e. these points can be a subset of the full body parts list. The
+other points will just be plotted into the 3D space. Here is how the config.yaml looks with some example inputs:
-(**CRITICAL!**) This step will also run the equivalent of ``analyze_videos`` (in 2D) for you and then apply a median filter to the 2D data (``filterpredictions=True`` is by default)! If you already ran the 2D analysis and there is a filtered output file, it will take this by default (otherwise it will take your unfiltered 2D analysis files)!
+(**CRITICAL!**) This step will also run the equivalent of `analyze_videos` (in 2D) for you and then apply a median
+filter to the 2D data (`filterpredictions=True`)! If you already ran the 2D analysis and there is a filtered output
+file, it will take this by default (otherwise it will take your unfiltered 2D analysis files)!
-Next, pass the ``config_path3d`` and now the video folder path, which is the path to the **folder** where all the videos from two cameras are stored. The triangulation can be done in deeplabcut by typing:
+Next, pass the `config_path3d` and now the video folder path, which is the path to the **folder** where all the videos
+from two cameras are stored. The triangulation can be done in deeplabcut by typing:
```python
-deeplabcut.triangulate(config_path3d, '/yourcomputer/fullpath/videofolder', filterpredictions=True/False)
+deeplabcut.triangulate(
+ config_path3d,
+ "/yourcomputer/fullpath/videofolder",
+ filterpredictions=True/False
+)
```
-NOTE: Windows users, you must input paths as: ``r`C:\Users\computername\videofolder' `` or ``C:\\Users\\computername\\videofolder'``.
+NOTE: Windows users, you must input paths as: ``r`C:\Users\computername\videofolder'`` or
+``C:\\Users\\computername\\videofolder'``.
**TIP:** Here are all the parameters you can pass:
@@ -204,8 +285,15 @@ destfolder: string, optional
save_as_csv: bool, optional
Saves the predictions in a .csv file. The default is ``False``; if provided it must be either ``True`` or ``False``
+
+track_method: str, optional
+ Method used for tracking: "box" or "ellipse"
```
-The **triangulated file** is now saved under the same directory where the video files reside (or the destination folder you set)! This can be used for future analysis. This step can be run at anytime as you collect new videos, and easily added to your automated analysis pipeline, i.e. such as **replacing** ``deeplabcut.triangulate(config_path3d, video_path)`` with ``deeplabcut.analyze_videos`` (as if it's not analyzed in 2D already, this function will take care of it ;):
+The **triangulated file** is now saved under the same directory where the video files reside (or the destination folder
+you set)! This can be used for future analysis. This step can be run at anytime as you collect new videos, and easily
+added to your automated analysis pipeline, i.e. such as **replacing**
+`deeplabcut.triangulate(config_path3d, video_path)` with `deeplabcut.analyze_videos` (as if it's not analyzed in 2D
+already, this function will take care of it ;):
@@ -213,25 +301,39 @@ The **triangulated file** is now saved under the same directory where the video
### (5) Visualize your 3D DeepLabCut Videos:
-In order to visualize both the 2D videos with tracked points plut the pose in 3D, the user can create a 3D video for certain frames (these are large files, so we advise just looking at a subset of frames). The user can specify the config file, the **path of the triangulated file folder**, and specify the start and end frame indices to create a 3D labeled video. Note that the ``triangulated_file_folder`` is where the newly created file that ends with ``yourDLC_3D_scorername.h5`` is located. This can be done using:
+In order to visualize both the 2D videos with tracked points plut the pose in 3D, the user can create a 3D video for
+certain frames (these are large files, so we advise just looking at a subset of frames). The user can specify the config
+file, the **path of the triangulated file folder**, and specify the start and end frame indices to create a 3D labeled
+video. Note that the `triangulated_file_folder` is where the newly created file that ends with
+`yourDLC_3D_scorername.h5` is located. This can be done using:
```python
-deeplabcut.create_labeled_video_3d(config_path, ['triangulated_file_folder'], start=50, end=250)
+deeplabcut.create_labeled_video_3d(
+ config_path,
+ ["triangulated_file_folder"],
+ start=50,
+ end=250
+)
```
-**TIP:** (see more parameters below) You can set how the axis of the 3D plot on the far right looks by changing the variables ``xlim``, ``ylim``, ``zlim`` and ``view``. Your checkerboard_3d.png image which was created above will show you the axis ranges. Here is an example:
+**TIP:** (see more parameters below) You can set how the axis of the 3D plot on the far right looks by changing the
+variables `xlim`, `ylim`, `zlim` and `view`. Your checkerboard_3d.png image which was created above will show you the
+axis ranges. Here is an example:
-``View`` is used to set the elevation and azimuth of the axes (defaults are [113, 270], and you should play around to find the view-point you like!). Also note that the video is created from a set of .png files in a "temp" directory, so as soon as you run this command you can open the first image, and if you don't like the view, hit ``CNTRL+C`` to stop, edit the values, and start again!
+`View` is used to set the elevation and azimuth of the axes (defaults are [113, 270], and you should play around to find
+the view-point you like!). Also note that the video is created from a set of .png files in a "temp" directory, so as
+soon as you run this command you can open the first image, and if you don't like the view, hit `CNTRL+C` to stop, edit
+the values, and start again!
**Other optional parameters include:**
-
+here
```python
videofolder: string
- Full path of the folder where the videos are stored. Use this if the vidoes are stored in a different location other than where the triangulation files are stored. By default is ``None`` and therefore looks for video files in the directory where the triangulation file is stored.
+ Full path of the folder where the videos are stored. Use this if the videos are stored in a different location other than where the triangulation files are stored. By default is ``None`` and therefore looks for video files in the directory where the triangulation file is stored.
trailpoints: int
Number of previous frames whose body parts are plotted in a frame (for displaying history). Default is set to 0.
@@ -251,8 +353,25 @@ ylim: list
zlim: list
A list of integers specifying the limits for zaxis of 3d view. By default it is set to [None,None], where the z limit is set by taking the minimum and maximum value of the z coordinates for all the bodyparts.
+
+draw_skeleton: bool
+ If True adds a line connecting the body parts making a skeleton on on each frame. The body parts to be connected and the color of these connecting lines are specified in the config file. By default: True
+
+color_by : string, optional (default='bodypart')
+ Coloring rule. By default, each bodypart is colored differently.
+ If set to 'individual', points belonging to a single individual are colored the same.
+
+figsize: tuple[int, int], optional, default=(80, 8)
+ Size of the figure
+
+fps: int, optional, default=30
+ Frames per second
+
+dpi: int, optional, default=300
+ Dots per inch (resplution)
```
### If you use this code:
-We kindly ask that you cite [Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y) **&** [Nath*, Mathis*, et al., 2019](https://doi.org/10.1038/s41596-019-0176-0). If you use 3D multi-animal: [Lauer et al. 2022](https://doi.org/10.1038/s41592-022-01443-0).
+We kindly ask that you cite [Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y) **&** [Nath*, Mathis*, et al., 2019](https://doi.org/10.1038/s41596-019-0176-0). If you use 3D
+multi-animal: [Lauer et al. 2022](https://doi.org/10.1038/s41592-022-01443-0).
diff --git a/docs/README.md b/docs/README.md
index df606c21b6..2812d8f001 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,3 +1,9 @@
-Please see https://deeplabcut.github.io/DeepLabCut for documentation on how to use this software.
+---
+deeplabcut:
+ last_content_updated: '2022-08-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+Please see https://deeplabcut.github.io/DeepLabCut for documentation on how to use this software.
This directory contains the source code for the docs.
diff --git a/docs/UseOverviewGuide.md b/docs/UseOverviewGuide.md
index d19c1feec5..77cf3eda53 100644
--- a/docs/UseOverviewGuide.md
+++ b/docs/UseOverviewGuide.md
@@ -1,14 +1,43 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(overview)=
-# 🥳 Get started with DeepLabCut: our key recommendations
+# 🥳 Get started with DeepLabCut: our key recommendations
Below we will first outline what you need to get started, the different ways you can use DeepLabCut, and then the full workflow. Note, we highly recommend you also read and follow our [Nature Protocols paper](https://www.nature.com/articles/s41596-019-0176-0), which is (still) fully relevant to standard DeepLabCut.
+```{Hint}
+💡📚 If you are new to Python and DeepLabCut, you might consider checking our [beginner guide](https://deeplabcut.github.io/DeepLabCut/docs/beginner-guides/beginners-guide.html) once you are ready to jump into using the DeepLabCut App!
+```
+
+
## [How to install DeepLabCut](how-to-install)
-- Decide on your needs: there are **two main modes, standard DeepLabCut or multi-animal DeepLabCut**. We highly recommend carefully considering which one is best for your needs. For example, a white mouse + black mouse would call for standard, while two black mice would use multi-animal. **[Important Information on how to use DLC in different scenarios (single vs multi animal)](important-info-regd-usage)** Then pick:
+We don't cover installation in depth on this page, so click on the link above if that is what you are looking for. See below for details on getting started with DeepLabCut!
-- (1) [How to use standard DeepLabCut](single-animal-userguide)
-- (2) [How to use multi-animal DeepLabCut](multi-animal-userguide)
+## What we support:
+
+We are primarily a package that enables deep learning-based pose estimation. We have a lot of models and options, but don't get overwhelmed -- the developer team has tried our best to "set the best defaults we possibly can"!
+
+- Decide on your needs: there are **two main modes, standard DeepLabCut or multi-animal DeepLabCut**. We highly recommend carefully considering which one is best for your needs. For example, a white mouse + black mouse would call for standard, while two black mice would use multi-animal. **[Important Information on how to use DLC in different scenarios (single vs multi animal)](important-info-regd-usage)** Then pick a user guide:
+
+ - (1) [How to use standard DeepLabCut](single-animal-userguide)
+ - (2) [How to use multi-animal DeepLabCut](multi-animal-userguide)
+
+- To note, as of DLC3+ the single and multi-animal code bases are more integrated and we support **top-down**, **bottom-up**, and a new "hybrid" approach that is state-of-the-art, called **BUCTD** (bottom-up conditional top down), models.
+ - If these terms are new to you, check out our [Primer on Motion Capture with Deep Learning!](https://www.sciencedirect.com/science/article/pii/S0896627320307170). In brief, both work for single or multiple animals and each method can be better or worse on your data.
+
+
+
+
+
+ - Here is more information on BUCTD:
+
+
+
**Additional Learning Resources:**
@@ -16,6 +45,7 @@ Below we will first outline what you need to get started, the different ways you
- [HOW-TO-GUIDES:](overview) step-by-step user guidelines for using DeepLabCut on your own datasets (see below)
- [EXPLANATIONS:](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials) resources on understanding how DeepLabCut works
- [REFERENCES:](https://github.com/DeepLabCut/DeepLabCut#references) read the science behind DeepLabCut
+ - [BEGINNER GUIDE TO THE GUI](https://deeplabcut.github.io/DeepLabCut/docs/beginner-guides/beginners-guide.html)
Getting Started: [a video tutorial on navigating the documentation!](https://www.youtube.com/watch?v=A9qZidI7tL8)
@@ -31,7 +61,7 @@ Getting Started: [a video tutorial on navigating the documentation!](https://www
- no specific cameras/videos are required; color, monochrome, etc., is all fine. If you can see what you want to measure, then this will work for you (given enough labeled data).
- - no specific computer is required (but see recommendations above), our software works on Linux, Windows, and MacOS, although we recommend Ubuntu.
+ - no specific computer is required (but see recommendations above), our software works on Linux, Windows, and MacOS.
### Overview:
@@ -42,26 +72,22 @@ Getting Started: [a video tutorial on navigating the documentation!](https://www
-
-### Overview of the workflow:
-This page contains a list of the essential functions of DeepLabCut as well as demos. There are many optional parameters with each described function, which you can find [here](functionDetails.md). For additional assistance, you can use the [help](UseOverviewGuide.md#help) function to better understand what each function does.
-
-
-
-
+
-**NOTE:** There is a highly similar workflow for 2.2+ (and your 2.X projects are still fully compatible with this format!).
-
-** DLC 2.2:** as of 2.2 we support "multi-animal projects," but these new features can also be used on single animals too (details below). The workflow is highly similar, but with a few key additional steps. Please carefully review the functions below for more details. You can search/look for **maDeepLabCut** for specific steps that are changed, or see this more comprehensive guide [here](/docs/maDLC_AdvUserGuide.md)
-
-
-
-
+### Overview of the workflow:
+This page contains a list of the essential functions of DeepLabCut as well as demos. There are many optional parameters with each described function. For detailed function documentation, please refer to the main user guides or API documentation. For additional assistance, you can use the [help](UseOverviewGuide.md#help) function to better understand what each function does.
+
+
+
+
+ View in full screen
+
+
-You can have as many projects on your computer as you wish. You can have DeepLabCut installed in an [environment](/conda-environments) and always exit and return to this environment to run the code. You just need to point to the correct ``config.yaml`` file to [jump back in](/docs/UseOverviewGuide.md#tips-for-daily-use)! The documentation below will take you through the individual steps.
+You can have as many projects on your computer as you wish. You can have DeepLabCut installed in an [environment](../conda-environments/README.md) and always exit and return to this environment to run the code. You just need to point to the correct ``config.yaml`` file to [jump back in](/docs/UseOverviewGuide.md#tips-for-daily-use)! The documentation below will take you through the individual steps.
@@ -74,13 +100,13 @@ You can have as many projects on your computer as you wish. You can have DeepLab
## Important information on using DeepLabCut:
-We recommend first using **DeepLabCut for a single animal scenario** to understand the workflow - even if it's just our demo data. Multi-animal tracking is more complex - i.e. it has several decisions the user needs to make. Then, when you are ready you can jump into 2.2...
+We recommend first using **DeepLabCut for a single animal scenario** to understand the workflow - even if it's just our demo data. Multi-animal tracking is more complex - i.e. it has several decisions the user needs to make. Then, when you are ready you can jump into multi-animals...
-### Additional information for getting started with maDeepLabCut (aka DeepLabCut 2.2):
+### Additional information for getting started with maDeepLabCut:
-We highly recommend using 2.2 first in the Project Manager GUI ([Option 3](docs/functionDetails.md#deeplabcut-project-manager-gui)). This will allow you to get used to the additional steps by being walked through the process. Then, you can always use all the functions in your favorite IDE, notebooks, etc.
+We highly recommend using it first in the Project Manager GUI ([Option 3](docs/functionDetails.md#deeplabcut-project-manager-gui)). This will allow you to get used to the additional steps by being walked through the process. Then, you can always use all the functions in your favorite IDE, notebooks, etc.
-#### *What scenario do you have?*
+### *What scenario do you have?*
- **I have single animal videos:**
- quick start: when you `create_new_project` (and leave the default flag to False in `multianimal=False`). This is the typical work path for many of you.
@@ -120,8 +146,8 @@ with the terminal interface you get the most versatility and options.
## Option 1: Demo Notebooks:
[VIDEO TUTORIAL AVAILABLE!](https://www.youtube.com/watch?v=DRT-Cq2vdWs)
-We provide Jupyter and COLAB notebooks for using DeepLabCut on both a pre-labeled dataset, and on the end user’s
-own dataset. See all the demo's [here!](/examples) Please note that GUIs are not easily supported in Jupyter in MacOS, as you need a framework build of python. While it's possible to launch them with a few tweaks, we recommend using the Project Manager GUI or terminal, so please follow the instructions below.
+We provide Jupyter and COLAB notebooks for using DeepLabCut on both a pre-labeled dataset, and on the end user's
+own dataset. See all the demo's [here!](../examples/README.md) Please note that GUIs are not easily supported in Jupyter in MacOS, as you need a framework build of python. While it's possible to launch them with a few tweaks, we recommend using the Project Manager GUI or terminal, so please follow the instructions below.
(using-project-manager-gui)=
## Option 2: using the Project Manager GUI:
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 0000000000..c8df32f9cf
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,103 @@
+/* custom.css */
+
+html[data-theme="light"] {
+ --sidebar-bg-color: #ac64e67f; /* #d2efff; */
+ --sidebar-text-color: #000000;
+ --sidebar-highlight-color: #73439a;
+ --sidebar-hover-text-color: #39214d; /* #0044bd; */
+ --header-bg-color: rgba(115, 67, 154, 0.75); /* #053346; */
+ --logo-filter: none;
+ --button-color: #fff;
+ --footer-text-color: #000000;
+}
+
+html[data-theme="dark"] {
+ --sidebar-bg-color: #455364; /* #053346; */
+ --sidebar-text-color: #ffffff;
+ --sidebar-highlight-color: #de6be8;
+ --sidebar-hover-text-color: #ea57f8; /* #00aeef; */
+ --header-bg-color: rgba(115, 67, 154, 0.45); /* #053346; */
+ /* --logo-filter: grayscale(100%) brightness(20); */
+ --button-color: #fff;
+ --footer-text-color: #b9b9b9;
+}
+
+/* Sidebar */
+.bd-sidebar-primary.bd-sidebar {
+ background-color: var(--sidebar-bg-color);
+ color: var(--sidebar-text-color);
+}
+
+.bd-sidebar-primary.bd-sidebar a {
+ color: var(--sidebar-text-color);
+}
+
+.bd-sidebar-primary.bd-sidebar a:hover {
+ color: var(--sidebar-hover-text-color);
+}
+
+/* Sidebar ToC */
+.toctree-l1.current.active {
+ color: var(--sidebar-highlight-color);
+}
+
+.toctree-l1.current.active * {
+ color: inherit;
+}
+
+/* Sidebar footer */
+.sidebar-primary-items__end::after {
+ content: "The DeepLabCut Community";
+ display: block;
+ text-align: center;
+ font-size: 12px;
+ color: var(--footer-text-color);
+ /* If you want an image at the footer */
+ /* background-image: url("images/logo_background.png"); */
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ padding-top: 150px;
+ height: 100px;
+ z-index: 1;
+}
+
+/* Logo (top of sidebar) */
+.navbar-brand img {
+ filter: var(--logo-filter, none);
+}
+
+/* Header */
+.bd-header-article {
+ /* position: fixed; */
+ background-color: var(--header-bg-color);
+ backdrop-filter: blur(2px);
+ z-index: 1000;
+}
+
+/* Header background image */
+.bd-header-article::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ /* background-image: url("images/header.jpg"); */
+ /* background-size: cover; */
+ /* background-repeat: no-repeat; */
+ /* background-position: center; */
+ opacity: 0.75;
+ z-index: -1;
+}
+
+/* Header buttons */
+.article-header-buttons .btn {
+ color: var(--button-color);
+ z-index: 1;
+}
+
+.article-header-buttons .btn:hover {
+ opacity: 0.8;
+ z-index: 1;
+}
diff --git a/docs/api/deeplabcut.analyze_videos.rst b/docs/api/deeplabcut.analyze_videos.rst
index d43e39ea4c..274a801f02 100644
--- a/docs/api/deeplabcut.analyze_videos.rst
+++ b/docs/api/deeplabcut.analyze_videos.rst
@@ -1 +1 @@
-.. autofunction:: deeplabcut.pose_estimation_tensorflow.predict_videos.analyze_videos
+.. autofunction:: deeplabcut.compat.analyze_videos
diff --git a/docs/api/deeplabcut.convert_detections2tracklets.rst b/docs/api/deeplabcut.convert_detections2tracklets.rst
new file mode 100644
index 0000000000..f69f721d84
--- /dev/null
+++ b/docs/api/deeplabcut.convert_detections2tracklets.rst
@@ -0,0 +1 @@
+.. autofunction:: deeplabcut.compat.convert_detections2tracklets
diff --git a/docs/api/deeplabcut.create_training_dataset_from_existing_split.rst b/docs/api/deeplabcut.create_training_dataset_from_existing_split.rst
new file mode 100644
index 0000000000..0b59472d91
--- /dev/null
+++ b/docs/api/deeplabcut.create_training_dataset_from_existing_split.rst
@@ -0,0 +1 @@
+.. autofunction:: deeplabcut.generate_training_dataset.trainingsetmanipulation.create_training_dataset_from_existing_split
diff --git a/docs/api/deeplabcut.evaluate_network.rst b/docs/api/deeplabcut.evaluate_network.rst
index f24ee4c481..56914774fe 100644
--- a/docs/api/deeplabcut.evaluate_network.rst
+++ b/docs/api/deeplabcut.evaluate_network.rst
@@ -1 +1 @@
-.. autofunction:: deeplabcut.pose_estimation_tensorflow.core.evaluate.evaluate_network
+.. autofunction:: deeplabcut.compat.evaluate_network
diff --git a/docs/api/deeplabcut.label_frames.rst b/docs/api/deeplabcut.label_frames.rst
index b1a810d284..4de3a1054c 100644
--- a/docs/api/deeplabcut.label_frames.rst
+++ b/docs/api/deeplabcut.label_frames.rst
@@ -1 +1 @@
-.. autofunction:: deeplabcut.gui.label_frames.label_frames
+.. autofunction:: deeplabcut.gui.tabs.label_frames.label_frames
diff --git a/docs/api/deeplabcut.refine_labels.rst b/docs/api/deeplabcut.refine_labels.rst
index 7c61d5586c..b54b640e46 100644
--- a/docs/api/deeplabcut.refine_labels.rst
+++ b/docs/api/deeplabcut.refine_labels.rst
@@ -1 +1 @@
-.. autofunction:: deeplabcut.gui.refine_labels.refine_labels
+.. autofunction:: deeplabcut.gui.tabs.label_frames.refine_labels
diff --git a/docs/api/deeplabcut.stitch_tracklets.rst b/docs/api/deeplabcut.stitch_tracklets.rst
new file mode 100644
index 0000000000..96677d31a9
--- /dev/null
+++ b/docs/api/deeplabcut.stitch_tracklets.rst
@@ -0,0 +1 @@
+.. autofunction:: deeplabcut.refine_training_dataset.stitch.stitch_tracklets
diff --git a/docs/api/deeplabcut.train_network.rst b/docs/api/deeplabcut.train_network.rst
index e724591d21..cd32c85295 100644
--- a/docs/api/deeplabcut.train_network.rst
+++ b/docs/api/deeplabcut.train_network.rst
@@ -1 +1 @@
-.. autofunction:: deeplabcut.pose_estimation_tensorflow.training.train_network
+.. autofunction:: deeplabcut.compat.train_network
diff --git a/docs/beginner-guides/Training-Evaluation.md b/docs/beginner-guides/Training-Evaluation.md
new file mode 100644
index 0000000000..802a22aec5
--- /dev/null
+++ b/docs/beginner-guides/Training-Evaluation.md
@@ -0,0 +1,59 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# Neural Network training and evaluation in the GUI
+
+
+
+Before training your model, the first step is to assemble your training dataset.
+
+**Create Training Dataset:** Move to the corresponding tab and click **`Create Training Dataset`**. For starters, the default settings will do just fine. While there are more powerful models and data augmentations you might want to consider, you can trust that for most projects the defaults are an ideal place to start.
+
+> 💡 **Note:** This guide assumes you have a GPU on your local machine. If you're CPU-bound and finding training challenging, consider using Google Colab. Our [Colab Guide](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb) can help you get started!
+
+## Kickstarting the Training Process
+
+With your training dataset ready, it's time to train your model.
+
+- **Navigate to Train Network:** Head over to the **`Train Network`** tab.
+- **Set Training Parameters:** Here, you'll specify:
+ - **`Display iterations/epochs`:** To specify how often the training progress will be visually updated. Note that our TensorFlow models are "iterations" while PyTorch is epochs.
+ - **`Maximum Iterations/epochs`:** Decide how many iterations to run. For TensorFlow models for a quick demo, 10K is great. For PyTorch models, 200 epochs is fine!
+ - **`Number of Snapshots to keep`:** Choose how many snapshots of the model you want to keep, **`Save iterations`:** and at what iteration intervals they should be saved.
+- **Launch Training:** Click on **`Train Network`** to begin.
+
+You can keep an eye on the training progress via your terminal window. This will give you a real-time update on how your model is learning (added bonus of the PyTorch model is it also shows you evaluation metrics after each epoch!).
+
+
+
+## Evaluate the Network
+
+After training, it's time to see how well your model performs.
+
+### Steps to Evaluate the Network
+
+1. Find and click on the **`Evaluate Network`** tab.
+2. **Choose Evaluation Options:**
+ - **Plot Predictions:** Select this to visualize the model's predictions, similar to standard DeepLabCut (DLC) evaluations.
+ - **Compare Bodyparts:** Opt to compare all the bodyparts for a comprehensive evaluation.
+3. Click the **`Evaluate Network`** button, located on the right side of the main window.
+
+>💡 Tip: If you wish to evaluate all saved snapshots, go to the configuration file and change the `snapshotindex` parameter to `all`.
+
+
+### Understanding the Evaluation Results
+
+- **Performance Metrics:** DLC will assess the latest snapshot of your model, generating a `.CSV` file with performance
+metrics. This file is stored in the **`evaluation-results`** (for TensorFlow models) or the
+**`evaluation-results-pytorch`** (for PyTorch models) folder within your project.
+
+
+)
+- **Visual Feedback:** Additionally, DLC creates subfolders containing your frames overlaid with both the labeled bodyparts and the model's predictions, allowing you to visually gauge the network's performance.
+
+)
+
+## Next, head over the beginner guide for [using your new neural network for video analysis](video-analysis)
diff --git a/docs/beginner-guides/beginners-guide.md b/docs/beginner-guides/beginners-guide.md
new file mode 100644
index 0000000000..784e522c65
--- /dev/null
+++ b/docs/beginner-guides/beginners-guide.md
@@ -0,0 +1,140 @@
+---
+deeplabcut:
+ last_content_updated: '2026-03-03'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(beginners-guide)=
+# Using DeepLabCut
+
+
+This guide, and related pages, are meant as a very-new-to-python beginner guide to DeepLabCut. After you are comfortable with this material we recommend then jumping into the more detailed User Guides!
+
+- **ProTip:** For even more 'in-depth' understanding, head over to check out the [DeepLabCut Course](https://deeplabcut.github.io/DeepLabCut/docs/course.html), which provides a deeper dive into the science behind DeepLabCut.
+
+## Installation
+
+Before you begin, make sure that DeepLabCut is installed on your system.
+
+- **ProTip:** For detailed installation instructions, geared towards a bit more advanced users, refer to the [Full Installation Guide](https://deeplabcut.github.io/DeepLabCut/docs/installation.html).
+
+## Beginner User Guide
+If you are new to Python, the best way to get Python installed onto your computer is with Anaconda. [Head over here and download the version that is best for your computer](https://www.anaconda.com/download).
+
+- "Conda", as it's often called, it a very nice way to create "environments (env)" on your computer. While there can be some cross-talk, in general, it allows you to separate the different tools you need to use to get your science done 💪.
+
+## Let's learn a bit and create a DeeplabCut env:
+
+After you have installed Anaconda, open the new program (Anaconda Terminal). You will be in your "root" directory by default.
+
+**(0) Create a fresh `conda environment`**
+
+In the terminal, type:
+
+```
+conda create -n deeplabcut python=3.10
+```
+You will be prompted (y/n) to install, and then wait for the magic to happen. At the end, check the terminal, it should prompt you to then type:
+
+```
+conda activate deeplabcut
+```
+Now, we are going to install the core dependencies. The way this works is that there are "package managers" such as `conda` itself and python's `pip`. We are going to deploy a mix based on what we know works across ooperating systems.
+
+**(1) Install PyTorch**
+
+`PyTorch` is the backend deep-learning language we wrote DLC3 in. To select the right version, head to the ["Install PyTorch"](https://pytorch.org/get-started/locally/) instructions in the official PyTorch Docs. Select your desired PyTorch build, operating system, select conda as your package manager and Python as the language. Select your compute platform (either a CUDA version or CPU only). Then, use the command to install the PyTorch package. Below are a few possible examples:
+
+- **GPU version of pytorch for CUDA 12.4**
+```
+pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
+```
+- **CPU only version of pytorch, using the latest version**
+```
+pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
+```
+
+**(2) Install DeepLabCut**
+
+Alright! Next, we will install all the `deeplabcut` source code 🔥. Please decide which version you want (stable or alpha), then type:
+
+- For the **Stable release:**
+```
+pip install "deeplabcut[gui,modelzoo,wandb]"
+```
+- This gives you DeepLabCut, the DLC GUI (gui), our latest neural networks (modelzoo) and a cool data logger (wandb) if you choose to use it later on!
+
+- OR for the **Alpha release (from GitHub bleeding edge of the code):**
+```
+pip install "git+https://github.com/DeepLabCut/DeepLabCut.git@pytorch_dlc#egg=deeplabcut[gui,modelzoo,wandb]"
+```
+
+## Starting DeepLabCut
+
+In the terminal, enter:
+```bash
+python -m deeplabcut
+```
+This will open the DeepLabCut App (note, the default is dark mode, but you can click "appearance" to change:
+
+
+
+> 💡 **Note:** For a visual guide on navigating through the DeepLabCut GUI, check out our [YouTube tutorial](https://www.youtube.com/watch?v=tr3npnXWoD4).
+
+## Starting a New Project
+
+### Navigating the GUI on Initial Launch
+
+When you first launch the GUI, you'll find three primary main options:
+
+1. **Create New Project:** Geared towards new initiatives. A good choice if you're here to start something new.
+2. **Load Project:** Use this to resume your on-hold or past work.
+3. **Model Zoo:** Best suited for those who want to explore Model Zoo.
+
+### Commencing Your Work:
+
+- For a first-time or new user, please click on **`Start New Project`**.
+
+## 🐾 Steps to Start a New Project
+
+1. **Launch New Project:**
+ - When you start a new project, you'll be presented with an empty project window. In DLC3+ you will see a new option "Engine".
+ - We recommend using the PyTorch Engine:
+
+ )
+
+2. **Filling in Project Details:**
+ - **Naming Your Project:**
+ - Give a specific, well-defined name to your project.
+
+ > **💡 Tip:** Avoid empty spaces in your project name.
+
+ - **Naming the Experimenter:**
+ - Fill in the name of the experimenter. This part of the data remains immutable.
+
+3. **Determine Project Location:**
+ - By default, your project will be located on the **Desktop**.
+ - To pick a different home, modify the path as needed.
+
+4. **Multi-Animal or Single-Animal Project:**
+ - Tick the 'Multi-Animal' option in the menu, but only if that's the mode of the project.
+ - Choose the 'Number of Cameras' as per your experiment.
+
+5. **Adding Videos:**
+ - First, click on **`Browse Videos`** button on the right side of the window, to search for the video contents.
+ - Once the media selection tool opens, navigate and select the folder with your videos.
+
+ > **💡 Tip:** DeepLabCut supports **`.mp4`**, **`.avi`**, **`.mkv`** and **`.mov`** files.
+ - A list will be created with all the videos inside this folder.
+ - Unselect the videos you wish to remove from the project.
+
+6. **Create your project:**
+ - Click on **`Create`** button on the bottom, right side of the main window.
+ - A new folder named after your project's name will be created in the location you chose above.
+
+
+### 📽 Video Tutorial: Setting Up Your Project in DeepLabCut
+
+
+
+## Next, head over to the beginner guide for [Setting up what keypoints to track](https://deeplabcut.github.io/DeepLabCut/docs/beginner-guides/manage-project.html)
diff --git a/docs/beginner-guides/labeling.md b/docs/beginner-guides/labeling.md
new file mode 100644
index 0000000000..546d76b96e
--- /dev/null
+++ b/docs/beginner-guides/labeling.md
@@ -0,0 +1,75 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(labeling)=
+# Labeling GUI
+
+## Selecting Frames to Label
+
+In DeepLabCut, choosing the right frames for labeling is a key step. The trick is always to select the MOST DIVERSE data you can that your model will see. That means good lighting, bad lighting, anything you want to throw at it. So, first, pick a range of diverse videos! Then, we will help you pick frames. You've got two easy ways to do this:
+
+1. **Let DeepLabCut Choose:** DeepLabCut can extract frames automatically for you. It's got two neat ways to do that:
+ - **Uniform:** This is like taking a snapshot at regular time intervals.
+ - **K-means clustering:** This one applies k-means and picks images from different clusters. This is typically better, as it gives you a variety of actions and poses. Note, as it is a clustering tool, it will miss rare events, so ideally run this step, then perhaps consider running the manual GUI to get some rare frames! You can do both within DLC.
+
+2. **Pick Frames Yourself:** Just like flipping through a photo album, you can go through your video and pick the frames that catch your eye - this is great for finding rare frames. Choose the **`manual`** extraction method.
+
+### Here's how to get started:
+
+- **Step 1:** Click on **`automatic`** in the frame selection area.
+- **Step 2:** Choose **`k-means`** for some variety.
+- **Step 3:** Hit the **`Extract Frames`** button, usually found at the bottom right corner.
+
+By default, DeepLabCut will grab 20 frames from each of your videos and put them into sub-folders, per video, under **labeled-data** in your project. Now, you're all set to start labeling!
+
+## Labeling Your First Set of Frames in DeepLabCut
+
+Alright, you've got your extracted frames ready. Now comes the labeling!
+
+### Entering the Label Frames Area
+
+- **Click on `Label Frames`:** This takes you straight to where your frames are, sorted in the **labeled-data** folder, each video in its own sub-folder.
+- **Open a Folder:** Click on the first one to start, and then click **`open`**.
+
+### The napari DeepLabCut Labeler
+
+- **Plugin Window Opens:** As soon as you click **`open`**, the napari DeepLabCut plugin window appears, your main stage for labeling.
+- **Tutorial Popup:** A quick tutorial window shows up. It's a brief guide, so give it a look to understand the basics.
+
+)
+
+### Labeling Setup
+
+- **Frames on Display:** Your frames are lined up in the middle, with a slider below to shuffle through them.
+- **Tools and Keypoints:** To the bottom right, you find a list of bodyparts from your configuration. On the top left, all your labeling tools are ready.
+
+### The Labeling Process
+
+- **Start with `Add points`:** Click this to begin placing keypoints on your first frame. If you can't see a bodypart, just move to the next one.
+- **Navigate Through Frames:** Use the slider to go from one frame to the next after you're done labeling.
+- **Save Progress:** Remember to save your work as you go with **`Command and S`** (or **`Ctrl and S`** on Windows).
+
+> 💡 **Note:** For a detailed walkthrough on using the Napari labeling GUI, have a look at the
+[DeepLabCut Napari Guide](file:napari-gui-landing). Additionally, you can watch our instructional
+[YouTube video](https://www.youtube.com/watch?v=hsA9IB5r73E) for more insights and tips.
+
+
+### Completing the Set
+
+Work through all the frames in the first folder. Then, proceed to the next, continuing this way until each folder in your **labeled-data** directory is done.
+
+## Checking Your Labels
+
+After you've labeled all your frames, it's important to ensure they're accurate.
+
+### How to Check Your Labels
+
+- **Return to the Main Window:** Once you're done with labeling, head back to DeepLabCut's main window, and click on **`Check Labels`**.
+- **Review the Labeled Folders:** The system will have created new folders for each labeled set inside your labeled-data folder. These folders contain your original frames overlaid with the keypoints you've added.
+
+
+
+Take the time to go through each folder. Accurate labels are key. If there are mistakes, the model might learn incorrectly and mislabel your videos later on. It's all about setting the right foundation for accurate analysis.
diff --git a/docs/beginner-guides/manage-project.md b/docs/beginner-guides/manage-project.md
new file mode 100644
index 0000000000..ee0e0f339e
--- /dev/null
+++ b/docs/beginner-guides/manage-project.md
@@ -0,0 +1,50 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# Setting up what keypoints to track
+
+
+**Edit the Configuration File**
+
+After creating your DeepLabCut project, you'll go to the main GUI window, where you'll start managing your project from the Project Management Tab.
+
+**Accessing the Configuration File**
+
+- **Locate the Configuration File:** At the top of the main window, you'll find the file path to the configuration file.
+- **Edit the File:** Click on **`Edit config.yaml`**. This action allows you to:
+ - Define the bodyparts you wish to track.
+ - Outline the skeleton structure (optional!).
+
+A **`Configuration Editor`** window will open, displaying all the configuration details. You'll need to modify some of these settings to align with your research requirements.
+
+## Steps to Edit the Configuration
+
+### 1. Defining Bodyparts
+
+- **Locate the Bodyparts Section:** In the Configuration Editor, find the **`bodyparts`** category.
+- **Modify the List:** Click on the arrow next to **`bodyparts`** to expand the list. Here, you can:
+ - Update the list with the names of the bodyparts relevant to your study.
+ - Add more entries by right-clicking on a row number and selecting **`Insert`**.
+
+
+
+
+
+### 2. Defining the Skeleton
+
+- **Navigate to the Skeleton Section:** Scroll down to the **`skeleton`** category.
+- **Adjust the Skeleton List:** Click on the arrow to expand this section. You can then:
+ - Update the pairs of bodyparts to define the skeleton structure of your model.
+
+
+
+> 💡 **Tip:** If you're new to DeepLabCut, spend some time visualizing how the chosen bodyparts can be connected effectively to form a coherent skeleton.
+
+### Saving Your Changes
+
+- **Save the Configuration:** Once you're satisfied with the modifications, click **`Save`**. This will store your changes and return you to the main GUI window.
+
+## Next, head over the beginner guide for [Labeling your data](labeling)
diff --git a/docs/beginner-guides/video-analysis.md b/docs/beginner-guides/video-analysis.md
new file mode 100644
index 0000000000..8c48d3209c
--- /dev/null
+++ b/docs/beginner-guides/video-analysis.md
@@ -0,0 +1,41 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# Video Analysis with DeepLabCut
+
+
+
+After training and evaluating your model, the next step is to apply it to your videos.
+
+**How to Analyze Videos**
+
+1. **Navigate to the 'Analyze Videos' Tab:** Begin applying your trained model to video data here.
+2. **Select Your Video Format and Files:**
+ - **Choose Video Format:** Pick the format of your video (`.mp4`, `.avi`, `.mkv`, or `.mov`).
+ - **Select Videos:** Click **`Select Videos`** to find and open your video file.
+3. **Start Analysis:** Click **`Analyze`**. The analysis time depends on video length and resolution. Track progress in the terminal or Anaconda prompt.
+
+## Reviewing Analysis Results
+
+- **Find Results in Your Project Folder:** After analysis, go to your project's video folder.
+- **Analysis Files:** Look also for a `.metapickle`, an `.h5`, and possibly a `.csv` file for detailed analysis data.
+- **Review the "plot-poses" subfolder:** This contains visual outputs of the video analysis.
+
+
+
+## Creating a Labeled Video
+
+1. **Go to 'Create Labeled Video' Tab:** The previously analyzed video should be selected.
+2. If not already selected, choose your video.
+3. Click **`Create Videos`**.
+
+## Viewing the Labeled Video
+
+- Your labeled video will be in your video folder, named after the original video plus model details and 'labeled'.
+- Watch the video to assess the model's labeling accuracy.
+
+## Happy DeepLabCutting!
+- Check out the more advanced user guides for even more options!
diff --git a/docs/benchmark.md b/docs/benchmark.md
index 114568decd..1b2e9a5d42 100644
--- a/docs/benchmark.md
+++ b/docs/benchmark.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# DeepLabCut benchmark
For further information and the leaderboard, see [the official homepage](https://benchmark.deeplabcut.org/).
@@ -32,4 +38,4 @@ benchmarks. For an example of how to implement a benchmark submission, refer to
.. automodule:: deeplabcut.benchmark.metrics
:members:
:show-inheritance:
-```
\ No newline at end of file
+```
diff --git a/docs/citation.md b/docs/citation.md
new file mode 100644
index 0000000000..f8a1234ff6
--- /dev/null
+++ b/docs/citation.md
@@ -0,0 +1,144 @@
+---
+deeplabcut:
+ last_content_updated: '2024-10-27'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# How to Cite DeepLabCut
+
+Thank you for using DeepLabCut! Here are our recommendations for citing and documenting your use of DeepLabCut in your Methods section:
+
+
+If you use this code or data we kindly ask that you please [cite Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y)
+and, if you use the Python package (DeepLabCut2.x+) please also cite [Nath, Mathis et al, 2019](https://doi.org/10.1038/s41596-019-0176-0).
+If you utilize the MobileNetV2s or EfficientNets please cite [Mathis, Biasi et al. 2021](https://openaccess.thecvf.com/content/WACV2021/papers/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.pdf).
+If you use multi-animal versions 2.2beta+ or 2.2rc1+, please cite [Lauer et al. 2022](https://www.nature.com/articles/s41592-022-01443-0).
+If you use our SuperAnimal models, please cite [Ye et al. 2024](https://www.nature.com/articles/s41467-024-48792-2).
+
+DOIs (#ProTip, for helping you find citations for software, check out [CiteAs.org](http://citeas.org/)!):
+
+- Mathis et al 2018: [10.1038/s41593-018-0209-y](https://doi.org/10.1038/s41593-018-0209-y)
+- Nath, Mathis et al 2019: [10.1038/s41596-019-0176-0](https://doi.org/10.1038/s41596-019-0176-0)
+- Lauer et al 2022: [10.1038/s41592-022-01443-0](https://doi.org/10.1038/s41592-022-01443-0)
+- Ye et al 2024: [10.1038/s41467-024-48792-2](https://www.nature.com/articles/s41467-024-48792-2)
+
+## Formatted citations:
+
+ @article{Mathisetal2018,
+ title = {DeepLabCut: markerless pose estimation of user-defined body parts with deep learning},
+ author = {Alexander Mathis and Pranav Mamidanna and Kevin M. Cury and Taiga Abe and Venkatesh N. Murthy and Mackenzie W. Mathis and Matthias Bethge},
+ journal = {Nature Neuroscience},
+ year = {2018},
+ url = {https://www.nature.com/articles/s41593-018-0209-y}}
+
+ @article{NathMathisetal2019,
+ title = {Using DeepLabCut for 3D markerless pose estimation across species and behaviors},
+ author = {Nath*, Tanmay and Mathis*, Alexander and Chen, An Chi and Patel, Amir and Bethge, Matthias and Mathis, Mackenzie W},
+ journal = {Nature Protocols},
+ year = {2019},
+ url = {https://doi.org/10.1038/s41596-019-0176-0}}
+
+ @InProceedings{Mathis_2021_WACV,
+ author = {Mathis, Alexander and Biasi, Thomas and Schneider, Steffen and Yuksekgonul, Mert and Rogers, Byron and Bethge, Matthias and Mathis, Mackenzie W.},
+ title = {Pretraining Boosts Out-of-Domain Robustness for Pose Estimation},
+ booktitle = {Proceedings of the IEEE/CVF Winter Conference on Applications of Computer Vision (WACV)},
+ month = {January},
+ year = {2021},
+ pages = {1859-1868}}
+
+ @article{Lauer2022MultianimalPE,
+ title={Multi-animal pose estimation, identification and tracking with DeepLabCut},
+ author={Jessy Lauer and Mu Zhou and Shaokai Ye and William Menegas and Steffen Schneider and Tanmay Nath and Mohammed Mostafizur Rahman and Valentina Di Santo and Daniel Soberanes and Guoping Feng and Venkatesh N. Murthy and George Lauder and Catherine Dulac and M. Mathis and Alexander Mathis},
+ journal={Nature Methods},
+ year={2022},
+ volume={19},
+ pages={496 - 504}}
+
+ @article{Ye2024SuperAnimal,
+ title={SuperAnimal pretrained pose estimation models for behavioral analysis},
+ author={Shaokai Ye and Anastasiia Filippova and Jessy Lauer and Steffen Schneider and Maxime Vidal and and Tian Qiu and Alexander Mathis and Mackenzie W. Mathis},
+ journal={Nature Communications},
+ year={2024},
+ volume={15}}
+
+
+### Review & Educational articles:
+
+ @article{Mathis2020DeepLT,
+ title={Deep learning tools for the measurement of animal behavior in neuroscience},
+ author={Mackenzie W. Mathis and Alexander Mathis},
+ journal={Current Opinion in Neurobiology},
+ year={2020},
+ volume={60},
+ pages={1-11}}
+
+ @article{Mathis2020Primer,
+ title={A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives},
+ author={Alexander Mathis and Steffen Schneider and Jessy Lauer and Mackenzie W. Mathis},
+ journal={Neuron},
+ year={2020},
+ volume={108},
+ pages={44-65}}
+
+### Other open-access pre-prints related to our work on DeepLabCut:
+
+ @article{MathisWarren2018speed,
+ author = {Mathis, Alexander and Warren, Richard A.},
+ title = {On the inference speed and video-compression robustness of DeepLabCut},
+ year = {2018},
+ doi = {10.1101/457242},
+ publisher = {Cold Spring Harbor Laboratory},
+ URL = {https://www.biorxiv.org/content/early/2018/10/30/457242},
+ eprint = {https://www.biorxiv.org/content/early/2018/10/30/457242.full.pdf},
+ journal = {bioRxiv}}
+
+
+
+## Methods Suggestion:
+
+For body part tracking we used DeepLabCut (version 2.X.X)* [Mathis et al, 2018, Nath et al, 2019, Lauer et al. 2022]. Specifically, we labeled X number of frames taken from X videos/animals (then X% was used for training (default is 95%). We used a X-based neural network (i.e. X = ResNet-50, ResNet-101, MobileNetV2-0.35, MobileNetV2-0.5, MobileNetV2-0.75, MobileNetV2-1***) with default parameters* for X number of training iterations. We validated with X number of shuffles, and found the test error was: X pixels, train: X pixels (image size was X by X). We then used a p-cutoff of X (i.e. 0.9) to condition the X,Y coordinates for future analysis. This network was then used to analyze videos from similar experimental settings.
+
+> Mathis, A. et al. Deeplabcut: markerless pose estimation
+> of user-defined body parts with deep learning. Nature
+> Neuroscience 21, 1281–1289 (2018).
+
+> Nath, T. et al. Using deeplabcut for 3d markerless pose
+> estimation across species and behaviors. Nature Protocols
+> 14, 2152–2176 (2019).
+
+*If any defaults were changed in *`pose_config.yaml`*, mention them here.
+
+i.e. common things one might change:
+* the loader (options are `default`, `imgaug`, `tensorpack`, `deterministic`).
+* the `post_dist_threshold` (default is 17 and determines training resolution).
+* optimizer: do you use the default `SGD` or `ADAM`?
+
+*** here, you could add additional citations.
+If you use ResNets, consider citing Insafutdinov et al 2016 & He et al 2016. If you use the MobileNetV2s consider citing Mathis et al 2019, and Sandler et al, 2018.
+
+
+> Mathis, A. et al. Pretraining boosts out-of-domain robustness for pose estimation
+> arXiv 1909.11229 (2019)
+
+> Insafutdinov, E., Pishchulin, L., Andres, B., Andriluka,
+> M. & Schiele, B. DeeperCut: A deeper, stronger, and
+> faster multi-person pose estimation model. In European
+> Conference on Computer Vision, 34–50 (Springer, 2016).
+
+> Sandler, M., Howard, A., Zhu, M., Zhmoginov, A. &
+> Chen, L.-C. Mobilenetv2: Inverted residuals and linear
+> bottlenecks. In Proceedings of the IEEE Conference
+> on Computer Vision and Pattern Recognition, 4510–4520
+> (2018).
+
+> He, K., Zhang, X., Ren, S. & Sun, J. Deep residual
+> learning for image recognition. In Proceedings of the
+> IEEE conference on computer vision and pattern recognition,
+> 770–778 (2016). URL https://arxiv.org/abs/
+> 1512.03385.
+
+## Graphics
+
+We also have the network graphic freely available on SciDraw.io if you'd like to use it! https://scidraw.io/drawing/290
+
+You are welcome to use our logo in your works as well.
diff --git a/docs/convert_maDLC.md b/docs/convert_maDLC.md
index 6671afb1ef..14e697c719 100644
--- a/docs/convert_maDLC.md
+++ b/docs/convert_maDLC.md
@@ -1,10 +1,18 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(convert-maDLC)=
-# How to convert a pre-2.2 project for use with DeepLabCut 2.2
+# How to convert a pre-2.2 project for use with DeepLabCut 2.2 or later
-If you have a pre-2.2 project (`labeled-data`) with a **single animal** that you want to use with a multianimal project in DLC 2.2, i.e. use your older data to now train the new multi-task deep neural network, here is what you need to do.
+If you have a pre-2.2 project (`labeled-data`) with a **single animal** that you want to use with a multianimal project
+in DLC 2.2 or later, i.e. use your older data to now train the new multi-task deep neural network, here is what you
+need to do.
(1) We recommend you make a back-up of your project folder.
@@ -14,7 +22,8 @@ If you have a pre-2.2 project (`labeled-data`) with a **single animal** that you
-- After `task, scorer, date, project_path` please add the following (i.e. in the image above, you would start adding below line 6) Note, the ordering isn't important but useful to keep consistent with the template:
+- After `task, scorer, date, project_path` please add the following (i.e. in the image above, you would start adding
+below line 6) Note, the ordering isn't important but useful to keep consistent with the template:
```python
multianimalproject: true
@@ -29,9 +38,12 @@ individuals:
- mouse1
```
-- `"uniquebodyparts: []` can stay blank, unless you have other items labeled you want to estimate (consider these as similar to bodyparts in pre-2.2); i.e. corners of a box, etc. All unique bodyparts should not be connected to the multianimal bodyparts in the skeleton you will eventually make. But see "advanced option" below.
+- `"uniquebodyparts: []` can stay blank, unless you have other items labeled you want to estimate (consider these as
+similar to bodyparts in pre-2.2); i.e. corners of a box, etc. All unique bodyparts should not be connected to the
+multianimal bodyparts in the skeleton you will eventually make. See "advanced option" below.
-- Please move your "bodyparts:" to "multianimalbodyparts:" (bodypart names must stay the same!) These are the parts that will always be interconnected fully!
+- Please move your "bodyparts:" to "multianimalbodyparts:" (bodypart names must stay the same!) These are the parts
+that will always be interconnected fully!
```python
multianimalbodyparts:
- snout
@@ -46,20 +58,25 @@ then you can set `bodyparts: MULTI!`
deeplabcut.convert2_maDLC(path_config_file, userfeedback=True)
```
-Now you will see that your data within `labeled-data` are converted to a new format, and the single animal format was saved for you under a new file named `CollectedData_ ...singleanimal.h5` and `.csv` as a back-up!
+Now you will see that your data within `labeled-data` are converted to a new format, and the single animal format was
+saved for you under a new file named `CollectedData_ ...singleanimal.h5` and `.csv` as a back-up!
-(4) We strongly recommend to first run check_labels and verify that the conversion was as expected before creating a multianimal training dataset. For instance, you can load this project `config.yaml` in the Project Manager GUI and check labels then create a multi-animal training set with
+(4) We strongly recommend to first run check_labels and verify that the conversion was as expected before creating a
+multianimal training dataset. For instance, you can load this project `config.yaml` in the Project Manager GUI and
+check labels then create a multi-animal training set with
```python
deeplabcut.create_multianimaltraining_dataset(path_config_file)
```
to begin training.
-**Advanced option:** You can also assign former `bodyparts` to either `uniquebodyparts` or `multianimalbodyparts` (you can even leave some unassigned, which means they will be dropped in the conversion).
+**Advanced option:** You can also assign former `bodyparts` to either `uniquebodyparts` or `multianimalbodyparts`
+(you can even leave some unassigned, which means they will be dropped in the conversion).
Example: Imagine you had a project with the moon and a rocket with two parts labeled:
`bodyparts: [moon, rocket_tip,rocket_bottom]`
-Now you want to use this former project (labeled-data) and work on a new dataset (videos) with one moon but multiple (3) rockets. Then convert it as follows:
+Now you want to use this former project (labeled-data) and work on a new dataset (videos) with one moon but multiple
+(3) rockets. Then convert it as follows:
```
individuals: [rocket1, rocket2, rocket3]
uniquebodyparts: [moon]
diff --git a/docs/course.md b/docs/course.md
new file mode 100644
index 0000000000..58c9d153c0
--- /dev/null
+++ b/docs/course.md
@@ -0,0 +1,136 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# DeepLabCut Self-paced Course
+
+::::{warning}
+This course was designed for DLC 2.
+An updated version for DLC 3 is in the works.
+::::
+
+Do you have video of animal behaviors? Step 1: Get Poses ...
+
+
+
+This document is an outline of resources for a course for those wanting to learn to use `Python` and `DeepLabCut`.
+We expect it to take *roughly* 1-2 weeks to get through if you do it rigorously. To get the basics, it should take 1-2 days.
+
+[CLICK HERE to launch the interactive graphic to get started!](https://view.genial.ly/5fb40a49f8a0ef13943d4e5e/horizontal-infographic-review-learning-to-use-deeplabcut) (mini preview below) Or, jump in below!
+
+
+
+
+
+
+## Installation:
+
+You need Python and DeepLabCut installed!
+- [See these "beginner docs" for help!](beginners-guide)
+
+- **WATCH:** overview of conda: [Python Tutorial: Anaconda - Installation and Using Conda](https://www.youtube.com/watch?v=YJC6ldI3hWk)
+
+
+## Outline:
+
+### **The basics of computing in Python, terminal, and overview of DeepLabCut:**
+
+- **Learning:** Using the program terminal / cmd on your computer: [Video Tutorial!](https://www.youtube.com/watch?v=5XgBd6rjuDQ)
+
+- **Learning:** although minimal to no Python coding is required (i.e. you could use the DLC GUI to run the full program without it), here are some resources you may want to check out. [Software Carpentry: Programming with Python](https://swcarpentry.github.io/python-novice-inflammation/)
+
+- **Learning:** learning and teaching signal processing, and overview from Prof. Demba Ba [talk at JupyterCon](https://www.youtube.com/watch?v=ywz-LLYwkQQ)
+
+- **DEMO:** Can I DEMO DEEPLABCUT (DLC) quickly?
+ - Yes: [you can click through this DEMO notebook](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb)
+ - AND follow along with me: [Video Tutorial!](https://www.youtube.com/watch?v=DRT-Cq2vdWs)
+
+
+- **WATCH:** How do you know DLC is installed properly? (i.e. how to use our test script!) [Video Tutorial!](https://youtu.be/IOWtKn3l33s)
+
+
+
+
+- **REVIEW PAPER:** The state of animal pose estimation w/ deep learning i.e. "Deep learning tools for the measurement of animal behavior in neuroscience" [arXiv](https://arxiv.org/abs/1909.13868) & [published version](https://www.sciencedirect.com/science/article/pii/S0959438819301151)
+
+- **REVIEW PAPER:** [A Primer on Motion Capture with Deep Learning: Principles, Pitfalls and Perspectives](https://www.sciencedirect.com/science/article/pii/S0896627320307170)
+
+
+- **WATCH:** There are a lot of docs... where to begin: [Video Tutorial!](https://www.youtube.com/watch?v=A9qZidI7tL8)
+
+### **Module 1: getting started on data**
+
+**What you need:** any videos where you can see the animals/objects, etc.
+You can use our demo videos, grab some from the internet, or use whatever older data you have. Any camera, color/monochrome, etc will work. Find diverse videos, and label what you want to track well :)
+- IF YOU ARE PART OF THE COURSE: you will be contributing to the DLC Model Zoo 😊
+
+ - **Slides:** [Overview of starting new projects](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials/blob/main/part1-labeling.pdf)
+ - **READ ME PLEASE:** [DeepLabCut, the science](https://rdcu.be/4Rep)
+ - **READ ME PLEASE:** [DeepLabCut, the user guide](https://rdcu.be/bHpHN)
+ - **WATCH:** Video tutorial 1: [using the Project Manager GUI](https://www.youtube.com/watch?v=KcXogR-p5Ak)
+ - Please go from project creation (use >1 video!) to labeling your data, and then check the labels!
+ - **WATCH:** Video tutorial 2: [using the Project Manager GUI for multi-animal pose estimation](https://www.youtube.com/watch?v=Kp-stcTm77g)
+ - Please go from project creation (use >1 video!) to labeling your data, and then check the labels!
+ - **WATCH:** Video tutorial 3: [using ipython/pythonw (more functions!)](https://www.youtube.com/watch?v=7xwOhUcIGio)
+ - multi-animal DLC: [labeling](https://www.youtube.com/watch?v=Kp-stcTm77g)
+ - Please go from project creation (use >1 video!) to labeling your data, and then check the labels!
+
+
+### **Module 2: Neural Networks**
+
+ - **Slides:** [Overview of creating training and test data, and training networks](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials/blob/main/part2-network.pdf)
+ - **READ ME PLEASE:** [What are convolutional neural networks?](https://towardsdatascience.com/a-comprehensive-guide-to-convolutional-neural-networks-the-eli5-way-3bd2b1164a53)
+
+ - **READ ME PLEASE:** Here is a new paper from us describing challenges in robust pose estimation, why PRE-TRAINING really matters - which was our major scientific contribution to low-data input pose-estimation - and it describes new networks that are available to you. [Pretraining boosts out-of-domain robustness for pose estimation](https://paperswithcode.com/paper/pretraining-boosts-out-of-domain-robustness)
+
+ - **MORE DETAILS:** ImageNet: check out the original paper and dataset: http://www.image-net.org/
+
+ - **REVIEW PAPER:** [A Primer on Motion Capture with Deep Learning: Principles, Pitfalls and Perspectives](https://www.sciencedirect.com/science/article/pii/S0896627320307170)
+
+
+
+
+ Before you create a training/test set, please read/watch:
+ - **More information:** [Which types neural networks are available, and what should I use?](https://github.com/DeepLabCut/DeepLabCut/wiki/What-neural-network-should-I-use%3F-(Trade-offs,-speed-performance,-and-considerations))
+ - **WATCH:** Video tutorial 1: [How to test different networks in a controlled way](https://www.youtube.com/watch?v=WXCVr6xAcCA)
+ - Now, decide what model(s) you want to test.
+ - IF you want to train on your CPU, then run the step `create_training_dataset`, in the GUI etc. on your own computer.
+ - IF you want to use GPUs on google colab, [**(1)** watch this FIRST/follow along here!](https://www.youtube.com/watch?v=qJGs8nxx80A) **(2)** move your whole project folder to Google Drive, and then [**use this notebook**](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb)
+
+ **MODULE 2 webinar**: https://youtu.be/ILsuC4icBU0
+
+
+### **Module 3: Evaluation of network performance**
+
+ - **Slides** [Evaluate your network](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials/blob/master/part3-analysis.pdf)
+ - **WATCH:** [Evaluate the network in ipython](https://www.youtube.com/watch?v=bgfnz1wtlpo)
+ - why evaluation matters; how to benchmark; analyzing a video and using scoremaps, conf. readouts, etc.
+
+### **Module 4: Scaling your analysis to many new videos**
+
+Once you have good networks, you can deploy them. You can create "cron jobs" to run a timed analysis script, for example. We run this daily on new videos collected in the lab. Check out a simple script to get started, and read more below:
+
+ - [Analyzing videos in batches, over many folders, setting up automated data processing](https://github.com/DeepLabCut/DLCutils/tree/master/SCALE_YOUR_ANALYSIS)
+
+ - How to automate your analysis in the lab: [datajoint.io](https://datajoint.io), Cron Jobs: [schedule your code runs](https://www.ostechnix.com/a-beginners-guide-to-cron-jobs/)
+
+### **Module 5: Got Poses? Now what ...**
+
+Pose estimation took away the painful part of digitizing your data, but now what? There is a rich set of tools out there to help you create your own custom analysis, or use others (and edit them to your needs). Check out more below:
+
+ - [Helper code and packages for use on DLC outputs](https://github.com/DeepLabCut/DLCutils)
+
+ - Create your own machine learning classifiers: https://scikit-learn.org/stable/
+
+ - **REVIEW PAPER:** [Toward a Science of Computational Ethology](https://www.sciencedirect.com/science/article/pii/S0896627314007934)
+
+ - **REVIEW PAPER:** The state of animal pose estimation w/ deep learning i.e. "Deep learning tools for the measurement of animal behavior in neuroscience" [arXiv](https://arxiv.org/abs/1909.13868) & [published version](https://www.sciencedirect.com/science/article/pii/S0959438819301151)
+
+ - **REVIEW PAPER:** [Big behavior: challenges and opportunities in a new era of deep behavior profiling](https://www.nature.com/articles/s41386-020-0751-7)
+
+ - **READ**: [Automated measurement of mouse social behaviors using depth sensing, video tracking, and machine learning](https://www.pnas.org/content/112/38/E5351)
+
+
+*compiled and edited by Mackenzie Mathis*
diff --git a/docs/deeplabcutlive.md b/docs/deeplabcutlive.md
deleted file mode 100644
index f8784b651d..0000000000
--- a/docs/deeplabcutlive.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# DeepLabCut-Live!
-
-We provide two additional pip packages that allow you to record and stream camera data and run DeeplabCut models in real-time.
-You can get an indepth overview of this work in [Kane et al, 2020 eLife](https://elifesciences.org/articles/61909).
-
-Here is information on the DLC-Live! Camera GUI:
-
-- [DLC-Live! Camera GUI](https://github.com/DeepLabCut/DeepLabCut-live-GUI)
-
-Here is information on the DLC-Live! software SDK:
-
-- [DLC-Live! Software](https://github.com/DeepLabCut/DeepLabCut-live)
diff --git a/docs/dlc-live/deeplabcutlive.md b/docs/dlc-live/deeplabcutlive.md
new file mode 100644
index 0000000000..1d4b38d9ff
--- /dev/null
+++ b/docs/dlc-live/deeplabcutlive.md
@@ -0,0 +1,23 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(deeplabcut-live)=
+# Running DeepLabCut models in real-time
+
+We provide two additional packages that allow you to record and stream camera data and run DeepLabCut models in real-time.
+
+You can get an in-depth overview of this work in [Kane et al, 2020 eLife](https://elifesciences.org/articles/61909).
+
+## DLC-Live! Software SDK
+
+You may find more information on the DLC-Live! software SDK on GitHub:
+
+- [DLC-Live! Software](https://github.com/DeepLabCut/DeepLabCut-live)
+
+## DLC-Live! GUI
+
+For the DLC-Live! Camera GUI, we provide documentation in the section below:
+
+- {doc}`DLC-Live! GUI <./dlc-live-gui/index>`
diff --git a/docs/dlc-live/dlc-live-gui/_static/images/main_window_100226.png b/docs/dlc-live/dlc-live-gui/_static/images/main_window_100226.png
new file mode 100644
index 0000000000..3a9589eaa6
Binary files /dev/null and b/docs/dlc-live/dlc-live-gui/_static/images/main_window_100226.png differ
diff --git a/docs/dlc-live/dlc-live-gui/index.md b/docs/dlc-live/dlc-live-gui/index.md
new file mode 100644
index 0000000000..8f82588b2d
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/index.md
@@ -0,0 +1,80 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+# DeepLabCut-live-GUI
+
+A graphical application for **real-time pose estimation with DeepLabCut** using one or more cameras.
+
+This GUI is designed for **scientists and experimenters** who want to preview, run inference, and record synchronized video with pose overlays—without writing code.
+
+## Table of Contents
+
+- {doc}`Installation <./quickstart/install>`
+- {doc}`Overview <./user_guide/overview>`
+- {doc}`Camera setup and backends <./user_guide/cameras_backends/camera_support>`
+- {doc}`Timestamp format and synchronization <./user_guide/misc/timestamp_format>`
+
+```{caution}
+Please be aware of the {ref}`sec:dlclivegui-index-limitations`
+```
+
+---
+
+## Description
+
+### What this software does
+
+- **Live camera preview** from one or multiple cameras
+- **Real-time pose inference** using DeepLabCut Live models
+- **Multi-camera support** with tiled display
+- **Video recording** (raw or with pose and bounding-box overlays)
+- **Session-based data organization** with reproducible naming
+- **Optional processor plugins** to extend behavior (e.g. remote control, triggers)
+
+The application is built with **PySide6 (Qt)** and is intended for interactive experimental use rather than offline batch processing.
+
+
+### Typical workflow
+
+1. **Install** the application and required camera backends
+2. **Configure cameras** (single or multi-camera)
+3. **Select a DeepLabCut Live model**
+4. **Start preview** and verify frame rate
+5. **Run pose inference** on a selected camera
+6. **Record video** (optionally with overlays)
+ - With **organized results** by session and run
+
+Each of these steps is covered in the *{doc}`Quickstart `*
+and *{doc}`User Guide `* sections of this documentation.
+
+### Who this is for
+
+- Neuroscience and behavior labs
+- Experimentalists running real-time tracking
+- Anyone who wants a **GUI-first** workflow for DeepLabCut Live
+
+---
+(sec:dlclivegui-index-limitations)=
+## Current limitations
+
+Before getting started, be aware of the following constraints:
+
+- Pose inference runs on **one selected camera at a time** (even in multi-camera mode)
+- Camera synchronization depends on **backend capabilities and hardware**
+ - OpenCV controls for resolution and FPS are "best effort" and may not work with all cameras.
+ Expect inconsistencies when setting certain frame rates or resolutions as resolution depends on the device driver.
+- DeepLabCut Live models must be **exported and compatible** with the selected backend
+ - Some SuperAnimal models from {ref}`file:model-zoo` may not work out of the box. This is currently a known issue for:
+ - SuperHuman model (missing detector)
+- **Performance** depends on camera resolution, frame rate, GPU availability, and codec choice
+ - Expect bottlenecks with heavy models, multiple high-resolution cameras, or CPU-only inference.
+
+---
+
+## Feedback, issues, and contributions
+
+> *This project is under active development. Feedback from real experimental use is highly valued.*
+>
+> [Please report issues, suggest features, or contribute to the codebase on GitHub !](https://github.com/DeepLabCut/DeepLabCut-live-GUI)
diff --git a/docs/dlc-live/dlc-live-gui/quickstart/install.md b/docs/dlc-live/dlc-live-gui/quickstart/install.md
new file mode 100644
index 0000000000..a9f0ccc228
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/quickstart/install.md
@@ -0,0 +1,238 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+# Installation
+
+This page explains how to install **DeepLabCut-live-GUI** for interactive, real‑time pose estimation.
+
+We support various installation methods, including `uv` and `mamba`/`conda`.
+
+```{tip}
+If you feel confident you meet the requirements and you just want to get started quickly, see the {ref}`sec:dlclivegui-install-quickstart` section below.
+```
+
+---
+
+## System requirements
+
+### Key takeaways
+
+- **On Windows**: If you want TensorFlow, use Python 3.10
+- **On macOS**: TensorFlow is only supported on CPU
+- **On Linux**: Full support for both PyTorch and TensorFlow
+
+### OS support
+
+| OS | PyTorch | TensorFlow | Notes & recommendations |
+| -- | ------- | ---------- | ----- |
+| Windows | ✅ | ❌ | Limited TensorFlow support due to lack of official Windows builds for Python 3.11+ onwards |
+| Linux | ✅ | ✅ | Full support for both backends |
+| macOS | ✅ | ⚠️ | PyTorch MPS support is improving but still has limitations; TensorFlow only supports CPU on macOS |
+
+### Hardware requirements
+
+- Any **compatible camera** (see *{ref}`file:dlclivegui-camera-support`*):
+ - **USB webcam, OBS virtual camera** → OpenCV-recognized cameras are accessible by default
+ - **Basler**
+ - *GenTL [^exp]*
+ - *Aravis [^exp]*
+- Optional but recommended:
+ - **CUDA-capable GPU** (for real‑time inference)
+ - NVIDIA drivers compatible with your PyTorch/TensorFlow version
+
+```{note}
+If you use an OpenCV-compatible camera (e.g. USB webcam, OBS virtual camera), you can simply install the package as it comes with OpenCV support by default.
+```
+
+### Software requirements
+
+- **Python 3.10, 3.11 or 3.12**
+- A machine learning framework for inference (instructions below for both):
+ - **PyTorch** (recommended for best performance and compatibility)
+ - **TensorFlow** (for backwards compatibility with existing models)
+- A working camera backend (see *{ref}`file:dlclivegui-camera-support`*)
+
+---
+(sec:dlclivegui-install-quickstart)=
+## Quickstart (recommended defaults)
+
+```bash
+mkdir -p dlclivegui
+cd dlclivegui
+uv venv -p 3.12 # or desired Python version
+source .venv/bin/activate # Windows: see tabs below
+uv pip install torch torchvision --index-url https://download.pytorch.org/whl/cu # e.g. cu128 for CUDA 12.8, or skip for CPU-only
+uv pip install --pre "deeplabcut-live-gui[pytorch]"
+dlclivegui
+```
+
+## Choose your installation method
+
+Below instructions cover installation with `uv` and `mamba`/`conda`, but you may also install with other package managers like `pdm` if preferred.
+
+```{note}
+The main DeepLabCut package and its GUI are not required to use this software, as it is designed to be a lightweight interface for real‑time pose estimation.
+```
+
+### Install DeepLabCut-live-GUI
+
+```{important}
+The current release is distributed on PyPI as a **pre-release**.
+Use `--pre` when installing with `pip`/`uv pip` so the release candidate can be resolved.
+```
+
+#### Install with `uv`
+
+We recommend installing with [`uv`](https://github.com/astral-sh/uv),
+but also support installation with `pip` or `conda` (see next section).
+
+##### Create and activate a new virtual environment
+
+`````{tab-set}
+````{tab-item} Linux / macOS
+```bash
+uv venv -p 3.12 # or desired Python version
+source .venv/bin/activate
+```
+````
+
+````{tab-item} Windows (Command Prompt)
+```cmd
+uv venv -p 3.12 # or desired Python version
+.\.venv\Scripts\activate.bat
+```
+````
+
+````{tab-item} Windows (PowerShell)
+```powershell
+uv venv -p 3.12 # or desired Python version
+.\.venv\Scripts\Activate.ps1
+```
+````
+`````
+
+##### Choose inference backend
+
+We offer two distinct inference backends: **PyTorch** and **TensorFlow**.
+You may install either or both, but you must **choose at least one** to run the pose estimation models.
+
+`````{tab-set}
+
+````{tab-item} PyTorch
+```{important}
+To **enable GPU support** and obtain detailed installation instructions,
+please refer to the [official PyTorch installation guide](https://pytorch.org/get-started/locally/) and install PyTorch **before** installing the GUI package.
+```
+```bash
+uv pip install --pre "deeplabcut-live-gui[pytorch]"
+```
+````
+
+````{tab-item} TensorFlow
+```{caution}
+Please note TensorFlow is **no longer available** on **Windows** for **Python > 3.10**.
+```
+
+```bash
+uv pip install --pre "deeplabcut-live-gui[tf]"
+```
+```{note}
+For detailed installation instructions, please refer to the [official TensorFlow installation guide](https://www.tensorflow.org/install/pip).
+```
+````
+
+````{tab-item} Both backends
+```{caution}
+Installing both backends may increase environment size and dependency resolution time.
+```
+
+```bash
+uv pip install --pre "deeplabcut-live-gui[pytorch,tf]"
+```
+````
+`````
+
+#### Install with `mamba` or `conda`
+
+##### Create and activate a new conda environment
+
+If you prefer using `mamba` or `conda`, you can create a new environment and install the package with:
+
+```bash
+conda create -n dlclivegui python=3.12 # pick your desired Python version
+conda activate dlclivegui
+```
+
+##### Choose inference backend
+
+We offer two distinct inference backends: **PyTorch** and **TensorFlow**.
+You may install either or both, but you must **choose at least one** to run the pose estimation models.
+
+`````{tab-set}
+
+````{tab-item} PyTorch
+```{important}
+To **enable GPU support** and obtain detailed installation instructions,
+please refer to the [official PyTorch installation guide](https://pytorch.org/get-started/locally/) and install PyTorch **before** installing the GUI package.
+```
+```bash
+pip install --pre "deeplabcut-live-gui[pytorch]"
+```
+````
+
+````{tab-item} TensorFlow
+```{caution}
+Please note TensorFlow is **no longer available** on **Windows** for **Python > 3.10**.
+```
+
+```bash
+pip install --pre "deeplabcut-live-gui[tf]"
+```
+```{note}
+For detailed installation instructions, please refer to the [official TensorFlow installation guide](https://www.tensorflow.org/install/pip).
+```
+````
+
+````{tab-item} Both backends
+```{caution}
+Installing both backends may increase environment size and dependency resolution time.
+```
+
+```bash
+pip install --pre "deeplabcut-live-gui[pytorch,tf]"
+```
+````
+`````
+
+## Verify installation
+
+After installation, you can verify that the package is installed correctly with:
+
+```bash
+dlclivegui --help
+```
+
+## Download and export a model from the model zoo
+
+See the {ref}`file:dlclivegui-pretrained-models` page for instructions on how to programmatically download and export pre-trained models from the DeepLabCut Model Zoo for use in the GUI.
+
+```{important}
+We may in the future add more direct, built-in support for browsing and downloading compatible models.
+For now, you can use the `dlclive.modelzoo` API to fetch and export models as described in the linked documentation.
+```
+
+## Run the application
+
+After installation, you can start the DeepLabCut-live-GUI application with:
+
+```bash
+dlclivegui # OR uv run dlclivegui
+```
+
+```{important}
+Make sure your venv or conda environment is activated before running the application, so it can access the installed dependencies.
+```
+
+[^exp]: Support for this backend is currently experimental and may not work out of the box. Please refer to the backend-specific documentation for details and troubleshooting tips, and report any issues you encounter on GitHub to help us improve support for these backends.
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend.md b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend.md
new file mode 100644
index 0000000000..9cacbe8bb4
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend.md
@@ -0,0 +1,348 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-camera-aravis-backend)=
+# Aravis backend
+
+The Aravis backend provides support for GenICam-compatible cameras using the
+[Aravis](https://github.com/AravisProject/aravis) library.
+
+```{important}
+Support for Aravis in the GUI is currently experimental.
+Please report issues on GitHub to help improve this backend.
+```
+
+---
+
+## Features
+
+- Support for GenICam / GigE Vision cameras via Aravis 0.8
+- Automatic device discovery without opening cameras
+- Configurable exposure, gain, frame rate, and resolution
+- Support for common mono and color pixel formats
+- Efficient streaming with configurable buffer count
+
+## Installation
+
+`````{tab-set}
+````{tab-item} Linux (Ubuntu / Debian
+```bash
+sudo apt-get install gir1.2-aravis-0.8 python3-gi
+```
+````
+
+````{tab-item} Linux (Fedora)
+```bash
+sudo dnf install aravis python3-gobject
+```
+````
+
+
+````{tab-item} Windows
+```{caution}
+Aravis support on Windows requires building from source or using WSL.
+
+For native Windows usage, consider the GenTL backend instead.
+```
+````
+
+````{tab-item} macOS
+```bash
+brew install aravis
+pip install pygobject
+```
+
+```{note}
+On macOS, installing `pygobject` may require additional system
+dependencies such as `gobject-introspection` and `cairo`.
+```
+````
+`````
+
+---
+
+## Basic configuration
+
+Select the Aravis backend either in the GUI or via configuration:
+
+```json
+{
+ "camera": {
+ "backend": "aravis",
+ "index": 0,
+ "fps": 30.0,
+ "exposure": 10000,
+ "gain": 5.0
+ }
+}
+```
+
+---
+
+## Camera selection
+
+### By index (default)
+
+```json
+{
+ "camera": {
+ "backend": "aravis",
+ "index": 0
+ }
+}
+```
+
+### By device ID (recommended for stability)
+
+```json
+{
+ "camera": {
+ "backend": "aravis",
+ "properties": {
+ "aravis": {
+ "device_id": "TheImagingSource-12345678"
+ }
+ }
+ }
+}
+```
+
+```{note}
+The backend may automatically populate additional read-only identity fields
+(vendor, model, serial, etc.) after a successful open. These are primarily
+used internally and set by the GUI.
+```
+
+---
+
+## Full properties and advanced configuration
+
+Aravis-specific options live under the `properties.aravis` namespace in
+the settings used by the GUI and configuration files.
+
+### Related camera settings
+
+```{tip}
+These values are accessible directly in the GUI and are shared for all backends.
+```
+
+| Property | Type | Description |
+|--------|------|-------------|
+| `width` | int | Requested image width (optional) |
+| `height` | int | Requested image height (optional) |
+| `fps` | float | Target acquisition frame rate |
+| `exposure` | float | Exposure time in microseconds |
+| `gain` | float | Camera gain value |
+
+### Common Aravis properties
+
+```{note}
+These properties are specific to the Aravis backend and must be set manually in the configuration file.
+```
+
+| Property | Type | Default | Description |
+|--------|------|---------|-------------|
+| `device_id` | string | — | Explicit Aravis device ID (overrides index) |
+| `pixel_format` | string | `Mono8` | Requested pixel format |
+| `timeout` | int | `2000000` | Frame timeout in microseconds |
+| `n_buffers` | int | `10` | Number of streaming buffers |
+
+### Pixel format
+
+Supported values:
+
+- `Mono8`
+- `Mono12`
+- `Mono16`
+- `RGB8`
+- `BGR8`
+
+Internally, all frames are converted to **BGR (8-bit)** for consistency.
+
+**Mono12 / Mono16 scaling behavior**:
+
+- 12-bit and 16-bit images are dynamically scaled **per frame** to 8-bit
+- Scaling is based on the maximum pixel value present in each frame
+- This improves visibility but may cause frame-to-frame brightness variation
+
+### Exposure and gain
+
+- **Exposure** is specified in microseconds
+- **Gain** is a unitless camera-specific value
+
+```json
+{
+ "camera": {
+ "exposure": 8000,
+ "gain": 10.0
+ }
+}
+```
+
+Behavior:
+
+- Exposure or gain values `<= 0` leave the camera in auto mode
+- Positive values disable auto-exposure / auto-gain automatically
+- Actual values are read back and may differ slightly due to camera constraints
+
+### Frame rate (FPS)
+
+```json
+{
+ "camera": {
+ "fps": 60.0
+ }
+}
+```
+
+- FPS is only applied when a positive value is provided
+- The backend attempts to set `AcquisitionFrameRate`
+- The **actual FPS** reported by the camera is stored and may differ slightly
+- Mismatches are logged but do not fail camera startup
+
+### Resolution handling
+
+Resolution is **only changed when explicitly requested**.
+If no resolution is specified, the camera's default configuration is preserved.
+
+Supported ways to request resolution:
+
+```json
+{
+ "camera": {
+ "width": 1920,
+ "height": 1080
+ }
+}
+```
+
+Notes:
+
+- The camera may clamp or adjust the requested resolution
+- The backend records and exposes the **actual resolution** after opening
+- A warning is logged if the requested and actual resolutions differ
+
+### Auto-populated Aravis metadata
+
+```{caution}
+These fields may appear in saved configurations but are managed
+automatically by the backend and GUI.
+It is not recommended to set these manually.
+```
+
+- `device_physical_id`
+- `device_vendor`
+- `device_model`
+- `device_serial_nbr`
+- `device_protocol`
+- `device_address`
+- `device_name`
+- `device_path`
+
+### Streaming and performance tuning
+
+#### Buffer Count
+
+Increase buffers for high-throughput or high-latency systems:
+
+```json
+{
+ "camera": {
+ "properties": {
+ "aravis": {
+ "n_buffers": 20
+ }
+ }
+ }
+}
+```
+
+#### Timeout
+
+Adjust frame timeout for slower cameras or congested networks:
+
+```json
+{
+ "camera": {
+ "properties": {
+ "aravis": {
+ "timeout": 5000000
+ }
+ }
+ }
+}
+```
+
+(5 seconds = 5,000,000 microseconds)
+
+---
+
+## Troubleshooting
+
+### No cameras detected
+
+1. Verify Aravis installation:
+ ```bash
+ arv-tool-0.8 -l
+ ```
+2. Check power, cabling, and network configuration
+3. Ensure sufficient permissions for USB or network devices
+
+### Timeout errors
+
+- Increase the `timeout` value
+- Increase `n_buffers`
+- Check GigE bandwidth and packet size configuration
+
+### Pixel format errors
+
+- Inspect supported formats:
+ ```bash
+ arv-tool-0.8 -n features
+ ```
+- Try a simpler format such as `Mono8`
+
+---
+
+## Comparison with GenTL backend
+
+| Feature | Aravis | GenTL |
+| ------- | ------ | ----- |
+| Best Platform | Linux | Windows |
+| Camera Support | GenICam / GigE | Vendor GenTL |
+| Installation | System packages | Vendor CTI files |
+| Auto-detection | Yes | Yes |
+| Performance | Excellent | Excellent |
+
+---
+
+## Example configuration
+
+```json
+{
+ "camera": {
+ "backend": "aravis",
+ "index": 0,
+ "fps": 60.0,
+ "exposure": 8000,
+ "gain": 10.0,
+ "properties": {
+ "aravis": {
+ "pixel_format": "Mono8",
+ "n_buffers": 15,
+ "timeout": 3000000
+ }
+ }
+ }
+}
+```
+
+---
+
+## Resources
+
+- [Aravis Project](https://github.com/AravisProject/aravis)
+- [GenICam Standard](https://www.emva.org/standards-technology/genicam/)
+- [Python GObject Documentation](https://pygobject.readthedocs.io/)
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend.md b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend.md
new file mode 100644
index 0000000000..7bd5b7097b
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend.md
@@ -0,0 +1,287 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-basler-backend)=
+# Basler backend
+
+The Basler backend provides support for Basler cameras using the official **pylon SDK** through the **pypylon** Python bindings.
+
+Download the official pylon SDK from Basler and install the `pypylon` Python package to use this backend.
+
+```{note}
+This backend requires the optional `pypylon` dependency. If `pypylon` is not installed, the backend will be unavailable.
+```
+
+---
+
+## Features & design
+
+- Native Basler camera support via **pypylon** (Pylon SDK bindings).
+- Best-effort device discovery without opening cameras (enumerates `DeviceInfo` entries).
+- Stable camera identity via **serial number** (`device_id`) with automatic index rebinding.
+- Configurable exposure, gain, frame rate, and resolution.
+- Frames are converted to **BGR (8-bit)** for consistency with other GUI backends.
+
+---
+
+## Installation
+
+```{important}
+For up to date installation instructions, please refer to the
+[official pypylon documentation](https://github.com/basler/pypylon?tab=readme-ov-file#Installation)
+and the [Basler pylon software installation guide](https://docs.baslerweb.com/camera-installation).
+```
+
+### 1) Install Basler pylon SDK
+
+Basler recommends installing **pylon** first (strongly recommended even if you install `pypylon` via pip).
+
+````{tab-set}
+```{tab-item} Linux
+Basler provides pylon for Linux as **Debian packages** and **.tar.gz archives** (x86_64 and ARM variants).
+
+- Download the matching pylon installer from Basler and follow the included `INSTALL` instructions.
+```
+
+```{tab-item} Windows
+
+- Install the Basler pylon Camera Software Suite (includes drivers and tooling).
+
+```
+
+```{tab-item} macOS
+
+- Install the Basler pylon package for macOS (Intel/ARM supported, depending on Basler release). Basler lists macOS as a supported system for pypylon usage.
+```
+````
+
+### 2) Install `pypylon`
+
+Install into the same Python environment as your GUI:
+
+```bash
+pip install pypylon # OR uv pip install pypylon
+```
+
+`pypylon` is the official Python wrapper for the Basler pylon Camera Software Suite
+
+---
+
+## Basic configuration
+
+Select the Basler backend in the GUI or via configuration:
+
+```json
+{
+ "camera": {
+ "backend": "basler",
+ "index": 0,
+ "fps": 30.0,
+ "exposure": 8000,
+ "gain": 5.0
+ }
+}
+```
+
+---
+
+## Camera selection
+
+### By index (default)
+
+```json
+{
+ "camera": {
+ "backend": "basler",
+ "index": 0
+ }
+}
+```
+
+### By serial number (recommended for stability)
+
+The backend supports a stable identity field `device_id` (serial number). When present, it is preferred over `index` and is also persisted automatically after a successful open.
+
+```json
+{
+ "camera": {
+ "backend": "basler",
+ "index": 0,
+ "properties": {
+ "basler": {
+ "device_id": "40312345"
+ }
+ }
+ }
+}
+```
+
+How selection works:
+
+1. If `properties.basler.device_id` is set, the backend selects the device with a matching serial number.
+2. Otherwise, the backend uses `index`.
+
+---
+
+## Full properties and advanced configuration
+
+Basler-specific options live under the `properties.basler` namespace.
+
+### Related camera settings (shared across backends)
+
+These values are configurable in the GUI and are shared for all backends.
+
+- `width` (int): requested image width. Use `0` for “Auto / keep device default”.
+- `height` (int): requested image height. Use `0` for “Auto / keep device default”.
+- `fps` (float): requested acquisition frame rate. Use `0.0` to not set.
+- `exposure` (float): exposure time in microseconds. Use `<= 0` to not set.
+- `gain` (float): camera gain value. Use `<= 0` to not set.
+
+### Basler namespace options
+
+These settings live under the `properties.basler` entry.
+
+- `device_id` (string): preferred stable identity (camera serial number).
+- `fast_start` (bool, default: false): probe-mode hint.
+ - When `true`, the backend will open the camera but **will not start grabbing** and will **not create the pixel format converter**.
+ - This is intended for fast capability probing; it is **not suitable for normal capture**.
+- `resolution` ([w, h]): optional override resolution pair.
+ - Used only if `width`/`height` are not set.
+
+### Auto-populated Basler metadata
+
+After a successful open, the backend may populate the following read-only convenience fields in `properties.basler`:
+
+- `device_name`: a friendly device name (if provided by the SDK).
+- (Optionally) `device_path`: a full identifier string (depending on SDK/device).
+
+These fields are managed automatically and are not required to configure the backend.
+
+---
+
+### Exposure and gain
+
+Behavior:
+
+- If `exposure > 0`, the backend attempts to disable `ExposureAuto` (if present) and sets `ExposureTime` in microseconds.
+- If `gain > 0`, the backend attempts to disable `GainAuto` (if present) and sets `Gain`.
+- If `exposure <= 0` or `gain <= 0`, the backend leaves the camera’s auto/manual mode unchanged.
+
+Example:
+
+```json
+{
+ "camera": {
+ "backend": "basler",
+ "exposure": 10000,
+ "gain": 6.0
+ }
+}
+```
+
+---
+
+### Frame rate (FPS)
+
+- FPS is only applied when `fps > 0`.
+- The backend attempts to enable `AcquisitionFrameRateEnable` when available, then sets `AcquisitionFrameRate`.
+- The backend reads back the **actual FPS** (if available) and exposes it via telemetry.
+
+---
+
+### Resolution handling
+
+Resolution is only changed when explicitly requested.
+
+Priority order for requesting a resolution:
+
+1. `width` + `height` (GUI fields)
+2. `properties.basler.resolution` (namespaced override)
+
+If no resolution is provided (or if width/height are `0`), the backend preserves the camera’s default configuration.
+
+Increment and range constraints:
+
+- Many Basler cameras restrict `Width`/`Height` to specific increments.
+- The backend snaps requested values down to the nearest valid increment (best-effort) and clamps to min/max.
+- A warning is logged if the requested and applied resolutions differ.
+
+---
+
+### Pixel format and color conversion
+
+To provide a consistent frame format across backends, the Basler backend converts frames to:
+
+- **BGR8 packed** (8-bit BGR)
+
+Internally, it uses a pypylon `ImageFormatConverter` configured for `PixelType_BGR8packed`.
+
+---
+
+### Device discovery
+
+The backend can enumerate devices without opening them and returns (best-effort):
+
+- `index`: current device list index
+- `label`: human-readable label (vendor/model + serial if available)
+- `device_id`: serial number (stable identity)
+- `path`: full name string (if available)
+
+Note that availability and richness of fields depend on camera transport and SDK support.
+
+---
+
+## Troubleshooting
+
+### Backend not available / import error
+
+- Ensure `pypylon` is installed:
+ ```bash
+ python -c "import pypylon"
+ ```
+- Install it if missing:
+ ```bash
+ pip install pypylon
+ ```
+
+
+### No cameras detected
+
+- Verify the Basler pylon runtime is installed and your camera is visible in Basler tooling.
+- On Linux, ensure you installed pylon using the Basler-provided packages/archives and followed the included `INSTALL` guide.
+
+### Resolution mismatch warnings
+
+If you request a resolution that violates camera constraints (min/max or increment), the backend will snap/clamp to valid values and log a warning.
+
+---
+
+## Example configuration
+
+```json
+{
+ "camera": {
+ "backend": "basler",
+ "index": 0,
+ "fps": 60.0,
+ "exposure": 8000,
+ "gain": 10.0,
+ "width": 1920,
+ "height": 1080,
+ "properties": {
+ "basler": {
+ "device_id": "40312345"
+ }
+ }
+ }
+}
+```
+
+---
+
+## Resources
+
+- Basler pypylon (PyPI): [Open on PyPI](https://pypi.org/project/pypylon/)
+- Basler pylon Software Installation (Linux): [See Basler documentation](https://docs.baslerweb.com/camera-installation)
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support.md b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support.md
new file mode 100644
index 0000000000..a3b7e1c440
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support.md
@@ -0,0 +1,96 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-camera-support)=
+# Camera support
+
+DeepLabCut-live-GUI supports multiple camera backends for different platforms and camera types:
+
+## Supported backends
+
+1. {ref}`OpenCV ` - "Universal" webcam and USB camera support *(all platforms)*
+ - Expect some limitations in camera control and performance
+2. {ref}`GenTL ` - Industrial cameras via GenTL producers *(Windows, Linux)*
+ - Requires vendor-provided CTI files
+3. {ref}`Aravis ` - GenICam/GigE Vision cameras *(Linux, experimental on macOS)*
+4. {ref}`Basler ` - Basler cameras via pypylon *(all platforms)*
+
+## Backend selection
+
+You can select the backend in the GUI from the "Backend" dropdown, or in your configuration file:
+
+```json
+{
+ ...
+ "camera": {
+ ...
+ "backend": "aravis",
+ ...
+ }
+}
+```
+
+## Platform-specific recommendations
+
+Below are some general recommendations for backend selection based on your operating system and camera type.
+
+```{note}
+Please understand this may not reflect the exact capabilities for every setup.
+
+Let us know about your experience with different cameras and backends on different platforms to help us improve our documentation and support!
+```
+
+### Windows
+
+- **OpenCV compatible cameras**: Best for webcams and simple USB cameras. OpenCV is installed with DeepLabCut-Live-GUI.
+- **GenTL backend**: Recommended for industrial cameras (The Imaging Source, etc.) via vendor-provided CTI files.
+- **Basler cameras**: Can use either GenTL or pypylon backend. OpenCV available but not recommended.
+
+### Linux
+
+- **OpenCV compatible cameras**: Good for webcams via Video4Linux drivers. Installed with DeepLabCut-Live-GUI.
+- **Aravis backend**: **Recommended** for GenICam/GigE Vision industrial cameras (The Imaging Source, Basler, Point Grey, etc.)
+ - Easy installation via system package manager
+ - Better Linux support than GenTL
+ - See {ref}`file:dlclivegui-camera-aravis-backend` for details and troubleshooting.
+- **GenTL backend**: Alternative for industrial cameras if vendor provides Linux CTI files.
+
+### macOS
+
+- **OpenCV compatible cameras**: For webcams and compatible USB cameras.
+- **Aravis backend**: Experimental support for GenICam/GigE Vision cameras.
+ Requires Homebrew and PyGObject; functionality depends heavily on camera model and setup.
+
+## Quick installation guide
+
+`````{tab-set}
+````{tab-item} Aravis (Linux/Ubuntu)
+```bash
+sudo apt-get install gir1.2-aravis-0.8 python3-gi
+```
+````
+
+````{tab-item} Aravis (macOS)
+```bash
+brew install aravis
+pip install pygobject
+```
+````
+
+````{tab-item} GenTL (Windows)
+Install vendor-provided camera drivers and SDK. CTI files are typically in:
+- `C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver\bin\`
+````
+`````
+
+## Backend comparison
+
+| Feature | OpenCV | GenTL | Aravis | Basler (pypylon) |
+|---------|--------|-------|--------|------------------|
+| Exposure control | No | Yes | Yes | Yes |
+| Gain control | No | Yes | Yes | Yes |
+| Windows | ✅ | ✅ | ❌ | ✅ |
+| Linux | ✅ | ✅ | ✅ | ✅ |
+| macOS | ✅ | ❌ | ⚠️ | ✅ |
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend.md b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend.md
new file mode 100644
index 0000000000..84a53aed1a
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend.md
@@ -0,0 +1,492 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+# GenTL backend
+
+The GenTL backend provides support for **GenICam / GenTL** compatible cameras using the **Harvesters** Python library (a GenTL consumer).
+
+```{note}
+This backend requires the optional `harvesters` dependency and at least one vendor **GenTL Producer** (`.cti`) installed on the system.
+```
+
+```{important}
+Support for GenTL in the GUI is currently experimental.
+Please report issues on GitHub to help improve this backend.
+```
+
+---
+
+## Features & design
+
+- Image acquisition via **Harvesters** (GenTL consumer for GenICam-compliant devices).
+- Loads **multiple GenTL Producers** (`.cti` files) to support mixed transports/vendors (USB3 Vision, GigE Vision, frame grabbers—depending on producer).
+- **CTI persistence + diagnostics**:
+ - `properties.gentl.cti_files`: all resolved CTI candidates (after resolution)
+ - `properties.gentl.cti_files_loaded`: CTIs successfully loaded into Harvesters
+ - `properties.gentl.cti_files_failed`: list of `{cti, error}` entries for producers that failed to load
+ - `properties.gentl.cti_file`: convenience “first CTI” (for backward compatibility / display)
+- **Stable identity** via `properties.gentl.device_id`:
+ - `device_id = "serial:"` when a serial number is available (**preferred**)
+ - `device_id = "fp:"` as a best-effort fallback when serials are missing/ambiguous
+- **Automated rebinding** (`rebind_settings`) maps stored `device_id` → the correct current index.
+- Best-effort configuration through the device GenApi node map:
+ - exposure, gain, frame rate, resolution, pixel format
+- Pixel-format normalization to **BGR (8-bit)** for consistency:
+ - Mono formats → BGR
+ - `RGB8` → BGR
+ - Non-8-bit frames → scaled down to 8-bit (per-frame scaling)
+
+---
+
+## Installation
+
+### 1) Install Harvesters
+
+Install Harvesters into the same Python environment as your GUI:
+
+```bash
+pip install harvesters
+```
+
+### 2) Install a GenTL Producer file (`.cti`)
+
+A GenTL Producer is a vendor-provided library that exposes cameras to GenTL consumer applications.
+It is typically distributed as part of the camera vendor SDK or framegrabber SDK.
+
+```{note}
+GenTL Producers are identified by files ending in `.cti`.
+```
+
+### 3) Make producers discoverable (environment variables)
+
+Most GenTL consumers locate producers via the standard environment variables:
+
+- `GENICAM_GENTL64_PATH` (64-bit producers)
+- `GENICAM_GENTL32_PATH` (32-bit producers)
+
+If you have multiple producers installed, separate entries with:
+
+- `;` on Windows
+- `:` on Linux/macOS (UNIX-like)
+
+```{tip}
+Many vendor installers set `GENICAM_GENTL64_PATH` automatically. If your camera is not discovered, explicitly set the variable (or provide `cti_file` / `cti_files` in configuration as described below).
+```
+
+---
+
+## Basic configuration
+
+Select the GenTL backend in the GUI or via configuration:
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "index": 0,
+ "fps": 30.0,
+ "exposure": 8000,
+ "gain": 5.0,
+ "properties": {
+ "gentl": {
+ "pixel_format": "Mono8",
+ "timeout": 2.0
+ }
+ }
+ }
+}
+```
+
+---
+
+## CTI / producer configuration
+
+### Default behavior: discover and load all producers
+
+By default, the backend will **discover** and **try to load all available** GenTL Producers (`.cti`) it can find.
+
+- If a producer fails to load, the backend continues and attempts to load the others.
+- Startup fails only when:
+ - **no CTI files can be found**, or
+ - **no CTI files can be loaded**, or
+ - **no devices are detected** after loading producers.
+
+### CTI resolution precedence (advanced)
+
+CTI locations are resolved in this order:
+
+1. **Namespace explicit CTIs** (`properties.gentl`):
+ - `properties.gentl.cti_files`
+ - `properties.gentl.cti_file`
+
+ Behavior depends on the persisted source marker `properties.gentl.cti_files_source`:
+
+ - If `cti_files_source == "user"` (or missing/unknown):
+ - Treated as a **user override**
+ - **strict**: missing paths cause `open()` to raise
+
+ - If `cti_files_source == "auto"`:
+ - Treated as an **auto-discovered cache**
+ - If cached paths are stale/missing, `open()` will **fall back to discovery** automatically
+
+2. **Discovery** (auto):
+ - environment: `GENICAM_GENTL64_PATH` / `GENICAM_GENTL32_PATH`
+ - optional: `properties.gentl.cti_search_paths` (glob patterns)
+ - optional: `properties.gentl.cti_dirs` (extra directories; non-recursive)
+ - plus built-in Windows fallback patterns for some common installations
+
+```{note}
+You typically do **not** need to set `cti_files_source` yourself.
+It is persisted by the backend so it can distinguish between a user-pinned CTI and an auto-discovered cache.
+```
+
+### Pin a known-good CTI (strict)
+
+Use this when a specific vendor producer is known to work reliably (or when other installed CTIs are incompatible).
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "properties": {
+ "gentl": {
+ "cti_file": "C:/Path/To/Your/Producer.cti"
+ }
+ }
+ }
+}
+```
+
+### Provide an explicit list of CTIs (strict)
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "properties": {
+ "gentl": {
+ "cti_files": [
+ "C:/Path/To/ProducerA.cti",
+ "C:/Path/To/ProducerB.cti"
+ ]
+ }
+ }
+ }
+}
+```
+
+### Provide CTI search patterns (auto)
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "properties": {
+ "gentl": {
+ "cti_search_paths": [
+ "C:/Program Files/YourVendor/**/bin/*.cti",
+ "/opt/yourvendor/lib/gentlproducer/*.cti"
+ ]
+ }
+ }
+ }
+}
+```
+
+### Provide extra CTI directories (auto)
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "properties": {
+ "gentl": {
+ "cti_dirs": [
+ "C:/Program Files/YourVendor/bin",
+ "/opt/yourvendor/lib/gentlproducer"
+ ]
+ }
+ }
+ }
+}
+```
+
+### CTI diagnostics persisted by `open()`
+
+After `open()` (success or failure), the backend writes:
+
+- `properties.gentl.cti_files`: all resolved candidates
+- `properties.gentl.cti_files_loaded`: successfully loaded into Harvesters
+- `properties.gentl.cti_files_failed`: `{cti, error}` for failures
+- `properties.gentl.cti_file`: first loaded CTI (or first candidate)
+
+These fields are intended for UI troubleshooting and do not normally need manual edits.
+
+---
+
+## Camera selection and stable identity
+
+### By index (default)
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "index": 0
+ }
+}
+```
+
+### By stable identity (recommended)
+
+Prefer `properties.gentl.device_id`, which is persisted automatically after a successful `open()`.
+
+- `serial:` when a serial number is available
+- `fp:<...>` fingerprint when serial numbers are missing
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "properties": {
+ "gentl": {
+ "device_id": "serial:40312345"
+ }
+ }
+ }
+}
+```
+
+### Selection order in `open()`
+
+The backend selects a device in this order:
+
+1. Exact match of `device_id` against computed IDs for discovered devices
+2. If `device_id` starts with `serial:`, match by exact serial number, then (if needed) substring
+3. Legacy serial keys (`serial_number` / `serial`) if present (exact then substring)
+4. Fallback to `index`
+
+If a serial substring matches **multiple** cameras, an “ambiguous” error is raised.
+
+```{tip}
+The backend updates `settings.index` to the selected device’s current index to improve UI stability.
+```
+
+---
+
+### Automated rebind (index changes, reconnects)
+
+When the UI restarts (or devices re-enumerate), the backend can **rebind settings**:
+
+- If `properties.gentl.device_id` exists, `rebind_settings()` tries to map it to the current device list.
+- It prefers the persisted CTIs when available:
+ - if `cti_files_source == "auto"` and cached CTIs are stale, it falls back to discovery automatically.
+ - if CTIs were user-pinned and no longer exist, it does **not** attempt to override them silently.
+
+Matching strategy:
+
+1. Exact match on computed `device_id`
+2. Fallback: treat stored value as a serial-like substring and match the first serial containing it
+
+---
+
+## Camera settings
+
+These settings are shared across backends and configurable in the GUI:
+
+- `width` (int): requested image width; **applied only if both width and height > 0**
+- `height` (int): requested image height; **applied only if both width and height > 0**
+- `fps` (float): requested frame rate; if unset/0, the backend does not set FPS
+- `exposure` (float): exposure time; `<= 0` means do not set
+- `gain` (float): gain value; `<= 0` means do not set
+
+---
+
+## Full properties and advanced configuration
+
+GenTL backend options live under `properties.gentl` of the camera settings object.
+
+### GenTL namespace options (`properties.gentl`)
+
+Core / CTI resolution:
+
+- `cti_file` (string): full path to a GenTL Producer `.cti` file
+- `cti_files` (list[string]): explicit list of producer `.cti` files to load
+- `cti_search_paths` (list[string] or string): glob patterns used to locate `.cti` files
+- `cti_dirs` (list[string] or string): extra directories to scan for `*.cti` (non-recursive)
+- `cti_files_source` (string): `"auto"` (cache) or `"user"` (strict override)
+ - typically written by the backend; rarely set manually
+
+Selection / identity:
+
+- `device_id` (string): stable identifier (`serial:...` or `fp:...`)
+- `serial_number` / `serial` (string): legacy selection helpers (still honored)
+
+Acquisition:
+
+- `pixel_format` (string, default: `Mono8`): requested `PixelFormat` symbolic
+- `timeout` (float, default: `2.0`): acquisition timeout in seconds (`fetch(timeout=...)`)
+
+Transforms:
+
+- `rotate` (int, default: `0`): rotate output by 0/90/180/270
+- `crop` ([top, bottom, left, right]): crop rectangle
+ - if bottom/right are `<= 0`, they default to full frame extent
+
+Probe / telemetry:
+
+- `fast_start` (bool, default: false): probe-mode hint; when true, `open()` does not start acquisition
+- `cti_files_loaded` (list[string]): populated automatically after open
+- `cti_files_failed` (list[object]): populated automatically after open; each entry has `cti` and `error`
+
+---
+
+### Pixel format
+
+- The backend attempts to set `node_map.PixelFormat` to the configured `pixel_format`
+ if it appears in `PixelFormat.symbolics`.
+- If the requested format is not available, it logs a warning and continues.
+
+Frames are normalized to **BGR (8-bit)**:
+
+- Mono images become BGR via grayscale-to-BGR conversion
+- `RGB8` is converted to BGR
+- Higher bit-depth images are scaled to 8-bit based on the frame’s max value (per frame)
+
+---
+
+### Exposure and gain
+
+Best-effort behavior (depends on producer + camera GenApi implementation):
+
+- If exposure is set (> 0):
+ - attempts to disable `ExposureAuto` by setting it to `Off`
+ - tries `ExposureTime` then `Exposure`
+- If gain is set (> 0):
+ - attempts to disable `GainAuto` by setting it to `Off`
+ - tries `Gain`
+
+If nodes are missing or read-only, the backend logs a warning and continues.
+
+---
+
+### Frame rate (FPS)
+
+If `fps` is set to a non-zero value:
+
+- attempts to enable frame rate control via:
+ - `AcquisitionFrameRateEnable` or `AcquisitionFrameRateControlEnable`
+- tries to set one of these nodes (first that works):
+ - `AcquisitionFrameRate`
+ - `ResultingFrameRate`
+ - `AcquisitionFrameRateAbs`
+
+The backend also tries to read back `ResultingFrameRate` for GUI telemetry (`actual_fps`).
+
+---
+
+### Resolution handling
+
+Resolution is applied **only when explicitly requested** (either `width+height`, or legacy `properties.resolution`).
+
+- Attempts to set `node_map.Width` and `node_map.Height`
+- Clamps to node min/max and snaps down to the nearest valid increment (`inc`) when available
+- Logs a warning if the applied values differ from the requested values
+
+If no resolution is specified, the device’s current/default configuration is preserved.
+
+---
+
+### Streaming and probe mode
+
+#### Normal capture
+
+- On successful `open()`, acquisition is started with `self._acquirer.start()`.
+
+#### Fast-start probe mode (`fast_start`)
+
+If `properties.gentl.fast_start` is `true`:
+
+- the backend configures the device and persists identity/metadata,
+- but does **not** start streaming.
+
+This is intended for capability probing and faster startup of probe workers.
+
+---
+
+## Troubleshooting
+
+### No `.cti` found
+
+Common causes:
+
+- Vendor GenTL producer not installed
+- `GENICAM_GENTL64_PATH` / `GENICAM_GENTL32_PATH` not set (or missing the producer directory)
+- Wrong producer bitness (32-bit vs 64-bit)
+
+Fix options:
+
+- Set `camera.properties.gentl.cti_file` (pin a CTI), or
+- set `GENICAM_GENTL64_PATH` / `GENICAM_GENTL32_PATH`, or
+- set `camera.properties.gentl.cti_search_paths` / `cti_dirs`
+
+### Producer load failures with multiple CTIs installed
+
+Some installed producers may be incompatible or broken on a given system.
+
+- Check `properties.gentl.cti_files_failed`
+- Pin a known-good producer:
+ - `properties.gentl.cti_file`, or
+ - `properties.gentl.cti_files`
+
+### Cached CTIs went stale (auto re-discovery)
+
+If `properties.gentl.cti_files_source == "auto"`, stale cached CTI paths will trigger fallback to discovery automatically.
+If you pinned CTIs as a user override and paths no longer exist, `open()` will fail (by design).
+
+### Timeouts
+
+- Increase `properties.gentl.timeout` (seconds)
+- Reduce frame rate or resolution
+- Check transport bandwidth (GigE: MTU/jumbo frames, NIC direct connection, etc.)
+
+### Pixel format errors
+
+- Inspect available formats via vendor tools or by checking `PixelFormat.symbolics`
+- Try a simpler format such as `Mono8`
+
+---
+
+## Example configuration
+
+```json
+{
+ "camera": {
+ "backend": "gentl",
+ "fps": 60.0,
+ "exposure": 8000,
+ "gain": 10.0,
+ "width": 1920,
+ "height": 1080,
+ "properties": {
+ "gentl": {
+ "device_id": "serial:40312345",
+ "pixel_format": "Mono8",
+ "timeout": 3.0,
+ "rotate": 0,
+ "crop": [0, 0, 0, 0]
+ }
+ }
+ }
+}
+```
+
+---
+
+## Resources
+
+- [Harvesters installation documentation](https://harvesters.readthedocs.io/en/latest/INSTALL.html)
+- [GenTL producer discovery variables](https://www.mvtec.com/products/interfaces/documentation/view/1307-standard-13-mvtecdoc-genicamtl) (`GENICAM_GENTL32_PATH` / `GENICAM_GENTL64_PATH`) and usage notes
+- [GenTL producer path separators and CTI overview](https://softwareservices.flir.com/Spinnaker/latest/_gen_i_cam_gen_t_l.html) (example vendor documentation)
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend.md b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend.md
new file mode 100644
index 0000000000..df58205f5b
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend.md
@@ -0,0 +1,308 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-opencv-backend)=
+# OpenCV backend
+
+The OpenCV backend provides camera support via `cv2.VideoCapture`.
+This backend is intended for UVC/webcams and other devices supported by your system’s native multimedia stack.
+
+```{important}
+Due to lack of standardization across OpenCV backends, exposure and gain controls are treated as unsupported in this GUI.
+
+**Other settings may not always behave as expected due to driver and backend limitations.**
+```
+
+---
+
+## Features & design
+
+- Cross-platform capture using OpenCV (`cv2.VideoCapture`).
+- Platform-optimized default backend selection:
+ - Windows: prefer DirectShow (`CAP_DSHOW`), fall back to MSMF/ANY.
+ - macOS: prefer AVFoundation (`CAP_AVFOUNDATION`), fall back to ANY.
+ - Linux: prefer V4L2 (`CAP_V4L2`), fall back to ANY.
+- Device discovery with stable identity (when enumeration is available):
+ - Uses an external enumerator (`opencv2-enumerate-cameras`)
+ - Persists `device_id` / VID / PID / name into configuration when available.
+- Best-effort format negotiation:
+ - Resolution and FPS are applied with tolerance-based verification.
+ - Mismatch handling is configurable (warn/strict/accept).
+- Optional MJPG (Windows) and explicit FOURCC requests.
+
+---
+
+## Dependencies information
+
+OpenCV must be available in the same Python environment as the GUI, but is **installed by default**
+as part of the core DeepLabCut-Live-GUI package, so **no additional installation is needed** to obtain OpenCV support.
+
+`cv2-enumerate-cameras` is also installed by default to provide camera enumeration support for this backend and make device selection more robust.
+
+---
+
+## Basic configuration
+
+Select the OpenCV backend in the GUI or via configuration:
+
+```json
+{
+ "camera": {
+ "backend": "opencv",
+ "index": 0,
+ "fps": 30.0,
+ "width": 1280,
+ "height": 720
+ }
+}
+```
+
+Notes:
+
+- If `width`/`height` are omitted or set to `0`, the backend keeps the camera’s default mode.
+- OpenCV may ignore FPS and resolution requests depending on driver/backend.
+
+---
+
+## Camera selection configuration
+
+### By index (default)
+
+```json
+{
+ "camera": {
+ "backend": "opencv",
+ "index": 0
+ }
+}
+```
+
+### By stable identity (recommended when available)
+
+When discovery is available, the backend can persist and later rebind a stable identity under `properties.opencv`:
+
+- `device_id`: stable ID from camera enumeration
+- `device_vid` / `device_pid`: USB VID/PID when known
+- `device_name`: camera name
+
+Example:
+
+```json
+{
+ "camera": {
+ "backend": "opencv",
+ "index": 0,
+ "properties": {
+ "opencv": {
+ "device_id": "usb:046d:0825:...",
+ "device_name": "Logitech C270"
+ }
+ }
+ }
+}
+```
+
+Selection priority in `open()`:
+
+1. `properties.opencv.device_id` (stable ID)
+2. `properties.opencv.device_name` (substring match)
+3. `properties.opencv.device_vid` + `device_pid`
+4. `index` fallback
+
+---
+
+## Advanced configuration
+
+### Full properties and configuration
+
+OpenCV-specific options live under `properties.opencv`.
+
+#### Related camera settings (shared across backends)
+
+- `width` (int): requested image width; `0` means keep device default
+- `height` (int): requested image height; `0` means keep device default
+- `fps` (float): requested FPS; `0.0` means do not set
+
+```{note}
+`exposure` and `gain` fields may be present but are currently treated as unsupported in this backend due to lack of standardization across OpenCV drivers.
+```
+
+#### OpenCV namespace options (`properties.opencv`)
+
+Device selection:
+
+- `device_id` (string): stable identity from enumeration
+- `device_name` (string): substring to match a device name
+- `device_vid` (int): USB vendor ID
+- `device_pid` (int): USB product ID
+
+Backend/open behavior:
+
+- `api` (string | null): preferred OpenCV API backend override.
+ - Common values: `DSHOW`, `MSMF`, `V4L2`, `AVFOUNDATION`, `ANY`.
+- `fast_start` (bool, default: false):
+ - Skips heavy negotiation; applies resolution in best-effort mode.
+ - Useful for faster startup when probing devices, no effect when opening the feed for a known device.
+
+Format negotiation policy:
+
+- `resolution_policy` (string, default: `warn`): how to handle mismatch between requested and applied resolution.
+ - `warn`: log a warning
+ - `strict`: raise an error
+ - `accept`: accept mismatch
+- `persist_last_applied_resolution` (bool, default: false):
+ - If enabled, stores `last_applied_resolution` in `properties.opencv` after successful negotiation.
+- `enforce_aspect` (string, default: `strict`): aspect ratio policy for verification.
+ - `strict`, `prefer`, `ignore`
+- `aspect_tol` (float, default: 0.01): aspect tolerance (fraction)
+- `area_tol` (float, default: 0.05): area tolerance (fraction)
+
+Codec policy:
+
+- `prefer_mjpg` (bool, default: false): attempt to enable MJPG on Windows.
+- `fourcc` (string | null): explicit FOURCC request, overrides `prefer_mjpg`.
+ - Examples: `MJPG`, `YUY2`, `NV12`, `H264`, `XRGB`, `BGR3`
+
+---
+
+### Resolution and FPS behavior
+
+#### Resolution
+
+- If `width` and `height` are both `> 0`:
+ - In normal mode, the backend uses a verified negotiation path and records the actual applied resolution.
+ - In `fast_start` mode, it applies width/height via `CAP_PROP_FRAME_WIDTH/HEIGHT` best-effort.
+- If `width` or `height` is `0`:
+ - The backend does not attempt to set resolution.
+ - It reads the current device defaults.
+
+#### FPS
+
+- If `fps > 0`, the backend attempts to set `CAP_PROP_FPS` best-effort.
+- Many drivers return `0.0` for FPS even when streaming successfully; this is normal for some OpenCV backends.
+
+---
+
+### Device discovery and rebind
+
+#### Discovery
+
+If camera enumeration is available, `discover_devices()` returns a list of `DetectedCamera` with:
+
+- `index`
+- `label`
+- `device_id` (stable ID)
+- `vid`, `pid`
+- `path` (if known)
+- `backend_hint`
+
+If enumeration is not available, `discover_devices()` returns `None` so the factory can fall back to probing.
+
+#### Rebinding
+
+If `properties.opencv.device_id` (or VID/PID/name) exists, `rebind_settings()` attempts to map the saved identity to the current index and refresh stored fields.
+
+---
+
+## Troubleshooting
+
+### Camera opens but frames are `None`
+
+- This indicates transient grab/retrieve failures. Common causes:
+ - Another application is using the camera.
+ - The selected backend (DSHOW/MSMF/V4L2/AVFOUNDATION) is unstable for this device.
+
+Try:
+
+- Set an explicit backend API:
+ ```json
+ {
+ "camera": {
+ "backend": "opencv",
+ "properties": {
+ "opencv": { "api": "DSHOW" }
+ }
+ }
+ }
+ ```
+
+
+### Slow open on Windows (MSMF)
+
+If MSMF is selected and opening is slow, consider setting:
+
+- `OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS=0`
+
+This must be set **before importing `cv2`**.
+
+### Resolution mismatch
+
+If you request a resolution that the driver cannot apply, you may see warnings.
+
+- Switch `resolution_policy` to `strict` to fail fast.
+- Or switch it to `accept` to silence warnings.
+
+### MJPG / codec issues
+
+On Windows, MJPG can reduce USB bandwidth and improve FPS for some webcams.
+
+- Enable MJPG attempt:
+ ```json
+ {
+ "camera": {
+ "backend": "opencv",
+ "properties": {
+ "opencv": { "prefer_mjpg": true }
+ }
+ }
+ }
+ ```
+
+- Or force a specific FOURCC:
+ ```json
+ {
+ "camera": {
+ "backend": "opencv",
+ "properties": {
+ "opencv": { "fourcc": "MJPG" }
+ }
+ }
+ }
+ ```
+
+---
+
+## Example configuration
+
+```json
+{
+ "camera": {
+ "backend": "opencv",
+ "index": 0,
+ "fps": 60.0,
+ "width": 1280,
+ "height": 720,
+ "properties": {
+ "opencv": {
+ "api": "DSHOW",
+ "resolution_policy": "warn",
+ "enforce_aspect": "strict",
+ "aspect_tol": 0.01,
+ "area_tol": 0.05,
+ "prefer_mjpg": true,
+ "timeout": 2.0
+ }
+ }
+ }
+}
+```
+
+---
+
+## Notes and limitations
+
+- Some OpenCV backends **do not report FPS reliably**.
+- Exposure and gain controls are marked unsupported in this backend (they are highly backend- and camera-specific in OpenCV).
+- For stable multi-camera setups, prefer saving and using `properties.opencv.device_id` (stable identity) when enumeration is available.
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing.md b/docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing.md
new file mode 100644
index 0000000000..40f8fa7d00
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing.md
@@ -0,0 +1,11 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+# Additional resources
+
+In this section, you can find additional resources related to the GUI and DLC-live, including:
+
+- {ref}`file:dlclivegui-pretrained-models` : How to download and export pre-trained models from the DeepLabCut Model Zoo for use in the GUI
+- {ref}`file:dlclivegui-timestamp-format` : Information on timestamp formats used in the GUI to help with synchronization
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads.md b/docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads.md
new file mode 100644
index 0000000000..1cd04b34d9
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads.md
@@ -0,0 +1,124 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-pretrained-models)=
+# Pre-trained models
+
+This page explains how to programmatically download and export **pre-trained, GUI-compatible** models from the DeepLabCut Model Zoo using the `dlclive.modelzoo` API, and convert them for use in DLC-live and by extension, the GUI.
+
+For a reference of available models, detectors and their capabilities, see {ref}`file:model-zoo` page.
+
+```{important}
+The `superhuman` model is currently not available in the model zoo due to a missing detector export. We are working on adding it back as soon as possible.
+```
+
+The core idea is:
+
+- Fetch a *SuperAnimal* model snapshot (weights) from the model zoo.
+- Package it together with the corresponding config into a single export artifact (e.g. `exported_superanimal_quadruped_resnet_50.pt`).
+- Point the GUI (or your config) to that exported model checkpoint.
+
+```{note}
+The example below is intended for the PyTorch engine.
+If you are using **TensorFlow models**, you will typically point the GUI to a DLC model *.pb file* instead of a model *.pth/.pt file*.
+```
+
+---
+
+## Quick start
+
+```{note}
+This example assumes you have already installed the GUI and its dependencies, with PyTorch.
+```
+
+### Example constants
+
+```python
+from pathlib import Path
+
+MODELS_DIR = Path("./models")
+
+TORCH_MODEL = "resnet_50"
+TORCH_CONFIG = {
+ "checkpoint": MODELS_DIR / f"exported_quadruped_{TORCH_MODEL}.pt",
+ "super_animal": "superanimal_quadruped",
+}
+```
+
+### Download + export
+
+```python
+from dlclive.modelzoo.pytorch_model_zoo_export import export_modelzoo_model
+
+export_modelzoo_model(
+ export_path=TORCH_CONFIG["checkpoint"],
+ super_animal=TORCH_CONFIG["super_animal"],
+ model_name=TORCH_MODEL, # or e.g. "hrnet_w32"
+ detector_name="fasterrcnn_resnet50_fpn_v2"
+)
+
+assert TORCH_CONFIG["checkpoint"].exists(), "Export failed"
+```
+
+What this does:
+
+1. Creates the destination directory if needed.
+2. Downloads the correct model snapshot (weights) for the specified `super_animal` + `model_name`.
+3. Writes a **single `.pt` export file** containing the model config and weights.
+
+---
+
+## API reference
+
+### `export_modelzoo_model(export_path, super_animal, model_name, detector_name=None)`
+
+- `export_path` (str | Path): Output path for the exported `.pt` file.
+- `super_animal` (str): The model zoo dataset key (e.g. `superanimal_quadruped`).
+- `model_name` (str): Backbone / architecture key (e.g. `resnet_50`).
+- `detector_name` (str | None): Optional detector weights to bundle alongside the pose model.
+
+Behavior:
+
+- If `export_path` already exists, the function **skips** exporting (and emits a warning).
+- If `detector_name` is provided, it downloads and exports a top-down model with the detector weights as well.
+
+---
+
+## What gets saved in the exported `.pt`
+
+The `.pt` file created by `export_modelzoo_model` is a `torch.save(...)` dictionary with (at least) these keys:
+
+- `config`: model configuration loaded via `load_super_animal_config(...)`
+- `pose`: a PyTorch `state_dict` (OrderedDict) for the pose model
+- `detector`: a PyTorch `state_dict` for the detector (or `None` if not used)
+
+## Example full script
+
+```python
+import warnings
+from pathlib import Path
+from dlclive.modelzoo.pytorch_model_zoo_export import export_modelzoo_model
+
+MODELS_DIR = Path("./models")
+model_name = "resnet_50"
+super_animal = "superanimal_quadruped"
+
+export_path = MODELS_DIR / "exported_models" / f"exported_{super_animal}_{model_name}.pt"
+
+export_modelzoo_model(
+ export_path=export_path,
+ super_animal=super_animal,
+ model_name=model_name,
+ detector_name="fasterrcnn_resnet50_fpn_v2"
+)
+
+print(f"Exported model zoo checkpoint to: {export_path}")
+```
+
+---
+
+## In the future
+
+- We may in the future integrate the model zoo functionality more tightly into the GUI, allowing you to browse and download models directly from the interface.
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format.md b/docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format.md
new file mode 100644
index 0000000000..e7aed2612e
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format.md
@@ -0,0 +1,100 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+(file:dlclivegui-timestamp-format)=
+# Video timestamp format
+
+When recording videos, the application automatically saves frame timestamps to a JSON file alongside the video file.
+
+## File naming
+
+```{note}
+If you would like more information on the output path structure and settings,
+please refer to the {ref}`sec:dlclivegui-recording-paths-info` section.
+```
+
+For a video file named `recording_2025-10-23_143052.mp4`, the timestamp file will be:
+
+
+```
+recording_2025-10-23_143052.mp4_timestamps.json
+```
+
+## JSON structure
+
+```json
+{
+ "video_file": "recording_2025-10-23_143052.mp4",
+ "num_frames": 1500,
+ "timestamps": [
+ 1729693852.123456,
+ 1729693852.156789,
+ 1729693852.190123
+ ],
+ "start_time": 1729693852.123456,
+ "end_time": 1729693902.123456,
+ "duration_seconds": 50.0
+}
+```
+
+### Fields
+
+- **video_file**: Name of the associated video file
+- **num_frames**: Total number of frames recorded
+- **timestamps**: Array of Unix timestamps (seconds since epoch with microsecond precision) for each frame
+- **start_time**: Timestamp of the first frame
+- **end_time**: Timestamp of the last frame
+- **duration_seconds**: Total recording duration in seconds
+
+### Usage
+
+The timestamps correspond to the time each frame was captured by the camera (from `FrameData.timestamp`) **when that timestamp is provided by the caller**.
+If no timestamp is provided, the recorder falls back to `time.time()` at enqueue time.
+
+This allows precise synchronization with:
+
+- DLC pose estimation results
+- External sensors or triggers
+- Other data streams recorded during the same session
+
+### Loading timestamps in Python
+
+```python
+import json
+from datetime import datetime
+
+# Load timestamps
+with open('recording_2025-10-23_143052.mp4_timestamps.json', 'r') as f:
+ data = json.load(f)
+
+print(f"Video: {data['video_file']}")
+print(f"Total frames: {data['num_frames']}")
+print(f"Duration: {data['duration_seconds']:.2f} seconds")
+
+# Convert first timestamp to human-readable format
+start_dt = datetime.fromtimestamp(data['start_time'])
+print(f"Recording started: {start_dt.isoformat()}")
+
+# Calculate average frame rate (based on timestamps)
+avg_fps = data['num_frames'] / data['duration_seconds'] if data['duration_seconds'] else 0.0
+print(f"Average FPS: {avg_fps:.2f}")
+
+# Access individual frame timestamps
+for frame_idx, timestamp in enumerate(data['timestamps']):
+ print(f"Frame {frame_idx}: {timestamp}")
+```
+
+## Notes
+
+### About timestamps
+
+- Timestamps use `time.time()` (Unix epoch seconds) when no explicit timestamp is supplied to the recorder.
+- Frame timestamps are captured when frames are enqueued for writing (before encoding), and when provided by the caller can represent camera capture time.
+- If frames are dropped due to queue overflow, those frames will not have timestamps in the array.
+- The timestamp array length should match the number of frames in the video file.
+
+The encoded video is written with a fixed input frame rate configured when recording starts.
+
+The timestamps reflect capture/enqueue timing and may not perfectly match the encoded frame pacing, especially if frames are dropped or capture timing varies.
diff --git a/docs/dlc-live/dlc-live-gui/user_guide/overview.md b/docs/dlc-live/dlc-live-gui/user_guide/overview.md
new file mode 100644
index 0000000000..521b872dda
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/overview.md
@@ -0,0 +1,310 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-03-17'
+ ignore: false
+---
+# GUI overview
+
+DeepLabCut-live-GUI (`dlclivegui`) is a **PySide6-based desktop application** for running real-time DeepLabCut pose estimation experiments with **one or multiple cameras**, optional **processor plugins**, and **video recording** (with or without overlays).
+
+This page gives you a **guided tour of the main window**, explains the **core workflow**, and introduces the key concepts used throughout the user guide.
+
+---
+
+## Main window at a glance
+
+```{important}
+Remember to activate your virtual/conda environment and launch the application with `dlclivegui` (or `uv run dlclivegui`) after installation.
+```
+
+When you first launch the application, you will see the main window with three primary areas:
+
+- A **Controls panel** (left) for configuring cameras, inference, recording, and overlays
+- A **Video panel** (right) showing the live preview (single or tiled multi-camera)
+- A **Stats area** (below the video) summarizing camera, inference, and recorder performance
+
+:::{figure} ../_static/images/main_window_100226.png
+:alt: Screenshot of the main window
+:width: 100%
+:align: center
+
+ The main window on startup, showing the Controls panel (left), Video panel (right), and Stats area (below video).
+:::
+
+---
+
+## Intended workflow
+
+On startup, the GUI is idle and waiting for you to configure cameras and settings,
+as well as pick a model for pose inference.
+
+To start running an experiment, the typical workflow is:
+
+1. **Configure Cameras**
+ - Use **Configure Cameras…** to select one or more cameras and their parameters.
+ - See {ref}`file:dlclivegui-camera-support` for details on supported camera backends and troubleshooting.
+
+2. **Start Preview**
+ - Click **Start Preview** to begin streaming all selected configured cameras.
+ - If multiple cameras are active, the preview becomes a **tiled view**.
+
+3. **Start Pose Inference** *(when ready)*
+ - Choose a **Model file**, optionally a DLC-live **Processor**[^processor-footnote], select the **Inference Camera**, then click **Start pose inference**.
+
+ - Toggle **Display pose predictions** to show or hide pose estimation overlays.
+
+4. **Start Recording** *(when ready)*
+ - Choose an **Output directory**, session/run naming options, and encoding settings, then click **Start recording**.
+ - Recording includes **all active cameras** in multi-camera mode in separate files.
+
+5. **Stop**
+ - Use **Stop Preview**, **Stop pose inference**, and/or **Stop recording** as needed.
+
+```{note}
+Pose inference requires the camera preview to be running.
+
+If you start pose inference while the preview is stopped, the GUI will automatically start the preview first.
+```
+
+---
+
+## Main control panel
+
+The main control panel on the left is where you configure all the settings for cameras, pose inference, recording, and visualization.
+
+```{tip}
+You can "undock" the control panel by dragging it by the title bar, allowing you to move it to a second monitor or give more space to the video preview if needed.
+```
+
+### Camera settings
+
+**Purpose:** Define which cameras are available and active.
+
+- **Configure Cameras…**
+ Opens the camera configuration dialog where you can:
+ - Add, enable, or disable cameras
+ - Select backend and index
+ - Adjust camera-specific properties
+ - Switch between single- and multi-camera setups
+
+```{important}
+Depending on the system, backend and camera model,
+settings may vary widely between proper support, partial support, or no support at all.
+
+This is especially true for the generalist OpenCV backend, which may work well with some cameras but not others.
+```
+
+- **Active**
+ Displays a summary of configured cameras:
+ - **Single camera:** `Name [backend:index] @ fps`
+ - **Multiple cameras:** `N cameras: camA, camB, …`
+
+```{important}
+In multi-camera mode, pose inference runs on **one selected camera at a time** (the *Inference Camera*),
+even though preview and recording may include multiple cameras.
+```
+
+---
+
+### DLCLive settings
+
+```{note}
+`DLCLive` stands for DeepLabCut Live, the real-time pose estimation engine that powers the inference capabilities of this application.
+
+Find more information here if needed: {ref}`deeplabcut-live`.
+```
+
+**Purpose:** Configure and run pose inference on the live stream.
+
+- **Model file**
+ Path to an exported DeepLabCut-Live model file (e.g. `.pt`, `.pb`).
+ We provide some pre-trained models, see {ref}`file:dlclivegui-pretrained-models` for details.
+
+- **Processor folder / Processor** *(optional)*
+ Processor plugins extend functionality (providing ways to setup experiment logic or external control).[^processor-footnote]
+
+- **Inference Camera**
+ Select which active camera is used for pose inference.
+ In multi-camera preview, pose overlays are drawn only on the corresponding tile.
+
+- **Start pose inference / Stop pose inference**
+ The button indicates inference state:
+ - *Initializing DLCLive!* → Model loading
+ - *DLCLive running!* → Inference active
+
+- **Allow processor-based control** *(optional)*
+ Allows compatible processors to have control over several aspects of the experiment, such as starting/stopping recording or triggering external devices.[^processor-footnote]
+
+- **Processor Status**
+ Displays processor-specific status information when available.
+
+---
+
+### Recording
+
+**Purpose:** Save videos from active cameras, optionally with pose overlays.
+
+```{note}
+Timestamps are additionally saved in a JSON file alongside the video, providing precise timing information for when each frame was processed.
+See {ref}`file:dlclivegui-timestamp-format` for details.
+```
+
+(sec:dlclivegui-recording-paths-info)=
+#### Recording output options
+
+- **Output directory**: Base directory for all recordings
+- **Session name**: Grouping of runs (e.g. `mouseA_day1`)
+- **Use timestamp for run folder name**:
+ - Enabled → `run_YYYYMMDD_HHMMSS_mmm`
+ - Disabled → `run_0001`, `run_0002`, …
+
+A live preview label shows the *approximate* output path, including camera placeholders.
+
+```{tip}
+You can hover over the preview path to see the full path, and click to copy it to the clipboard.
+```
+
+#### Encoding options
+
+- **Container** (e.g. `mp4`, `avi`, `mov`)
+- **Codec** (availability depends on OS and hardware)
+- **CRF** (quality/compression tradeoff; lower values = higher quality)
+
+#### Controls
+
+- **Start recording / Stop recording** Controls the recording state.
+- **Open recording folder** Shows the current session's output directory in the system file explorer.
+
+#### Additional options
+
+- **Record video with overlays**
+ Include pose predictions and/or bounding boxes directly in the recorded video.
+ :::{danger}
+ This **cannot be easily undone** once the recording is saved.
+
+ Use with caution if you want to preserve **raw footage** intact.
+ :::
+
+### About frame size mismatches
+
+```{warning}
+Frame size must remain constant for a recording session. If the recorder is configured with an expected `frame_size` and a frame with a different size is written, the recorder enters an error state to prevent encoder corruption:
+
+- The mismatched frame is rejected (`write(...)` returns `False`)
+- Subsequent `write(...)` calls will raise an exception indicating encoding failed
+- Stop the recorder and start a new recording after fixing the frame size
+```
+
+
+```{note}
+Frames are converted automatically for encoding:
+
+- Non-`uint8` frames are scaled/clipped into the `uint8` range.
+- Grayscale frames (`H x W`) are expanded to 3 channels (`H x W x 3`).
+- Frames are made contiguous in memory before being passed to the encoder.
+```
+
+---
+
+### Visualization settings
+
+**Purpose:** Configure the keypoint colormap, bounding box, and other overlay options.
+
+- **Keypoint colormap**: Choose a predefined colormap for keypoint visualization
+- **Display pose predictions**: Toggle keypoint overlay on the video preview, but not in recordings (unless "Record video with overlays" is enabled)
+- **Show bounding box**: Enable or disable overlay
+- **Coordinates**: `x0`, `y0`, `x1`, `y1`
+
+In multi-camera mode, the bounding box is applied relative to the **inference camera tile**, ensuring correct alignment in tiled previews.
+
+```{tip}
+To adjust the bounding box intuitively, hover over a coordinate field (`x0`, `y0`, `x1`, `y1`)
+and drag horizontally.
+```
+
+---
+
+## Video Panel and Stats
+
+### Video preview
+
+- Displays a logo screen when idle
+- Shows the live video feed when preview is running
+- Uses a tiled layout automatically when multiple cameras are active
+
+### Stats panel
+
+Three continuously updated sections:
+
+- **Camera**: Per-camera measured frame rate
+- **DLCLive Inference**: Inference throughput, latency, queue depth, and dropped frames
+- **Recorder**: Recording status and write performance
+
+```{tip}
+Stats text can be selected and copied directly from the GUI
+```
+
+---
+
+## Menu bar actions
+
+Menu actions are available from the menu bar at the top of the application window.
+
+### File menu
+
+- **Load configuration…**
+ Load an existing JSON configuration file.
+
+- **Save configuration**
+ Save the current application settings.
+
+- **Save configuration as…**
+ Save the current settings under a new file name.
+
+- **Open recording folder**
+ Open the output directory for the current session.
+
+- **Close window**
+ Close the application window.
+
+Configuration files store camera configurations, model paths, recording options, and other application settings.
+
+### View → Appearance
+
+- **Show controls**
+ Toggle visibility of the left-side control panel.
+
+- **System theme**
+ Use the default system appearance.
+
+- **Dark theme**
+ Enable the application’s dark theme.
+
+## Keyboard Shortcuts
+
+- **Ctrl+O**: Load configuration
+- **Ctrl+S**: Save configuration
+- **Ctrl+Shift+S**: Save configuration as...
+- **Ctrl+Q**: Quit application
+
+---
+
+## Configuration and Persistence
+
+The GUI can restore settings across sessions using:
+
+- Explicitly saved **JSON configuration files**
+ - Load manually from the File menu or with **Ctrl+O**.
+- A stored snapshot of the most recent configuration (if saved)
+ - The last-saved configuration is automatically loaded on startup if available
+- Remembered paths (e.g. last-used model directory)
+
+On startup, the application attempts to **restore your last‑used settings** if saved,
+but you can always manually load and save configurations.
+
+```{tip}
+For reproducible experiments, prefer saving the configuration files
+rather than relying on the remembered application state snapshot.
+```
+
+[^processor-footnote]: Processors are optional Python plugins that can be loaded by the application to extend its functionality, provided by [DLC-Live](https://github.com/DeepLabCut/DeepLabCut-live). They can provide custom logic for controlling the experiment, such as starting/stopping recording based on specific conditions, sending triggers to external devices, or implementing closed-loop control based on pose estimation results. You can find **documentation on how to write your own processor in the `dlclivegui.processors` folder**, along with **example processors** that demonstrate some of these features.
diff --git a/docs/docker.md b/docs/docker.md
index 1fdb3f9b6e..dbca607353 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -1,7 +1,25 @@
+---
+deeplabcut:
+ last_content_updated: '2025-04-15'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(docker-containers)=
# DeepLabCut Docker containers
-For DeepLabCut 2.2.0.2 and onwards, we provide container containers on [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut). Using Docker is an alternative approach to using DeepLabCut, which only requires the user to install [Docker](https://www.docker.com/) on your machine, vs. following the step-by-step installation guide for a Anaconda setup. All dependencies needed to run DeepLabCut in terminal or GUI mode, or running Jupyter notebooks with DeepLabCut pre-installed are shipped with the provided Docker images.
+For DeepLabCut 2.2.0.2 and onwards, we provide container containers on [DockerHub](
+https://hub.docker.com/r/deeplabcut/deeplabcut). Using Docker is an alternative approach
+to using DeepLabCut, which only requires the user to install [Docker](
+https://www.docker.com/) on your machine, vs. following the step-by-step installation
+guide for a Anaconda setup. All dependencies needed to run DeepLabCut in the terminal or
+running Jupyter notebooks with DeepLabCut pre-installed are shipped with the provided
+Docker images.
+
+The [`napari-deeplabcut` labelling GUI](
+https://deeplabcut.github.io/DeepLabCut/docs/gui/napari_GUI.html) can be used to label
+your data, but it cannot be run in a Docker container: it should be installed as
+documented in the link above: `pip install napari-deeplabcut` (checkout the [workflow](
+https://deeplabcut.github.io/DeepLabCut/docs/gui/napari_GUI.html#workflow) as well!).
Advanced users can directly head to [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut) and use the provided images there. To get started with using the images, we however also provide a helper tool, `deeplabcut-docker`, which makes the transition to docker images particularly convenient; to install the tool, run
@@ -17,7 +35,7 @@ Note that this will *not* disprupt or install Tensorflow, or any other DeepLabCu
With `deeplabcut-docker`, you can use the images in two modes.
- *Note 1: When running any of the following commands first, it can take some time to complete (a few minutes, depending on your internet connection), since it downloads the Docker image in the background. If you do not see any errors in your terminal, assume that everything is working fine! Subsequent runs of the command will be faster.*
-- *Note 2: The Terminal mode image can be used on all supported platforms (Linux and MacOS). The GUI images can only be considered stable on Linux systems as of DeepLabCut 2.2.0.2 and need additional configuration on Mac.*
+- *Note 2: The labelling GUI cannot be used through the Docker images. However, you can install [`napari-deeplabcut`](https://github.com/DeepLabCut/napari-deeplabcut/tree/main?tab=readme-ov-file#napari-deeplabcut-keypoint-annotation-for-pose-estimation) in a conda environment to do the labelling!*
- *Note 3: For any mode below, you might want to set which directory is the base, namely, so you can have read/write (or read-only access). Here is how to do so:
If you want to mount the whole directory could e.g., pass*
@@ -28,14 +46,21 @@ If you want to mount the whole directory could e.g., pass*
If read-only access is enough, `deeplabcut-docker bash -v /home/mackenzie/DEEPLABCUT:/home/mackenzie/DEEPLABCUT:ro`
-### Terminal mode
+### Terminal mode
-If you not need the GUI, you can run the light version of DeepLabCut and open a terminal by running
+You can run the light version of DeepLabCut and open a terminal by running
``` bash
$ deeplabcut-docker bash
```
+**Important:** if have GPUs on your machine and want to use them to train models, you
+need to pass the `--gpus all` argument to `deeplabcut-docker`:
+
+``` bash
+$ deeplabcut-docker bash --gpus all
+```
+
Inside the terminal, you can confirm that DeepLabCut is correctly installed by running and noting which version installs.
``` bash
@@ -45,10 +70,10 @@ $ ipython
### Jupyter Notebook mode
-Finally, you can run DeepLabCut by starting a jupyter notebook server. The corresponding image can be pulled and started by running
+You can run DeepLabCut by starting a jupyter notebook server. The corresponding image can be pulled and started by running
``` bash
-$ deeplabcut-docker notebook
+$ deeplabcut-docker notebook
```
which will start a Jupyter notebook server. Follow the terminal instructions to open the notebook, by entering `http://127.0.0.1:8888` in your favorite browser. When prompted for a password, use `deeplabcut`, which is the pre-set option in the container.
@@ -57,25 +82,25 @@ The DeepLabCut version in this container is equivalent to the one you install wi
### Advanced usage
-Advanced users and developers can visit the `/docker` subdirectory in the DeepLabCut codebase on Github. We provide Dockerfiles for all images, along with build instructions there.
+Advanced users and developers can visit the [`/docker` subdirectory](https://github.com/DeepLabCut/DeepLabCut/tree/main/docker) in the DeepLabCut codebase on Github. We provide Dockerfiles for all images, along with build instructions there.
## Prerequisites (if you don't have Docker installed already)
**(1)** Install Docker. See https://docs.docker.com/install/ & for Ubuntu: https://docs.docker.com/install/linux/docker-ce/ubuntu/
-Test docker:
+Test docker:
$ sudo docker run hello-world
-
+
The output should be: ``Hello from Docker! This message shows that your installation appears to be working correctly.``
-*if you get the error ``docker: Error response from daemon: Unknown runtime specified nvidia.`` just simply restart docker:
-
+*if you get the error ``docker: Error response from daemon: Unknown runtime specified nvidia.`` just simply restart docker:
+
$ sudo systemctl daemon-reload
$ sudo systemctl restart docker
-
+
**(2)** Add your user to the docker group (https://docs.docker.com/install/linux/linux-postinstall/#manage-docker-as-a-non-root-user)
-Quick guide to create the docker group and add your user:
+Quick guide to create the docker group and add your user:
Create the docker group.
$ sudo groupadd docker
diff --git a/docs/PROJECT_GUI.md b/docs/gui/PROJECT_GUI.md
similarity index 88%
rename from docs/PROJECT_GUI.md
rename to docs/gui/PROJECT_GUI.md
index f922587184..479b6c01d9 100644
--- a/docs/PROJECT_GUI.md
+++ b/docs/gui/PROJECT_GUI.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(project-manager-gui)=
# Interactive Project Manager GUI
@@ -10,7 +16,7 @@ As some users may be more comfortable working with an interactive interface, we
(1) Install DeepLabCut using the simple-install with Anaconda found [here!](how-to-install)*.
Now you have DeepLabCut installed, but if you want to update it, either follow the prompt in the GUI which will ask you to upgrade when a new version is available, or just go into your env (activate DEEPLABCUT) then run:
-` pip install 'deeplabcut[gui,tf,modelzoo]'` *but please see [full install guide](https://deeplabcut.github.io/DeepLabCut/docs/installation.html)!
+` pip install 'deeplabcut[gui,modelzoo]'` *but please see [full install guide](how-to-install)!
(2) Open the terminal and run: `python -m deeplabcut`
@@ -23,15 +29,14 @@ Now you have DeepLabCut installed, but if you want to update it, either follow t
Start at the Project Management Tab and work your way through the tabs to built your customized model and deploy it on new data.
We recommend to keep the terminal visible (as well as the GUI) so you can see the ongoing processes as you step through your project, or any errors that might arise.
-- For specific napari-based labeling features, see the ["napari gui" docs](https://deeplabcut.github.io/DeepLabCut/docs/napari_GUI.html#usage).
+- For specific napari-based labeling features, see the ["napari gui" docs](file:napari-gui-landing).
- To change from dark to light mode, set appearance at the top:
-
-## VIDEO DEMOS: How to launch and run the Project Manager GUI:
+## Video Demos: How to launch and run the Project Manager GUI:
**Click on the images!**
@@ -39,17 +44,17 @@ Note that currently the video demo is the wxPython version, but the logic is the
[](https://youtu.be/KcXogR-p5Ak)
-### Using the Project Manager GUI with the latest DLC code (single animals, plus objects): :arrow_down:
+### Using the Project Manager GUI with the latest DLC code (single animals, plus objects): ⬇️
[](https://www.youtube.com/watch?v=JDsa8R5J0nQ)
-[READ MORE HERE](important-info-regd-usage)
+[Read more here](important-info-regd-usage)
### Using the Project Manager GUI with the latest DLC code (multiple identical-looking animals, plus objects):
[](https://www.youtube.com/watch?v=Kp-stcTm77g)
-[READ MORE HERE](important-info-regd-usage)
+[Read more here](important-info-regd-usage)
## VIDEO DEMO: How to benchmark your data with the new networks and data augmentation pipelines:
diff --git a/docs/gui/napari/advanced_usage.md b/docs/gui/napari/advanced_usage.md
new file mode 100644
index 0000000000..20aaafd254
--- /dev/null
+++ b/docs/gui/napari/advanced_usage.md
@@ -0,0 +1,75 @@
+---
+deeplabcut:
+ last_content_updated: '2026-04-09'
+ last_metadata_updated: '2026-04-09'
+ ignore: false
+ last_verified: '2026-04-09'
+ verified_for: 3.0.0rc14
+---
+
+(file:napari-dlc-advanced-features)=
+
+# napari-DLC - Advanced features
+
+napari-DLC provides several additional features to enhance the annotation experience.
+
+This section covers some of these features in more detail.
+For more basic features and workflows, see the {ref}`basic usage section `.
+
+## Layer status panel
+
+### Current folder
+
+The current folder associated with the active Points layer is displayed at the top of the dock widget.
+This is the folder where annotations will be saved when using **File -> Save Selected Layer(s)** (or `Ctrl+S`).
+
+### Labeling progress
+
+When a labeled data folder is loaded, the widget shows a percentage of labeled frames, based on the theoretical maximum number of keypoints (i.e. number of body parts x number of individuals x number of frames) that could be labeled.
+
+```{note}
+This can be a useful reference to track labeling progress.
+Since visibility cannot be accounted for, it should be considered an estimate of relative labeling progress rather than an absolute measure of completeness. (as not all videos would need 100% labeling, i.e. every body part on every individual in every frame).
+```
+
+### Point size slider
+
+The dock widget includes a slider to adjust the size of all keypoints in the viewer; the selected dot size will be saved in `config.yaml` for convenience, meaning DLC will reuse it for future sessions.
+
+## Copy-paste annotations
+
+To copy-paste keypoints from one frame to another:
+
+- Select the keypoints you want to copy using the selection tool (shortcut `3`)
+- Press `Ctrl+C` to copy the selected keypoints
+- Navigate to the target frame and press `Ctrl+V` to paste the keypoints
+
+## Color scheme display features
+
+The plugin shows a list of body parts and their corresponding colors in the dock widget. You can toggle the visibility of this color scheme using the **Show color scheme** button.
+
+```{tip}
+The display only shows keypoints that are currently visible in the viewer.
+To show all bodyparts in the color scheme from the config, use the checkbox at the top of the color scheme list.
+```
+
+### Quick body part/individual selection
+
+Clicking on a body part in the color scheme will select all keypoints of that body part in the viewer (including across individuals if applicable).
+
+This can be useful for quickly selecting and editing all keypoints of a specific body part.
+
+In individual coloring mode, the color scheme also shows the individuals list, and clicking on an individual will select all keypoints belonging to that individual.
+
+### Jump to body part in viewer
+
+If showing all body parts in the color scheme from the config, clicking on a keypoint in the list that is not currently visible in the viewer will jump to the first instance of that body part in the viewer and select it, if applicable.
+This helps quickly find a specific body part in the viewer.
+
+## Trajectory plot
+
+The **Show trajectories** button opens a trajectory plot in a separate dock widget. This plot shows the trajectories of all **selected keypoints** over time, and will color-code them according to the active color scheme (bodyparts or individuals).
+
+To show the trajectory of a specific keypoint, simply select that keypoint in the viewer (using the selection tool or by clicking on the corresponding body part in the color scheme).
+
+Additional controls in the trajectory plot dock widget allow you to zoom and pan the plot, as well as adjust the time window shown.
diff --git a/docs/gui/napari/basic_usage.md b/docs/gui/napari/basic_usage.md
new file mode 100644
index 0000000000..b6f2cb6f3f
--- /dev/null
+++ b/docs/gui/napari/basic_usage.md
@@ -0,0 +1,277 @@
+---
+deeplabcut:
+ last_content_updated: '2026-04-09'
+ last_metadata_updated: '2026-04-09'
+ ignore: false
+ last_verified: '2026-04-09'
+ verified_for: 3.0.0rc14
+---
+
+(file:napari-dlc-basic-usage)=
+# napari-DLC - Basic usage
+
+`napari-deeplabcut` is a napari plugin for keypoint annotation and label refinement. It can be used either as part of the DeepLabCut GUI or as a standalone annotation tool.
+
+## Before you start
+
+If you installed `DeepLabCut[gui]`, `napari-deeplabcut` is already included.
+
+### In the DeepLabCut GUI
+
+When labeling frames, checking labels, or manually extracting frames from videos, the napari plugin will open automatically.
+
+### As a standalone plugin
+
+You can also install it as a standalone plugin:
+
+```bash
+pip install napari-deeplabcut
+```
+
+Start napari from a terminal:
+
+```bash
+napari
+```
+
+Then open the plugin from:
+
+**Plugins -> napari-deeplabcut: Keypoint controls**
+
+## Supported inputs
+
+The plugin reader can open the following inputs:
+
+- DeepLabCut `config.yaml`
+- Image folders (supports `.png`, `.jpg`, extracted frames from DLC, as well as folders of mixed formats)
+- Videos (`.mp4`, `.avi`, `.mov`)
+- `.h5` annotation files
+
+You can load files either by:
+
+- dragging and dropping them onto the napari viewer, or
+- using the **File** menu
+
+```{tip}
+If you drag and drop a compatible labeled-data folder, the widget opens automatically.
+```
+
+## Recommended basic labeling workflow
+
+The simplest way to **start labeling** is:
+
+1. Open an image-only folder
+1. Open the corresponding `config.yaml` from your DeepLabCut project
+
+**OR**
+
+1. Open a folder inside a DeepLabCut project's `labeled-data` directory with a `CollectedData_.h5` file already present
+
+```{note}
+In this case, you do not have to load in the `config.yaml` as the plugin will automatically read the project config from the expected location relative to the `CollectedData...` file.
+```
+
+This creates:
+
+- an **Image** layer containing the images (or video frames)
+- a **Points** layer initialized with the keypoints defined in the project config
+ - The `CollectedData_.h5` contains your ground truth annotations
+ - Any `machinelabels-iter<...>.h5` files contain machine predictions that can be refined and saved into `CollectedData...`
+
+```{tip}
+When machine labels are present, you will see keypoints from ALL current layers.
+Before editing, make sure to hide other layers to avoid confusion, and select the correct layer to edit (e.g. the `machinelabels...` layer if you want to refine machine predictions).
+```
+
+You can then start annotating directly in the **Points** layer.
+To do so, make sure the correct **Points** layer is selected in the layer list (left panel of the viewer). Click on the **+** icon to start adding keypoints; the selection tool to edit existing keypoints; and the pan/zoom tool to navigate the viewer.
+
+## Labeling
+
+Once the **Points** layer is active, you can place and edit keypoints in the viewer.
+
+### Widget options
+
+- **Keypoint selection**: The dropdown shows which bodypart will be added when placing a new keypoint in the Points layer. It can be changed manually, and will be updated according to the active labeling mode (see below).
+- **View shortcuts**: opens a reference of napari-deeplabcut shortcuts and their context (i.e. when they are active).
+- **Show tutorial**: opens the napari-DLC tutorial panels.
+
+#### Labeling mode
+
+- **Sequential**: when a keypoint is placed, the next keypoint in the config list is automatically selected. This is useful for labeling frames in order. Adding an already present keypoint in the frame does nothing.
+- **Quick**: As sequential, but adding an already present keypoint in the frame will move it to the new location.
+- **Loop**: The currently selected bodypart is retained and the viewer advances to the next frame. This is useful for labeling a specific body part across many frames in a row. If the end of the video is reached, the viewer will loop back to the beginning.
+
+The dock widget also provides additional controls, including:
+
+- **Warn on overwrite**: enable or disable overwrite confirmation
+- **Show trails**: display keypoint trails over time
+- **Show trajectories**: open a trajectory plot in a separate dock widget
+- **Show color scheme**: display the active color mapping
+- **Video tools**: extract frames and store crop coordinates when a video is loaded
+
+## Saving annotations
+
+To save annotations, select the **Points** layer you want to save and use:
+
+**File -> Save Selected Layer(s)...**
+
+or press:
+
+```text
+Ctrl+S
+```
+
+```{note}
+If you open a folder that is outside a DeepLabCut project and then save a Points layer, you will be prompted to provide the corresponding `config.yaml`. After saving, you can move the labeled-data folder into your project for downstream DeepLabCut workflows.
+```
+
+Annotations are saved into the dataset folder as:
+
+```text
+CollectedData_.h5
+```
+
+These are the ground truth annotations that DeepLabCut will use for training and evaluation.
+A companion CSV file is also written:
+
+```text
+CollectedData_.csv
+```
+
+```{important}
+DeepLabCut uses the `.h5` file as the authoritative annotation file. CSVs and machine labels will not be taken into account for training.
+```
+
+### Save behavior and notes
+
+- Make sure the correct **Points** layer is selected before saving.
+- If several Points layers are selected at the same time, the plugin will not save them in order to avoid ambiguity.
+- If saving would overwrite existing annotations, the plugin will ask for confirmation.
+ - This confirmation can be disabled by unchecking **Warn on overwrite** in the dock widget.
+
+```{note}
+Several plugin functions expect `config.yaml` to be located two folders above the saved `CollectedData...` file, matching the standard DeepLabCut project structure.
+Keeping data inside the project directory is recommended for best compatibility. Fallbacks asking for the config file location are provided when this structure is not respected, but some features may be disabled or limited in that case.
+```
+
+### Useful shortcuts
+
+- napari native:
+ - `2` / `3`: switch between labeling and selection mode
+ - `4`: pan and zoom mode
+ - `Ctrl+R`: reset the viewer to the default zoom and position
+- napari-deeplabcut specific:
+ - `M`: cycle through annotation modes
+ - `E`: toggle edge coloring
+ - `F`: toggle between individual and body-part coloring modes
+ - `V`: toggle visibility of the selected layer
+ - `Backspace`: delete selected point(s)
+ - `Ctrl+C` / `Ctrl+V`: copy and paste selected points
+
+```{tip}
+Use the **View shortcuts** button in the dock widget for a quick reference of napari-deeplabcut shortcuts and their context (i.e. when they are active).
+```
+
+### More quality-of-life features
+
+See the {ref}`Advanced features ` for useful features such as copy-pasting annotations, quick body part selection, and more.
+
+## Labeling workflows
+
+### Labeling from scratch
+
+Use this when the image folder does **not** yet contain a `CollectedData_.h5` file.
+
+1. Open a folder of extracted images
+1. Open the corresponding DeepLabCut `config.yaml`
+1. Select the created **Points** layer
+1. Label keypoints
+1. Save with `Ctrl+S`
+
+After saving, the folder will contain:
+
+```text
+CollectedData_.h5
+CollectedData_.csv
+```
+
+### Resuming labeling
+
+Use this when the folder already contains a `CollectedData_.h5` file.
+
+- Open (or drag and drop) the folder in napari.
+
+Existing annotations and keypoint metadata will be loaded automatically from the H5 file.
+In this case, loading `config.yaml` is usually **not needed** unless :
+
+- The project's body parts have changed or
+- You want to refresh the configured color scheme
+
+### Refining machine labels
+
+Use this when the folder contains a machine predictions file such as:
+
+```text
+machinelabels-iter<...>.h5
+```
+
+Open the folder in napari.
+
+If both a `CollectedData...` file and a `machinelabels...` file are present:
+
+1. Edit the `machinelabels` layer
+1. Optionally press `E` to show edge coloring (red edges indicate confidence below the threshold defined in `config.yaml`)
+1. Hide other layers to avoid confusion while editing
+1. Edit keypoints in the `machinelabels` layer to refine machine predictions
+1. Save the selected `machinelabels` layer
+
+The refined annotations will be merged into `CollectedData...`.
+
+If only `machinelabels...` is present, saving refinements will still create a new `CollectedData...` target.
+
+```{important}
+Saving a `machinelabels...` layer does **not** overwrite the machine labels file itself.
+Refinements are written into the appropriate `CollectedData...` file.
+Make sure overwrite confirmation is enabled if you want to avoid accidentally overwriting existing `CollectedData...` annotations.
+```
+
+## Video workflow (crop and frame extraction)
+
+Videos can also be opened directly in napari.
+
+```{tip}
+This works best by using the main DLC GUI and following steps there for manual frame extraction, which will automatically open the video in napari.
+The workflow is otherwise the same when opening a video directly in napari.
+```
+
+When a video is loaded, the plugin provides a small video action panel that can be used to:
+
+- Extract the current frame into the dataset
+- Optionally export existing machine labels for that frame (load the corresponding h5 file first)
+- Define and save crop coordinates to the DeepLabCut `config.yaml`
+
+Keypoints from video-based workflows can be edited and saved in the same way as image-folder workflows.
+
+## Working with multiple folders
+
+We do not currently support working on **more than one dataset folder at a time**.
+If a new folder is opened while another one is already open, the plugin will prevent new frames from being loaded, attempt to load annotations using the current folder context, and show a warning.
+
+After finishing one folder, simply:
+
+1. Save the relevant **Points** layer
+1. Remove the current layers from the viewer using the layer list (left panel)
+1. Open the next folder (e.g. by dragging and dropping it onto the viewer)
+
+This helps keep saving behavior unambiguous.
+
+## Demo
+
+A short demo video is available here:
+
+```{warning}
+This demo may be outdated, but the general annotation workflow remains the same. If you would like an updated video tutorial, please open a feature request issue on GitHub, and we will update it.
+
+[Link to video](https://youtu.be/hsA9IB5r73E)
+```
diff --git a/docs/gui/napari_GUI.md b/docs/gui/napari_GUI.md
new file mode 100644
index 0000000000..0bafe5c84f
--- /dev/null
+++ b/docs/gui/napari_GUI.md
@@ -0,0 +1,18 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-04-09'
+ ignore: false
+ last_verified: '2026-04-09'
+ verified_for: 3.0.0rc14
+---
+(file:napari-gui-landing)=
+# napari GUI
+
+Welcome to the documentation for napari-DLC, the napari plugin for keypoint annotation and label refinement. This plugin can be used either as part of the DeepLabCut GUI or as a standalone annotation tool.
+
+## Table of contents
+
+- [Installation (on GitHub)](https://github.com/DeepLabCut/napari-deeplabcut?tab=readme-ov-file#installation)
+- {ref}`Basic usage `
+- {ref}`Advanced features `
diff --git a/docs/images/box1-multi.png b/docs/images/box1-multi.png
new file mode 100644
index 0000000000..2da7320667
Binary files /dev/null and b/docs/images/box1-multi.png differ
diff --git a/docs/images/box1-single.png b/docs/images/box1-single.png
new file mode 100644
index 0000000000..c5c802d486
Binary files /dev/null and b/docs/images/box1-single.png differ
diff --git a/docs/installation.md b/docs/installation.md
index 19786fb3db..333a1e72bc 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,31 +1,86 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-23'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(how-to-install)=
# How To Install DeepLabCut
-- DeepLabCut can be run on Windows, Linux, or MacOS (see also [technical considerations](tech-considerations-during-install) and if you run into issues also check out the [Installation Tips](https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html) page).
-- Please note, there are several modes of installation, and the user should decide to either use a **system-wide** (see [note below](system-wide-considerations-during-install)), **conda environment** based installation (**recommended**), or the supplied [**Docker container**](docker-containers) (recommended for Ubuntu advanced users). One can of course also use other Python distributions than Anaconda, but **Anaconda is the easiest route.**
-- We recommend for most users to use our supplied CONDA environment.
+- **DeepLabCut can be run on Windows, Linux, or MacOS as long as you have Python 3.10 installed**
+ - (see also [technical considerations](tech-considerations-during-install) and if you run into issues also check out the [Installation Tips](https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html) page).
+- 🚧 Please note, there are several modes of installation:
+ - please decide to either use a [**conda environment**](https://deeplabcut.github.io/DeepLabCut/docs/installation.html#conda-the-installation-process-is-as-easy-as-this-figure) based installation (**recommended**),
+ - or the supplied [**Docker container**](docker-containers) (recommended for Ubuntu advanced users).
+- 🚀 Please note, you will get the best performance with using a **GPU**!
+ - Please see the section on [GPU support](https://deeplabcut.github.io/DeepLabCut/docs/installation.html#gpu-support) to install your GPU driver and CUDA.
+
+```{Hint} Familiar with python packages and conda? Quick Install Guide:
+
+This assumes you have `conda`/`mamba` installed and this will install DeepLabCut in a fresh
+environment. If you have an NVIDIA GPU, install PyTorch according to [their instructions
+](https://pytorch.org/get-started/locally/) (with your desired CUDA version) - you just
+need your GPU drivers installed.
+
+```bash
+conda create -n DEEPLABCUT python=3.12
+conda activate DEEPLABCUT
+
+# install PyTorch with your desired CUDA version (or for CPU only) - check [their
+](https://pytorch.org/get-started/locally/) website:
+# GPU version of pytorch for CUDA 11.3
+conda install pytorch cudatoolkit=11.3 -c pytorch
+
+
+# install the latest version of DeepLabCut
+pip install --pre deeplabcut
+# or if you want to use the GUI
+pip install --pre deeplabcut[gui]
+
+# ONLY IF YOU HAVE A CUDA GPU - check that PyTorch can access your GPU; this
+# should print `True`
+python -c "import torch; print(torch.cuda.is_available())"
+```
+
+- If you're familiar with the command line and want TensorFlow support, look [below](
+deeplabcut-with-tf-install) for a fresh installation that has worked for us (on Linux)
+and makes it possible to use the GPU with both PyTorch and TensorFlow.
## CONDA: The installation process is as easy as this figure! -->
+### 🚨 Before you start with our conda file, do you have a GPU?
+````{admonition} 🚨 Click here for more information!
+:class: dropdown
+- We recommend having a GPU if possible!
+- You **need to decide if you want to use a CPU or GPU for your models**: (Note, you can also use the CPU-only for project management and labeling the data! Then, for example, use Google Colaboratory GPUs for free (read more [here](https://github.com/DeepLabCut/DeepLabCut/tree/master/examples#demo-4-deeplabcut-training-and-analysis-on-google-colaboratory-with-googles-gpus) and there are a lot of helper videos on [our YouTube channel!](https://www.youtube.com/playlist?list=PLjpMSEOb9vRFwwgIkLLN1NmJxFprkO_zi)).
+
+ - **CPU?** Great, jump to the next section below!
+
+ - **NVIDIA GPU?** If you want to use your own GPU (i.e., a GPU is in your workstation), then you need to be sure you have a CUDA compatible GPU, CUDA, and cuDNN installed. Please note, which CUDA you install depends on what version of PyTorch you want to use. So, please check "GPU Support" below carefully. **Note, DeepLabCut is up to date with the latest CUDA and PyTorch!**
+
+ - **Apple M-chip GPU?** Be sure to install miniconda3, and your GPU will be used by default.
+````
+
### Step 1: Install Python via Anaconda
-#### Install [anaconda](https://www.anaconda.com/distribution/), or use miniconda3 for MacOS users (see below)
+### Install [anaconda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html#), or use miniconda3 for MacOS users (see below)
- Anaconda is an easy way to install Python and additional packages across various operating systems. With Anaconda you create all the dependencies in an [environment](https://conda.io/docs/user-guide/tasks/manage-environments.html) on your machine.
```{Hint}
-Download anaconda for your operating system: https://www.anaconda.com/distribution/.
+Download anaconda for your operating system: [anaconda.com/download/
+](https://www.anaconda.com/download/)
```
-- IF you use a M1 or M2 chip in your MacBook with v12.5+ (typically 2020 or newer machines), you should use **miniconda3,** which operates with the same principles as anaconda. This is straight forward and explained in detail here: https://docs.conda.io/projects/conda/en/latest/user-guide/install/macos.html. But in short, open the program "terminal" and copy/paste and run the code that is supplied below.
+- IF you use a M1 or M2 chip in your MacBook with v12.5+ (typically 2020 or newer machines), we recommend **miniconda3,** which operates with the same principles as anaconda. This is straight forward and explained in detail here: https://docs.conda.io/projects/conda/en/latest/user-guide/install/macos.html. But in short, open the program "terminal" and copy/paste and run the code that is supplied below.
-#### 💡 miniconda for Mac
+### 💡 miniconda for Mac
````{admonition} Click the button to see code for miniconda for Mac
:class: dropdown
-wget https://repo.anaconda.com/miniconda/Miniconda3-py39_4.12.0-MacOSX-arm64.sh -O ~/miniconda.sh
+wget https://repo.anaconda.com/miniconda/Miniconda3-py310_4.12.0-MacOSX-arm64.sh -O ~/miniconda.sh
bash ~/miniconda.sh -b -p $HOME/miniconda
source ~/miniconda/bin/activate
conda init zsh
@@ -41,63 +96,96 @@ Windows users: Be sure you have `git` installed along with anaconda: https://git
- TO DIRECTLY DOWNLOAD THE CONDA FILE conda:
- - click ➡️ for [Windows, Linux or Apple Intel w/o M1/M2](https://github.com/DeepLabCut/DeepLabCut/blob/main/conda-environments/DEEPLABCUT.yaml#:~:text=Raw%20file%20content-,Download,-%E2%8C%98) and then click the "..." and select Download
+ - click ➡️ for [CONDA FILE](https://github.com/DeepLabCut/DeepLabCut/blob/main/conda-environments/DEEPLABCUT.yaml#:~:text=Raw%20file%20content-,Download,-%E2%8C%98) and then click the "..." and select Download
- - click ➡️ for [Apple w/M1/M2](https://github.com/DeepLabCut/DeepLabCut/blob/main/conda-environments/DEEPLABCUT_M1.yaml#:~:text=Raw%20file%20content-,Download,-%E2%8C%98), and then click the "..." and Download
-
-
-**Alternatively,** you can git clone this repo and install (if the download did not work or you just want to have the source code handy)!
-
-- **Windows/Linux/MacBooks:** git clone this repo (in the terminal/cmd program, while **in a folder** you wish to place DeepLabCut
-To git clone type: ``git clone https://github.com/DeepLabCut/DeepLabCut.git``). Note, this can be anywhere, even downloads is fine.)
+- **Now, in Terminal (or Anaconda Command Prompt for Windows users)**, if you clicked to download, go to your downloads folder.
```{Hint}
Windows users: Be sure to open the program terminal/cmd/anaconda prompt with a RIGHT-click, "open as admin"
```
-- **Now, in Terminal (or Anaconda Command Prompt for Windows users)**, if you clicked to download, go to your downloads folder. Or, if you cloned the repo, go to the DeepLabCut folder.
-
```{Hint}
:class: dropdown
If you cloned the repo onto your Desktop, the command may look like:
``cd C:\Users\YourUserName\Desktop\DeepLabCut\conda-environments``
You can (on Windows) hold SHIFT and right-click > Copy as path, or (on Mac) right-click and while in the menu press the OPTION key to reveal Copy as Pathname.
```
-
-- Now, in the terminal run (Windows/Linux/MacBook Intel chip):
+Be sure you are in the folder that has the `.yaml` file, then run:
``conda env create -f DEEPLABCUT.yaml``
-- or for Apple M1 / M2 chips:
-
-``conda env create -f DEEPLABCUT_M1.yaml``
- You can now use this environment from anywhere on your computer (i.e., no need to go back into the conda- folder). Just enter your environment by running:
- - Ubuntu/MacOS: ``source/conda activate nameoftheenv`` (i.e. on your Mac: ``conda activate DEEPLABCUT`` or ``conda activate DEEPLABCUT_M1``)
+ - Ubuntu/MacOS: ``source/conda activate nameoftheenv`` (i.e. on your Mac: ``conda activate DEEPLABCUT``)
- Windows: ``activate nameoftheenv`` (i.e. ``activate DEEPLABCUT``)
-Now you should see (`nameofenv`) on the left of your terminal screen, i.e. ``(DEEPLABCUT_M1) YourName-MacBook...``
+Now you should see (`nameofenv`) on the left of your terminal screen, i.e. ``(DEEPLABCUT) YourName-MacBook...``
NOTE: no need to run pip install deeplabcut, as it is already installed!!! :)
+(deeplabcut-with-tf-install)=
+### 💡 Notice: PyTorch and TensorFlow Support within DeepLabCut
+
+````{admonition} DeepLabCut TensorFlow Support
+:class: dropdown
+As of June 2024 we have a PyTorch Engine backend and we will be depreciating the
+TensorFlow backend by the end of 2024. Currently, if you want to use TensorFlow, you
+need to run `pip install deeplabcut[tf]` in order to install the correct version of
+TensorFlow in your conda env. Please note, we will be providing bug fixes, but we will
+not be supporting new TensorFlow versions beyond 2.10 (Windows), and 2.12 for other OS.
+
+Installing TensorFlow and getting it to have access to the GPU can be a bit tricky.
+Check TensorFlow's [compatibility matrix](https://www.tensorflow.org/install/source#gpu)
+to know which version of CUDA and cuDNN you should install.
+
+We have found that installing DeepLabCut with the following commands works well for
+Linux users to install PyTorch 2.3.1, TensorFlow 2.12, CUDA 11.8 and cuDNN 8 in a Conda
+environment:
+
+```bash
+conda create -n deeplabcut-with-tf "python=3.10"
+conda activate deeplabcut-with-tf
+
+# Install the desired TensorFlow version, built for CUDA 11.8 and cuDNN 8
+pip install "tensorflow==2.12" "tensorpack>=0.11" "tf_slim>=1.1.0"
+
+# Install PyTorch with a version using CUDA 11.8 and cuDNN 8
+pip install "torch==2.3.1" torchvision --index-url https://download.pytorch.org/whl/cu118
+
+# Create symbolic links to NVIDIA shared libraries for TensorFlow
+# -> as described in their installation docs:
+# https://www.tensorflow.org/install/pip#step-by-step_instructions
+
+pushd $(dirname $(python -c 'print(__import__("tensorflow").__file__)'))
+ln -svf ../nvidia/*/lib/*.so* .
+popd
+
+pip install --pre deeplabcut
+```
+````
+
**Great, that's it! DeepLabCut is installed!** 🎉💜
-🚨 Next, [head over to the Docs to decide which mode to use DeepLabCut in. You have both standard and multi-animal installed!](https://deeplabcut.github.io/DeepLabCut/docs/UseOverviewGuide.html#what-you-need-to-get-started)
-## PIP:
+### Step 3: Really, that's it! Let's run DeepLabCut
-- Everything you need to build custom models within DeepLabCut (i.e., use our source code and our dependencies) can be installed with `pip install 'deeplabcut[gui,tf]'` (for GUI support w/tensorflow) or without the gui: `pip install 'deeplabcut[tf]'`.
-- If you want to use the SuperAnimal models, then please use `pip install 'deeplabcut[gui,tf,modelzoo]'`.
+Head over to the [User Guide Overview](https://deeplabcut.github.io/DeepLabCut/docs/UseOverviewGuide.html) for information.
-#### We recommend having a GPU.
+🎉 Launch DeepLabCut in your new env by running `python -m deeplabcut`
-- You **need to decide if you want to use a CPU or GPU for your models**: (Note, you can also use the CPU-only for project management and labeling the data! Then, for example, use Google Colaboratory GPUs for free (read more [here](https://github.com/DeepLabCut/DeepLabCut/tree/master/examples#demo-4-deeplabcut-training-and-analysis-on-google-colaboratory-with-googles-gpus) and there are a lot of helper videos on [our YouTube channel!](https://www.youtube.com/playlist?list=PLjpMSEOb9vRFwwgIkLLN1NmJxFprkO_zi)).
+## Other ways to install DeepLabCut and additional tips
- - **CPU?** Great, jump to the next section below!
+### Alternatively, you can git clone this repo and install from source!
+i.e., if the download did not work or you just want to have the source code handy!
- - **NVIDIA GPU?** If you want to use your own GPU (i.e., a GPU is in your workstation), then you need to be sure you have a CUDA compatible GPU, CUDA, and cuDNN installed. Please note, which CUDA you install depends on what version of tensorflow you want to use. So, please check "GPU Support" below carefully. **Note, DeepLabCut is up to date with the latest CUDA and tensorflow versions!**
-
- - **Apple M1/M2 GPU?** Be sure to install miniconda3, and your GPU will be used by default.
+- **Windows/Linux/MacBooks:** git clone this repo (in the terminal/cmd program, while **in a folder** you wish to place DeepLabCut
+To git clone type: ``git clone https://github.com/DeepLabCut/DeepLabCut.git``). Note, this can be anywhere, even downloads is fine.)
+- Then follow the same steps as in Step 2 above, adjusting for the file now being in the downloaded folder.
+
+### PIP:
+
+- Everything you need to build custom models within DeepLabCut (i.e., use our source code and our dependencies) can be installed with `pip install 'deeplabcut[gui]'` (for GUI support w/PyTorch) or without the gui: `pip install 'deeplabcut'`.
+- If you want to use the SuperAnimal models, then please use `pip install 'deeplabcut[gui,modelzoo]'`.
## DOCKER:
@@ -105,16 +193,31 @@ NOTE: no need to run pip install deeplabcut, as it is already installed!!! :)
## Pro Tips:
-More [installation ProTips](installTips) are also available.
+More [installation ProTips](installation-tips) are also available.
-If you ever want to update your DLC, just run `pip install --upgrade deeplabcut` once you are inside your env. If you want to use a specific release, then you need to specify the version you want, such as `pip install deeplabcut==2.2`. Once installed, you can check the version by running `import deeplabcut` `deeplabcut.__version__`. Don't be afraid to update, DLC is backwards compatible with your 2.0+ projects and performance continues to get better and new features are added nearly monthly.
+If you ever want to update your DLC, just run `pip install --upgrade deeplabcut` once
+you are inside your env. If you want to use a specific release, then you need to specify
+the version you want, such as `pip install deeplabcut==3.0`. Once installed, you can
+check the version by running `import deeplabcut` `deeplabcut.__version__`. Don't be
+afraid to update, DLC is backwards compatible with your 2.0+ projects and performance
+continues to get better and new features are added nearly monthly.
-Here are some conda environment management tips: https://kapeli.com/cheat_sheets/Conda.docset/Contents/Resources/Documents/index
+**All of the data you labelled in version 2.X is also compatible with version 3+ and the
+PyTorch engine**! There is no change in the workflow or the way labels are handled: the
+big changes happen under-the-hood! If you've been working with DeepLabCut 2.X and want
+to learn more about moving to the PyTorch engine, checkout our docs on [moving from
+TensorFlow to PyTorch](dlc3-user-guide)
-**Pro Tip:** If you want to modify code and then test it, you can use our provided testscripts. This would mean you need to be up-to-date with the latest GitHub-based code though! Please see [here](installTips) on how to get the latest GitHub code, and how to test your installation by following this video: https://www.youtube.com/watch?v=IOWtKn3l33s.
+Here are some conda environment management tips: [kapeli.com: Conda Cheat Sheet](
+https://kapeli.com/cheat_sheets/Conda.docset/Contents/Resources/Documents/index)
+**Pro Tip:** If you want to modify code and then test it, you can use our provided
+testscripts. This would mean you need to be up-to-date with the latest GitHub-based code
+though! Please see [here](installation-tips) on how to get the latest GitHub code, and
+how to test your installation by following this video:
+https://www.youtube.com/watch?v=IOWtKn3l33s.
-### Creating your own customized conda env (recommended route for Linux: Ubuntu, CentOS, Mint, etc.)
+## Creating your own customized conda env (recommended route for Linux: Ubuntu, CentOS, Mint, etc.)
*Note in a fresh ubuntu install, you will often have to run: ``sudo apt-get install gcc python3-dev`` to install the GNU Compiler Collection and the python developing environment.
@@ -122,13 +225,12 @@ Some users might want to create their own customize env. - Here is an example.
In the terminal type:
-`conda create -n DLC python=3.8`
+`conda create -n DLC python=3.10`
-**Current version:** The only thing you then need to add to the env is deeplabcut (`pip install deeplabcut[tf]`) or `pip install 'deeplabcut[gui,tf]'` which has a pyside/napari based GUI.
+**Current version:** The only thing you then need to add to the env is deeplabcut (
+`pip install deeplabcut`) or `pip install 'deeplabcut[gui]'` which has a napari based
+GUI.
-**Pre-version2.3 (Dec 2022):** The only thing you then need to add to the env is deeplabcut (`pip install deeplabcut`) or `pip install 'deeplabcut[gui]'` which has wxPython for GUI support. For Windows and MacOS, you just run `pip install -U wxPython<4.1.0` but for linux you might need the specific wheel (https://wxpython.org/pages/downloads/index.html).
-
-We have some tips for linux users here, as the latest Ubuntu doesn't easily support a 1-click install: https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html
## **GPU Support:**
@@ -136,35 +238,51 @@ The ONLY thing you need to do **first** if you have an NVIDIA GPU and the matchi
- CUDA: https://developer.nvidia.com/cuda-downloads (just follow the prompts here!)
- DRIVERS: https://www.nvidia.com/Download/index.aspx
-#### The most common "new user" hurdle is installing and using your GPU, so don't get discouraged!
+### The most common "new user" hurdle is installing and using your GPU, so don't get discouraged!
-**CRITICAL:** If you have a GPU, you should FIRST **install the NVIDIA CUDA package and an appropriate driver for your specific GPU**, then you can use the supplied conda file. Please follow the instructions found here https://www.tensorflow.org/install/gpu, and more tips below, to install the correct version of CUDA and your graphic card driver. The order of operations matters.
+**CRITICAL:** If you have a GPU, you should FIRST **install an appropriate driver for
+your specific GPU**, then you can use the supplied conda file. You'll need an NVIDIA GPU
+which is compatible with CUDA. To see a list of CUDA-enabled NVIDIA GPUs, please [see
+their website](https://developer.nvidia.com/cuda-gpus).
-- Here we provide notes on how to install and check your GPU use with TensorFlow (which is used by DeepLabCut and already installed with the Anaconda files above). Thus, you do not need to independently install tensorflow.
+- Here we provide notes on how to install and check your GPU use with TensorFlow (which
+is used by DeepLabCut and already installed with the Anaconda files above). Thus, you do
+not need to independently install tensorflow.
+**FIRST**, install a driver for your GPU. Find DRIVER HERE:
+https://www.nvidia.com/download/index.aspx
-**FIRST**, install a driver for your GPU. Find DRIVER HERE: https://www.nvidia.com/download/index.aspx
-- check which driver is installed by typing this into the terminal: ``nvidia-smi``.
+- Check which driver is installed by typing this into the terminal: ``nvidia-smi``.
**SECOND**, install CUDA: https://developer.nvidia.com/ (Note that cuDNN, https://developer.nvidia.com/cudnn, is supplied inside the anaconda environment files, so you don't need to install it again).
**THIRD:** Follow the steps above to get the `DEEPLABCUT` conda file and install it!
-##### Notes:
-
- - **All of the TensorFlow versions work with DeepLabCut**. But, please be mindful different versions of TensorFlow require different CUDA versions.
- - As the combination of TensorFlow and CUDA matters, we strongly encourage you to **check your driver/cuDNN/CUDA/TensorFlow versions** [on this StackOverflow post](https://stackoverflow.com/questions/30820513/what-is-version-of-cuda-for-nvidia-304-125/30820690#30820690).
- - To check your GPU is working, in the terminal, run:
-
- `nvcc -V` to check your installed version(s).
-
-- The best practice is to then run the supplied `testscript.py` (this is inside the examples folder you acquired when you git cloned the repo). Here is more information/a short [video on running the testscript](https://www.youtube.com/watch?v=IOWtKn3l33s).
-
-- Additionally, if you want to use the bleeding edge, with yout git clone you also get the latest code. While inside the main DeepLabCut folder, you can run `./reinstall.sh` to be sure it's installed (more here: https://github.com/DeepLabCut/DeepLabCut/wiki/How-to-use-the-latest-GitHub-code)
-
-- You can test that your GPU is being properly engaged with these additional [tips](https://www.tensorflow.org/programmers_guide/using_gpu).
-
-- Ubuntu users might find this [installation guide](https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html#installation-on-ubuntu-20-04-lts) for a fresh ubuntu install useful as well.
+### Notes:
+
+- **As of version 3.0+ we moved to PyTorch. The Last supported version of TensorFlow is
+2.10 (window users) and 2.12 for others (we have not tested beyond this).**
+- Please be mindful different versions of TensorFlow require different CUDA versions.
+- As the combination of TensorFlow and CUDA matters, we strongly encourage you to
+**check your driver/cuDNN/CUDA/TensorFlow versions** [on this StackOverflow post](
+https://stackoverflow.com/questions/30820513/what-is-version-of-cuda-for-nvidia-304-125/30820690#30820690
+).
+- To check your GPU is working, in the terminal, run:
+
+`nvcc -V` to check your installed version(s).
+
+- The best practice is to then run the supplied `testscript_pytorch_single_animal.py`
+(or `testscript_tensorflow_single_animal.py` for the TensorFlow engine); this is inside the examples folder you
+acquired when you git cloned the repo. Here is more information/a short
+[video on running the testscript](https://www.youtube.com/watch?v=IOWtKn3l33s).
+- Additionally, if you want to use the bleeding edge, with your git clone you also get
+the latest code. While inside the main DeepLabCut folder, you can run `./reinstall.sh`
+to be sure it's installed (more [here](installation-tips))
+- You can test that your GPU is being properly engaged with these additional [tips](
+https://www.tensorflow.org/programmers_guide/using_gpu).
+- Ubuntu users might find this [installation guide](
+https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html#installation-on-ubuntu-20-04-lts
+) for a fresh ubuntu install useful as well.
## Troubleshooting:
@@ -183,8 +301,6 @@ Here are some additional resources users have found helpful (posted without endo
- https://developer.nvidia.com/cuda-toolkit-archive
-- http://www.python36.com/install-tensorflow-gpu-windows/
-
FFMPEG:
@@ -217,10 +333,6 @@ If you perform the system-wide installation, and the computer has other Python p
- Anaconda/Python3: Anaconda: a free and open source distribution of the Python programming language (download from https://www.anaconda.com/). DeepLabCut is written in Python 3 (https://www.python.org/) and not compatible with Python 2.
- `pip install deeplabcut`
- TensorFlow
- - You will need [TensorFlow](https://www.tensorflow.org/) (we used version 1.0 in the Nature Neuroscience paper, later versions also work with the provided code (we tested **TensorFlow versions 1.0 to 1.15, and 2.0 to 2.10**; we recommend TF2.10 now) for Python 3.8, 3.9, 3.10 with GPU support.
+ - If you want to use a pre3.0 version, you will need [TensorFlow](https://www.tensorflow.org/) (we used version 1.0 in the Nature Neuroscience paper, later versions also work with the provided code (we tested **TensorFlow versions 1.0 to 1.15, and 2.0 to 2.10**; we recommend TF2.10 now) for Python 3.8, 3.9, 3.10 with GPU support.
- To note, is it possible to run DeepLabCut on your CPU, but it will be VERY slow (see: [Mathis & Warren](https://www.biorxiv.org/content/early/2018/10/30/457242)). However, this is the preferred path if you want to test DeepLabCut on your own computer/data before purchasing a GPU, with the added benefit of a straightforward installation! Otherwise, use our COLAB notebooks for GPU access for testing.
- Docker: We highly recommend advanced users use the supplied [Docker container](docker-containers)
-
-
-
-Return to [readme](readme).
diff --git a/docs/intro.md b/docs/intro.md
index 104e5cebda..cab6f4b183 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -1,202 +1,7 @@
-
-
-[](https://badge.fury.io/py/deeplabcut)
-[](https://pepy.tech/project/deeplabcut)
-[](https://pepy.tech/project/deeplabcut)
-[](https://github.com/DeepLabCut/DeepLabCut)
-
-[](CONTRIBUTING.md)
-[](https://www.gnu.org/licenses/lgpl-3.0)
-[](https://forum.image.sc/tag/deeplabcut)
-[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
-[](https://twitter.com/DeepLabCut)
-[](https://github.com/DeepLabCut/DeepLabCut)
-
-
-
-
-
-# DeepLabCut Documentation
-
-DeepLabCut is a toolbox for markerless pose estimation of animals performing various tasks. Read a short development and application summary below. As long as you can see (label) what you want to track, you can use this toolbox, as it is animal and object agnostic.
-
-This new JupyterBook Docs Hub serves as a landing page for both new and advanced users. Check out the left sidebar for new the main docs, but also tutorials and "recipes" for interesting ways to use DLC and functionality that is not documented elsewhere. Have a new recipe? Please contribute!
-
-**Latest updates:**
-
-- **DeepLabCut supports multi-animal pose estimation!** maDLC is out of beta/rc mode and beta is depreciated, thanks to the testers out there! Your labeled data will be backwards compatible, but not all other steps. Please see the [new `2.2+` releases](https://github.com/DeepLabCut/DeepLabCut/releases) for what's new & how to install it, please see our new paper in [Nature Methods (2022)](https://www.nature.com/articles/s41592-022-01443-0), and the docs on how to use it!
-
-- We have a **real-time DeepLabCut-live!** package available! http://DLClive.deeplabcut.org
-
-- Check out the docs in JupyterBook! https://deeplabcut.github.io/DeepLabCut (or in the [README](readme)).
-
-- For a step-by-step user guide, please also see the [Nature Protocols paper](https://doi.org/10.1038/s41596-019-0176-0)!
-
-- For a deeper understanding and more resources for you to get started with Python and DeepLabCut, please check out our free online course! http://DLCcourse.deeplabcut.org
-
-
-
-
-
-## Why use DeepLabCut?
-
-In 2018, we demonstrated the capabilities for [trail tracking](https://vnmurthylab.org/), [reaching in mice](http://www.mousemotorlab.org/) and various Drosophila behaviors during egg-laying (see [Mathis et al.](https://www.nature.com/articles/s41593-018-0209-y) for details). There is, however, nothing specific that makes the toolbox only applicable to these tasks and/or species. The toolbox has already been successfully applied (by us and others) to [rats](http://www.mousemotorlab.org/deeplabcut), humans, various fish species, bacteria, leeches, various robots, cheetahs, [mouse whiskers](http://www.mousemotorlab.org/deeplabcut) and [race horses](http://www.mousemotorlab.org/deeplabcut). DeepLabCut utilized the feature detectors (ResNets + readout layers) of one of the state-of-the-art algorithms for human pose estimation by Insafutdinov et al., called DeeperCut, which inspired the name for our toolbox (see references below). Since this time, the package has changed substantially. The code has been re-tooled and re-factored since 2.1+: We have added faster and higher performance variants with MobileNetV2s, EfficientNets, and our own DLCRNet backbones (see [Pretraining boosts out-of-domain robustness for pose estimation](https://arxiv.org/abs/1909.11229) and [Lauer et al 2021](https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1)). Additionally, we have improved the inference speed and provided both additional and novel augmentation methods, added real-time, and multi-animal support. We currently provide state-of-the-art performance for animal pose estimation.
-
-
-
-
-
-
-
-
-**Left:** Due to transfer learning it requires **little training data** for multiple, challenging behaviors (see [Mathis et al. 2018](https://www.nature.com/articles/s41593-018-0209-y) for details). **Mid Left:** The feature detectors are robust to video compression (see [Mathis/Warren](https://www.biorxiv.org/content/early/2018/10/30/457242) for details). **Mid Right:** It allows 3D pose estimation with a single network and camera (see [Mathis/Warren](https://www.biorxiv.org/content/early/2018/10/30/457242)). **Right:** It allows 3D pose estimation with a single network trained on data from multiple cameras together with standard triangulation methods (see [Nath* and Mathis* et al. 2019](https://doi.org/10.1038/s41596-019-0176-0)).
-
-**DeepLabCut** is embedding in a larger open-source eco-system, providing behavioral tracking for neuroscience, ecology, medical, and technical applications. Moreover, many new tools are being actively developed. See [DLC-Utils](https://github.com/DeepLabCut/DLCutils) for some helper code.
-
-
-
-
-
-## Code contributors:
-
-DLC code was originally developed by [Alexander Mathis](https://github.com/AlexEMG) & [Mackenzie Mathis](https://github.com/MMathisLab), and was extended in 2.0 with [Tanmay Nath](http://www.mousemotorlab.org/team), and currently (2.1+) actively developed with our CZI DLC Fellow, [Jessy Lauer](https://github.com/jeylau). DeepLabCut is an open-source tool and has benefited from suggestions and edits by many individuals including Mert Yuksekgonul, Tom Biasi, Richard Warren, Ronny Eichler, Hao Wu, Federico Claudi, Gary Kane and Jonny Saunders as well as the [contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors). Please see [AUTHORS](https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS) for more details!
-
-This is an actively developed package and we welcome community development and involvement.
-
-## Community Support, Developers, & Help:
-
-- We are a community partner on the [](https://forum.image.sc/tag/deeplabcut). Please post help and support questions on the forum with the tag DeepLabCut. Check out their mission statement [Scientific Community Image Forum: A discussion forum for scientific image software](https://journals.plos.org/plosbiology/article?id=10.1371/journal.pbio.3000340).
-
-- If you encounter a previously unreported bug/code issue, please post here (we encourage you to search issues first): https://github.com/DeepLabCut/DeepLabCut/issues
-
-- For quick discussions amongst users, please see here: [](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
-
-- If you want to contribute to the code, please read our guide [here!](https://github.com/DeepLabCut/DeepLabCut/blob/master/CONTRIBUTING.md)
-
-- The project [road map](dev-roadmap). Get in touch with us if you want to help!
-
-## References:
-
-If you use this code or data we kindly as that you please [cite Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y) and, if you use the Python package (DeepLabCut2.x) please also cite [Nath, Mathis et al, 2019](https://doi.org/10.1038/s41596-019-0176-0). If you utilize the MobileNetV2s or EfficientNets please cite [Mathis, Biasi et al. 2021](https://openaccess.thecvf.com/content/WACV2021/papers/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.pdf). If you use multi-animal code please cite [Lauer et al. 2022](https://www.nature.com/articles/s41592-022-01443-0).
-
-DOIs (#ProTip, for helping you find citations for software, check out [CiteAs.org](http://citeas.org/)!):
-
-- Mathis et al 2018: [10.1038/s41593-018-0209-y](https://doi.org/10.1038/s41593-018-0209-y)
-- Nath, Mathis et al 2019: [10.1038/s41596-019-0176-0](https://doi.org/10.1038/s41596-019-0176-0)
-
-
-Please check out the following references for more details:
-
- @article{Mathisetal2018,
- title = {DeepLabCut: markerless pose estimation of user-defined body parts with deep learning},
- author = {Alexander Mathis and Pranav Mamidanna and Kevin M. Cury and Taiga Abe and Venkatesh N. Murthy and Mackenzie W. Mathis and Matthias Bethge},
- journal = {Nature Neuroscience},
- year = {2018},
- url = {https://www.nature.com/articles/s41593-018-0209-y}}
-
- @article{NathMathisetal2019,
- title = {Using DeepLabCut for 3D markerless pose estimation across species and behaviors},
- author = {Nath*, Tanmay and Mathis*, Alexander and Chen, An Chi and Patel, Amir and Bethge, Matthias and Mathis, Mackenzie W},
- journal = {Nature Protocols},
- year = {2019},
- url = {https://doi.org/10.1038/s41596-019-0176-0}}
-
- @InProceedings{Mathis_2021_WACV,
- author = {Mathis, Alexander and Biasi, Thomas and Schneider, Steffen and Yuksekgonul, Mert and Rogers, Byron and Bethge, Matthias and Mathis, Mackenzie W.},
- title = {Pretraining Boosts Out-of-Domain Robustness for Pose Estimation},
- booktitle = {Proceedings of the IEEE/CVF Winter Conference on Applications of Computer Vision (WACV)},
- month = {January},
- year = {2021},
- pages = {1859-1868}}
-
- @article{Lauer2022MultianimalPE,
- title={Multi-animal pose estimation, identification and tracking with DeepLabCut},
- author={Jessy Lauer and Mu Zhou and Shaokai Ye and William Menegas and Steffen Schneider and Tanmay Nath and Mohammed Mostafizur Rahman and Valentina Di Santo and Daniel Soberanes and Guoping Feng and Venkatesh N. Murthy and George Lauder and Catherine Dulac and M. Mathis and Alexander Mathis},
- journal={Nature Methods},
- year={2022},
- volume={19},
- pages={496 - 504}}
-
- @article{insafutdinov2016eccv,
- title = {DeeperCut: A Deeper, Stronger, and Faster Multi-Person Pose Estimation Model},
- author = {Eldar Insafutdinov and Leonid Pishchulin and Bjoern Andres and Mykhaylo Andriluka and Bernt Schiele},
- booktitle = {ECCV'16},
- url = {http://arxiv.org/abs/1605.03170}}
-
-Review articles:
-
- @article{Mathis2020DeepLT,
- title={Deep learning tools for the measurement of animal behavior in neuroscience},
- author={Mackenzie W. Mathis and Alexander Mathis},
- journal={Current Opinion in Neurobiology},
- year={2020},
- volume={60},
- pages={1-11}}
-
- @article{Mathis2020Primer,
- title={A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives},
- author={Alexander Mathis and Steffen Schneider and Jessy Lauer and Mackenzie W. Mathis},
- journal={Neuron},
- year={2020},
- volume={108},
- pages={44-65}}
-
-Other open-access pre-prints related to our work on DeepLabCut:
-
- @article{MathisWarren2018speed,
- author = {Mathis, Alexander and Warren, Richard A.},
- title = {On the inference speed and video-compression robustness of DeepLabCut},
- year = {2018},
- doi = {10.1101/457242},
- publisher = {Cold Spring Harbor Laboratory},
- URL = {https://www.biorxiv.org/content/early/2018/10/30/457242},
- eprint = {https://www.biorxiv.org/content/early/2018/10/30/457242.full.pdf},
- journal = {bioRxiv}
- }
-
-## License:
-
-This project is licensed under the GNU Lesser General Public License v3.0. Note that the software is provided "as is", without warranty of any kind, express or implied. If you use the code or data, please cite us! Note, artwork (DeepLabCut logo) and images are copyrighted; please do not take or use these images without written permission.
-
-## Versions:
-
-VERSION 2.2: Multi-animal pose estimation and tracking with DeepLabCut.
-
-VERSION 2.0-2.1: This is the **Python package** of [DeepLabCut](https://www.nature.com/articles/s41593-018-0209-y) that was originally released with our [Nature Protocols](https://doi.org/10.1038/s41596-019-0176-0) paper (preprint [here](https://www.biorxiv.org/content/10.1101/476531v1)).
-This package includes graphical user interfaces to label your data, and take you from data set creation to automatic behavioral analysis. It also introduces an active learning framework to efficiently use DeepLabCut on large experimental projects, and data augmentation tools that improve network performance, especially in challenging cases (see [panel b](https://camo.githubusercontent.com/77c92f6b89d44ca758d815bdd7e801247437060b/68747470733a2f2f737461746963312e73717561726573706163652e636f6d2f7374617469632f3537663664353163396637343536366635356563663237312f742f3563336663316336373538643436393530636537656563372f313534373638323338333539352f636865657461682e706e673f666f726d61743d37353077)).
-
-VERSION 1.0: The initial, Nature Neuroscience version of [DeepLabCut](https://www.nature.com/articles/s41593-018-0209-y) can be found in the history of git, or here: https://github.com/DeepLabCut/DeepLabCut/releases/tag/1.11
-
-## News (and in the news):
-
-- April 2022: multi-animal identification and tracking with DeepLabCut is published in Nature Methods!
-
-- August 2021: 2.2 becomes the new stable release for DeepLabCut.
-
-- July 2021: Docs are now at https://deeplabcut.github.io/DeepLabCut and we now include TensorFlow 2 support!
-
-- May 2021: DeepLabCut hit 200,000 downloads! Also, Our preprint on 2.2, multi-animal DeepLabCut is released!
-
-- Jan 2021: [Pretraining boosts out-of-domain robustness for pose estimation](https://openaccess.thecvf.com/content/WACV2021/html/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.html) published in the IEEE Winter Conference on Applications of Computer Vision. We also added EfficientNet backbones to DeepLabCut, those are best trained with cosine decay (see paper). To use them, just pass "`efficientnet-b0`" to "`efficientnet-b6`" when creating the trainingset!
-- Dec 2020: We released a real-time package that allows for online pose estimation and real-time feedback. See [DLClive.deeplabcut.org](http://DLClive.deeplabcut.org).
-- 5/22 2020: We released 2.2beta5. This beta release has some of the features of DeepLabCut 2.2, whose major goal is to integrate multi-animal pose estimation to DeepLabCut.
-- Mar 2020: Inspired by suggestions we heard at this weeks CZI's Essential Open Source Software meeting in Berkeley, CA we updated our [docs](overview). Let us know what you think!
-- Feb 2020: Our [review on animal pose estimation is published!](https://www.sciencedirect.com/science/article/pii/S0959438819301151)
-- Nov 2019: DeepLabCut was recognized by the Chan Zuckerberg Initiative (CZI) with funding to support this project. Read more in the [Harvard Gazette](https://news.harvard.edu/gazette/story/newsplus/harvard-researchers-awarded-czi-open-source-award/), on [CZI's Essential Open Source Software for Science site](https://chanzuckerberg.com/eoss/proposals/) and in their [Medium post](https://medium.com/@cziscience/how-open-source-software-contributors-are-accelerating-biomedicine-1a5f50f6846a)
-- Oct 2019: DLC 2.1 released with lots of updates. In particular, a Project Manager GUI, MobileNetsV2, and augmentation packages (Imgaug and Tensorpack). For detailed updates see [releases](https://github.com/DeepLabCut/DeepLabCut/releases)
-- Sept 2019: We published two preprints. One showing that [ImageNet pretraining contributes to robustness](https://arxiv.org/abs/1909.11229) and a [review on animal pose estimation](https://arxiv.org/abs/1909.13868). Check them out!
-- Jun 2019: DLC 2.0.7 released with lots of updates. For updates see [releases](https://github.com/DeepLabCut/DeepLabCut/releases)
-- Feb 2019: DeepLabCut joined [twitter](https://twitter.com/deeplabcut) [](https://twitter.com/DeepLabCut)
-- Jan 2019: We hosted workshops for DLC in Warsaw, Munich and Cambridge. The materials are available [here](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials)
-- Jan 2019: We joined the Image Source Forum for user help: [](https://forum.image.sc/tag/deeplabcut)
-
-- Nov 2018: We posted a detailed guide for DeepLabCut 2.0 on [BioRxiv](https://www.biorxiv.org/content/early/2018/11/24/476531). It also contains a case study for 3D pose estimation in cheetahs.
-- Nov 2018: Various (post-hoc) analysis scripts contributed by users (and us) will be gathered at [DLCutils](https://github.com/DeepLabCut/DLCutils). Feel free to contribute! In particular, there is a script guiding you through
-importing a project into the new data format for DLC 2.0
-- Oct 2018: new pre-print on the speed video-compression and robustness of DeepLabCut on [BioRxiv](https://www.biorxiv.org/content/early/2018/10/30/457242)
-- Sept 2018: Nature Lab Animal covers DeepLabCut: [Behavior tracking cuts deep](https://www.nature.com/articles/s41684-018-0164-y)
-- Kunlin Wei & Konrad Kording write a very nice News & Views on our paper: [Behavioral Tracking Gets Real](https://www.nature.com/articles/s41593-018-0215-0)
-- August 2018: Our [preprint](https://arxiv.org/abs/1804.03142) appeared in [Nature Neuroscience](https://www.nature.com/articles/s41593-018-0209-y)
-- August 2018: NVIDIA AI Developer News: [AI Enables Markerless Animal Tracking](https://news.developer.nvidia.com/ai-enables-markerless-animal-tracking/)
-- July 2018: Ed Yong covered DeepLabCut and interviewed several users for the [Atlantic](https://www.theatlantic.com/science/archive/2018/07/deeplabcut-tracking-animal-movements/564338).
-- April 2018: first DeepLabCut preprint on [arXiv.org](https://arxiv.org/abs/1804.03142)
+---
+deeplabcut:
+ last_content_updated: '2024-06-14'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+Please see the main [READ ME!](https://deeplabcut.github.io/DeepLabCut/README.html)
diff --git a/docs/maDLC_UserGuide.md b/docs/maDLC_UserGuide.md
index 3a276d3f49..1c4dcc5731 100644
--- a/docs/maDLC_UserGuide.md
+++ b/docs/maDLC_UserGuide.md
@@ -1,12 +1,25 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(multi-animal-userguide)=
# DeepLabCut for Multi-Animal Projects
This document should serve as the user guide for maDLC,
and it is here to support the scientific advances presented in [Lauer et al. 2022](https://doi.org/10.1038/s41592-022-01443-0).
-
Note, we strongly encourage you to use the [Project Manager GUI](project-manager-gui) when you first start using multi-animal mode. Each tab is customized for multi-animal when you create or load a multi-animal project. As long as you follow the recommendations within the GUI, you should be good to go!
+````{versionadded} 3.0.0
+PyTorch is now available as a deep learning engine for pose estimation models, along
+with new model architectures! For more information about moving from TensorFlow to
+PyTorch (if you're already familiar with DeepLabCut & the TensorFlow engine),
+check out [the PyTorch user guide](dlc3-user-guide). If you're just starting
+out with DeepLabCut, we suggest you use the PyTorch backend.
+````
+
## How to think about using maDLC:
You should think of maDLC being **four** parts.
@@ -21,9 +34,9 @@ Thus, you should always label, train, and evaluate the pose estimation performan
## Install:
-**Quick start:** If you are using DeepLabCut on the cloud, or otherwise cannot use the GUIs and you should install with: `pip install 'deeplabcut[tf]'`; if you need GUI support, please use: `pip install 'deeplabcut[tf,gui]'`. On newer Apple computers (with an M1/M2 chip), use `pip install 'deeplabcut[apple_mchips]'` or `pip install 'deeplabcut[apple_mchips,gui]'` instead.
+**Quick start:** If you are using DeepLabCut on the cloud, or otherwise cannot use the GUIs and you should install with: `pip install 'deeplabcut'`; if you need GUI support, please use: `pip install 'deeplabcut[gui]'`. Check the [installation page](how-to-install) for more information, including GPU support.
-IF you want to use the bleeding edge version to make edits to the code, see here on how to install it and test it (https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html#how-to-use-the-latest-updates-directly-from-github).
+IF you want to use the bleeding edge version to make edits to the code, see [here on how to install it and test it](https://deeplabcut.github.io/DeepLabCut/docs/recipes/installTips.html#how-to-use-the-latest-updates-directly-from-github).
## Get started in the terminal or Project GUI:
@@ -35,7 +48,7 @@ Then follow the tabs! It might be useful to read the following, however, so you
```{Hint}
🚨 If you use Windows, please always open the terminal with administrator privileges! Right click, and "run as administrator".
```
- Please read more [here](https://github.com/DeepLabCut/Docker4DeepLabCut2.0), and in our Nature Protocols paper [here](https://www.nature.com/articles/s41596-019-0176-0). And, see our [troubleshooting wiki](https://github.com/DeepLabCut/DeepLabCut/wiki/Troubleshooting-Tips).
+ Please read more [here](https://deeplabcut.github.io/DeepLabCut/docs/docker.html), and in our Nature Protocols paper [here](https://www.nature.com/articles/s41596-019-0176-0). And, see our [troubleshooting wiki](https://github.com/DeepLabCut/DeepLabCut/wiki/Troubleshooting-Tips).
Open an ``ipython`` session and import the package by typing in the terminal:
```python
@@ -43,20 +56,25 @@ ipython
import deeplabcut
```
-```{TIP:}
+```{TIP}
for every function there is a associated help document that can be viewed by adding a **?** after the function name; i.e. ``deeplabcut.create_new_project?``. To exit this help screen, type ``:q``.
```
-### Create a New Project:
+### (A) Create a New Project
```python
-deeplabcut.create_new_project('ProjectName','YourName', ['/usr/FullPath/OfVideo1.avi', '/usr/FullPath/OfVideo2.avi', '/usr/FullPath/OfVideo1.avi'],
- copy_videos=True, multianimal=True)
+deeplabcut.create_new_project(
+ "ProjectName",
+ "YourName",
+ ["/usr/FullPath/OfVideo1.avi", "/usr/FullPath/OfVideo2.avi", "/usr/FullPath/OfVideo1.avi"],
+ copy_videos=True,
+ multianimal=True,
+)
```
-Tip: if you want to place the project folder somewhere specific, please also pass : ``working_directory = 'FullPathOftheworkingDirectory'``
+Tip: if you want to place the project folder somewhere specific, please also pass : ``working_directory = "FullPathOftheworkingDirectory"``
-- Note, if you are a linux/macos user the path should look like: ``['/home/username/yourFolder/video1.mp4']``; if you are a Windows user, it should look like: ``[r'C:\username\yourFolder\video1.mp4']``
+- Note, if you are a linux/macOS user the path should look like: ``["/home/username/yourFolder/video1.mp4"]``; if you are a Windows user, it should look like: ``[r"C:\username\yourFolder\video1.mp4"]``
- Note, you can also put ``config_path = `` in front of the above line to create the path to the config.yaml that is used in the next step, i.e. ``config_path=deeplabcut.create_project(...)``)
- If you do not, we recommend setting a variable so this can be easily used! Once you run this step, the config_path is printed for you once you run this line, so set a variable for ease of use, i.e. something like:
```python
@@ -64,9 +82,20 @@ config_path = '/thefulloutputpath/config.yaml'
```
- just be mindful of the formatting for Windows vs. Unix, see above.
-This set of arguments will create a project directory with the name **Name of the project+name of the experimenter+date of creation of the project** in the **Working directory** and creates the symbolic links to videos in the **videos** directory. The project directory will have subdirectories: **dlc-models**, **labeled-data**, **training-datasets**, and **videos**. All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be curated in separation from other projects. The purpose of the subdirectories is as follows:
-
-**dlc-models:** This directory contains the subdirectories *test* and *train*, each of which holds the meta information with regard to the parameters of the feature detectors in configuration files. The configuration files are YAML files, a common human-readable data serialization language. These files can be opened and edited with standard text editors. The subdirectory *train* will store checkpoints (called snapshots in TensorFlow) during training of the model. These snapshots allow the user to reload the trained model without re-training it, or to pick-up training from a particular saved checkpoint, in case the training was interrupted.
+This set of arguments will create a project directory with the name **Name of the project+name of the experimenter+date of creation of the project** in the **Working directory** and creates the symbolic links to videos in the **videos** directory. The project directory will have subdirectories: **dlc-models**, **dlc-models-pytorch**, **labeled-data**, **training-datasets**, and **videos**. All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be curated in separation from other projects. The purpose of the subdirectories is as follows:
+
+**dlc-models** and **dlc-models-pytorch** have a similar structure: the first contains
+files for the TensorFlow engine while the second contains files for the PyTorch engine.
+At the top level in these directories, there are
+directories referring to different iterations of labels refinement (see below): **iteration-0**, **iteration-1**, etc.
+The refinement iterations directories store shuffle directories, each shuffle directory stores model data related to a
+particular experiment: trained and tested on a particular training and testing sets, and with a particular model
+architecture. Each shuffle directory contains the subdirectories *test* and *train*, each of which holds the meta
+information with regard to the parameters of the feature detectors in configuration files. The configuration files are
+YAML files, a common human-readable data serialization language. These files can be opened and edited with standard text
+editors. The subdirectory *train* will store checkpoints (called snapshots) during training of the model. These
+snapshots allow the user to reload the trained model without re-training it, or to pick-up training from a particular
+saved checkpoint, in case the training was interrupted.
**labeled-data:** This directory will store the frames used to create the training dataset. Frames from different videos are stored in separate subdirectories. Each frame has a filename related to the temporal index within the corresponding video, which allows the user to trace every frame back to its origin.
@@ -75,33 +104,55 @@ This set of arguments will create a project directory with the name **Name of th
**videos:** Directory of video links or videos. When **copy\_videos** is set to ``False``, this directory contains symbolic links to the videos. If it is set to ``True`` then the videos will be copied to this directory. The default is ``False``. Additionally, if the user wants to add new videos to the project at any stage, the function **add\_new\_videos** can be used. This will update the list of videos in the project's configuration file. Note: you neither need to use this folder for videos, nor is it required for analyzing videos (they can be anywhere).
```python
-deeplabcut.add_new_videos('Full path of the project configuration file*', ['full path of video 4', 'full path of video 5'], copy_videos=True/False)
+deeplabcut.add_new_videos(
+ "Full path of the project configuration file*",
+ ["full path of video 4", "full path of video 5"],
+ copy_videos=True/False,
+)
```
*Please note, *Full path of the project configuration file* will be referenced as ``config_path`` throughout this protocol.
-You can also use annotated data from singe-animal projects, by converting those files. There are docs for this: [convert single to multianimal annotation data](convert-maDLC)
+You can also use annotated data from single-animal projects, by converting those files.
+There are docs for this: [convert single to multianimal annotation data](convert-maDLC)
+
+
+
+### API Docs
+````{admonition} Click the button to see API Docs
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_new_project.rst
+```
+````
-### Configure the Project:
+### (B) Configure the Project
-- open the **config.yaml** file (in a text editor (like atom, gedit, vim etc.)), which can be found in the subfolder created when you set your project name, to change parameters and identify label names! This is a crucial step.
+Next, open the **config.yaml** file, which was created during **create\_new\_project**.
+You can edit this file in any text editor. Familiarize yourself with the meaning of the
+parameters (Box 1). You can edit various parameters, in particular you **must add the list of *individuals* and *bodyparts* (or points of interest)**.
-Next, open the **config.yaml** file, which was created during **create\_new\_project**. You can edit this file in any text editor. Familiarize yourself with the meaning of the parameters (Box 1). You can edit various parameters, in particular you **must add the list of *bodyparts* (or points of interest)** that you want to track. You can also set the *colormap* here that is used for all downstream steps (can also be edited at anytime), like labeling GUIs, videos, etc. Here any [matplotlib colormaps](https://matplotlib.org/tutorials/colors/colormaps.html) will do!
+You can also set the *colormap* here that is used for all downstream steps (can also be edited at anytime), like labeling GUIs, videos, etc. Here any [matplotlib colormaps](https://matplotlib.org/tutorials/colors/colormaps.html) will do!
An easy way to programmatically edit the config file at any time is to use the function **edit\_config**, which takes the full path of the config file to edit and a dictionary of key–value pairs to overwrite.
-````python
-edits = {'colormap': 'summer',
- 'individuals': ['mickey', 'minnie', 'bianca'],
- 'skeleton': [['snout', 'tailbase'], ['snout', 'rightear']]}
+```python
+import deeplabcut
+
+config_path = "/path/to/project-dlc-2025-01-01/config.yaml"
+edits = {
+ "colormap": "summer",
+ "individuals": ["mickey", "minnie", "bianca"],
+ "skeleton": [["snout", "tailbase"], ["snout", "rightear"]]
+}
deeplabcut.auxiliaryfunctions.edit_config(config_path, edits)
-````
+```
Please DO NOT have spaces in the names of bodyparts, uniquebodyparts, individuals, etc.
-**ATTENTIONt:** You need to edit the config.yaml file to **modify the following items** which specify the animal ID, body parts, and any unique labels. Note, we also highly recommend that you use **more bodypoints** that you might be interested in for your experiment, i.e., labeling along the spine/tail for 8 bodypoints would be better than four. This will help the performance.
+**ATTENTION:** You need to edit the config.yaml file to **modify the following items** which specify the animal ID, bodyparts, and any unique labels. Note, we also highly recommend that you use **more bodyparts** that you might be interested in for your experiment, i.e., labeling along the spine/tail for 8 bodyparts would be better than four. This will help the performance.
Modifying the `config.yaml` is crucial:
@@ -123,6 +174,7 @@ multianimalbodyparts:
identity: True/False
```
+
**Individuals:** are names of "individuals" in the annotation dataset. These should/can be generic (e.g. mouse1, mouse2, etc.). These individuals are comprised of the same bodyparts defined by `multianimalbodyparts`. For annotation in the GUI and training, it is important that all individuals in each frame are labeled. Thus, keep in mind that you need to set individuals to the maximum number in your labeled-data set, .i.e., if there is (even just one frame) with 17 animals then the list should be `- indv1` to `- indv17`. Note, once trained if you have a video with more or less animals, that is fine - you can have more or less animals during video analysis!
**Identity:** If you can tell the animals apart, i.e., one might have a collar, or a black marker on the tail of a mouse, then you should label these individuals consistently (i.e., always label the mouse with the black marker as "indv1", etc). If you have this scenario, please set `identity: True` in your `config.yaml` file. If you have 4 black mice, and you truly cannot tell them apart, then leave this as `false`.
@@ -131,14 +183,22 @@ identity: True/False
**Uniquebodyparts:** are points that you want to track, but that appear only once within each frame, i.e. they are "unique". Typically these are things like unique objects, landmarks, tools, etc. They can also be animals, e.g. in the case where one German shepherd is attending to many sheep the sheep bodyparts would be multianimalbodyparts, the shepherd parts would be uniquebodyparts and the individuals would be the list of sheep (e.g. Polly, Molly, Dolly, ...).
-### Select Frames to Label:
+### (C) Select Frames to Label
**CRITICAL:** A good training dataset should consist of a sufficient number of frames that capture the breadth of the behavior. This ideally implies to select the frames from different (behavioral) sessions, different lighting and different animals, if those vary substantially (to train an invariant, robust feature detector). Thus for creating a robust network that you can reuse in the laboratory, a good training dataset should reflect the diversity of the behavior with respect to postures, luminance conditions, background conditions, animal identities, etc. of the data that will be analyzed. For the simple lab behaviors comprising mouse reaching, open-field behavior and fly behavior, 100−200 frames gave good results [Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y). However, depending on the required accuracy, the nature of behavior, the video quality (e.g. motion blur, bad lighting) and the context, more or less frames might be necessary to create a good network. Ultimately, in order to scale up the analysis to large collections of videos with perhaps unexpected conditions, one can also refine the data set in an adaptive way (see refinement below). **For maDLC, be sure you have labeled frames with closely interacting animals!**
The function `extract_frames` extracts frames from all the videos in the project configuration file in order to create a training dataset. The extracted frames from all the videos are stored in a separate subdirectory named after the video file’s name under the ‘labeled-data’. This function also has various parameters that might be useful based on the user’s need.
+
```python
-deeplabcut.extract_frames(config_path, mode='automatic/manual', algo='uniform/kmeans', userfeedback=False, crop=True/False)
+deeplabcut.extract_frames(
+ config_path,
+ mode='automatic/manual',
+ algo='uniform/kmeans',
+ userfeedback=False,
+ crop=True/False,
+)
```
+
**CRITICAL POINT:** It is advisable to keep the frame size small, as large frames increase the training and
inference time, or you might not have a large enough GPU for this.
When running the function `extract_frames`, if the parameter crop=True, then you will be asked to draw a box within the GUI (and this is written to the config.yaml file).
@@ -160,63 +220,99 @@ behaviors, and not extract the frames across the whole video. This can be achiev
parameters in the config.yaml file. Also, the user can change the number of frames to extract from each video using
the numframes2extract in the config.yaml file.
- **For maDLC, be sure you have labeled frames with closely interacting animals!** Therefore, manually selecting some frames is a good idea if interactions are not highly frequent in the video.
+```{TIP}
+For maDLC, **be sure you have labeled frames with closely interacting animals**!
+Therefore, manually selecting some frames is a good idea if interactions are not highly
+frequent in the video.
+```
-However, picking frames is highly dependent on the data and the behavior being studied. Therefore, it is hard to
-provide all purpose code that extracts frames to create a good training dataset for every behavior and animal. If the user feels specific frames are lacking, they can extract hand selected frames of interest using the interactive GUI
+However, picking frames is highly dependent on the data and the behavior being studied.
+Therefore, it is hard to provide all purpose code that extracts frames to create a good
+training dataset for every behavior and animal. If the user feels specific frames are
+lacking, they can extract hand selected frames of interest using the interactive GUI
provided along with the toolbox. This can be launched by using:
+
```python
deeplabcut.extract_frames(config_path, 'manual')
```
-The user can use the *Load Video* button to load one of the videos in the project configuration file, use the scroll
-bar to navigate across the video and *Grab a Frame* (or a range of frames, as of version 2.0.5) to extract the frame(s). The user can also look at the extracted frames and e.g. delete frames (from the directory) that are too similar before reloading the set and then manually annotating them.
+// FIXME(niels) - add a napari frame extractor description.
+The user can use the *Load Video* button to load one of the videos in the project
+configuration file, use the scroll bar to navigate across the video and *Grab a Frame*.
+The user can also look at the extracted frames and e.g. delete frames (from the
+directory) that are too similar before reloading the set and then manually annotating
+them.
+
+````{admonition} Click the button to see API Docs
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.extract_frames.rst
+```
+````
-### Label Frames:
+### (D) Label Frames
```python
deeplabcut.label_frames(config_path)
```
-As of 2.2 there is a new multi-animal labeling GUI (as long as in your `config.yaml` says `multianimalproject: true` at the top, this will automatically launch).
+The toolbox provides a function **label_frames** which helps the user to easily label
+all the extracted frames using an interactive graphical user interface (GUI). The user
+should have already named the bodyparts to label (points of interest) in the
+project’s configuration file by providing a list. The following command invokes the
+napari-deeplabcut labelling GUI.
-The toolbox provides a function **label_frames** which helps the user to easily label all the extracted frames using
-an interactive graphical user interface (GUI). The user should have already named the body parts to label (points of
-interest) in the project’s configuration file by providing a list. The following command invokes the labeling toolbox.
+[🎥 DEMO](https://youtu.be/hsA9IB5r73E)
-The user needs to use the *Load Frames* button to select the directory which stores the extracted frames from one of
-the videos. Subsequently, the user can use one of the radio buttons (top right) to select a body part to label. **RIGHT** click to add the label. Left click to drag the label, if needed. If you label a part accidentally, you can use the middle button on your mouse to delete (or hit the delete key while you hover over the point)! If you cannot see a body part in the frame, skip over the label! Please see the ``HELP`` button for more user instructions. This auto-advances once you labeled the first body part. You can also advance to the next frame by clicking on the RIGHT arrow on your keyboard (and go to a previous frame with LEFT arrow).
-Each label will be plotted as a dot in a unique color.
+HOT KEYS IN THE Labeling GUI (also see "help" in GUI):
-The user is free to move around the body part and once satisfied with its position, can select another radio button
-(in the top right) to switch to the respective body part (it otherwise auto-advances). The user can skip a body part if it is not visible. Once all the visible body parts are labeled, then the user can use ‘Next Frame’ to load the following frame. The user needs to save the labels after all the frames from one of the videos are labeled by clicking the save button at the bottom right. Saving the labels will create a labeled dataset for each video in a hierarchical data file format (HDF) in the
-subdirectory corresponding to the particular video in **labeled-data**. You can save at any intermediate step (even without closing the GUI, just hit save) and you return to labeling a dataset by reloading it!
+```
+Ctrl + C: Copy labels from previous frame.
+Keyboard arrows: advance frames.
+Delete key: delete label.
+```
-**CRITICAL POINT:** It is advisable to **consistently label similar spots** (e.g., on a wrist that is very large, try
-to label the same location). In general, invisible or occluded points should not be labeled by the user, unless you want to teach the network to "guess" - this is possible, but could affect accuracy. If you don't want/or don't see a bodypart, they can simply be skipped by not applying the label anywhere on the frame.
+
-OPTIONAL: In the event of adding more labels to the existing labeled dataset, the user need to append the new
-labels to the bodyparts in the config.yaml file. Thereafter, the user can call the function **label_frames**. A box will pop up and ask the user if they wish to display all parts, or only add in the new labels. Saving the labels after all the images are labelled will append the new labels to the existing labeled dataset.
+**CRITICAL POINT:** It is advisable to **consistently label similar spots** (e.g., on a
+wrist that is very large, try to label the same location). In general, invisible or
+occluded points should not be labeled by the user, unless you want to teach the network
+to "guess" - this is possible, but could affect accuracy. If you don't want/or don't see
+a bodypart, they can simply be skipped by not applying the label anywhere on the frame.
-**maDeepLabCut CRITICAL POINT:** For multi-animal labeling, unless you can tell apart the animals, you do not need to worry about the "ID" of each animal. For example: if you have a white and black mouse label the white mouse as animal 1, and black as animal 2 across all frames. If two black mice, then the ID label 1 or 2 can switch between frames - no need for you to try to identify them (but always label consistently within a frame). If you have 2 black mice but one always has an optical fiber (for example), then DO label them consistently as animal1 and animal_fiber (for example). The point of multi-animal DLC is to train models that can first group the correct bodyparts to individuals, then associate those points in a given video to a specific individual, which then also uses temporal information to link across the video frames.
+OPTIONAL: In the event of adding more labels to the existing labeled dataset, the user
+needs to append the new labels to the bodyparts in the config.yaml file. Thereafter, the
+user can call the function **label_frames**. A box will pop up and ask the user if they
+wish to display all parts, or only add in the new labels. Saving the labels after all
+the images are labelled will append the new labels to the existing labeled dataset.
-Note, we also highly recommend that you use more bodypoints that you might otherwise have (see the example below).
+**maDeepLabCut CRITICAL POINT:** For multi-animal labeling, unless you can tell apart
+the animals, you do not need to worry about the "ID" of each animal. For example: if you
+have a white and black mouse label the white mouse as animal 1, and black as animal 2
+across all frames. If two black mice, then the ID label 1 or 2 can switch between
+frames - no need for you to try to identify them (but always label consistently within a
+frame). If you have 2 black mice but one always has an optical fiber (for example), then
+DO label them consistently as animal1 and animal_fiber (for example). The point of
+multi-animal DLC is to train models that can first group the correct bodyparts to
+individuals, then associate those points in a given video to a specific individual,
+which then also uses temporal information to link across the video frames.
-**Example Labeling with maDeepLabCut:**
-- note you should within an animal be consistent, i.e., all bodyparts on mouse1 should be on mouse1, but across frames "mouse1" can be any of the black mice (as here it is nearly impossible to tell them apart visually). IF you can tell them apart, do label consistently!
+Note, we also highly recommend that you use more bodyparts that you might otherwise have
+(see the example below).
-
-
-
+For more information, checkout the [napari-deeplabcut docs](file:napari-gui-landing) for
+more information about the labelling workflow.
-### Check Annotated Frames:
+### (E) Check Annotated Frames
Checking if the labels were created and stored correctly is beneficial for training, since labeling
is one of the most critical parts for creating the training dataset. The DeepLabCut toolbox provides a function
-‘check_labels’ to do so. It is used as follows:
+`check_labels` to do so. It is used as follows:
+
```python
deeplabcut.check_labels(config_path, visualizeindividuals=True/False)
```
+
**maDeepLabCut:** you can check and plot colors per individual or per body part, just set the flag `visualizeindividuals=True/False`. Note, you can run this twice in both states to see both images.
@@ -225,13 +321,33 @@ deeplabcut.check_labels(config_path, visualizeindividuals=True/False)
For each video directory in labeled-data this function creates a subdirectory with **labeled** as a suffix. Those directories contain the frames plotted with the annotated body parts. The user can double check if the body parts are labeled correctly. If they are not correct, the user can reload the frames (i.e. `deeplabcut.label_frames`), move them around, and click save again.
+````{admonition} Click the button to see API Docs
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.check_labels.rst
+```
+````
+
+### (F) Create Training Dataset
+
+At this point, you'll need to select your neural network type.
+
+For the **PyTorch engine**, please see [the PyTorch Model Architectures](
+dlc3-architectures) for options.
-### Create Training Dataset:
+For the **TensorFlow engine**, please see Lauer et al. 2021 for options. Multi-animal
+models will use `imgaug`, ADAM optimization, our new DLCRNet, and batch training. We
+suggest keeping these defaults at this time. At this step, the ImageNet pre-trained
+networks (i.e. ResNet-50) weights will be downloaded. If they do not download (you will
+see this downloading in the terminal, then you may not have permission to do so (
+something we have seen with some Windows users - see the **[
+WIKI troubleshooting for more help!](
+https://github.com/DeepLabCut/DeepLabCut/wiki/Troubleshooting-Tips)**).
-At this point you also select your neural network type. Please see Lauer et al. 2021 for options. For **create_multianimaltraining_dataset** we already changed this such that by default you will use imgaug, ADAM optimization, our new DLCRNet, and batch training. We suggest these defaults at this time. Then run:
+Then run:
```python
-deeplabcut.create_multianimaltraining_dataset(config_path)
+deeplabcut.create_training_dataset(config_path)
```
- The set of arguments in the function will shuffle the combined labeled dataset and split it to create train and test
@@ -242,47 +358,33 @@ keeps track of how often the dataset was refined).
- OPTIONAL: If the user wishes to benchmark the performance of the DeepLabCut, they can create multiple
training datasets by specifying an integer value to the `num_shuffles`; see the docstring for more details.
-- Each iteration of the creation of a training dataset will create several files, which is used by the feature detectors,
-and a ``.pickle`` file that contains the meta information about the training dataset. This also creates two subdirectories
-within **dlc-models** called ``test`` and ``train``, and these each have a configuration file called pose_cfg.yaml.
-Specifically, the user can edit the **pose_cfg.yaml** within the **train** subdirectory before starting the training. These
-configuration files contain meta information with regard to the parameters of the feature detectors. Key parameters
-are listed in Box 2.
-
-- At this step, the ImageNet pre-trained networks (i.e. ResNet-50) weights will be downloaded. If they do not download (you will see this downloading in the terminal, then you may not have permission to do so (something we have seen with some Windows users - see the **[WIKI troubleshooting for more help!](https://github.com/DeepLabCut/DeepLabCut/wiki/Troubleshooting-Tips)**).
-
-**OPTIONAL POINTS:**
-
-With the data-driven skeleton selection introduced in 2.2rc1+, DLC networks are trained by default
-on complete skeletons (i.e., they learn all possible redundant connections), before being optimally pruned
-at model evaluation. Although this procedure is by far superior to manually defining a graph,
-we leave manually-defining a skeleton as an option for the advanced user:
-
-```python
-my_better_graph = [[0, 1], [1, 2], [2, 3]] # These are indices in the list of multianimalbodyparts
-deeplabcut.create_multianimaltraining_dataset(config_path, paf_graph=my_better_graph)
-```
-
-Alternatively, the `skeleton` defined in the `config.yaml` file can also be used:
-
-```python
-deeplabcut.create_multianimaltraining_dataset(config_path, paf_graph='config')
-```
-
-Importantly, if a user-defined graph is used it still is required to cover all multianimalbodyparts at least once.
-
-**DATA AUGMENTATION:** At this stage you can also decide what type of augmentation to use. The default loaders work well for most all tasks (as shown on www.deeplabcut.org), but there are many options, more data augmentation, intermediate supervision, etc. Please look at the [**pose_cfg.yaml**](https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml) file for a full list of parameters **you might want to change before running this step.** There are several data loaders that can be used. For example, you can use the default loader (introduced and described in the Nature Protocols paper), [TensorPack](https://github.com/tensorpack/tensorpack) for data augmentation (currently this is easiest on Linux only), or [imgaug](https://imgaug.readthedocs.io/en/latest/). We recommend `imgaug` (which is default now!). You can set this by passing:``` deeplabcut.create_training_dataset(config_path, augmenter_type='imgaug') ```
-
-The differences of the loaders are as follows:
-- `default`: our standard DLC 2.0 introduced in Nature Protocols variant (scaling, auto-crop augmentation) *will be renamed to `crop_scale` in a future release!*
-- `imgaug`: a lot of augmentation possibilities, efficient code for target map creation & batch sizes >1 supported. You can set the parameters such as the `batch_size` in the `pose_cfg.yaml` file for the model you are training.
-- `tensorpack`: a lot of augmentation possibilities, multi CPU support for fast processing, target maps are created less efficiently than in imgaug, does not allow batch size>1
-- `deterministic`: only useful for testing, freezes numpy seed; otherwise like default.
-
-Our recent [A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives](https://www.cell.com/neuron/pdf/S0896-6273(20)30717-0.pdf), details the advantage of augmentation for a worked example (see Fig 7). TL;DR: use imgaug and use the symmetries of your data!
-
-
-Alternatively, you can set the loader (as well as other training parameters) in the **pose_cfg.yaml** file of the model that you want to train. Note, to get details on the options, look at the default file: [**pose_cfg.yaml**](https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml).
+- Each iteration of the creation of a training dataset will create several files, which
+is used by the feature detectors, and a ``.pickle`` file that contains the meta
+information about the training dataset. This also creates two subdirectories within
+**dlc-models-pytorch** (**dlc-models** for the TensorFlow engine) called ``test`` and
+``train``, and these each have a configuration file called pose_cfg.yaml. Specifically,
+the user can edit the **pytorch_config.yaml** (**pose_cfg.yaml** for TensorFlow engine)
+within the **train** subdirectory before starting the training. These configuration
+files contain meta information with regard to the parameters of the feature detectors.
+Key parameters are listed in Box 2.
+
+**DATA AUGMENTATION:** At this stage you can also decide what type of augmentation to
+use. Once you've called `create_training_dataset`, you can edit the
+[**pytorch_config.yaml**](dlc3-pytorch-config) file that was created (or for the
+TensorFlow engine, the [**pose_cfg.yaml**](
+https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml) file).
+
+- PyTorch Engine: [Albumentations](https://albumentations.ai/docs/) is used for data
+augmentation. Look at the [**pytorch_config.yaml**](dlc3-pytorch-config) for more
+information about image augmentation options.
+- TensorFlow Engine: The default augmentation works well for most tasks (as shown on
+www.deeplabcut.org), but there are many options, more data augmentation, intermediate
+supervision, etc. Only `imgaug` augmentation is available for multi-animal projects.
+
+[A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives](
+https://www.cell.com/neuron/pdf/S0896-6273(20)30717-0.pdf), details the advantage of
+augmentation for a worked example (see Fig 8). TL;DR: use imgaug and use the symmetries
+of your data!
Importantly, image cropping as previously done with `deeplabcut.cropimagesandlabels` in multi-animal projects
is now part of the augmentation pipeline. In other words, image crops are no longer stored in labeled-data/..._cropped
@@ -292,80 +394,220 @@ In addition, one can specify a crop sampling strategy: crop centers can either b
As a reminder, cropping images into smaller patches is a form of data augmentation that simultaneously
allows the use of batch processing even on small GPUs that could not otherwise accommodate larger images + larger batchsizes (this usually increases performance and decreasing training time).
+**MODEL COMPARISON**: You can also test several models by creating the same train/test
+split for different networks.
+You can easily do this in the Project Manager GUI (by selecting the "Use an existing
+data split" option), which also lets you compare PyTorch and TensorFlow models.
-### Train The Network:
-
-```python
-deeplabcut.train_network(config_path, allow_growth=True)
-```
+````{versionadded} 3.0.0
+You can now create new shuffles using the same train/test split as
+existing shuffles with `create_training_dataset_from_existing_split`. This allows you to
+compare model performance (between different architectures or when using different
+training hyper-parameters) as the shuffles were trained on the same data, and evaluated
+on the same test data!
-The set of arguments in the function starts training the network for the dataset created for one specific shuffle. Note that you can change the loader (imgaug/default/etc) as well as other training parameters in the **pose_cfg.yaml** file of the model that you want to train (before you start training).
+Example usage - creating 3 new shuffles (with indices 10, 11 and 12) for a ResNet 50
+pose estimation model, using the same data split as was used for shuffle 0:
-Example parameters that one can call:
```python
-deeplabcut.train_network(config_path, shuffle=1, trainingsetindex=0, gputouse=None, max_snapshots_to_keep=5, autotune=False, displayiters=100, saveiters=15000, maxiters=30000, allow_growth=True)
+deeplabcut.create_training_dataset_from_existing_split(
+ config_path,
+ from_shuffle=0,
+ shuffles=[10, 11, 12],
+ net_type="resnet_50",
+)
```
+````
-By default, the pretrained networks are not in the DeepLabCut toolbox (as they are around 100MB each), but they get downloaded before you train. However, if not previously downloaded from the TensorFlow model weights, it will be downloaded and stored in a subdirectory *pre-trained* under the subdirectory *models* in *Pose_Estimation_Tensorflow*.
-At user specified iterations during training checkpoints are stored in the subdirectory *train* under the respective iteration directory.
-
-If the user wishes to restart the training at a specific checkpoint they can specify the full path of the checkpoint to
-the variable ``init_weights`` in the **pose_cfg.yaml** file under the *train* subdirectory (see Box 2).
-
-**CRITICAL POINT:** It is recommended to train the networks for thousands of iterations until the loss plateaus (typically around **500,000**) if you use batch size 1, and **50-100K** if you use batchsize 8 (the default).
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_training_dataset.rst
+```
+````
-If you use **maDeepLabCut** the recommended training iterations is **20K-100K** (it automatically stops at 200K!), as we use Adam and batchsize 8; if you have to reduce the batchsize for memory reasons then the number of iterations needs to be increased.
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_model_comparison
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_training_model_comparison.rst
+```
+````
-The variables ``display_iters`` and ``save_iters`` in the **pose_cfg.yaml** file allows the user to alter how often the loss is displayed and how often the weights are stored.
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset_from_existing_split
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_training_dataset_from_existing_split.rst
+```
+````
-**maDeepLabCut CRITICAL POINT:** For multi-animal projects we are using not only different and new output layers, but also new data augmentation, optimization, learning rates, and batch training defaults. Thus, please use a lower ``save_iters`` and ``maxiters``. I.e. we suggest saving every 10K-15K iterations, and only training until 50K-100K iterations. We recommend you look closely at the loss to not overfit on your data. The bonus, training time is much less!!!
+### (G) Train The Network
-**Parameters:**
+```python
+deeplabcut.train_network(config_path, shuffle=1)
```
-config : string
- Full path of the config.yaml file as a string.
-shuffle: int, optional
- Integer value specifying the shuffle index to select for training. Default is set to 1
+The set of arguments in the function starts training the network for the dataset created
+for one specific shuffle. Note that you can change training parameters in the
+[**pytorch_config.yaml**](dlc3-pytorch-config) file (or **pose_cfg.yaml** for TensorFlow
+models) of the model that you want to train (before you start training).
+
+At user specified iterations during training checkpoints are stored in the subdirectory
+*train* under the respective iteration & shuffle directory.
-trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+````{admonition} Tips on training models with the PyTorch Engine
+:class: dropdown
-gputouse: int, optional. Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU, put None.
-See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+Example parameters that one can call:
-max_snapshots_to_keep: int, or None. Sets how many snapshots are kept, i.e. states of the trained network. For every saving iteration a snapshot is stored, however, only the last max_snapshots_to_keep many are kept! If you change this to None, then all are kept.
-See: https://github.com/DeepLabCut/DeepLabCut/issues/8#issuecomment-387404835
+```python
+deeplabcut.train_network(
+ config_path,
+ shuffle=1,
+ trainingsetindex=0,
+ device="cuda:0",
+ max_snapshots_to_keep=5,
+ displayiters=100,
+ save_epochs=5,
+ epochs=200,
+)
+```
+
+Pytorch models in DeepLabCut 3.0 are trained for a set number of epochs, instead of a
+maximum number of iterations (which is what was used for TensorFlow models). An epoch
+is a single pass through the training dataset, which means your model has seen each
+training image exactly once. So if you have 64 training images for your network, an
+epoch is 64 iterations with batch size 1 (or 32 iterations with batch size 2, 16 with
+batch size 4, etc.).
+
+By default, the pretrained networks are not in the DeepLabCut toolbox (as they can be
+more than 100MB), but they get downloaded automatically before you train.
+
+If the user wishes to restart the training at a specific checkpoint they can specify the
+full path of the checkpoint to the variable ``resume_training_from`` in the [
+**pytorch_config.yaml**](
+dlc3-pytorch-config) file (checkout the "Restarting Training at a Specific Checkpoint"
+section of the docs) under the *train* subdirectory.
+
+**CRITICAL POINT:** It is recommended to train the networks **until the loss plateaus**
+(depending on the dataset, model architecture and training hyper-parameters this happens
+after 100 to 250 epochs of training).
+
+The variables ``display_iters`` and ``save_epochs`` in the [**pytorch_config.yaml**](
+dlc3-pytorch-config) file allows the user to alter how often the loss is displayed
+and how often the weights are stored. We suggest saving every 5 to 25 epochs.
+````
-autotune: property of TensorFlow, somehow faster if 'false' (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). Default: False
+````{admonition} Tips on training models with the TensorFlow Engine
+:class: dropdown
-displayiters: this variable is actually set in pose_config.yaml. However, you can overwrite it with this hack. Don't use this regularly, just if you are too lazy to dig out
-the pose_config.yaml file for the corresponding project. If None, the value from there is used, otherwise it is overwritten! Default: None
+Example parameters that one can call:
-saveiters: this variable is actually set in pose_config.yaml. However, you can overwrite it with this hack. Don't use this regularly, just if you are too lazy to dig out
-the pose_config.yaml file for the corresponding project. If None, the value from there is used, otherwise it is overwritten! Default: None
+```python
+deeplabcut.train_network(
+ config_path,
+ shuffle=1,
+ trainingsetindex=0,
+ gputouse=None,
+ max_snapshots_to_keep=5,
+ autotune=False,
+ displayiters=100,
+ saveiters=15000,
+ maxiters=30000,
+ allow_growth=True,
+)
+```
+
+By default, the pretrained networks are not in the DeepLabCut toolbox (as they are
+around 100MB each), but they get downloaded before you train. However, if not previously
+downloaded from the TensorFlow model weights, it will be downloaded and stored in a
+subdirectory *pre-trained* under the subdirectory *models* in
+*Pose_Estimation_Tensorflow*. At user specified iterations during training checkpoints
+are stored in the subdirectory *train* under the respective iteration directory.
+
+If the user wishes to restart the training at a specific checkpoint they can specify the
+full path of the checkpoint to the variable ``init_weights`` in the **pose_cfg.yaml**
+file under the *train* subdirectory (see Box 2).
+
+**CRITICAL POINT:** It is recommended to train the networks for thousands of iterations
+until the loss plateaus (typically around **500,000**) if you use batch size 1, and
+**50-100K** if you use batchsize 8 (the default).
+
+If you use **maDeepLabCut** the recommended training iterations is **20K-100K**
+(it automatically stops at 200K!), as we use Adam and batchsize 8; if you have to reduce
+ the batchsize for memory reasons then the number of iterations needs to be increased.
+
+The variables ``display_iters`` and ``save_iters`` in the **pose_cfg.yaml** file allows
+the user to alter how often the loss is displayed and how often the weights are stored.
+
+**maDeepLabCut CRITICAL POINT:** For multi-animal projects we are using not only
+different and new output layers, but also new data augmentation, optimization, learning
+rates, and batch training defaults. Thus, please use a lower ``save_iters`` and
+``maxiters``. I.e. we suggest saving every 10K-15K iterations, and only training until
+50K-100K iterations. We recommend you look closely at the loss to not overfit on your
+data. The bonus, training time is much less!!!
+````
-maxiters: This sets how many iterations to train. This variable is set in pose_config.yaml. However, you can overwrite it with this. If None, the value from there is used, otherwise it is overwritten! Default: None
+````{admonition} Click the button to see API Docs for train_network
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.train_network.rst
```
+````
-### Evaluate the Trained Network:
+### (H) Evaluate the Trained Network
+
+It is important to evaluate the performance of the trained network. This performance is
+measured by computing two metrics:
+
+- **Average root mean square error** (RMSE) between the manual labels and the ones
+predicted by your trained DeepLabCut model. The RMSE is proportional to the mean average
+Euclidean error (MAE) between the manual labels and the ones predicted by DeepLabCut.
+The MAE is displayed for all pairs and only likely pairs (>p-cutoff). This helps to
+exclude, for example, occluded body parts. One of the strengths of DeepLabCut is that
+due to the probabilistic output of the scoremap, it can, if sufficiently trained, also
+reliably report if a body part is visible in a given frame. (see discussions of finger
+tips in reaching and the Drosophila legs during 3D behavior in [Mathis et al, 2018]).
+- **Mean Average Precision** (mAP) and **Mean Average Recall** (mAR) for the individuals
+predicted by your trained DeepLabCut model. This metric describes the precision of your
+model, based on a considered definition of what a correct detection of an individual is.
+It isn't as useful for single-animal models, as RMSE does a great job of evaluating your
+model in that case.
+
+```{admonition} A more detailed description of mAP and mAR
+:class: dropdown
+
+For multi-animal pose estimation, multiple predictions can be made for each image.
+We want to get some idea of the proportion of correct predictions among all predictions
+that are made.
+However, the notion of "correct prediction" for pose estimation is not straightforward:
+is a prediction correct if all predicted keypoints are within 5 pixels of the ground
+truth? Within 2 pixels of the ground truth? What if all pixels but one match the ground
+truth perfectly, but the wrong prediction is 50 pixels away? Mean average precision (
+and mean average recall) estimate the precision/recall of your models by setting
+different "thresholds of correctness" and averaging results. How "correct" a
+prediction is can be evaluated through [object-keypoint similarity](
+https://cocodataset.org/#keypoints-eval).
+
+A good resource to get a deeper understanding of mAP is the [Stanford CS230 course](
+https://cs230.stanford.edu/section/8/#object-detection-iou-ap-and-map). While it
+describes mAP for object detection (where bounding boxes are predicted instead of
+keypoints), the same metric can be computed for pose estimation, where similarity
+between predictions and ground truth is computed through [object-keypoint similarity](
+https://cocodataset.org/#keypoints-eval) instead of intersection-over-union (IoU).
+```
+
+It's also important to visually inspect predictions on individual frames to assess the
+performance of your model. You can do this by setting `plotting=True` when you call
+`evaluate_network`. The evaluation results are computed by typing:
-Here, for traditional projects you will get a pixel distance metric and you should inspect the individual frames:
```python
-deeplabcut.evaluate_network(config_path, plotting=True)
+deeplabcut.evaluate_network(config_path, Shuffles=[1], plotting=True)
```
-:movie_camera:[VIDEO TUTORIAL AVAILABLE!](https://www.youtube.com/watch?v=bgfnz1wtlpo)
-It is important to evaluate the performance of the trained network. This performance is measured by computing
-the mean average Euclidean error (MAE; which is proportional to the average root mean square error) between the
-manual labels and the ones predicted by DeepLabCut. The MAE is saved as a comma separated file and displayed
-for all pairs and only likely pairs (>p-cutoff). This helps to exclude, for example, occluded body parts. One of the
-strengths of DeepLabCut is that due to the probabilistic output of the scoremap, it can, if sufficiently trained, also
-reliably report if a body part is visible in a given frame. (see discussions of finger tips in reaching and the Drosophila
-legs during 3D behavior in [Mathis et al, 2018]). The evaluation results are computed by typing:
+🎥 [VIDEO TUTORIAL AVAILABLE!](https://www.youtube.com/watch?v=bgfnz1wtlpo)
Setting ``plotting`` to True plots all the testing and training frames with the manual and predicted labels; these will
-be colored by body part type by default. They can alternatively be colored by individual by passing `plotting`=`individual`.
+be colored by body part type by default. They can alternatively be colored by individual by passing `plotting="individual"`.
The user should visually check the labeled test (and training) images that are created in the ‘evaluation-results’ directory.
Ideally, DeepLabCut labeled unseen (test images) according to the user’s required accuracy, and the average train
and test errors are comparable (good generalization). What (numerically) comprises an acceptable MAE depends on
@@ -373,37 +615,59 @@ many factors (including the size of the tracked body parts, the labeling variabi
also be larger than the training error due to human variability (in labeling, see Figure 2 in Mathis et al, Nature Neuroscience 2018).
**Optional parameters:**
-```
- Shuffles: list, optional -List of integers specifying the shuffle indices of the training dataset. The default is [1]
- plotting: bool, optional -Plots the predictions on the train and test images. The default is `False`; if provided it must be either `True` or `False`
+- `Shuffles: list, optional` - List of integers specifying the shuffle indices of the training dataset.
+The default is [1]
- show_errors: bool, optional -Display train and test errors. The default is `True`
+- `plotting: bool | str, optional` - Plots the predictions on the train and test images. The default is `False`;
+if provided it must be either `True`, `False`, `"bodypart"`, or `"individual"`.
- comparisonbodyparts: list of bodyparts, Default is all -The average error will be computed for those body parts only (Has to be a subset of the body parts).
+- `show_errors: bool, optional` - Display train and test errors. The default is `True`
- gputouse: int, optional -Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU, put None. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
-```
+- `comparisonbodyparts: list of bodyparts, Default is all` - The average error will be computed for those body parts
+only (Has to be a subset of the body parts).
+
+- `gputouse: int, optional` - Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not
+have a GPU, put None. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+- `pcutoff: float | list[float] | dict[str, float], optional`
+(Only applicable when using the PyTorch engine. For TensorFlow, set `pcutoff` in the `config.yaml` file.)
+Specifies the cutoff value(s) used to compute evaluation metrics.
+ - If `None` (default), the cutoff will be loaded from the project configuration.
+ - To apply a single cutoff value to all bodyparts, provide a `float`.
+ - To specify different cutoffs per bodypart, provide either:
+ - A `list[float]`: one value per bodypart, with an additional value for each unique bodypart if applicable.
+ - A `dict[str, float]`: where keys are bodypart names and values are the corresponding cutoff values.
+If a bodypart is not included in the provided dictionary, a default `pcutoff` of `0.6` will be used for that bodypart.
The plots can be customized by editing the **config.yaml** file (i.e., the colormap, scale, marker size (dotsize), and
-transparency of labels (alphavalue) can be modified). By default each body part is plotted in a different color
+transparency of labels (alpha-value) can be modified). By default each body part is plotted in a different color
(governed by the colormap) and the plot labels indicate their source. Note that by default the human labels are
-plotted as plus (‘+’), DeepLabCut’s predictions either as ‘.’ (for confident predictions with likelihood > p-cutoff) and
+plotted as plus (‘+’), DeepLabCut’s predictions either as ‘.’ (for confident predictions with likelihood > `pcutoff`) and
’x’ for (likelihood <= `pcutoff`).
-The evaluation results for each shuffle of the training dataset are stored in a unique subdirectory in a newly created
-directory ‘evaluation-results’ in the project directory. The user can visually inspect if the distance between the labeled
-and the predicted body parts are acceptable. In the event of benchmarking with different shuffles of same training
-dataset, the user can provide multiple shuffle indices to evaluate the corresponding network. If the generalization is
-not sufficient, the user might want to:
+The evaluation results for each shuffle of the training dataset are stored in a unique
+subdirectory in a newly created directory ‘evaluation-results-pytorch’ (or
+‘evaluation-results’ for TensorFlow models) in the project directory.
+The user can visually inspect if the distance between the labeled and the predicted body
+parts are acceptable. In the event of benchmarking with different shuffles of same training
+dataset, the user can provide multiple shuffle indices to evaluate the corresponding
+network. If the generalization is not sufficient, the user might want to:
-• check if the labels were imported correctly; i.e., invisible points are not labeled and the points of interest are
-labeled accurately
+• check if the labels were imported correctly; i.e., invisible points are not labeled
+and the points of interest are labeled accurately
• make sure that the loss has already converged
• consider labeling additional images and make another iteration of the training data set
+````{admonition} Click the button to see API Docs for evaluate_network
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.evaluate_network.rst
+```
+````
+
**maDeepLabCut: (or on normal projects!)**
In multi-animal projects, model evaluation is crucial as this is when
@@ -416,22 +680,55 @@ You should also plot the scoremaps, locref layers, and PAFs to assess performanc
```python
deeplabcut.extract_save_all_maps(config_path, shuffle=shuffle, Indices=[0, 5])
```
-you can drop "Indices" to run this on all training/testing images (this is very slow!)
+
+You can drop "Indices" to run this on all training/testing images (this is very slow!)
+
+### (I) Analyze new Videos
+
+````{versionadded} 3.0.0
+With the addition of conditional top-down models in DeepLabCut 3.0, it's now possible to
+track individuals directly **during video analysis**. If you choose to train any model
+with a name that starts with `ctd_`, you'll be able to call `deeplabcut.analyze_videos`
+with `ctd_tracking=True`. To learn more about tracking with CTD, see the [
+`COLAB_BUCTD_and_CTD_tracking`](
+https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb)
+COLAB notebook.
+````
**-------------------- DECISION POINT -------------------**
**ATTENTION!**
-**Pose estimation and tracking should be thought of as separate steps.** If you do not have good pose estimation evaluation metrics at this point, stop, check original labels, add more data, etc --> don't move forward with this model. If you think you have a good model, please test the "raw" pose estimation performance on a video to validate performance:
+**Pose estimation and tracking should be thought of as separate steps.** If you do not
+have good pose estimation evaluation metrics at this point, stop, check original labels,
+add more data, etc --> don't move forward with this model. If you think you have a good
+model, please test the "raw" pose estimation performance on a video to validate
+performance:
Please run:
```python
-scorername = deeplabcut.analyze_videos(config_path,['/fullpath/project/videos/testVideo.mp4'], videotype='.mp4')
-deeplabcut.create_video_with_all_detections(config_path, ['/fullpath/project/videos/testVideo.mp4'], videotype='.mp4')
+videos_to_analyze = ['/fullpath/project/videos/testVideo.mp4']
+scorername = deeplabcut.analyze_videos(config_path, videos_to_analyze, videotype='.mp4')
+deeplabcut.create_video_with_all_detections(config_path, videos_to_analyze, videotype='.mp4')
```
-Please note that you do **not** get the .h5/csv file you might be used to getting (this comes after tracking). You will get a `pickle` file that is used in `create_video_with_all_detections`.
-Another sanity check may be to examine the distributions of edge affinity costs using `deeplabcut.utils.plot_edge_affinity_distributions`. Easily separable distributions indicate that the model has learned strong links to group keypoints into distinct individuals — likely a necessary feature for the assembly stage (note that the amount of overlap will also depend on the amount of interactions between your animals in the daset).
-IF you have good clean out video, ending in `....full.mp4` (and the evaluation metrics look good, scoremaps look good, plotted evaluation images, and affinity distributions are far apart for most edges), then go forward!!!
+
+Please note that you do **not** get the .h5/csv file you might be used to getting (this
+comes after tracking). You will get a `pickle` file that is used in
+`create_video_with_all_detections`.
+
+For models predicting part-affinity fields, another sanity check may be to
+examine the distributions of edge affinity costs using `deeplabcut.utils.plot_edge_affinity_distributions`. Easily separable distributions
+indicate that the model has learned strong links to group keypoints into distinct
+individuals — likely a necessary feature for the assembly stage (note that the amount of
+overlap will also depend on the amount of interactions between your animals in the
+dataset). All TensorFlow multi-animal models use part-affinity fields and PyTorch models
+consisting of just a backbone name (e.g. `resnet_50`, `resnet_101`) use part-affinity
+fields. If you're unsure whether your PyTorch model has a one, check
+the **pytorch_config.yaml** for a `DLCRNetHead`.
+
+IF you have good clean out video, ending in `....full.mp4` (and the evaluation metrics
+look good, scoremaps look good, plotted evaluation images, and affinity distributions
+are far apart for most edges), then go forward!!!
If this does not look good, we recommend extracting and labeling more frames (even from more videos). Try to label close interactions of animals for best performance. Once you label more, you can create a new training set and train.
@@ -439,30 +736,53 @@ You can either:
1. extract more frames manually from existing or new videos and label as when initially building the training data set, or
2. let DeepLabCut find frames where keypoints were poorly detected and automatically extract those for you. All you need is
to run:
+
```python
deeplabcut.find_outliers_in_raw_data(config_path, pickle_file, video_file)
```
+
where pickle_file is the `_full.pickle` one obtains after video analysis.
Flagged frames will be added to your collection of images in the corresponding labeled-data folders for you to label.
-## Animal Assembly and Tracking across frames
+### Animal Assembly and Tracking across frames
-After pose estimation, now you perform assembly and tracking. *NEW* in 2.2 is a novel data-driven way to set the optimal skeleton and assembly metrics, so this no longer requires user input. The metrics, in case you do want to edit them, can be found in the `inference_cfg.yaml` file.
+After pose estimation, now you perform assembly and tracking.
+
+````{versionadded} v2.2.0
+*NEW* in 2.2 is a novel data-driven way to set the optimal skeleton and assembly
+metrics, so this no longer requires user input. The metrics, in case you do want to edit
+them, can be found in the `inference_cfg.yaml` file.
+````
### Optimized Animal Assembly + Video Analysis:
-- Please note that **novel videos DO NOT need to be added to the config.yaml file**. You can simply have a folder elsewhere on your computer and pass the video folder (then it will analyze all videos of the specified type (i.e. ``videotype='.mp4'``), or pass the path to the **folder** or exact video(s) you wish to analyze:
+Please note that **novel videos DO NOT need to be added to the config.yaml file**. You
+can simply have a folder elsewhere on your computer and pass the video folder (then it
+will analyze all videos of the specified type (i.e. ``videotype='.mp4'``), or pass the
+path to the **folder** or exact video(s) you wish to analyze:
```python
deeplabcut.analyze_videos(config_path, ['/fullpath/project/videos/'], videotype='.mp4', auto_track=True)
```
-#### IF auto_track = True:
-- *NEW* in 2.2.0.3+: `deeplabcut.analyze_videos` has a new argument `auto_track=True`, chaining pose estimation, tracking, and stitching in a single function call with defaults we found to work well. Thus, you'll now get the `.h5` file you might be used to getting in standard DLC. If `auto_track=False`, one must run `convert_detections2tracklets` and `stitch_tracklets` manually (see below), granting more control over the last steps of the workflow (ideal for advanced users).
+### IF auto_track = True:
-#### IF auto_track = False:
+```{versionadded} v2.2.0.3
+A new argument `auto_track=True`, was added to `deeplabcut.analyze_videos` chaining pose
+estimation, tracking, and stitching in a single function call with defaults we found to
+work well. Thus, you'll now get the `.h5` file you might be used to getting in standard
+DLC. If `auto_track=False`, one must run `convert_detections2tracklets` and
+`stitch_tracklets` manually (see below), granting more control over the last steps of
+the workflow (ideal for advanced users).
+```
+
+### IF auto_track = False:
- - You can validate the tracking parameters. Namely, you can iteratively change the parameters, run `convert_detections2tracklets` then load them in the GUI (`refine_tracklets`) if you want to look at the performance. If you want to edit these, you will need to open the `inference_cfg.yaml` file (or click button in GUI). The options are:
+You can validate the tracking parameters. Namely, you can iteratively change the
+parameters, run `convert_detections2tracklets` then load them in the GUI
+(`refine_tracklets`) if you want to look at the performance. If you want to edit these,
+you will need to open the `inference_cfg.yaml` file (or click button in GUI). The
+options are:
```python
# Tracking:
@@ -501,8 +821,13 @@ from the final h5 file as was customary in single animal projects.
**Next, tracklets are stitched to form complete tracks with:
```python
-deeplabcut.stitch_tracklets(config_path, ['videofile_path'], videotype='mp4',
- shuffle=1, trainingsetindex=0)
+deeplabcut.stitch_tracklets(
+ config_path,
+ ['videofile_path'],
+ videotype='mp4',
+ shuffle=1,
+ trainingsetindex=0,
+)
```
Note that the base signature of the function is identical to `analyze_videos` and `convert_detections2tracklets`.
@@ -513,15 +838,42 @@ can be directly specified as follows:
```python
deeplabcut.stitch_tracklets(..., n_tracks=n)
```
+
In such cases, file columns will default to dummy animal names (ind1, ind2, ..., up to indn).
+### API Docs
+
+````{admonition} Click the button to see API Docs for analyze_videos
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.analyze_videos.rst
+```
+````
+
+````{admonition} Click the button to see API Docs for convert_detections2tracklets
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.convert_detections2tracklets.rst
+```
+````
+
+````{admonition} Click the button to see API Docs for stitch_tracklets
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.stitch_tracklets.rst
+```
+````
+
### Using Unsupervised Identity Tracking:
-In Lauer et al. 2022 we introduced a new method to do unsupervised reID of animals. Here, you can use the tracklets to learn the identity of animals to enhance your tracking performance. To use the code:
+In Lauer et al. 2022 we introduced a new method to do unsupervised reID of animals.
+Here, you can use the tracklets to learn the identity of animals to enhance your
+tracking performance. To use the code:
```python
deeplabcut.transformer_reID(config, videos_to_analyze, n_tracks=None, videotype="mp4")
```
+
Note you should pass the n_tracks (number of animals) you expect to see in the video.
### Refine Tracklets:
@@ -547,7 +899,7 @@ Short demo:
-### Once you have analyzed video data (and refined your maDeepLabCut tracklets):
+### (J) Filter Pose Data
Firstly, Here are some tips for scaling up your video analysis, including looping over many folders for batch processing: https://github.com/DeepLabCut/DeepLabCut/wiki/Batch-Processing-your-Analysis
@@ -557,7 +909,14 @@ deeplabcut.filterpredictions(config_path,['/fullpath/project/videos/reachingvide
```
Note, this creates a file with the ending filtered.h5 that you can use for further analysis. This filtering step has many parameters, so please see the full docstring by typing: ``deeplabcut.filterpredictions?``
-### Plotting Results:
+````{admonition} Click the button to see API Docs
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.filterpredictions.rst
+```
+````
+
+### (K) Plot Trajectories , (L) Create Labeled Videos
- **NOTE :bulb::mega::** Before you create a video, you should set what threshold to use for plotting. This is set in the `config.yaml` file as `pcutoff` - if you have a well trained network, this should be high, i.e. set it to `0.8` or higher! IF YOU FILLED IN GAPS, you need to set this to `0` to "see" the filled in parts.
@@ -577,6 +936,20 @@ Create videos:
(more details [here](functionDetails.md#i-video-analysis-and-plotting-results))
+````{admonition} Click the button to see API Docs for plot_trajectories
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.plot_trajectories.rst
+```
+````
+
+````{admonition} Click the button to see API Docs for create_labeled_video
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_labeled_video.rst
+```
+````
+
### HELP:
In ipython/Jupyter notebook:
@@ -624,10 +997,10 @@ Now, you can run any of the functions described in this documentation.
[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
- If you want to share some results, or see others:
-[](https://twitter.com/DeepLabCut)
+[](https://x.com/DeepLabCut)
- If you have a code bug report, please create an issue and show the minimal code to reproduce the error: https://github.com/DeepLabCut/DeepLabCut/issues
-- if you are looking for resources to increase your understanding of the software and general guidelines, we have an open source, free course: http://DLCcourse.deeplabcut.org.
+- if you are looking for resources to increase your understanding of the software and general guidelines, we have an open source, free course: https://deeplabcut.github.io/DeepLabCut/docs/course.html.
**Please note:** what we cannot do is provided support or help designing your experiments and data analysis. The number of requests for this is too great to sustain in our inbox. We are happy to answer such questions in the forum as a community, in a scalable way. We hope and believe we have given enough tools and resources to get started and to accelerate your research program, and this is backed by the >700 citations using DLC, 2 clinical trials by others, and countless applications. Thus, we believe this code works, is accessible, and with limited programming knowledge can be used. Please read our [Missions & Values statement](mission-and-values) to learn more about what we DO hope to provide you.
diff --git a/docs/napari_GUI.md b/docs/napari_GUI.md
deleted file mode 100644
index 5fe4fb84c5..0000000000
--- a/docs/napari_GUI.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# napari labeling GUI
-
-We replaced wxPython with PySide6 + as of version 2.3. Here is how to use the napari-aspects of the new GUI. It is available in napari-hub as a stand alone GUI as well as integrated into our main GUI, [please see docs here](https://deeplabcut.github.io/DeepLabCut/docs/PROJECT_GUI.html).
-
-[](https://www.gnu.org/licenses/bsd3)
-[](https://pypi.org/project/napari-deeplabcut)
-[](https://python.org)
-[](https://github.com/DeepLabCut/napari-deeplabcut/actions)
-[](https://codecov.io/gh/DeepLabCut/napari-deeplabcut)
-[](https://napari-hub.org/plugins/napari-deeplabcut)
-
-A napari plugin for keypoint annotation with DeepLabCut.
-
-
-## Installation
-
-You can install the full DeepLabCut napari-based GUI via [pip] by running this in your conda env:
-
-`pip install 'deeplabcut[tf,gui]'` or mac M1/M2 chip users: `pip install 'deeplabcut[apple_mchips,gui]'`
-
-*please note this is available since v2.3
-
-This is not needed if you ran the above installation, but you can install the stand-alone `napari-deeplabcut` via [pip]:
-
-` pip install napari-deeplabcut `
-
-
-To install latest development version:
-
- ` pip install git+https://github.com/DeepLabCut/napari-deeplabcut.git `
-
-
-## Usage
-
-To use the full GUI, please run:
-
-`python -m deeplabcut`
-
-To use the stand-alone napari plugin, please launch napari:
-
-`napari `
-
-Then, activate the plugin in Plugins > napari-deeplabcut: Keypoint controls.
-
-All accepted files (`config.yaml`, images, `.h5` data files) can be loaded either by dropping them directly onto the canvas or via the File menu.
-
-The easiest way to get started is to drop a folder (typically a folder from within a DeepLabCut's `labeled-data` directory), and, if labeling from scratch, drop the corresponding `config.yaml` to automatically add a `Points layer` and populate the dropdown menus.
-
-[🎥 DEMO
-](https://youtu.be/hsA9IB5r73E)
-
-**Tools & shortcuts are:**
-
-- `2` and `3`, to easily switch between labeling and selection mode
-- `4`, to enable pan & zoom (which is achieved using the mouse wheel or finger scrolling on the Trackpad)
-- `M`, to cycle through regular (sequential), quick, and cycle annotation mode (see the description [here](https://github.com/DeepLabCut/DeepLabCut-label/blob/ee71b0e15018228c98db3b88769e8a8f4e2c0454/dlclabel/layers.py#L9-L19))
-- `E`, to enable edge coloring (by default, if using this in refinement GUI mode, points with a confidence lower than 0.6 are marked
-in red)
-- `F`, to toggle between animal and body part color scheme.
-- `V`, to toggle visibility of the selected layer.
-- `backspace` to delete a point.
-- Check the box "display text" to show the label names on the canvas.
-- To move to another folder, be sure to save (Ctrl+S), then delete the layers, and re-drag/drop the next folder.
-
-
-
-
-
-### Save Layers
-
-Annotations and segmentations are saved with `File > Save Selected Layer(s)...` (or its shortcut `Ctrl+S`).
-Only when saving segmentation masks does a save file dialog pop up to name the destination folder;
-keypoint annotations are otherwise automatically saved in the corresponding folder as `CollectedData_.h5`.
-- As a reminder, DLC will only use the H5 file; so be sure if you open already labeled images you save/overwrite the H5.
-- Note, before saving a layer, make sure the points layer is selected. If the user clicked on the image(s) layer first, does `Save As`, then closes the window, any labeling work during that session will be lost!
-- Modifying and then saving points in a `machinelabels...` layer will add to or overwrite the existing `CollectedData` layer and will **not** save to the `machinelabels` file.
-
-### Video frame extraction and prediction refinement
-
-Since v0.0.4, videos can be viewed in the GUI.
-
-Since v0.0.5, trailing points can be visualized; e.g., helping in the identification
-of swaps or outlier, jittery predictions.
-
-Loading a video (and its corresponding output h5 file) will enable the video actions
-at the top of the dock widget: they offer the option to manually extract video
-frames from the GUI, or to define cropping coordinates.
-Note that keypoints can be displaced and saved, as when annotating individual frames.
-
-
-## Workflow
-
-Suggested workflows, depending on the image folder contents:
-
-1. **Labeling from scratch** – the image folder does not contain `CollectedData_.h5` file.
-
- Open *napari* as described in [Usage](#usage) and open an image folder together with the DeepLabCut project's `config.yaml`.
- The image folder creates an *image layer* with the images to label.
- Supported image formats are: `jpg`, `jpeg`, `png`.
- The `config.yaml` file creates a *Points layer*, which holds metadata (such as keypoints read from the config file) necessary for labeling.
- Select the *Points layer* in the layer list (lower left pane on the GUI) and click on the *+*-symbol in the layer controls menu (upper left pane) to start labeling.
- The current keypoint can be viewed/selected in the keypoints dropdown menu (right pane).
- The slider below the displayed image (or the left/right arrow keys) allows selecting the image to label.
-
- To save the labeling progress refer to [Save Layers](#save-layers).
- `Data successfully saved` should be shown in the status bar, and the image folder should now contain a `CollectedData_.h5` file.
- (Note: For convenience, a CSV file with the same name is also saved.)
-
-2. **Resuming labeling** – the image folder contains a `CollectedData_.h5` file.
-
- Open *napari* and open an image folder (which needs to contain a `CollectedData_.h5` file).
- In this case, it is not necessary to open the DLC project's `config.yaml` file, as all necessary metadata is read from the `h5` data file.
-
- Saving works as described in *1*.
-
- ***Note that if a new body part has been added to the `config.yaml` file after having started to label, loading the config in the GUI is necessary to update the dropdown menus and other metadata.***
-
- ***As `viridis` is `napari-deeplabcut` default colormap, selecting the colormap in the GUI or loading the config in the GUI can be used to update the color scheme.***
-
-4. **Refining labels** – the image folder contains a `machinelabels-iter<#>.h5` file.
-
- The process is analog to *2*.
- Open *napari* and open an image folder.
- If the video was originally labeled, *and* had outliers extracted it will contain a `CollectedData_.h5` file and a `machinelabels-iter<#>.h5` file. In this case, select the `machinelabels` layer in the GUI, and type `e` to show edges. Red indicates likelihood < 0.6. As you navigate through frames, images with labels with edges will need to be refined (moved, deleted, etc). Images with labels without edges will be on the `CollectedData` (previous manual annotations) layer and shouldn't need refining. However, you can switch to that layer and fix errors. You can also right-click on the `CollectedData` layer and select `toggle visibility` to hide that layer. Select the `machinelabels` layer before saving which will append your refined annotations to `CollectedData`.
-
- If the folder only had outliers extracted and wasn't originally labeled, it will not have a `CollectedData` layer. Work with the `machinelabels` layer selected to refine annotation positions, then save.
-
- In this case, it is not necessary to open the DLC project's `config.yaml` file, as all necessary metadata is read from the `h5` data file.
-
- Saving works as described in *1*.
-
-6. **Drawing segmentation masks**
-
- Drop an image folder as in *1*, manually add a *shapes layer*. Then select the *rectangle* in the layer controls (top left pane),
- and start drawing rectangles over the images. Masks and rectangle vertices are saved as described in [Save Layers](#save-layers).
- Note that masks can be reloaded and edited at a later stage by dropping the `vertices.csv` file onto the canvas.
-
-### Workflow flowchart
-
-```{mermaid}
-graph TD
- id1[What stage of labeling?]
- id2[deeplabcut.label_frames]
- id3[deeplabcut.refine_labels]
- id4[Add labels to, or modify in, \n `CollectedData...` layer and save that layer]
- id5[Modify labels in `machinelabels` layer and save \n which will create a `CollectedData...` file]
- id6[Have you refined some labels from the most recent iteration and saved already?]
- id7["All extracted frames are already saved in `CollectedData...`.
-1. Hide or trash all `machinelabels` layers.
-2. Then modify in and save `CollectedData`"]
- id8["
-1. hide or trash all `machinelabels` layers except for the most recent.
-2. Select most recent `machinelabels` and hit `e` to show edges.
-3. Modify only in `machinelabels` and skip frames with labels without edges shown.
-4. Save `machinelabels` layer, which will add data to `CollectedData`.
- - If you need to revisit this video later, ignore `machinelabels` and work only in `CollectedData`"]
-
- id1 -->|I need to manually label new frames \n or fix my labels|id2
- id1 ---->|I need to refine outlier frames \nfrom analyzed videos|id3
- id2 -->id4
- id3 -->|I only have a `machinelabels...` file|id5
- id3 ---->|I have both `machinelabels` and `CollectedData` files|id6
- id6 -->|yes|id7
- id6 ---->|no, I just extracted outliers|id8
-```
-
-### Labeling multiple image folders
-
-Labeling multiple image folders has to be done in sequence; i.e., only one image folder can be opened at a time.
-After labeling the images of a particular folder is done and the associated *Points layer* has been saved, *all* layers should be removed from the layers list (lower left pane on the GUI) by selecting them and clicking on the trashcan icon.
-Now, another image folder can be labeled, following the process described in *1*, *2*, or *3*, depending on the particular image folder.
-
-
-### Defining cropping coordinates
-
-Prior to defining cropping coordinates, two elements should be loaded in the GUI:
-a video and the DLC project's `config.yaml` file (into which the crop dimensions will be stored).
-Then it suffices to add a `Shapes layer`, draw a `rectangle` in it with the desired area,
-and hit the button `Store crop coordinates`; coordinates are automatically written to the configuration file.
-
-
-## Contributing
-
-Contributions are very welcome. Tests can be run with [tox], please ensure
-the coverage at least stays the same before you submit a pull request.
-
-To locally install the code, please git clone the repo and then run `pip install -e .`
-
-
-## Issues
-
-If you encounter any problems, please [file an issue] along with a detailed description.
-
-[file an issue]: https://github.com/DeepLabCut/napari-deeplabcut/issues
-
-
-## Acknowledgements
-
-
-This [napari] plugin was generated with [Cookiecutter] using [@napari]'s [cookiecutter-napari-plugin] template. We thank the Chan Zuckerberg Initiative (CZI) for funding this work!
-
-
-
-
-[napari]: https://github.com/napari/napari
-[Cookiecutter]: https://github.com/audreyr/cookiecutter
-[@napari]: https://github.com/napari
-[cookiecutter-napari-plugin]: https://github.com/napari/cookiecutter-napari-plugin
-[BSD-3]: http://opensource.org/licenses/BSD-3-Clause
-[tox]: https://tox.readthedocs.io/en/latest/
-[pip]: https://pypi.org/project/pip/
-[PyPI]: https://pypi.org/
diff --git a/docs/pytorch/Benchmarking_shuffle_guide.md b/docs/pytorch/Benchmarking_shuffle_guide.md
new file mode 100644
index 0000000000..ba54ceca9f
--- /dev/null
+++ b/docs/pytorch/Benchmarking_shuffle_guide.md
@@ -0,0 +1,141 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# DeepLabCut Benchmarking - User Guide
+
+## Reasoning for benchmarking models in DLC (across DLC versions and architectures)
+
+DeepLabCut 3.0+ introduced using PyTorch 🔥 as a deep learning engine (and TensorFlow will be depreciated).
+It is of importance for replicability of data analysis to benchmark existing models created using DeepLabCut versions
+prior to 3.0 against new models created in DeepLabCut 3.0+ and later versions.
+
+When comparing different models, it's important to use the same train-test data
+split to ensure fair comparisons. If the models are trained on different datasets,
+their performance metrics can't be accurately compared. This is crucial when
+comparing the performance of models with different architectures or different
+sets of hyperparameters. For example, if we compare the RMSE of a model on an
+"easy" test image with the RMSE of another model on a "hard" test image, it
+doesn't determine whether a model is better than the other because the
+architecture performs better or because the training images were "better" to
+learn from. Thus, we not only need to compare the models based on metrics
+computed on the same test images, but also train them on an identical fixed
+training set in order to "decouple" the dataset from the model architecture.
+
+Creating a model using the same data split can be carried out using a GUI or
+using code, and this guide outlines the steps for both.
+
+## Important files & folders
+
+```
+dlc-project
+|
+|___dlc-models-pytorch
+| |__ iterationX
+| |__ shuffleX
+| |__ pytorch_config.yaml
+|
+|___training-datasets
+| |__ metadata.yaml
+|
+|___config.yaml
+```
+
+## Benchmarking a TensorFlow model against a PyTorch model
+
+### Creating a shuffle
+
+Creating a new shuffle with the same train/test split as an existing one:
+### In the DeepLabCut GUI
+1. Front page > Load project > Open project folder > choose *config.yaml*
+2. Select *'Create training dataset'* tab
+3. Tick *Use an existing data split* option
+
+ ![create_from_existing]()
+4. Click 'View existing shuffles':
+ - This is used to view the indices of shuffles created for a project to determine which index is available to assign to a new shuffle.
+ - The elements described in this window are:
+ - train_fraction: The fraction of the dataset used for training.
+ - index: The index of the shuffle.
+ - split: The data split for the shuffle. The integer value on its own does not
+hold any meaning, but this "split" value indicates which shuffles have the same split
+(as their results can then be compared)
+ - engine: Whether it is a PyTorch or TensorFlow shuffle
+
+ ![view_existing_sh]()
+5. Choose the index of the training shuffle to replicate. Let us assume we want
+to replicate the train-test split from OpenfieldOct30-trainset95shuffle3, in which
+`split: 3`. In this case, we insert in the *'From shuffle'* menu
+
+ ![choose_existing_index]()
+6. To create this new dataset, set the shuffle option to an un-used shuffle
+(here 4)
+
+ ![choose_new_index]()
+7. Click *'Create training dataset'* and move on to *'train network'*. Shuffle should be
+set to the new shuffle entered at the previous step (in this case, 4)
+
+ ![create_from_existing]()
+8. To view/edit the specifications of the model you created, you can go to `pytoch_config.yaml` file at:
+ ```
+ dlc-project
+ |
+ |___ dlc-models-pytorch
+ |__ iterationX
+ |__ shuffleX
+ |__ pytorch_config.yaml
+ ```
+
+### In Code
+
+With the `deeplabcut` module in Python, use the
+`create_training_dataset_from_existing_split()` method to create new shuffles from
+existing ones (e.g. TensorFlow shuffles).
+
+Similarly, here, we create a new shuffle '4' from the existing shuffle '3'.
+
+```python
+import deeplabcut
+from deeplabcut.core.engine import Engine
+
+config = "path/to/project/config.yaml"
+
+training_dataset = deeplabcut.create_training_dataset_from_existing_split(
+ config=config,
+ from_shuffle=3,
+ from_trainsetindex=0,
+ shuffles=[4],
+ net_type="resnet_50",
+)
+```
+
+We can then train our new PyTorch model with the same data split as the
+TensorFlow model.
+
+```python
+deeplabcut.train_network(config, shuffle=4, engine=Engine.PYTORCH, batch_size=8)
+```
+
+Once trained we can evaluate our model using
+
+```python
+deeplabcut.evaluate_network(config, Shuffles=[4], snapshotindex="all")
+```
+Now, we can compare performances with peace of mind!
+
+### Good practices: naming shuffles created from existing ones
+
+In a setting where one has multiple TensorFlow models and intends to benchmark
+their performances against new PyTorch models, it is good practice to follow
+a naming pattern for the shuffles we create.
+
+Say we have TensorFlow shuffles 0, 1, and 2. We can create new PyTorch shuffles
+from them by naming them 1000, 1001, and 1002. This allows us to quickly
+recognize that the shuffles belonging to the 100x range are PyTorch shuffles
+and that shuffle 1001, for example, has the same data split as TensorFlow
+shuffle 1. This way, the comparison can be more straightforward and guaranteed
+to be correct!
+
+This was contributed by the [2024 DLC AI Residents](https://www.deeplabcutairesidency.org/our-team)!
diff --git a/docs/pytorch/architectures.md b/docs/pytorch/architectures.md
new file mode 100644
index 0000000000..755a967a77
--- /dev/null
+++ b/docs/pytorch/architectures.md
@@ -0,0 +1,153 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(dlc3-architectures)=
+# DeepLabCut 3.0 - PyTorch Model Architectures
+
+## Introduction
+
+You can see a list of supported architectures/variants by using:
+
+```python
+from deeplabcut.pose_estimation_pytorch import available_models
+print(available_models())
+```
+
+You can see a list of supported object detection architectures/variants by using:
+
+```python
+from deeplabcut.pose_estimation_pytorch import available_detectors
+print(available_detectors())
+```
+
+## Neural Networks Architectures
+
+Several architectures are currently implemented in DeepLabCut PyTorch (more will come,
+and you can add more easily in our new model registry). Also check out the explanations of bottom-up/top-down below.
+
+**ResNets**
+- Adapted from [He, Kaiming, et al. "Deep residual learning for image recognition." Proceedings of the IEEE conference on Computer Vision and Pattern Recognition. 2016.](https://openaccess.thecvf.com/content_cvpr_2016/html/He_Deep_Residual_Learning_CVPR_2016_paper.html) and [Insafutdinov, Eldar et al. "DeeperCut: A Deeper, Stronger, and Faster Multi-Person Pose Estimation Model". European Conference on Computer Vision (ECCV) 2016.]
+- Current bottom-up variants are `resnet_50`, `resnet_101`
+- Current top-down variants are `top_down_resnet_101`, `top_down_resnet_50`
+
+**HRNet**
+- Adapted from [Wang, Jingdong, et al. "Deep high-resolution representation learning for visual recognition." IEEE transactions on pattern analysis and machine intelligence 43.10 (2020): 3349-3364.](https://arxiv.org/abs/1908.07919)
+- Current variants are `hrnet_w18`, `hrnet_w32`, `hrnet_w48`,
+- Current top-down variants are `top_down_hrnet_w18`, `top_down_hrnet_w32`, `top_down_hrnet_w48`
+- Slower but typically more powerful than ResNets
+
+**DEKR**
+- Adapted from [Geng, Zigang et al. "Bottom-Up Human Pose Estimation Via Disentangled Keypoint Regression." Proceedings of the IEEE conference on Computer Vision and Pattern Recognition. 2021.](https://openaccess.thecvf.com/content/CVPR2021/papers/Geng_Bottom-Up_Human_Pose_Estimation_via_Disentangled_Keypoint_Regression_CVPR_2021_paper.pdf)
+- This model is a bottom-up model using HRNet as a backbone. It learns to predict the center of each animal, and predicts the offset between each animal center and their keypoints
+- Current variants that are implemented (from smallest to largest): `dekr_w18`, `dekr_w32`, `dekr_w48`
+- Note, this is a powerful multi-animal model but very heavy (slow)
+
+**BUCTD**
+- Adapted from [Zhou\*, Stoffl\*, Mathis, Mathis. "Rethinking Pose Estimation in Crowds: Overcoming the Detection Information Bottleneck and Ambiguity." Proceedings of the IEEE/CVF International Conference on Computer Vision (ICCV). 2023](https://openaccess.thecvf.com/content/ICCV2023/papers/Zhou_Rethinking_Pose_Estimation_in_Crowds_Overcoming_the_Detection_Information_Bottleneck_ICCV_2023_paper.pdf)
+- [](https://paperswithcode.com/sota/pose-estimation-on-crowdpose?p=rethinking-pose-estimation-in-crowds)
+- This is a top-performing multi-animal method that combines the strengths of bottom-up and top-down approaches, and delivers exceptional performance on humans too (which are also animals)
+- It can be used with a diverse set of architectures. Current variants are: `ctd_coam_w32`, `ctd_coam_w48`/`ctd_coam_w48_human`, `ctd_prenet_hrnet_w32`, `ctd_prenet_hrnet_w48`, `ctd_prenet_rtmpose_s`, `ctd_prenet_rtmpose_m`, `ctd_prenet_rtmpose_x`/`ctd_prenet_rtmpose_x_human`
+
+**DLCRNet**
+- From [Lauer, Zhou, et al. "Multi-animal pose estimation, identification and tracking with DeepLabCut." Nature Methods 19.4 (2022): 496-504.](https://www.nature.com/articles/s41592-022-01443-0)
+- This model uses a multi-scale variant of a ResNet as a backbone, and part-affinity fields to assemble individuals
+- Variants: `dlcrnet_stride16_ms5`, `dlcrnet_stride32_ms5`
+
+**RTMPose**
+- From [Jiang, Tao et al. "RTMPose: Real-Time Multi-Person Pose Estimation based on MMPose"](https://arxiv.org/abs/2303.07399)
+- Top-down pose estimation model using a fast CSPNeXt backbone with a SimCC-style head
+- Variants: `rtmpose_s`, `rtmpose_m`, `rtmpose_x`
+
+**AnimalTokenPose**
+- Adapted from [Li, Yanjie, et al. "Tokenpose: Learning keypoint tokens for human pose estimation." Proceedings of the IEEE/CVF International conference on computer vision. 2021.](https://arxiv.org/abs/2104.03516) as in Ye et al. "SuperAnimal pretrained pose estimation models for behavioral analysis." Nature Communications. 2024](https://arxiv.org/abs/2203.07436)
+ - One variant is implemented as: `animal_tokenpose_base` for video inference only (we don't support directly training this within deeplabcut)
+
+
+## Information on Single Animal Models
+
+Single-animal models are composed of a backbone (encoder) and a head (decoder)
+predicting the position of keypoints. The default head contains a single deconvolutional
+layer. To create the single animal model composed of a backbone and head, you can call
+`deeplabcut.create_training_dataset` with `net_type` set to the backbone name (e.g.
+`resnet_50` or `hrnet_w32`).
+
+If you want to add a second deconvolutional layer (which will make your model slower,
+but it might improve performance), you can simply edit your `pytorch_config.yaml` file.
+
+Of course, any multi-animal model can also be used for single-animal projects!
+
+## Approaches to Multi-Animal pose estimation
+
+Single-animal pose estimation is quite straightforward: the model takes an image as
+input, and it outputs the predicted coordinate of each bodypart.
+
+Multi-animal pose estimation is more complex. Not only do you need to localize bodyparts
+in the image, but you also need to group bodyparts per individual. There are two main
+approaches to multi-animal pose estimation.
+
+### Bottom-up estimation
+
+The first approach, **bottom-up** pose estimation, starts by detecting bodyparts in the
+image before figuring out how they belong together (i.e., which keypoints belong to the
+same animal).
+
+
+
+### Backbones with Part-Affinity Fields
+
+As in DeepLabCut 2.X, the base multi-animal model is composed of a backbone (encoder)
+and a head predicting keypoints and part-affinity fields (PAFs). These PAFs are used to
+assemble keypoints for individuals.
+
+Passing a backbone as a net type (e.g., `resnet_50`, `hrnet_w32`) for a multi-animal
+project will create a model consisting of a backbone and a heatmap + PAF head.
+
+### Top-down estimation
+
+The second approach, **top-down** pose estimation, uses a two-step approach. A first
+model (an object detector) is used to localize every animal present in the image through
+its bounding box. Then, the pose for each animal is determined by predicting bodyparts
+in each bounding box. The pose estimation
+
+
+
+The top-down approach tends to be more accurate in less crowded scenes, as the pose
+model only needs to process the pixels related to a single animal. However, in more
+crowded scenes, the pose estimation task becomes ambiguous. Multiple overlapping
+individuals will have very similar bounding boxes, and the pose model has no way of
+knowing which animal it is supposed to predict keypoints for.
+
+The bottom-up approach does not have this ambiguïty, and also has the advantage of
+only needing to run a pose estimation model, instead of needing to run an object
+detector first. However, grouping keypoints is a difficult problem.
+
+
+Hence any single-animal model can be transformed into a top-down, multi-animal model. To
+do so, simply prefix `top_down` to your single-animal model name. Currently, the
+following detectors are available: `ssdlite`, `fasterrcnn_mobilenet_v3_large_fpn`,
+`fasterrcnn_resnet50_fpn_v2`.
+
+
+### Hybrid, Bottom-up (BU) plus a ``conditioned" Top-down (CTD)
+
+A new approach to pose estimation, named bottom-up conditioned top-down (or **BUCTD**), was
+introduced in [Zhou, Stoffl, Mathis, Mathis. "Rethinking Pose Estimation in Crowds:
+Overcoming the Detection Information Bottleneck and Ambiguity." Proceedings of the
+IEEE/CVF International Conference on Computer Vision (ICCV). 2023](
+https://openaccess.thecvf.com/content/ICCV2023/papers/Zhou_Rethinking_Pose_Estimation_in_Crowds_Overcoming_the_Detection_Information_Bottleneck_ICCV_2023_paper.pdf)
+. It's a hybrid two-stage approach leveraging the strengths of the bottom-up and
+top-down approaches to overcome the ambiguïty introduced through bounding boxes. Instead
+of using an object detection model to localize individuals, it uses a bottom-up pose
+estimation model. The predictions made by the bottom-up model are given as proposals (or
+_conditions_) to the pose estimation model. This is illustrated in the figure below. In modern language, one could state that CTD models are "pose-promptable".
+
+
+
+Zhou, Mu, et al. *"Rethinking pose estimation in crowds: overcoming the
+detection information bottleneck and ambiguity."* Proceedings of the IEEE/CVF
+International Conference on Computer Vision. 2023.
diff --git a/docs/pytorch/assets/bboxes_from_kpts.png b/docs/pytorch/assets/bboxes_from_kpts.png
new file mode 100644
index 0000000000..f5be1eb3a0
Binary files /dev/null and b/docs/pytorch/assets/bboxes_from_kpts.png differ
diff --git a/docs/pytorch/assets/bottom-up-approach.png b/docs/pytorch/assets/bottom-up-approach.png
new file mode 100644
index 0000000000..025c292c8d
Binary files /dev/null and b/docs/pytorch/assets/bottom-up-approach.png differ
diff --git a/docs/pytorch/assets/img1.png b/docs/pytorch/assets/img1.png
new file mode 100644
index 0000000000..dde3d96115
Binary files /dev/null and b/docs/pytorch/assets/img1.png differ
diff --git a/docs/pytorch/assets/img2.png b/docs/pytorch/assets/img2.png
new file mode 100644
index 0000000000..6e86649dd8
Binary files /dev/null and b/docs/pytorch/assets/img2.png differ
diff --git a/docs/pytorch/assets/img3.png b/docs/pytorch/assets/img3.png
new file mode 100644
index 0000000000..39c516bfb7
Binary files /dev/null and b/docs/pytorch/assets/img3.png differ
diff --git a/docs/pytorch/assets/img4.png b/docs/pytorch/assets/img4.png
new file mode 100644
index 0000000000..a231130b5f
Binary files /dev/null and b/docs/pytorch/assets/img4.png differ
diff --git a/docs/pytorch/assets/img5.png b/docs/pytorch/assets/img5.png
new file mode 100644
index 0000000000..4e7a44ca0d
Binary files /dev/null and b/docs/pytorch/assets/img5.png differ
diff --git a/docs/pytorch/assets/top-down-approach.png b/docs/pytorch/assets/top-down-approach.png
new file mode 100644
index 0000000000..c28265bcb1
Binary files /dev/null and b/docs/pytorch/assets/top-down-approach.png differ
diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md
new file mode 100644
index 0000000000..cbd435c46a
--- /dev/null
+++ b/docs/pytorch/pytorch_config.md
@@ -0,0 +1,641 @@
+---
+deeplabcut:
+ last_content_updated: '2025-10-02'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(dlc3-pytorch-config)=
+# The PyTorch Configuration file
+
+The `pytorch_config.yaml` file specifies the configuration for your PyTorch pose models,
+from the model architecture to which optimizer will be used for training, how training
+runs will be logged, the data augmentation that will be applied and which metric should
+be used to save the "best" model snapshot.
+
+You can create default configurations for a shuffle using
+`deeplabcut.create_training_set` or `deeplabcut.create_training_model_comparison`. This
+will create a `pytorch_config.yaml` file for your selected net type. The basic structure
+of the file is as follows:
+
+```yaml
+data: # which data augmentations will be used
+ ...
+device: auto # the default device to use for training and evaluation
+inference: # configures inference-related parameters (multithreading, different torch options)
+metadata: # metadata regarding the project (bodyparts, individuals, paths, ...) - filled automatically
+ ...
+method: bu # indicates how pose predictions are made (bottom-up (`bu`) or top-down (`td`))
+model: # configures the model architecture (which backbone, heads, ...)
+ ...
+net_type: resnet_50 # the type of neural net configured in the file
+runner: # configuring the runner used for training
+ ...
+train_settings: # generic training settings, such as batch size and maximum number of epochs
+ ...
+logger: # optional: the configuration for a logger if you want one
+resume_training_from: # optional: restart the training at the specific checkpoint
+```
+
+## Sections
+
+### Singleton Parameters
+
+There are a few singleton parameters defined in the PyTorch configuration file:
+
+- `device`: The device to use for training/inference. The default is `auto`, which sets
+the device to `cuda` if an NVIDIA GPU is available, and `cpu` otherwise. For users
+running models on macOS with an M1/M2/M3 chip, this is set to `mps` for certain models
+(not all operations are currently supported on Apple GPUs - so some models like HRNets
+need to be trained on CPU, while others like ResNets can take advantage of the GPU).
+- `method`: Either `bu` for bottom-up models, or `td` for top-down models.
+- `net_type`: The type of pose model configured by the file (e.g. `resnet_50`).
+
+### Data
+
+The data section configures:
+
+- `bbox_margin`: The margin (in pixels) to add around ground truth pose when generating
+bounding boxes. For more information, see [generating bounding boxes from pose](
+#bbox-from-pose).
+- `colormode`: in which format images are given to the model (e.g., `RGB`, `BGR`)
+- `inference`: which transformations should be applied to images when running evaluation
+or inference
+- `train`: which transformations should be applied to images when training
+
+The default configuration for a pose model is:
+
+```yaml
+data:
+ bbox_margin: 20
+ colormode: RGB # should never be changed
+ inference: # the augmentations to apply to images during inference
+ normalize_images: true # this should always be set to true
+ train:
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [0.5, 1.25]
+ translation: 0
+ covering: true
+ crop_sampling:
+ width: 448 # if your images are very small or very large, you may need to edit!
+ height: 448 # see below for more information about crop_sampling!
+ max_shift: 0.1
+ method: hybrid
+ gaussian_noise: 12.75
+ motion_blur: true
+ normalize_images: true # this should always be set to true
+```
+
+The following transformations are available for the `train` and `inference` keys.
+
+**Affine**: Applies an affine (rotation, translation, scaling) transformation to the
+images.
+
+```yaml
+affine:
+ p: 0.9 # float: the probability that an affine transform is applied
+ rotation: 30 # int: the maximum angle of rotation applied to the image (in degrees)
+ scaling: [ 0.5, 1.25 ] # [float, float]: the (min, max) scale to use to resize images
+ translation: 40 # int: the maximum translation to apply to images (in pixels)
+```
+
+**Auto-Padding**: Pads the image to some desired shape (e.g., a minimum height/width or
+such that the height/width are divisible by a given number). Some backbones (such as
+HRNets) require the height and width of images to be multiples of 32. Setting up
+auto-padding with `pad_height_divisor: 32` and `pad_width_divisor: 32` ensures that is
+the case. Note that **not all keys need to be set**! The values shown are the default
+values. Only one of 'min_height' and 'pad_height_divisor' parameters must be set, and
+only one of 'min_width' and 'pad_width_divisor' parameters must be set.
+
+```yaml
+auto_padding:
+ min_height: null # int: if not None, the minimum height of the image
+ min_width: null # int: if not None, the minimum width of the image
+ pad_height_divisor: null # int: if not None, ensures image height is dividable by value of this argument.
+ pad_width_divisor: null # int: if not None, ensures image width is dividable by value of this argument.
+ position: random # str: position of the image, one of 'A.PadIfNeeded.Position'
+ border_mode: reflect_101 # str: 'constant' or 'reflect_101' (see cv2.BORDER modes)
+ border_value: null # str: padding value if border_mode is 'constant'
+ border_mask_value: null # str: padding value for mask if border_mode is 'constant'
+```
+
+**Covering**: Based on Albumentations's [CoarseDropout](
+https://albumentations.ai/docs/api_reference/augmentations/dropout/coarse_dropout/#albumentations.augmentations.dropout.coarse_dropout)
+augmentation, this "cuts" holes out of the image. As defined in
+[Improved Regularization of Convolutional Neural Networks with Cutout](
+https://arxiv.org/abs/1708.04552).
+
+```yaml
+covering: true # bool: if true, applies a coarse dropout with probability 50%
+```
+
+**Gaussian Noise**: Applies gaussian noise to the input image. Can either be a float
+(the standard deviation of the noise) or simply a boolean (the standard deviation of
+the noise will be set as 12.75).
+
+```yaml
+gaussian_noise: 12.75 # bool, float: add gaussian noise
+```
+
+**Horizontal Flips**: This flips the image horizontally around the y-axis. As the
+resulting image is mirrored, it does not preserve labels (the left hand would become the
+right hand, and vice versa). This augmentation should not be used for pose models if you
+have symmetric keypoints! However, it is safe to use it to train detectors. If you want
+to use horizontal flips with symmetric keypoints, you need to specify them through the
+`symmetries` parameter!
+
+```yaml
+# augmentation for object detectors or when no symmetric (left/right) keypoints exist:
+hflip: true
+
+# augmentation if your bodyparts are [snout, eye_L, eye_R, ear_L, ear_R]
+hflip:
+ p: 0.5 # apply a horizontal flip with 50% probability
+ symmetries: [[1, 2], [3, 4]] # the indices of symmetric keypoints
+```
+
+**Histogram Equalization**: Applies histogram equalization with probability 50%.
+
+```yaml
+hist_eq: true # bool: whether to apply histogram equalization
+```
+
+**Motion Blur**: Applies motion blur to the image with probability 50%.
+
+```yaml
+motion_blur: true # bool: whether to apply motion blur
+```
+
+**Normalization**: This should always be set to `true`.
+
+```yaml
+normalize_images: true # normalizes images
+```
+
+### Dealing with Variable Image Sizes
+
+```{NOTE}
+When training with batch size 1 (or if all images in your dataset have the same size),
+you don't need to worry about any of this! However, you can still use `crop_sampling`
+which may help your model generalize.
+```
+
+When training with a batch size greater than 1, all images in a batch **must** have the
+same size. PyTorch **collates** all images into one tensor of shape `[b, c, h, w]`,
+where `b` is the batch size, `c` the number of channels in the image, `h` and `w` the
+height and width of images in the batches. There are a few different ways to ensure that
+all images in a batch have the same size:
+
+1. **Crop sampling**. This is the default behavior for the PyTorch engine in DeepLabCut.
+A part of each image (of a fixed size) is cropped and given to the model to train. See
+below for more information.
+2. **A custom collate function**. Collate functions define a way that images of different
+sizes can be combined into one tensor. This involves resizing and padding images to the
+same size and aspect ratio. Available collate functions are defined in
+`deeplabcut/pose_estimation_pytorch/data/collate.py`.
+3. **Resizing all images**. All images can simply be resized to the same size. This
+usually doesn't lead to the best performance.
+
+**Resizing - Crop Sampling**: An alternative way to ensure all images have the same size
+is through cropping. The `crop_sampling` crops images down to a maximum width and
+height, with options to sample the center of the crop according to the positions of the
+keypoints. The methods to sample the center of the crop are as follows:
+
+- `uniform`: randomly over the image
+- `keypoints`: randomly over the annotated keypoints
+- `density`: weighing preferentially dense regions of keypoints
+- `hybrid`: alternating randomly between `uniform` and `density`
+
+```yaml
+crop_sampling:
+ height: 400 # int: the height of the crop
+ width: 400 # int: the height of the crop
+ max_shift: 0.4 # float: maximum allowed shift of the cropping center position as a fraction of the crop size.
+ method: hybrid # str: the center sampling method (one of 'uniform', 'keypoints', 'density', 'hybrid')
+```
+
+**Collate**: Defines how images are collated into batches. The default way collate
+function to use is `ResizeFromDataSizeCollate` (other collate functions are defined in
+`deeplabcut/pose_estimation_pytorch/data/collate.py`). For each batch to collate, this
+implementation:
+1. Selects the target width & height all images will be resized to by getting the size
+of the first image in the batch, and multiplying it by a scale sampled uniformly at
+random from `(min_scale, max_scale)`.
+2. Resizes all images in the batch (while preserving their aspect ratio) such that they
+are the smallest size such that the target size fits entirely in the image.
+3. Crops each resulting image into the target size with a random crop.
+
+```yaml
+collate: # rescales the images when putting them in a batch
+ type: ResizeFromDataSizeCollate # You can also use `ResizeFromListCollate`
+ max_shift: 10 # the maximum shift, in pixels, to add to the random crop (this means
+ # there can be a slight border around the image)
+ max_size: 1024 # the maximum size of the long edge of the image when resized. If the
+ # longest side will be greater than this value, resizes such that the longest side
+ # is this size, and the shortest side is smaller than the desired size. This is
+ # useful to keep some information from images with extreme aspect ratios.
+ min_scale: 0.4 # the minimum scale to resize the image with
+ max_scale: 1.0 # the maximum scale to resize the image with
+ min_short_side: 128 # the minimum size of the target short side
+ max_short_side: 1152 # the maximum size of the target short side
+ multiple_of: 32 # pads the target height, width such that they are multiples of 32
+ to_square: false # instead of using the aspect ratio of the first image, only the
+ # short side of the first image will be used to sample a "side", and the images will
+ # be cropped in squares
+```
+
+**Resizing**: Resizes the images while preserving the aspect ratio (first resizes to the
+maximum possible size, then adds padding for the missing pixels).
+
+```yaml
+resize:
+ height: 640 # int: the height to which all images will be resized
+ width: 480 # int: the width to which all images will be resized
+ keep_ratio: true # bool: whether the aspect ratio should be preserved when resizing
+```
+
+### Model
+
+The model configuration is further split into a `backbone`, optionally a `neck` and a
+number of heads.
+
+Changing the `model` configuration should only be done by expert users, and in rare
+occasions. When updating a model configuration (e.g. adding more deconvolution layers
+to a `HeatmapHead`) must be done in a way where the model configuration still makes
+sense for the project (e.g. the number of heatmaps output needs to match the number of
+bodyparts in the project).
+
+An example model configuration for a single-animal HRNet would look something like:
+
+```yaml
+model:
+ backbone: # the BaseBackbone used by the pose model
+ type: HRNet
+ model_name: hrnet_w18 # creates an HRNet W18 backbone
+ backbone_output_channels: 18
+ heads: # configures how the different heads will make predictions
+ bodypart: # configures how pose will be predicted for bodyparts
+ type: HeatmapHead
+ predictor: # the BasePredictor used to make predictions from the head's outputs
+ type: HeatmapPredictor
+ ...
+ target_generator: # the BaseTargetGenerator used to create targets for the head
+ type: HeatmapPlateauGenerator
+ ...
+ criterion: # the loss criterion used for the head
+ ...
+ ... # head-specific options, such as `heatmap_config` or `locref_config` for a "HeatmapHead"
+```
+
+The `backbone`, `neck` and `head` configurations are loaded using the
+`deeplabcut.pose_estimation_pytorch.models.backbones.base.BACKBONES`,
+`deeplabcut.pose_estimation_pytorch.models.necks.base.NECKS` and
+`deeplabcut.pose_estimation_pytorch.models.heads.base.HEADS` registries. You specify
+which type to load with the `type` parameter. Any argument for the head can then be used
+in the configuration.
+
+So to use an `HRNet` backbone for your model (as defined in
+`deeplabcut.pose_estimation_pytorch.models.backbones.hrnet.HRNet`), you could set:
+
+```yaml
+model:
+ backbone:
+ type: HRNet
+ model_name: hrnet_w32 # creates an HRNet W32
+ pretrained: true # the backbone weights for training will be loaded from TIMM (pre-trained on ImageNet)
+ interpolate_branches: false # don't interpolate & concatenate channels from all branches
+ increased_channel_count: true # use the incre_modules defined in the TIMM HRNet
+ backbone_output_channels: 128 # number of channels output by the backbone
+```
+
+### Runner
+
+The runner contains elements relating to the training runner to use (including the optimizer and
+learning rate schedulers). Unless you're experienced with machine learning and training
+models **it is not recommended to change the optimizer or scheduler**.
+
+```yaml
+runner:
+ type: PoseTrainingRunner # should not need to modify this
+ key_metric: "test.mAP" # the metric to use to select the "best snapshot"
+ key_metric_asc: true # whether "larger=better" for the key_metric
+ eval_interval: 1 # the interval between each passes through the evaluation dataset
+ optimizer: # the optimizer to use to train the model
+ ...
+ scheduler: # optional: a learning rate scheduler
+ ...
+ load_scheduler_state_dict: true/false # whether to load scheduler state when resuming training from a snapshot,
+ snapshots: # parameters for the TorchSnapshotManager
+ max_snapshots: 5 # the maximum number of snapshots to save (the "best" model does not count as one of them)
+ save_epochs: 25 # the interval between each snapshot save
+ save_optimizer_state: false # whether the optimizer state should be saved with the model snapshots (very little reason to set to true)
+ gpus: # GPUs to use to train the network
+ - 0
+ - 1
+```
+
+**Key metric**: Every time the model is evaluated on the test set, metrics are computed
+to see how the model is performing. The key metric is used to determine whether the
+current model is the "best" so far. If it is, the snapshot is saved as `...-best.pt`.
+For pose models, metrics to choose from would be `test.mAP` (with `key_metric_asc: true`
+) or `test.rmse` (with `key_metric_asc: false`).
+
+**Evaluation interval**: Evaluation slows down training (it takes time to go through all
+the evaluation images, make predictions and log results!). So instead of evaluating
+after every epoch, you could decide to evaluate every 5 epochs (by setting
+`eval_interval: 5`). While this means you get coarser information about how your model
+is training, it can speed up training on large datasets.
+
+**Optimizer**: Any optimizer inheriting `torch.optim.Optimizer`. More information about
+optimizers can be found in [PyTorch's documentation](
+https://pytorch.org/docs/stable/optim.html). Examples:
+
+```yaml
+ # SGD with initial learning rate 1e-3 and momentum 0.9
+ # see https://pytorch.org/docs/stable/generated/torch.optim.SGD.html
+ optimizer:
+ type: SGD
+ params:
+ lr: 1e-3
+ momentum: 0.9
+
+ # AdamW optimizer with initial learning rate 1e-4
+ # see https://pytorch.org/docs/stable/generated/torch.optim.AdamW.html
+ optimizer:
+ type: AdamW
+ params:
+ lr: 1e-4
+```
+
+**Scheduler**: You can use [any scheduler](
+https://pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate) defined in
+`torch.optim.lr_scheduler`, where the arguments given are arguments of the scheduler.
+The default scheduler is an LRListScheduler, which changes the learning rates at each
+milestone to the corresponding values in `lr_list`. Examples:
+
+```yaml
+ # reduce to 1e-5 at epoch 160 and 1e-6 at epoch 190
+ scheduler:
+ type: LRListScheduler
+ params:
+ lr_list: [ [ 1e-5 ], [ 1e-6 ] ]
+ milestones: [ 160, 190 ]
+
+ # Decays the learning rate of each parameter group by gamma every step_size epochs
+ # see https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.StepLR.html
+ scheduler:
+ type: StepLR
+ params:
+ step_size: 100
+ gamma: 0.1
+```
+
+You can also use schedulers that use other schedulers as parameters, such as a
+[`ChainedScheduler`](
+https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.ChainedScheduler.html)
+or a [`SequentialLR`](
+https://pytorch.org/docs/stable/generated/torch.optim.lr_scheduler.SequentialLR.html).
+
+The `SequentialLR` can be particularly useful, such as to use a first scheduler for some
+warmup epochs, and a second scheduler later. An example usage would be:
+
+```yaml
+ # Multiply the learning rate by `factor` for the first `total_iters` epochs
+ # After 5 epochs, start decaying the learning rate by `gamma` every `step_size` epochs
+ # If the initial learning rate is set to 1, the learning rates will be:
+ # epoch 0: 0.01 - using ConstantLR
+ # epoch 1: 0.01 - using ConstantLR
+ # epoch 2: 1.0 - using ConstantLR
+ # epoch 3: 1.0 - using ConstantLR
+ # epoch 4: 1.0 - using ConstantLR
+ # epoch 5: 1.0 - using StepLR
+ # epoch 6: 1.0 - using StepLR
+ # epoch 7: 0.1 - using StepLR
+ # epoch 8: 0.1 - using StepLR
+ scheduler:
+ type: SequentialLR
+ params:
+ schedulers:
+ - type: ConstantLR
+ params:
+ factor: 0.01
+ total_iters: 2
+ - type: StepLR
+ params:
+ step_size: 2
+ gamma: 0.1
+ milestones:
+ - 5
+```
+
+### Train Settings
+
+The `train_settings` key contains parameters that are specific to training. For more
+information about the `dataloader_workers` and `dataloader_pin_memory` settings, see
+[Single- and Multi-process Data Loading](
+https://pytorch.org/docs/stable/data.html#single-and-multi-process-data-loading)
+and [memory pinning](https://pytorch.org/docs/stable/data.html#memory-pinning). Setting
+`dataloader_workers: 0` uses single-process data loading, while setting it to 1 or more
+will use multi-process data loading. You should always keep
+`dataloader_pin_memory: true` when training on an NVIDIA GPU.
+
+```yaml
+train_settings:
+ batch_size: 1 # the batch size used for training
+ dataloader_workers: 0 # the number of workers for the PyTorch Dataloader
+ dataloader_pin_memory: true # pin DataLoader memory
+ display_iters: 500 # the number of iterations (steps) between each log print
+ epochs: 200 # the maximum number of epochs for which to train the model
+ seed: 42 # the random seed to set for reproducibility
+```
+
+### Logger
+
+Training runs are logged to the model folder (where the snapshots are stored) by
+default.
+
+Additionally, you can log results to [Weights and Biases](https://wandb.ai/site), by adding a
+`WandbLogger`. Just make sure you're logged in to your `wandb` account before starting
+your training run (with `wandb login` from your shell). For more information, see their
+[tutorials](https://docs.wandb.ai/tutorials) and their documentation for [`wandb.init`](https://docs.wandb.ai/ref/python/init).
+
+Logging to `wandb` is a good way to keep track of what you've run, including performance
+and metrics.
+
+```yaml
+logger:
+ type: WandbLogger
+ project_name: my-dlc3-project # the name of the project where the run should be logged
+ run_name: dekr-w32-shuffle0 # the name of the run to log
+ ... # any other argument you can pass to `wandb.init`, such as `tags: ["dekr", "split=0"]`
+```
+
+If you set up a `WandbLogger`, the corresponding run info (`entity`, `project`, `run_id`)
+will be saved in a `wandb_info.yaml` file in the model train directory, so that the WandB run
+can be easily be recovered at a later stage.
+
+You can also log images as they are seen by the model to `wandb`
+with the `image_log_interval`. This logs a random train and test image, as well as the
+targets and heatmaps for that image.
+
+### Restarting Training at a Specific Checkpoint
+
+If you wish to restart the training at a specific checkpoint, you can specify the
+full path of the checkpoint to the `resume_training_from` variable, as shown below. In this
+example, `snapshot-010.pt` will be loaded before training starts, and the model will
+continue to train from the 10th epoch on.
+
+```yaml
+# model configuration
+...
+# weights from which to resume training
+resume_training_from: /Users/john/dlc-project-2021-06-22/dlc-models-pytorch/iteration-0/dlcJun22-trainset95shuffle0/train/snapshot-010.pt
+```
+
+When continuing to train a model, you may want to modify the learning rate scheduling
+that was being used (by editing the configuration under the `scheduler` key). When doing
+so, you *must set `load_scheduler_state_dict: false`* in your `runner` config!
+Otherwise, the parameters for the scheduler your started training with will be loaded
+from the state dictionary, and your edits might not be kept!
+
+### Inference
+The `inference:` block in `pytorch_config.yaml` allows configuring **inference-specific
+behavior** for your model. It is independent of training settings and can include multiple
+sub-configs, currently supporting **multithreading**, **compile**, **autocast**, and **conditions**.
+
+**Example**
+```yaml
+inference:
+ multithreading:
+ enabled: true
+ queue_length: 4
+ timeout: 30.0
+ compile:
+ enabled: false
+ backend: "inductor"
+ autocast:
+ enabled: false
+ conditions:
+ config_path: /path/to/model-dir/pytorch_config.yaml
+ snapshot_path: /path/to/model-dir/snapshot-best-150.pth
+```
+
+**Sub-configs**
+- `multithreading`
+ Controls producer-consumer threading during inference for preprocessing and batching.
+ - `enabled` (`bool`): Enable/disable multithreading.
+ - `queue_length` (`int`): Maximum number of batches to queue between preprocessing and model prediction.
+ - `timeout` (`float`): Timeout in seconds for the preprocessing queue.
+- `compile`
+ Controls optional `torch.compile` usage during inference.
+ **Note:** Using `torch.compile` may speed up inference but introduces some initialization overhead.
+ It is also known to fail in certain setups, environments, or architectures (e.g., `ctd_coam_*` models).
+ Use at your own risk.
+ - `enabled` (`bool`): Enable/disable compilation. Default: `false`.
+ - `backend` (`str`): Backend to use when compiling (`"inductor"`, `"aot_eager"`, etc.).
+- `autocast`
+ Controls optional mixed precision during inference.
+ - `enabled` (`bool`): Enable/disable `torch.autocast`. Default: `false`.
+ Note: Enabling autocast may reduce inference accuracy. It is disabled by default.
+- `conditions`
+ Only used for **Conditional Top-Down (CTD)** models to specify which conditions should be used during inference.
+
+## Training Top-Down Models
+
+Top-down models are split into two main elements: a detector (localizing individuals in
+the images) and a pose model predicting each individual's pose (once localization is
+done, obtaining pose is just like getting pose in a single-animal model!).
+
+The "pose" part of the model configuration is exactly the same as for single-animal or
+bottom-up models (configured through the `data`, `model`, `runner` and `train_settings`
+). The detector is configured through a detector key, at the top-level of the
+configuration.
+
+### Detector Configuration
+
+When training top-down models, you also need to configure how the detector will be
+trained. All information relating to the detector is placed under the `detector` key.
+
+```yaml
+detector:
+ data: # which data augmentations will be used, same options as for the pose model
+ colormode: RGB
+ inference: # default inference configuration for detectors
+ normalize_images: true
+ train: # default train configuration for detectors
+ affine:
+ p: 0.9
+ rotation: 30
+ scaling: [ 0.5, 1.25 ]
+ translation: 40
+ hflip: true
+ normalize_images: true
+ model: # the detector to train
+ type: FasterRCNN
+ variant: fasterrcnn_mobilenet_v3_large_fpn
+ pretrained: true
+ runner: # detector train runner configuration (same keys as for the pose model)
+ type: DetectorTrainingRunner
+ ...
+ train_settings: # detector train settings (same keys as for the pose model)
+ ...
+ resume_training_from: # optional: restart the training at the specific checkpoint
+```
+
+Currently, the only detectors available are `FasterRCNN` and `SSDLite`. However, multiple variants of
+`FasterRCNN` are available (you can view the different variants on
+[torchvision's object detection page](https://pytorch.org/vision/stable/models.html#object-detection)). It's recommended to use the fastest
+detector that brings enough performance. The recommended variants are the following
+(from fastest to most powerful, taken from torchvision's documentation):
+
+| name | Box MAP (larger = more powerful) | Params (larger = more powerful) | GFLOPS (larger = slower) |
+|-----------------------------------|---------------------------------:|--------------------------------:|-------------------------:|
+| SSDLite | 21.3 | 3.4M | 0.58 |
+| fasterrcnn_mobilenet_v3_large_fpn | 32.8 | 19.4M | 4.49 |
+| fasterrcnn_resnet50_fpn | 37 | 41.8M | 134.38 |
+| fasterrcnn_resnet50_fpn_v2 | 46.7 | 43.7M | 280.37 |
+
+
+### Restarting Training of an Object Detector at a Specific Checkpoint
+
+If you wish to restart the training of a detector at a specific checkpoint, you can
+specify the full path of the checkpoint to the detector's `resume_training_from` variable, as
+shown below. In this example, `snapshot-detector-020.pt` will be loaded before training
+starts, and the model will continue to train from the 20th epoch on.
+
+```yaml
+detector:
+ # detector configuration
+ ...
+ # weights from which to resume training
+ resume_training_from: /Users/john/dlc-project-2021-06-22/dlc-models-pytorch/iteration-0/dlcJun22-trainset95shuffle0/train/snapshot-detector-020.pt
+```
+
+When continuing to train a detector, you may want to modify the learning rate scheduling
+that was being used (by editing the configuration under the `scheduler` key). When doing
+so, you *must set `load_scheduler_state_dict: false`* in your `detector`: `runner`
+config! Otherwise, the parameters for the scheduler your started training with will be
+loaded from the state dictionary, and your edits might not be kept!
+
+(bbox-from-pose)=
+### Generating Bounding Boxes from Pose
+
+To train object detection models (for top-down pose estimation), ground truth bounding
+boxes are needed. As they are not annotated in DeepLabCut, they are generated from the
+ground truth pose: simply take the minimum and maximum for the x and y axes, add a small
+margin and you have your bounding box! The default setting adds a margin of 20 pixels
+around the pose. This works well in most cases, but in some cases you should update this
+value (e.g. when you have very small or large images).
+
+You can edit that value in the `pytorch_config.yaml` for your model through the
+`data: bbox_margin` parameter for the detector:
+
+```yaml
+detector:
+ data:
+ bbox_margin: 20
+ ...
+```
+
+
diff --git a/docs/pytorch/user_guide.md b/docs/pytorch/user_guide.md
new file mode 100644
index 0000000000..d4e5e6f2e9
--- /dev/null
+++ b/docs/pytorch/user_guide.md
@@ -0,0 +1,100 @@
+---
+deeplabcut:
+ last_content_updated: '2025-07-01'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(dlc3-user-guide)=
+# DeepLabCut 3.0 - PyTorch User Guide
+
+## Using DeepLabCut 3.0
+
+**DeepLabCut 3.0 keeps the same high-level API that you know, but has a full new PyTorch backend.
+Moreover, it is a rewrite that is more developer friendly, more powerful, and built for modern deep
+learning-based computer vision applications.**
+
+**NOTE**🔥: We suggest that if you're just starting with DeepLabCut you start with the
+PyTorch backend. You will easily know which "engine" you are using by looking at the
+main `config.yaml` file, or top right corner in the GUI. If you have DeepLabCut projects
+in TensorFlow, we've got you covered too: you can seamlessly switch to train your
+already labeled data by simply switching the engine (and thereby also compare
+performance). In short, expect a boost 🔥.
+
+In short, PyTorch models can be trained in any DeepLabCut project. If you have a project
+already made, simply add a new key to your project `config.yaml` file specifying
+`engine: pytorch`. Then any new training dataset that will be created will be a PyTorch
+model (see [Creating Shuffles and Model Configuration](
+#Creating-Shuffles-and-Model-Configuration)) to learn more about training PyTorch
+models. To train Tensorflow models again, you can set `engine: tensorflow`.
+
+### Installation
+
+To see the DeepLabCut 3.0 installation guide, check the [installation docs](how-to-install).
+
+### Using the GUI
+
+You can use the GUI to train DeepLabCut projects. You can switch between the PyTorch
+and TensorFlow engine through the drop-down menu in the top right corner.
+
+### Quick guide (standard API)
+
+The standard use of DLC does not change (via the high-level API), as you can see in the standard guide: for [single](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide) and [multiple individuals](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide).
+
+Also check out several COLAB notebooks on how you can use the code.
+
+For the
+
+## Major changes
+
+### From iterations to epochs
+
+Pytorch models in DeepLabCut 3.0 are trained for a set number of `epochs`, instead of a
+maximum number of `iterations`. An epoch is a single pass through the training dataset,
+which means your model has seen each training image exactly once.
+
+- So if you have 64 training images for your network, an epoch is 64 iterations with batch
+size 1 (or 32 iterations with batch size 2, 16 with batch size 4, etc.).
+
+## API
+
+### Creating Shuffles and Model Configuration
+
+You can configure models using the `pytorch_config.yaml` file, as described
+[here](dlc3-pytorch-config). You can use the same methods to create new shuffles in
+DeepLabCut 3.0 as you did for Tensorflow models (`deeplabcut.create_training_dataset`
+and `deeplabcut.create_training_model_comparison`).
+
+More information about the different PyTorch model architectures available in DeepLabCut
+is available [here](architectures). You can see a list of supported
+architectures/variants by using:
+
+```python
+from deeplabcut.pose_estimation_pytorch import available_models
+print(available_models())
+```
+
+
+
+### Development State and Road Map 🚧
+
+The table below describes the DeepLabCut API methods that have been implemented for the
+PyTorch engine, as well as indications which options are not yet implemented, and which
+parameters are not valid for the DLC 3.0 PyTorch API.
+
+
+| API Method | Implemented | Parameters not yet implemented | Parameters invalid for pytorch |
+|--------------------------------|:-----------:|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------|
+| `train_network` | 🟢 | | `maxiters`, `saveiters`, `allow_growth`, `autotune` |
+| `return_train_network_path` | 🟢 | | |
+| `evaluate_network` | 🟢 | | |
+| `return_evaluate_network_data` | 🔴 | | `TFGPUinference`, `allow_growth` |
+| `analyze_videos` | 🟠 | `greedy`, `calibrate`, `window_size` | |
+| `create_tracking_dataset` | 🟢 | | |
+| `analyze_time_lapse_frames` | 🟢 | the name has changed to `analyze_images` to better reflect what it actually does (no video needed) | |
+| `convert_detections2tracklets` | 🟠 | `greedy`, `calibrate`, `window_size` | |
+| `extract_maps` | 🟢 | | |
+| `visualize_scoremaps` | 🟢 | | |
+| `visualize_locrefs` | 🟢 | | |
+| `visualize_paf` | 🟢 | | |
+| `extract_save_all_maps` | 🟢 | | |
+| `export_model` | 🟢 | | |
diff --git a/docs/pytorch_dlc.md b/docs/pytorch_dlc.md
new file mode 100644
index 0000000000..6d2ddca45b
--- /dev/null
+++ b/docs/pytorch_dlc.md
@@ -0,0 +1,173 @@
+---
+deeplabcut:
+ last_content_updated: '2024-01-17'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# DeepLabCut: PyTorch API
+
+## Modules
+
+- [data](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/project.py#L7):
+The `deeplabcut.pose_estimations_pytorch.data` package contains all code for pytorch
+dataset creation and test/train splitting.
+ - `Project` class provides train and test splitting and converts dataset to required
+ format. For instance, to [COCO]() format.
+ - `PoseTrainDataset` class is a [torch.utils.Dataset](https://pytorch.org/docs/stable/data.html) class, which converts raw
+ images and keypoints to a tensor dataset for training and evaluation.
+- [models](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models):
+The `deeplabcut.pose_estimations_pytorch.models` package contains all related to
+building a model with `backbone`, `neck` (optional) and `head`.
+- [train_module](https://github.com/nastya236/DLCdev/blob/69005057eeac3c1492712863303f8268cee776e6/deeplabcut/pose_estimation_pytorch/data/models):
+The `deeplabcut.pose_estimations_pytorch.train_module` contains all classes for model
+training and validation.
+
+## API
+
+The PyTorch implementation of DeepLabCut is very similar to the Tensorflow multi-animal
+implementation: the same steps need to be followed, just with slightly different API
+calls (and different model names).
+
+Up until it's time to create the training dataset, there are no changes to the way a
+PyTorch or Tensorflow project should be created.
+
+### Creating a Training Dataset
+
+To create a training dataset for a DeepLabCut PyTorch model, simply call:
+```python
+import deeplabcut
+deeplabcut.create_training_dataset(
+ path_config_file,
+ net_type="dekr_32",
+)
+```
+
+This will create folders for the training dataset in the same way as the Tensorflow
+version, with an addition configuration file in the `train` folder:
+`pytorch_config.yaml`. This is the file that can be edited to modify the model
+architecture or training parameters.
+
+There are currently two "families" of models implemented in PyTorch: DEKR (Geng, Zigang,
+et al. "Bottom-up human pose estimation via disentangled keypoint regression."
+Proceedings of the IEEE/CVF conference on computer vision and pattern recognition.
+2021.) and Tokenpose (Li, Yanjie, et al. "Tokenpose: Learning keypoint tokens for human
+pose estimation." Proceedings of the IEEE/CVF International conference on computer
+vision. 2021.). The choices of `net_type` that will create PyTorch training sets are:
+- `"dekr_16"`
+- `"dekr_32"`
+- `"dekr_48"`
+- `"token_pose_w16"`
+- `"token_pose_w32"`
+- `"token_pose_w48"`
+
+Note that Tokenpose models cannot currently be used with projects that contain unique
+keypoints.
+
+### Training the network
+Training a PyTorch model is done in a very similar manner as a tensorflow model, though
+currently the PyTorch API needs to be called directly:
+```python
+import deeplabcut.pose_estimation_pytorch.apis as api
+api.train_network(config_path, shuffle=1, trainingsetindex=0)
+```
+
+**Parameters**
+```
+config : path to the yaml config file of the project
+shuffle : index of the shuffle we want to train on
+trainingsetindex : training set index
+transform: Augmentation pipeline for the images
+ if None, the augmentation pipeline is built from config files
+ Advice if you want to use custom transformations:
+ Keep in mind that in order for transfer learning to be efficient, your
+ data statistical distribution should resemble the one used to pretrain your backbone
+ In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with
+ A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225])
+transform_cropped: Augmentation pipeline for the cropped images around animals
+ if None, the augmentation pipeline is built from config files
+ Advice if you want to use custom transformations:
+ Keep in mind that in order for transfer learning to be efficient, your
+ data statistical distribution should resemble the one used to pretrain your backbone
+ In most cases (e.g backbone was pretrained on ImageNet), that means it should be Normalized with
+ A.Normalize(mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225])
+modelprefix: directory containing the deeplabcut configuration files to use
+ to train the network (and where snapshots will be saved). By default, they
+ are assumed to exist in the project folder.
+snapshot_path: if resuming training, used to specify the snapshot from which to resume
+detector_path: if resuming training of a top down model, used to specify the detector snapshot from
+ which to resume
+**kwargs : could be any entry of the pytorch_config dictionary. Examples are
+ to see the full list see the pytorch_cfg.yaml file in your project folder
+```
+
+### Evaluating the network
+As for training, the main difference is the need to call the API directly.
+```python
+import deeplabcut.pose_estimation_pytorch.apis as api
+api.evaluate_network(config_path, shuffle=1, trainingsetindex="all")
+```
+
+**Parameters**
+```
+config: path to the project's config file
+shuffles: Iterable of integers specifying the shuffle indices to evaluate.
+trainingsetindex: Integer specifying which training set fraction to use.
+ Evaluates all fractions if set to "all"
+snapshotindex: index (starting at 0) of the snapshot we want to load. To
+ evaluate the last one, use -1. To evaluate all snapshots, use "all". For
+ example if we have 3 models saved
+ - snapshot-0.pt
+ - snapshot-50.pt
+ - snapshot-100.pt
+ and we want to evaluate snapshot-50.pt, snapshotindex should be 1. If None,
+ the snapshotindex is loaded from the project configuration.
+plotting: Plots the predictions on the train and test images. If provided it must
+ be either ``True``, ``False``, ``"bodypart"``, or ``"individual"``. Setting
+ to ``True`` defaults as ``"bodypart"`` for multi-animal projects.
+show_errors: display train and test errors.
+transform: transformation pipeline for evaluation
+ ** Should normalise the data the same way it was normalised during training **
+modelprefix: directory containing the deeplabcut models to use when evaluating
+ the network. By default, they are assumed to exist in the project folder.
+batch_size: the batch size to use for evaluation
+```
+
+### Analyzing novel videos
+One big difference between the PyTorch and Tensorflow implementations comes in the way
+animal assembly happens (for multi-animal models). While in Tensorflow, assembly was a
+separate step that needed to be done from the keypoints, in the PyTorch version it's
+integrated directly into the models. From an API standpoint, that does not change much.
+
+Again, the PyTorch API needs to be invoked directly (it also has the `auto_track`
+option).
+```python
+import deeplabcut.pose_estimation_pytorch.apis as api
+api.analyze_videos(config_path, ["/fullpath/project/videos/test.mp4"], videotype=".mp4")
+```
+
+The PyTorch detections need to be converted to tracklets using the PyTorch API, but then
+the original tracklet stitching can be used.
+```python
+import deeplabcut
+import deeplabcut.pose_estimation_pytorch.apis as api
+api.convert_detections2tracklets(
+ config_path,
+ videos=['/fullpath/project/videos/test.mp4'],
+ videotype=".mp4",
+)
+deeplabcut.stitch_tracklets(
+ config_path,
+ videos=['/fullpath/project/videos/test.mp4'],
+ videotype=".mp4",
+)
+```
+
+Creating labeled videos can then be called in exactly the same way as before.
+```python
+import deeplabcut
+deeplabcut.create_labeled_video(
+ config_path,
+ videos=['/fullpath/project/videos/test.mp4'],
+ videotype=".mp4",
+)
+```
diff --git a/docs/quick-start/single_animal_quick_guide.md b/docs/quick-start/single_animal_quick_guide.md
new file mode 100644
index 0000000000..99362ea9d0
--- /dev/null
+++ b/docs/quick-start/single_animal_quick_guide.md
@@ -0,0 +1,78 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# QUICK GUIDE to single Animal Training:
+**The main steps to take you from project creation to analyzed videos:**
+
+Open ipython in the terminal:
+```
+ipython
+```
+
+Import DeepLabCut:
+```
+import deeplabcut
+```
+
+Create a new project:
+```
+deeplabcut.create_new_project("project_name", "experimenter", ["path of video 1", "path of video2", ..])
+```
+
+Set a config_path variable for ease of use + go edit this file!:
+```
+config_path = "yourdirectory/project_name/config.yaml"
+```
+
+Extract frames:
+```
+deeplabcut.extract_frames(config_path)
+```
+
+Label frames:
+```
+deeplabcut.label_frames(config_path)
+```
+
+Check labels [OPTIONAL]:
+```
+deeplabcut.check_labels(config_path)
+```
+
+Create training dataset:
+```
+deeplabcut.create_training_dataset(config_path)
+```
+
+Train the network:
+```
+deeplabcut.train_network(config_path)
+```
+
+Evaluate the trained network:
+```
+deeplabcut.evaluate_network(config_path)
+```
+
+ Video analysis:
+```
+deeplabcut.analyze_videos(config_path, ["path of video 1", "path of video2", ..])
+```
+
+Filter predictions [OPTIONAL]:
+```
+deeplabcut.filterpredictions(config_path, ["path of video 1", "path of video2", ..])
+```
+
+Plot results (trajectories):
+```
+deeplabcut.plot_trajectories(config_path, ["path of video 1", "path of video2", ..], filtered=True)
+```
+
+Create a video:
+```
+deeplabcut.create_labeled_video(config_path, ["path of video 1", "path of video2", ..], filtered=True)
+```
diff --git a/docs/tutorial.md b/docs/quick-start/tutorial_maDLC.md
similarity index 92%
rename from docs/tutorial.md
rename to docs/quick-start/tutorial_maDLC.md
index 947e036513..111ed996f5 100644
--- a/docs/tutorial.md
+++ b/docs/quick-start/tutorial_maDLC.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Multi-animal pose estimation with DeepLabCut: A 5-minute tutorial
## GUI:
@@ -69,7 +75,17 @@ deeplabcut.create_multianimaltraining_dataset(
```
**(7) Train the network**
+
```python
+# PyTorch Engine
+deeplabcut.train_network(
+ config_path,
+ device="cuda",
+ save_epochs=5,
+ epochs=200,
+)
+
+# TensorFlow Engine
deeplabcut.train_network(
config_path,
saveiters=10000,
diff --git a/docs/recipes/BatchProcessing.md b/docs/recipes/BatchProcessing.md
index abac0bf27d..279433eceb 100644
--- a/docs/recipes/BatchProcessing.md
+++ b/docs/recipes/BatchProcessing.md
@@ -1,12 +1,19 @@
-
+---
+deeplabcut:
+ last_content_updated: '2025-09-16'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Automate training and video analysis: Batch Processing
## Tips for working with DLC networks:
-- Now you have a DLC network and are happy with the performance on selected videos, you may want to run it on all your videos without hassle. If all your videos are in one folder this is easy, simply pass the foldername to `deeplabcut.analyze_videos(config,[folder])` and you are fine. What if the videos are scattered?
-
+Now you have a DLC network and are happy with the performance on selected videos, you may want to run it on all your
+videos without hassle. If all your videos are in one folder this is easy, simply pass the foldername to
+`deeplabcut.analyze_videos(config,[folder])` and you are fine. What if the videos are scattered?
-You could create a simply script that runs over all your video folders with the network of choice. Your "key" to this network is your config.yaml file.
+You can create a simple script that runs over all your video folders with the network of choice. Your "key" to this
+network is your config.yaml file.

@@ -33,18 +40,18 @@ import deeplabcut
def getsubfolders(folder):
''' returns list of subfolders '''
- return [os.path.join(folder,p) for p in os.listdir(folder) if os.path.isdir(os.path.join(folder,p))]
+ return [os.path.join(folder, p) for p in os.listdir(folder) if os.path.isdir(os.path.join(folder, p))]
-project='ComplexWheelD3-12-Fumi-2019-01-28'
+project = "ComplexWheelD3-12-Fumi-2019-01-28"
-shuffle=1
+shuffle = 1
-prefix='/home/alex/DLC-workshopRowland'
+prefix = "/home/alex/DLC-workshopRowland"
-projectpath=os.path.join(prefix,project)
-config=os.path.join(projectpath,'config.yaml')
+projectpath = os.path.join(prefix, project)
+config = os.path.join(projectpath, "config.yaml")
-basepath='/home/alex/BenchmarkingExperimentsJan2019' #data'
+basepath = "/home/alex/BenchmarkingExperimentsJan2019"
'''
@@ -57,13 +64,13 @@ Imagine that the data (here: videos of 3 different types) are in subfolders:
'''
-subfolders=getsubfolders(basepath)
+subfolders = getsubfolders(basepath)
for subfolder in subfolders: #this would be January, February etc. in the upper example
- print("Starting analyze data in:", subfolder)
- subsubfolders=getsubfolders(subfolder)
- for subsubfolder in subsubfolders: #this would be Febuary1, etc. in the upper example...
- print("Starting analyze data in:", subsubfolder)
- for vtype in ['.mp4','.m4v','.mpg']:
+ print("Starting analyze data in: ", subfolder)
+ subsubfolders = getsubfolders(subfolder)
+ for subsubfolder in subsubfolders: #this would be February1, etc. in the upper example...
+ print("Starting analyze data in: ", subsubfolder)
+ for vtype in [".mp4", ".m4v", ".mpg"]:
deeplabcut.analyze_videos(config,[subsubfolder],shuffle=shuffle,videotype=vtype,save_as_csv=True)
```
@@ -90,25 +97,25 @@ import os
import deeplabcut
-Maxiter=int(1.5*10**5)
+epochs = 200
model=int(sys.argv[1])
-Projects=[['project1-phoenix-2019-01-28'],['ComplexWheelD3-12-Fumi-2019-01-28', 'maze-ariel-2019-01-28'], ['TBI-BvA-2019-01-28','group-eli-2019-01-28']]
+Projects=[["project1-phoenix-2019-01-28"], ["ComplexWheelD3-12-Fumi-2019-01-28", "maze-ariel-2019-01-28"], ["TBI-BvA-2019-01-28", "group-eli-2019-01-28"]]
shuffle=1
-prefix='/home/alex/DLC-workshopRowland'
+prefix = "/home/alex/DLC-workshopRowland"
for project in Projects[model]:
- projectpath=os.path.join(prefix,project)
- config=os.path.join(projectpath,'config.yaml')
+ projectpath = os.path.join(prefix, project)
+ config = os.path.join(projectpath, "config.yaml")
- cfg=deeplabcut.auxiliaryfunctions.read_config(config)
- previous_path=cfg['project_path']
+ cfg = deeplabcut.auxiliaryfunctions.read_config(config)
+ previous_path = cfg["project_path"]
- cfg['project_path']=projectpath
- deeplabcut.auxiliaryfunctions.write_config(config,cfg)
+ cfg["project_path"]=projectpath
+ deeplabcut.auxiliaryfunctions.write_config(config, cfg)
print("This is the name of the script: ", sys.argv[0])
print("Shuffle: ", shuffle)
@@ -116,22 +123,18 @@ for project in Projects[model]:
deeplabcut.create_training_dataset(config, Shuffles=[shuffle])
- deeplabcut.train_network(config, shuffle=shuffle, max_snapshots_to_keep=5, maxiters=Maxiter)
+ deeplabcut.train_network(config, shuffle=shuffle, max_snapshots_to_keep=5, epochs=epochs)
print("Evaluating...")
- deeplabcut.evaluate_network(config, Shuffles=[shuffle],plotting=True)
+ deeplabcut.evaluate_network(config, Shuffles=[shuffle], plotting=True)
print("Analyzing videos..., switching to last snapshot...")
- #cfg=deeplabcut.auxiliaryfunctions.read_config(config)
- #cfg['snapshotindex']=-1
- #deeplabcut.auxiliaryfunctions.write_config(config,cfg)
-
for vtype in ['.mp4','.m4v','.mpg']:
try:
- deeplabcut.analyze_videos(config,[str(os.path.join(projectpath,'videos'))],shuffle=shuffle,videotype=vtype,save_as_csv=True)
- except:
+ deeplabcut.analyze_videos(config, [str(os.path.join(projectpath, "videos"))], shuffle=shuffle, videotype=vtype, save_as_csv=True)
+ except Exception:
pass
print("DONE WITH ", project," resetting to original path")
- cfg['project_path']=previous_path
- deeplabcut.auxiliaryfunctions.write_config(config,cfg)
+ cfg["project_path"] = previous_path
+ deeplabcut.auxiliaryfunctions.write_config(config, cfg)
```
diff --git a/docs/recipes/ClusteringNapari.md b/docs/recipes/ClusteringNapari.md
index 4d309d74e5..bb7faf41c9 100644
--- a/docs/recipes/ClusteringNapari.md
+++ b/docs/recipes/ClusteringNapari.md
@@ -1,62 +1,93 @@
-
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Clustering in the napari-DeepLabCut GUI
-To increase model performance, one can find the errors in the user-defined label (or in output H5 files after video inference). You can correct the errors and add them back into the training dataset, a process called active learning.
+To increase model performance, one can find the errors in the user-defined label (or in output H5 files after video
+inference). You can correct the errors and add them back into the training dataset, a process called active learning.
-User errors can be detrimental to model performance, so beyond just `check_labels`, this tool allows you to find your mistakes. If you are curious about how errors affect performance, read the paper: [A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives](https://www.sciencedirect.com/science/article/pii/S0896627320307170).
+User errors can be detrimental to model performance, so beyond just `check_labels`, this tool allows you to find your
+mistakes. If you are curious about how errors affect performance, read the paper:
+[A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives](https://www.sciencedirect.com/science/article/pii/S0896627320307170).
**TL;DR: your data quality matters!**
-
+
```{Hint}
**Labeling Pitfalls: How Corruptions Affect Performance**
-(A) Illustration of two types of labeling errors. Top is ground truth, middle is missing a label at the tailbase, and bottom is if the labeler swapped the ear identity (left to right, etc.). (B) Using a small training dataset of 106 frames, how do the corruptions in (A) affect the percent of correct keypoints (PCK) on the test set as the distance to ground truth increases from 0 pixels (perfect prediction) to 20 pixels (larger error)? The x axis denotes the difference in the ground truth to the predicted location (RMSE in pixels), whereas the y axis is the fraction of frames considered accurate (e.g., z80% of frames fall within 9 pixels, even on this small training dataset, for points that are not corrupted, whereas for swapped points this falls to z65%). The fraction of the dataset that is corrupted affects this value. Shown is when missing the tailbase label (top) or swapping the ears in 1%, 5%, 10%, and 20% of frames (of 106 labeled training images). Swapping versus missing labels has a more notable adverse effect on network performance.
+(A) Illustration of two types of labeling errors. Top is ground truth, middle is missing a label at the tailbase, and
+bottom is if the labeler swapped the ear identity (left to right, etc.). (B) Using a small training dataset of 106
+frames, how do the corruptions in (A) affect the percent of correct keypoints (PCK) on the test set as the distance
+to ground truth increases from 0 pixels (perfect prediction) to 20 pixels (larger error)? The x axis denotes the
+difference in the ground truth to the predicted location (RMSE in pixels), whereas the y axis is the fraction of
+frames considered accurate (e.g., z80% of frames fall within 9 pixels, even on this small training dataset, for
+points that are not corrupted, whereas for swapped points this falls to z65%). The fraction of the dataset that is
+corrupted affects this value. Shown is when missing the tailbase label (top) or swapping the ears in 1%, 5%, 10%,
+and 20% of frames (of 106 labeled training images). Swapping versus missing labels has a more notable adverse effect
+on network performance.
```
-The DeepLabCut toolbox supports **active learning** by extracting outlier frames be several methods and allowing the user to correct the frames, then retrain the model. See the [Nature Protocols paper](https://www.nature.com/articles/s41596-019-0176-0) for the detailed steps, or in the docs, [here](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#m-optional-active-learning-network-refinement-extract-outlier-frames).
+The DeepLabCut toolbox supports **active learning** by extracting outlier frames be several methods and allowing the
+user to correct the frames, then retrain the model. See the
+[Nature Protocols paper](https://www.nature.com/articles/s41596-019-0176-0) for the detailed steps, or in the docs,
+[here](active-learning).
-To facilitate this process, here we propose a new way to detect 'outlier frames', which is planned to be released in ~Sept 2022. Your contributions and suggestions are welcomed, so test the [PR](https://github.com/DeepLabCut/napari-deeplabcut/pull/38) and give us feedback!
+To facilitate this process, here we propose a new way to detect 'outlier frames'.
+Your contributions and suggestions are welcomed, so test the
+[PR](https://github.com/DeepLabCut/napari-deeplabcut/pull/38) and give us feedback!
-This #cookbook recipe aims to show a usecase of **clustering in napari** and is contributed by 2022 DLC AI Resident [Sabrina Benas](https://twitter.com/Sabrineiitor) 💜.
+This #cookbook recipe aims to show a usecase of **clustering in napari** and is contributed by 2022 DLC AI Resident
+[Sabrina Benas](https://x.com/Sabrineiitor) 💜.
## Detect Outliers to Refine Labels
### Open `napari` and the `DeepLabCut plugin`
- - Then open your `CollectedData_.h5` file. We used the Horse-30 dataset, presented in [Mathis, Biasi et al. WACV 2022](http://horse10.deeplabcut.org/), as our demo and development set. Here is an example of what it should look like:
+Then open your `CollectedData_.h5` file. We used the Horse-30 dataset, presented in
+[Mathis, Biasi et al. WACV 2022](http://horse10.deeplabcut.org/), as our demo and development set. Here is an example of what it should look like:
-
+
### Clustering
-- Click on the button `cluster` and wait a few seconds until it displays a new layer with the cluster:
+Click on the button `cluster` and wait a few seconds until it displays a new layer with the cluster:
-
+
You can click on a point and see the image on the right with the keypoints:
-
+
### Visualize & refine
-If you decided to refine that frame (we moved the points to make outliers obvious), click `show img` and refine them using the plugin features and instructions:
+If you decided to refine that frame (we moved the points to make outliers obvious), click `show img` and refine them
+using the plugin features and instructions:
-
+
- ```{Attention}
- When you're done, you need to click `ctl-s` to save it.
+```{Attention}
+When you're done, you need to click `ctl-s` to save it.
```
-- You can go back to the cluster layer by clicking on `close img` and refine another image. Reminder, when you're done editing you need to click `ctl-s` to save your work. And now you can take the updated `CollectedData` file, create and **new training shuffle**, and train the network! Read more about how to [create a training dataset](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#f-create-training-dataset-s).
+You can go back to the cluster layer by clicking on `close img` and refine another image. Reminder, when you're done
+editing you need to click `ctl-s` to save your work. And now you can take the updated `CollectedData` file, create
+and **new training shuffle**, and train the network! Read more about how to
+[create a training dataset](create-training-dataset).
```{hint}
-If you want to change the clustering method, you can modify the file [kmeans.py](https://github.com/DeepLabCutAIResidency/napari-deeplabcut/blob/cluster1/src/napari_deeplabcut/kmeans.py)
+If you want to change the clustering method, you can modify the file
+[kmeans.py](https://github.com/DeepLabCutAIResidency/napari-deeplabcut/blob/cluster1/src/napari_deeplabcut/kmeans.py)
+```
::::{important}
-You have to keep the way the file is opened (pandas dataframe) and the output has to be the cluster points, the points colors in the cluster colors and the frame names (in this order).
+You have to keep the way the file is opened (pandas dataframe) and the output has to be the cluster points, the points
+colors in the cluster colors and the frame names (in this order).
::::
```
diff --git a/docs/recipes/DLCMethods.md b/docs/recipes/DLCMethods.md
index b2f7a5a7af..b3710b7d55 100644
--- a/docs/recipes/DLCMethods.md
+++ b/docs/recipes/DLCMethods.md
@@ -1,8 +1,20 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# How to write a DLC Methods Section
**Pose estimation using DeepLabCut**
-For body part tracking we used DeepLabCut (version 2.X.X) [Mathis et al, 2018, Nath et al, 2019]. Specifically, we labeled X number of frames taken from X videos/animals (then X% was used for training (default is 95%). We used a X-based neural network (i.e., X = ResNet-50, ResNet-101, MobileNetV2-0.35, MobileNetV2-0.5, MobileNetV2-0.75, MobileNetV2-1, EfficientNet ..X, dlcrnet_ms5, etc.)*** with default parameters* for X number of training iterations. We validated with X number of shuffles, and found the test error was: X pixels, train: X pixels (image size was X by X). We then used a p-cutoff of X (i.e. 0.9) to condition the X,Y coordinates for future analysis. This network was then used to analyze videos from similar experimental settings.
+For body part tracking we used DeepLabCut (version 3.X.X) [Mathis et al, 2018, Nath et al, 2019]. Specifically, we
+labeled X number of frames taken from X videos/animals (then X% was used for training (default is 95%). We used a
+X-based neural network (i.e., X = ResNet-50, ResNet-101, MobileNetV2-0.35, MobileNetV2-0.5, MobileNetV2-0.75,
+MobileNetV2-1, EfficientNet ..X, dlcrnet_ms5, cspnext_s, dekr_w32, rtmpose_s, etc.)*** with default parameters* for X
+number of training iterations. We validated with X number of shuffles, and found the test error was: X pixels, train:
+X pixels (image size was X by X). We then used a p-cutoff of X (i.e. 0.9) to condition the X,Y coordinates for future
+analysis. This network was then used to analyze videos from similar experimental settings.
*If any defaults were changed in *`pose_config.yaml`*, mention them.
@@ -43,4 +55,5 @@ If you use ResNets, consider citing Insafutdinov et al 2016 & He et al 2016. If
> 770–778 (2016). URL https://arxiv.org/abs/
> 1512.03385.
-We also have the network graphic freely available on SciDraw.io if you'd like to use it! https://scidraw.io/drawing/290. If you use our DLC logo, please include the TM symbol, thank you!
+We also have the network graphic freely available on SciDraw.io if you'd like to use it! https://scidraw.io/drawing/290.
+If you use our DLC logo, please include the TM symbol, thank you!
diff --git a/docs/recipes/MegaDetectorDLCLive.md b/docs/recipes/MegaDetectorDLCLive.md
index 20e35a38fc..416a23125c 100644
--- a/docs/recipes/MegaDetectorDLCLive.md
+++ b/docs/recipes/MegaDetectorDLCLive.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# 💚 MegaDetector+DeepLabCut 💜
[DeepLabCut-Live](https://github.com/DeepLabCut/DeepLabCut-live) is an open source and free real-time package from DeepLabCut that allows for real-time, low-latency pose estimation. [The DeepLabCut-ModelZoo](http://modelzoo.deeplabcut.org/) is our growing collection of pretrained animal models for rapid deployment; no training is typically required to use these models. MegaDetector is a free open software trained to detect animals, people, and vehicles from camera trap images. Check [here](https://github.com/microsoft/CameraTraps/blob/main/megadetector.md) for further information.
@@ -14,7 +20,9 @@ MegaDetector detects an animal and generates a bounding box around the animal. T
## DeepLabCut-Live
-DeepLabCut-Live! is a real-time package for running DeepLabCut. However, you can also use it as a lighter-weight package for running DeeplabCut even if you don't need real-time. It's very useful to use in HPC or servers, or in Apps, as we do here. To read more, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/deeplabcutlive.html).
+DeepLabCut-Live! is a real-time package for running DeepLabCut. However, you can also use it as a lighter-weight
+package for running DeeplabCut even if you don't need real-time. It's very useful to use in HPC or servers, or in Apps,
+as we do here. To read more, check out the [docs](deeplabcut-live).
### MegaDetector meets DeepLabCut
@@ -66,9 +74,9 @@ All information seen on the output image is recorded on the **Download JSON file
"file": "image0.jpg", //image filename uploaded
"number_of_bb": 1, //number of bounding boxes detected on the image
"dlc_model": "full_dog", //model used
- "bb_0": {
+ "bb_0": {
"corner_1": [ //top left corner
- 76.08082580566406, //x
+ 76.08082580566406, //x
91.02932739257812 //y
],
"corner_2": [ //bottom right corner
@@ -100,7 +108,7 @@ We encourage you to try out and experiment on your camera trap or other animal i
-Or these lil' cuties 🐶🐶🙀🐶 outside a restaurant, from the [Twitter meme](https://twitter.com/standardpuppies/status/1563188163962515457?s=21&t=f2kM2HoUygyLmmAH7Ho-HQ).
+Or these lil' cuties 🐶🐶🙀🐶 outside a restaurant.
diff --git a/docs/recipes/OpenVINO.md b/docs/recipes/OpenVINO.md
index 2033045ce8..06bcbba3b5 100644
--- a/docs/recipes/OpenVINO.md
+++ b/docs/recipes/OpenVINO.md
@@ -1,10 +1,21 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Intel OpenVINO backend
+::::{warning}
+This feature is currently implemented for TensorFlow-based models only.
+::::
+
DeepLabCut provides an option to run deep learning model with [OpenVINO](https://github.com/openvinotoolkit/openvino) backend.
-To enable OpenVINO in your pipeline, use `use_openvino` flag of `analyze_videos` method with one of string values indicating device:
-* "CPU" - Use CPU. This is a default value.
-* "GPU" - Use iGPU (requires OpenCL to be installed). First launch might take some time for kernels initialization.
-* "MULTI:CPU,GPU" - Use CPU and GPU simultaneously. In most cases this option provides the best efficiency.
+To enable OpenVINO in your pipeline, use `use_openvino` flag of `analyze_videos` method with one of string values
+indicating device:
+* ```"CPU"``` - Use CPU. This is a default value.
+* ```"GPU"``` - Use GPU (requires OpenCL to be installed). First launch might take some time for kernels initialization.
+* ```"MULTI:CPU,GPU"``` - Use CPU and GPU simultaneously. In most cases this option provides the best efficiency.
```python
def analyze_videos(
diff --git a/docs/recipes/OtherData.md b/docs/recipes/OtherData.md
new file mode 100644
index 0000000000..21efb74f49
--- /dev/null
+++ b/docs/recipes/OtherData.md
@@ -0,0 +1,39 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+# How to use data labeled outside of DeepLabCut
+- and/or if you merge projects across scorers (see below):
+
+
+
+## Using data labeled elsewhere:
+
+Some users may have annotation data in different formats, yet want to use the DLC pipeline. In this case, you need to convert the data to our format. Simply, you can format your data in an excel sheet (.csv file) or pandas array (.h5 file).
+
+Here is a guide to do this via the ".csv" route: (the pandas array route is identical, just format the pandas array in the same way).
+
+**Step 1**: create a project as describe in the user guide: https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/UseOverviewGuide.md#create-a-new-project
+
+**Step 2**: edit the ``config.yaml`` file to include the body part names, please take care that spelling, spacing, and capitalization are IDENTICAL to the "labeled data body part names".
+
+**Step 3**: Please inspect the excel formatted sheet (.csv) from our [demo project](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/Reaching-Mackenzie-2018-08-30/labeled-data/reachingvideo1)
+- i.e. this file: https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/Reaching-Mackenzie-2018-08-30/labeled-data/reachingvideo1/CollectedData_Mackenzie.csv
+
+**Step 4**: Edit the .csv file such that it contains the X, Y pixel coordinates, the body part names, the scorer name as well as the relative path to the image: e.g. /labeled-data/somefolder/img017.jpg
+Then make sure the scorer name, and body parts are the same in the config.yaml file.
+
+Also add for each folder a video to the `video_set` in the config.yaml file. This can also be a dummy variable, but should be e.g.
+C://somefolder.avi if the folder is called somefolder. See demo config.yaml file for proper formatting.
+
+**Step 5**: When you are done, run ``deeplabcut.convertcsv2h5('path_to_config.yaml', scorer= 'experimenter')``
+
+ - The scorer name must be identical to the input name for experimenter that you used when you created the project. This will automatically update "Mackenzie" to your name in the example demo notebook.
+
+## If you merge projects:
+
+**Step 1**: rename the CSV files to be the target name.
+
+**Step 2**: run and pass the target name ``deeplabcut.convertcsv2h5('path_to_config.yaml', scorer= 'experimenter')``. This will overwrite the H5 file so the data is all merged under the target name.
diff --git a/docs/recipes/TechHardware.md b/docs/recipes/TechHardware.md
index f4610f5442..9ab75ade22 100644
--- a/docs/recipes/TechHardware.md
+++ b/docs/recipes/TechHardware.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Technical (Hardware) Considerations
## Quick summary:
@@ -11,7 +17,14 @@ For reference, we use e.g. Dell workstations (79xx series) with **Ubuntu 16.04 L
### Computer Hardware:
-Ideally, you will use a strong GPU with *at least* 8GB memory such as the [NVIDIA GeForce 1080 Ti, 2080 Ti, or 3090](https://www.nvidia.com/en-us/shop/geforce/?page=1&limit=9&locale=en-us). A GPU is not strictly necessary, but on a CPU the (training and evaluation) code is considerably slower (10x) for ResNets, but MobileNets and EfficientNets are slightly faster. Still, a GPU will give you a massive speed boost. You might also consider using cloud computing services like [Google cloud/amazon web services](https://github.com/DeepLabCut/DeepLabCut/issues/47) or Google Colaboratory.
+Ideally, you will use a strong GPU with *at least* 8GB memory such as the [NVIDIA GeForce 1080 Ti, 2080 Ti, or 3090](https://marketplace.nvidia.com/en-us/consumer/graphics-cards/). A GPU is not strictly necessary, but on a CPU the (training and evaluation) code is considerably slower (10x) for ResNets, but MobileNets and EfficientNets are slightly faster. Still, a GPU will give you a massive speed boost. You might also consider using cloud computing services like [Google cloud/amazon web services](https://github.com/DeepLabCut/DeepLabCut/issues/47) or Google Colaboratory.
+
+```{note}
+If you encounter errors during inference related to
+`torch.inference_mode` and DirectML, set the environment variable
+`DLC_DIRECTML_NO_GRAD=true` before starting Python. This switches the inference
+context to `torch.no_grad`, which is compatible with the DirectML execution path.
+```
### Camera Hardware:
@@ -23,10 +36,20 @@ The software is very robust to track data from any camera (cell phone cameras, g
**Anaconda/Python3:** Anaconda: a free and open source distribution of the Python programming language (download from https://www.anaconda.com/). DeepLabCut is written in Python 3 (https://www.python.org/) and not compatible with Python 2.
+**For the TensorFlow Engine:** You will need [TensorFlow](https://www.tensorflow.org/).
+We used version 1.0 in the paper, later versions also work with the provided code (we
+tested **TensorFlow versions 1.0 to 1.15, and 2.0 to 2.12 (2.10 for Windows)**; we
+recommend TF2.12 for MacOS/Ubuntu and 2.10 for Windows) for Python 3.10 with GPU
+support.
-**TensorFlow** You will need [TensorFlow](https://www.tensorflow.org/) (we used version 1.0 in the paper, later versions also work with the provided code (we tested **TensorFlow versions 1.0 to 1.15, and 2.0 to 2.5**; we recommend TF2.5 now) for Python 3.7, 3.8, or 3.9 with GPU support.
+To note, is it possible to run DeepLabCut on your CPU, but it will be VERY slow (see:
+[Mathis & Warren](https://www.biorxiv.org/content/early/2018/10/30/457242)). However, this is the preferred path if you want to test
+DeepLabCut on your own computer/data before purchasing a GPU, with the added benefit of
+a straightforward installation! Otherwise, use our COLAB notebooks for GPU access for
+testing.
-To note, is it possible to run DeepLabCut on your CPU, but it will be VERY slow (see: [Mathis & Warren](https://www.biorxiv.org/content/early/2018/10/30/457242)). However, this is the preferred path if you want to test DeepLabCut on your own computer/data before purchasing a GPU, with the added benefit of a straightforward installation! Otherwise, use our COLAB notebooks for GPU access for testing.
+Docker: We highly recommend advanced users use the supplied [Docker container](
+docker-containers).
-Docker: We highly recommend advaced users use the supplied [Docker container](https://github.com/MMathisLab/Docker4DeepLabCut2.0).
-NOTE: [this container does not work on windows hosts!](https://github.com/NVIDIA/nvidia-docker/issues/43)
+NOTE: [Currently GPU support in Docker Desktop is only available on Windows with the
+WSL2 backend.](https://docs.docker.com/desktop/features/gpu/)
diff --git a/docs/recipes/UsingModelZooPupil.md b/docs/recipes/UsingModelZooPupil.md
index 2aac4a4d00..2b906ad755 100644
--- a/docs/recipes/UsingModelZooPupil.md
+++ b/docs/recipes/UsingModelZooPupil.md
@@ -1,17 +1,31 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Using ModelZoo models on your own datasets
-Animal behavior has to be analyzed with painstaking accuracy. Therefore, animal pose estimation has been an important tool to study animal behavior precisely.
+
Animal behavior has to be analyzed with painstaking accuracy. Therefore, animal pose estimation has been
+an important tool to study animal behavior precisely.
-Beside providing an open source toolbox for researchers to develop customized deep neural networks for markerless pose estimation, we at DeepLabCut also aim to build robust, generalizable models. Part of this effort is via the [DeeplabCut ModelZoo](http://www.mackenziemathislab.org/dlc-modelzoo).
+Beside providing an open source toolbox for researchers to develop customized deep neural networks for markerless pose
+estimation, we at DeepLabCut also aim to build robust, generalizable models. Part of this effort is via the
+[DeeplabCut ModelZoo](http://modelzoo.deeplabcut.org/).
-The Zoo hosts user-contributed and #teamDLC developed models that are trained on specific animals and scenarios. You can analyze your videos directly with these models without training. The models have strong zero-shot performance on unseen out-of-domain data which can be further improved via pseudo-labeling. Please check the first [ModelZoo manuscript](https://arxiv.org/abs/2203.07436v1) for further details.
+The Zoo hosts user-contributed and DLC-team developed models that are trained on specific animals and scenarios. You can
+analyze your videos directly with these models without training. The models have strong zero-shot performance on unseen
+out-of-domain data which can be further improved via pseudo-labeling. Please check the first
+[ModelZoo manuscript](https://arxiv.org/abs/2203.07436v1) for further details.
-This recipe aims to show a usecase of the **mouse_pupil_vclose** and is contributed by 2022 DLC AI Resident [Neslihan Wittek](https://github.com/neslihanedes) 💜.
+This recipe aims to show a usecase of the **mouse_pupil_vclose** and is contributed by 2022 DLC AI Resident
+[Neslihan Wittek](https://github.com/neslihanedes) 💜.
## `mouse_pupil_vclose` model
This model was contributed by Jim McBurney-Lin at University of California Riverside, USA.
-The model was trained on images of C57/B6J mice eyes, and also then augmented with mouse eye data from the Mathis Lab at EPFL.
+The model was trained on images of C57/B6J mice eyes, and also then augmented with mouse eye data from the Mathis Lab at
+EPFL.
@@ -30,23 +44,37 @@ The model was trained on images of C57/B6J mice eyes, and also then augmented wi
| 8 | VLpupil | Ventral/left aspect of pupil |
-Since we would like to evaluate the models performance on out-of-domain data, we will analyze pigeon pupils. For more discussions and work on so-called out-of-domain data, see [Mathis, Biasi 2020](http://www.mackenziemathislab.org/horse10).
+Since we would like to evaluate the models performance on out-of-domain data, we will analyze pigeon pupils. For more
+discussions and work on so-called out-of-domain data, see
+[Mathis, Biasi 2020](https://paperswithcode.com/dataset/horse-10).
## Pigeon Pupil
-The eye pupil admits and regulates the amount of light entering the retina in order to enable image perception. Beside this curicial role, the pupil also reflects the state of the brain. The systemic behavior of the pupil has not been vastly studied in birds, although researchers from Max Planck Institute for Ornithology in Seewiesen have shed light on pupil behaviors in pigeons.
+The eye pupil admits and regulates the amount of light entering the retina in order to enable image perception. Beside
+this curicial role, the pupil also reflects the state of the brain. The systemic behavior of the pupil has not been
+vastly studied in birds, although researchers from
+Max Planck Institute for Ornithology in Seewiesen
+have shed light on pupil behaviors in pigeons.
-The pupils of male pigeons get smaller during courtship behavior. This is in contrast to mammals, for which the pupil size dilates in response to an increase in arousal. In addition, the pupil size of pigeons dilates during non-REM sleep, while they rapidly constrict during REM sleep. Examining these differences and the reason behind them, might be helpful to understand the pupillary behavior in general.
+The pupils of male pigeons get smaller during courtship behavior. This is in contrast to mammals, for which the pupil
+size dilates in response to an increase in arousal. In addition, the pupil size of pigeons dilates during non-REM sleep,
+while they rapidly constrict during REM sleep. Examining these differences and the reason behind them, might be helpful
+to understand the pupillary behavior in general.
-In light of these findings, we wanted to show whether the **mouse_pupil_vclose** model give us an accurate tracking performance for the pigeon pupil as well.
+In light of these findings, we wanted to show whether the **mouse_pupil_vclose** model give us an accurate tracking
+performance for the pigeon pupil as well.
### Jupyter & Google Colab Notebook
-DeepLabCut provides a Google Colab Notebook to analyze your video with a pretrained networks from the ModelZoo. No need for local installation of DeepLabCut!
+DeepLabCut provides a Google Colab Notebook to analyze your video with a pretrained networks from the ModelZoo. No need
+for local installation of DeepLabCut!
-Since we are interested in the accuracy of the **mouse_pupil_vclose** on pigeon pupil data, we will use a video which consists of 7 recordings of pigeon pupils.
+Since we are interested in the accuracy of the **mouse_pupil_vclose** on pigeon pupil data, we will use a video which
+consists of 7 recordings of pigeon pupils.
-Check ModelZoo Colab page and a video tutorial on how to use the ModelZoo on Google Colab.
+Check the
+[ModelZoo Colab page](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_DLC_ModelZoo.ipynb)
+and a video tutorial on how to use the ModelZoo on Google Colab.
@@ -63,35 +91,39 @@ files.download("/content/file.zip")
### Analyze Videos at Your Local Machine
-DeepLabCut host models from the
DeepLabCut ModelZoo Project .
+DeepLabCut host models from the [DeepLabCut ModelZoo Project](http://modelzoo.deeplabcut.org/).
The `create_pretrained_project` function will create a new project directory with the necessary sub-directories and a basic configuration file.
It will also initialize your project with a pre-trained model from the DeepLabCut ModelZoo.
The rest of the code should be run within your DeepLabCut environment.
-Check
here for the instructions for the DeepLabCut installation.
+Check [here](how-to-install) for the instructions for the DeepLabCut installation.
+To initialize a new project directory with a pre-trained model from the DeepLabCut ModelZoo, run the code below.
+
+::::{warning}
+This method is currently implemented for Tensorflow only, Pytorch compatibility is coming soon.
+::::
```python
import deeplabcut
-```
-To initialize a new project directory with a pre-trained model from the DeepLabCut ModelZoo, run the code below.
-```python
deeplabcut.create_pretrained_project(
"projectname",
"experimenter",
[r"path_for_the_videos"],
- model= "mouse_pupil_vclose",
- working_directory= r"project_directory",
- copy_videos= True,
- videotype= ".mp4 or .avi?",
- analyzevideo= True,
- filtered= True,
- createlabeledvideo= True,
- trainFraction= None
+ model="mouse_pupil_vclose",
+ working_directory=r"project_directory",
+ copy_videos=True,
+ videotype=".mp4 or .avi?",
+ analyzevideo=True,
+ filtered=True,
+ createlabeledvideo=True,
+ trainFraction=None,
+ engine=deeplabcut.Engine.TF,
)
```
+
::::{important}
Your videos should be cropped around the eye for better model accuracy! 👁🐭
::::
@@ -100,13 +132,12 @@ Excitingly, 6 out of the 7 pigeon pupils were tracked nicely:
-
-When we further evaluate the model accuracy by checking the likelihood of tracked points, we see that the tracking is low confidience when the pigeons close their eyelid (which is of course expected, and can be leveraged to measure blinking 👁).
-
+When we further evaluate the model accuracy by checking the likelihood of tracked points, we see that the tracking is
+low confidience when the pigeons close their eyelid (which is of course expected, and can be leveraged to measure
+blinking 👁).
-
But you also might encounter larger problems than small tracking glitches:
@@ -117,12 +148,26 @@ The more problems you encounter, the higher the number of frames you might want
You should also add the path of the video(s) into the `config.yaml` file, or run the following command to add the videos to your project:
```python
-deeplabcut.add_new_videos('/pathofproject/config.yaml', ['/pathofvideos/pigeon.mp4'], copy_videos=False, coords=None, extract_frames=False)
+deeplabcut.add_new_videos(
+ "/pathofproject/config.yaml",
+ ["/pathofvideos/pigeon.mp4"],
+ copy_videos=False,
+ coords=None,
+ extract_frames=False
+)
```
The `deeplabcut.extract_outlier_frames` function will check for outliers and ask your feedback on whether to extract these outliers frames.
```python
-deeplabcut.extract_outlier_frames('/pathofproject/config.yaml', ['/pathofvideos/pigeon.mp4'], automatic=True)
+deeplabcut.analyze_videos(
+ "/pathofproject/config.yaml",
+ ["/pathofvideos/pigeon.mp4"]
+)
+deeplabcut.extract_outlier_frames(
+ "/pathofproject/config.yaml",
+ ["/pathofvideos/pigeon.mp4"],
+ automatic=True
+)
```
The `deeplabcut.refine_labels` function starts the GUI which allows you to refine the outlier frames manually.
You should load the outlier frames directory and corresponding `.h5` file from the previous model.
@@ -130,9 +175,9 @@ It will ask you to define the `likelihood` threshold: labels under the threshold
After refining, you should combine these data with your previous model's data set and create a new training data set.
```python
-deeplabcut.refine_labels('/pathofproject/config.yaml')
-deeplabcut.merge_datasets('/pathofproject/config.yaml')
-deeplabcut.create_training_dataset('/pathofproject/config.yaml')
+deeplabcut.refine_labels("/pathofproject/config.yaml")
+deeplabcut.merge_datasets("/pathofproject/config.yaml")
+deeplabcut.create_training_dataset("/pathofproject/config.yaml")
```
Before starting the training of your model, there is one last step left: editing the `init_weights` parameter in your `pose_cfg.yaml` file.
Go to your project and check the latest snapshot (e.g., `snapshot-610000`) of your model in `dlc-models/train` directory.
@@ -142,7 +187,7 @@ Edit the value of the `init_weights` key in the `pose_cfg.yaml` file and start t
`init_weights: pathofyourproject\dlc-models\iteration-0\DLCFeb31-trainset95shuffle1\train\snapshot-610000`
```python
-deeplabcut.train_network('/pathofproject/config.yaml', shuffle=1, saveiters=25000)
+deeplabcut.train_network("/pathofproject/config.yaml", shuffle=1, saveiters=25000)
```
```{hint}
Check this video for model refining!
diff --git a/docs/recipes/flip_and_rotate.ipynb b/docs/recipes/flip_and_rotate.ipynb
index 9cce5b39c8..bea9391cb8 100644
--- a/docs/recipes/flip_and_rotate.ipynb
+++ b/docs/recipes/flip_and_rotate.ipynb
@@ -47,8 +47,13 @@
"source": [
"import deeplabcut\n",
"\n",
- "project_folder = \"/home/user/projects/\" #the folder in which the DLC project will be created\n",
- "deeplabcut.create_new_project(project='bat_augmentation_austin_2020_bat_data',experimenter='DLC',videos=['/home/user/dummyVideos/'],working_directory=project_folder)"
+ "project_folder = \"/home/user/projects/\" # the folder in which the DLC project will be created\n",
+ "deeplabcut.create_new_project(\n",
+ " project=\"bat_augmentation_austin_2020_bat_data\",\n",
+ " experimenter=\"DLC\",\n",
+ " videos=[\"/home/user/dummyVideos/\"],\n",
+ " working_directory=project_folder,\n",
+ ")"
]
},
{
@@ -76,11 +81,11 @@
},
"outputs": [],
"source": [
- "#define config file\n",
+ "# define config file\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
"\n",
- "#import tools for modifying our config file\n",
- "from deeplabcut.utils.auxiliaryfunctions import read_config, edit_config\n"
+ "# import tools for modifying our config file\n",
+ "from deeplabcut.utils.auxiliaryfunctions import edit_config, read_config"
]
},
{
@@ -102,7 +107,29 @@
"outputs": [],
"source": [
"# replace the default list of bodyparts with a list of the parts that we have actually digitized\n",
- "edit_config(config_path,{\"bodyparts\":['t3L', 'wstL', 't5L', 'elbL', 'shdL', 'ankL', 'nl', 'str', 'lmb', 'shdR', 'ankR', 'elbR', 'wstR', 't5R', 't3R', 'tail']})"
+ "edit_config(\n",
+ " config_path,\n",
+ " {\n",
+ " \"bodyparts\": [\n",
+ " \"t3L\",\n",
+ " \"wstL\",\n",
+ " \"t5L\",\n",
+ " \"elbL\",\n",
+ " \"shdL\",\n",
+ " \"ankL\",\n",
+ " \"nl\",\n",
+ " \"str\",\n",
+ " \"lmb\",\n",
+ " \"shdR\",\n",
+ " \"ankR\",\n",
+ " \"elbR\",\n",
+ " \"wstR\",\n",
+ " \"t5R\",\n",
+ " \"t3R\",\n",
+ " \"tail\",\n",
+ " ]\n",
+ " },\n",
+ ")"
]
},
{
@@ -115,9 +142,9 @@
},
"outputs": [],
"source": [
- "#fetch the list of videos from an older project using the same videos\n",
+ "# fetch the list of videos from an older project using the same videos\n",
"videolist = read_config(\"/home/user/projects/old_project-DLC-2022-08-03/config.yaml\")[\"video_sets\"]\n",
- "edit_config(config_path,{'video_sets':videolist})"
+ "edit_config(config_path, {\"video_sets\": videolist})"
]
},
{
@@ -142,6 +169,7 @@
"source": [
"# Convert training data into the DeepLabCut format\n",
"import deeplabcut\n",
+ "\n",
"deeplabcut.convertcsv2h5(config_path, userfeedback=False)\n",
"\n",
"# Check labels (sanity check)\n",
@@ -163,7 +191,7 @@
"source": [
"## Structuring the project for testing\n",
"\n",
- "Since bats are such a challenging animal to automatcially digitize, in my lab, we've been relying on what I call \"refining\" to improve DLC accuracy. This just means that for each video that we want to analyze, we first digitize a few frames from it and include those frames as training data for the DeepLabCut network.\n",
+ "Since bats are such a challenging animal to automatically digitize, in my lab, we've been relying on what I call \"refining\" to improve DLC accuracy. This just means that for each video that we want to analyze, we first digitize a few frames from it and include those frames as training data for the DeepLabCut network.\n",
"\n",
"We typically digitize approximately one frame per wingbeat. For a wingbeat frequency of 15 and a framerate of 800, this means approximately every 50th frame. For a lower framerate, say 400, we instead need to digitize every 50th frame. This is obviously less labour than digitizing every frame, but the workflow still scales poorly with increased acquisition. Therefore, I want to reduce the required amount of manual digitization. The challenge I've set myself is therefore this - **to use augmentation to match or beat the accuracy of refining**.\n",
"\n",
@@ -252,10 +280,13 @@
"# We need pandas for creatig a nice list to parse\n",
"import pandas as pd\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# create empty lists for putting testing and training indices in\n",
"test_inds = []\n",
@@ -276,7 +307,7 @@
" trainIndices=[train_inds],\n",
" testIndices=[test_inds],\n",
" net_type=\"resnet_50\",\n",
- " augmenter_type=\"../imagesaug\"\n",
+ " augmenter_type=\"../imagesaug\",\n",
")\n",
"\n",
"# train on half+ref, shuffle 2\n",
@@ -296,7 +327,7 @@
" trainIndices=[train_inds],\n",
" testIndices=[test_inds],\n",
" net_type=\"resnet_50\",\n",
- " augmenter_type=\"../imagesaug\"\n",
+ " augmenter_type=\"../imagesaug\",\n",
")\n",
"\n",
"# train on full, test data is OOD, shuffle 3\n",
@@ -316,7 +347,7 @@
" trainIndices=[train_inds],\n",
" testIndices=[test_inds],\n",
" net_type=\"resnet_50\",\n",
- " augmenter_type=\"../imagesaug\"\n",
+ " augmenter_type=\"../imagesaug\",\n",
")\n",
"\n",
"# train on full+ref, shuffle 4\n",
@@ -338,7 +369,7 @@
" trainIndices=[train_inds],\n",
" testIndices=[test_inds],\n",
" net_type=\"resnet_50\",\n",
- " augmenter_type=\"../imagesaug\"\n",
+ " augmenter_type=\"../imagesaug\",\n",
")"
]
},
@@ -383,8 +414,11 @@
"outputs": [],
"source": [
"import os\n",
- "files = os.listdir(\"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18\")\n",
- "print(*files,sep='\\n')"
+ "\n",
+ "files = os.listdir(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18\"\n",
+ ")\n",
+ "print(*files, sep=\"\\n\")"
]
},
{
@@ -426,46 +460,50 @@
"# sure deeplabcut is imported and the config_path defined\n",
"import deeplabcut\n",
"\n",
- "config_path = '/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
"\n",
"# we also need the package os for folder manipulation\n",
"import os\n",
+ "\n",
"# and shutil for copying files\n",
"import shutil\n",
"\n",
- "#import tools for reading our config file\n",
+ "# import tools for reading our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import read_config\n",
"\n",
"# Number and name for our model folder\n",
"model_number = 0\n",
- "modelprefix_pre = 'data_augm'\n",
- "daug_str = 'base'\n",
+ "modelprefix_pre = \"data_augm\"\n",
+ "daug_str = \"base\"\n",
"\n",
"# Get config as dict and associated paths\n",
"cfg = read_config(config_path)\n",
- "project_path = cfg[\"project_path\"] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
+ "project_path = cfg[\n",
+ " \"project_path\"\n",
+ "] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
"training_datasets_path = os.path.join(project_path, \"training-datasets\")\n",
"\n",
"# Define shuffles\n",
- "shuffles = [1,2,3,4]\n",
+ "shuffles = [1, 2, 3, 4]\n",
"trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# Get train and test pose config file paths from base project, for each shuffle\n",
"list_base_train_pose_config_file_paths = []\n",
"list_base_test_pose_config_file_paths = []\n",
- "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices):\n",
- " base_train_pose_config_file_path_TEMP,\\\n",
- " base_test_pose_config_file_path_TEMP,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle_number,\n",
- " trainingsetindex=trainingsetindex) # base_train_pose_config_file\n",
+ "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " base_train_pose_config_file_path_TEMP, base_test_pose_config_file_path_TEMP, _ = (\n",
+ " deeplabcut.return_train_network_path(config_path, shuffle=shuffle_number, trainingsetindex=trainingsetindex)\n",
+ " ) # base_train_pose_config_file\n",
" list_base_train_pose_config_file_paths.append(base_train_pose_config_file_path_TEMP)\n",
" list_base_test_pose_config_file_paths.append(base_test_pose_config_file_path_TEMP)\n",
"\n",
"# Create subdirs for this augmentation method\n",
- "model_prefix = '_'.join([modelprefix_pre, \"{0:0=2d}\".format(model_number), daug_str]) # modelprefix_pre = aug_\n",
+ "model_prefix = \"_\".join([modelprefix_pre, f\"{model_number:0=2d}\", daug_str]) # modelprefix_pre = aug_\n",
"aug_project_path = os.path.join(project_path, model_prefix)\n",
- "aug_dlc_models = os.path.join(aug_project_path, \"dlc-models\", )\n",
+ "aug_dlc_models = os.path.join(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
"\n",
"# make the folder for this modelprefix\n",
"try:\n",
@@ -475,25 +513,20 @@
" print(\"Skipping this one as it already exists\")\n",
"\n",
"# Copy base train pose config file to the directory of this augmentation method\n",
- "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles,trainingsetindices)):\n",
- " one_train_pose_config_file_path,\\\n",
- " one_test_pose_config_file_path,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
- " \n",
+ "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles, trainingsetindices, strict=False)):\n",
+ " one_train_pose_config_file_path, one_test_pose_config_file_path, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
+ "\n",
" # make train and test directories for this subdir\n",
- " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
- " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
- " \n",
+ " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
+ " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
+ "\n",
" # copy test and train config from base project to this subdir\n",
" # copy base train config file\n",
- " shutil.copyfile(list_base_train_pose_config_file_paths[j],\n",
- " one_train_pose_config_file_path) \n",
+ " shutil.copyfile(list_base_train_pose_config_file_paths[j], one_train_pose_config_file_path)\n",
" # copy base test config file\n",
- " shutil.copyfile(list_base_test_pose_config_file_paths[j],\n",
- " one_test_pose_config_file_path)\n"
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
]
},
{
@@ -514,25 +547,27 @@
},
"outputs": [],
"source": [
- "\n",
- "model_prefix = 'data_augm_00_base'\n",
+ "model_prefix = \"data_augm_00_base\"\n",
"\n",
"## Initialise dict with additional edits to train config: optimizer\n",
"train_edits_dict = {}\n",
- "dict_optimizer = {'optimizer':'adam',\n",
- " 'batch_size': 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
- " 'multi_step': [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]]} # if no yaml file passed, initialise as an empty dict\n",
- "train_edits_dict.update({'optimizer': dict_optimizer['optimizer'], #'adam',\n",
- " 'batch_size': dict_optimizer['batch_size'],\n",
- " 'multi_step': dict_optimizer['multi_step']})\n",
- "\n",
- "for shuffle, trainingsetindex in zip(shuffles,trainingsetindices):\n",
- " one_train_pose_config_file_path,\\\n",
- " _,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
+ "dict_optimizer = {\n",
+ " \"optimizer\": \"adam\",\n",
+ " \"batch_size\": 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
+ " \"multi_step\": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]],\n",
+ "} # if no yaml file passed, initialise as an empty dict\n",
+ "train_edits_dict.update(\n",
+ " {\n",
+ " \"optimizer\": dict_optimizer[\"optimizer\"], #'adam',\n",
+ " \"batch_size\": dict_optimizer[\"batch_size\"],\n",
+ " \"multi_step\": dict_optimizer[\"multi_step\"],\n",
+ " }\n",
+ ")\n",
+ "\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " one_train_pose_config_file_path, _, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
"\n",
" edit_config(str(one_train_pose_config_file_path), train_edits_dict)"
]
@@ -556,27 +591,28 @@
"outputs": [],
"source": [
"import deeplabcut\n",
+ "\n",
"# define config path and model prefix\n",
- "config_path='/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
- "model_prefix = 'data_augm_00_base'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
+ "model_prefix = \"data_augm_00_base\"\n",
"\n",
"# the computer I'm working on has several gpus, here I used the third one.\n",
- "gputouse=3\n",
+ "gputouse = 3\n",
"\n",
"# define shuffles and trainingsetindices\n",
- "shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# loop over shuffles and train each\n",
- "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices):\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
" deeplabcut.train_network(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" modelprefix=model_prefix,\n",
" gputouse=gputouse,\n",
" trainingsetindex=trainingsetindex,\n",
- " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
- " saveiters=50000\n",
+ " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
+ " saveiters=50000,\n",
" )"
]
},
@@ -585,7 +621,7 @@
"metadata": {},
"source": [
"# Perform evaluation\n",
- "After training the networks, we need to test how they perform. To do that, we first need to evaluate them using the buil-in DeepLabCut method ```evaluate_network```. By default this will evaluate the last snapshot, in our case, this means that the network will be evaluated after 150k iterations, but we want to know how well it does at 50k and 100k too, so let's edit our config file to test all saved snapshot.\n",
+ "After training the networks, we need to test how they perform. To do that, we first need to evaluate them using the built-in DeepLabCut method ```evaluate_network```. By default this will evaluate the last snapshot, in our case, this means that the network will be evaluated after 150k iterations, but we want to know how well it does at 50k and 100k too, so let's edit our config file to test all saved snapshot.\n",
"\n",
"\n"
]
@@ -604,11 +640,11 @@
"# sure deeplabcut is imported and the config_path defined\n",
"import deeplabcut\n",
"\n",
- "config_path = '/home/juser/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
+ "config_path = \"/home/juser/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
"\n",
- "from deeplabcut.utils.auxiliaryfunctions import read_config, edit_config\n",
+ "from deeplabcut.utils.auxiliaryfunctions import edit_config, read_config\n",
"\n",
- "edit_config(config_path,{'snapshotindex':'all'})"
+ "edit_config(config_path, {\"snapshotindex\": \"all\"})"
]
},
{
@@ -629,13 +665,16 @@
"outputs": [],
"source": [
"import deeplabcut\n",
+ "\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix = 'data_augm_00_base'\n",
- "Shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "model_prefix = \"data_augm_00_base\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
- "for shuffle, trainingsetindex in zip(Shuffles,trainingsetindices):\n",
- " deeplabcut.evaluate_network(config_path, modelprefix = model_prefix, Shuffles = [shuffle], trainingsetindex=trainingsetindex)"
+ "for shuffle, trainingsetindex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex\n",
+ " )"
]
},
{
@@ -663,22 +702,26 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix = 'data_augm_00_base'\n",
- "Shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "model_prefix = \"data_augm_00_base\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# We need pandas for creatig a nice list to parse\n",
+ "import sys\n",
+ "\n",
"import pandas as pd\n",
"\n",
- "import sys\n",
- "sys.path.append('..') #my python file for this function is stored in the parent folder as I'm running this\n",
- "from getErrorDistribution import getErrorDistribution #import the getErrorDistribution function\n",
+ "sys.path.append(\"..\") # my python file for this function is stored in the parent folder as I'm running this\n",
"import numpy as np\n",
+ "from getErrorDistribution import getErrorDistribution # import the getErrorDistribution function\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# get test indices\n",
"test_inds = []\n",
@@ -689,30 +732,25 @@
"error_distributions = []\n",
"error_distributions_pcut = []\n",
"\n",
- "for shuffle, trainFractionIndex in zip(Shuffles,trainingsetindices):\n",
- " error_distributions_temp = []\n",
- " error_distributions_pcut_temp = []\n",
- " for snapshot in [0,1,2]: #we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
- " (\n",
- " ErrorDistribution_all,\n",
- " ErrorDistribution_test,\n",
- " ErrorDistribution_train,\n",
- " ErrorDistributionPCutOff_all,\n",
- " _,\n",
- " _\n",
- " ) = getErrorDistribution(\n",
- " config_path,\n",
- " shuffle=shuffle,\n",
- " snapindex=snapshot,\n",
- " trainFractionIndex = trainFractionIndex,\n",
- " modelprefix = model_prefix\n",
- " )\n",
- " error_distributions_temp.append(ErrorDistribution_all.iloc[test_inds].values.flatten())\n",
- " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
- " error_distributions.append(error_distributions_temp)\n",
- " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
- "error_distributionsb = np.array(error_distributions) # array with dimensions [shuffle, snapshot, frames]\n",
- "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]"
+ "for shuffle, trainFractionIndex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " error_distributions_temp = []\n",
+ " error_distributions_pcut_temp = []\n",
+ " for snapshot in [0, 1, 2]: # we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
+ " (ErrorDistribution_all, ErrorDistribution_test, ErrorDistribution_train, ErrorDistributionPCutOff_all, _, _) = (\n",
+ " getErrorDistribution(\n",
+ " config_path,\n",
+ " shuffle=shuffle,\n",
+ " snapindex=snapshot,\n",
+ " trainFractionIndex=trainFractionIndex,\n",
+ " modelprefix=model_prefix,\n",
+ " )\n",
+ " )\n",
+ " error_distributions_temp.append(ErrorDistribution_all.iloc[test_inds].values.flatten())\n",
+ " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
+ " error_distributions.append(error_distributions_temp)\n",
+ " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
+ "error_distributionsb = np.array(error_distributions) # array with dimensions [shuffle, snapshot, frames]\n",
+ "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]"
]
},
{
@@ -723,32 +761,39 @@
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
- "fig, (ax1,ax2) = plt.subplots(1,2)\n",
+ "fig, (ax1, ax2) = plt.subplots(1, 2)\n",
"fig.set_figheight(8)\n",
"fig.set_figwidth(10)\n",
"\n",
- "for shuffle in [0,1,2,3]: #we start counting at 0, so for now, let's consider each index one less\n",
- " ax1.errorbar(np.array([50,100,150])-1.5+shuffle,np.nanmean(error_distributions[shuffle,:],axis=1), np.nanstd(error_distributions[shuffle,:],axis=1)/len(test_inds)**.5)\n",
- " ax2.errorbar(np.array([50,100,150])-1.5+shuffle,np.nanmean(error_distributions_pcut[shuffle,:],axis=1), np.nanstd(error_distributions_pcut[shuffle,:],axis=1)/len(test_inds)**.5)\n",
+ "for shuffle in [0, 1, 2, 3]: # we start counting at 0, so for now, let's consider each index one less\n",
+ " ax1.errorbar(\n",
+ " np.array([50, 100, 150]) - 1.5 + shuffle,\n",
+ " np.nanmean(error_distributions[shuffle, :], axis=1),\n",
+ " np.nanstd(error_distributions[shuffle, :], axis=1) / len(test_inds) ** 0.5,\n",
+ " )\n",
+ " ax2.errorbar(\n",
+ " np.array([50, 100, 150]) - 1.5 + shuffle,\n",
+ " np.nanmean(error_distributions_pcut[shuffle, :], axis=1),\n",
+ " np.nanstd(error_distributions_pcut[shuffle, :], axis=1) / len(test_inds) ** 0.5,\n",
+ " )\n",
"\n",
"ax1.set_xticks([50, 100, 150])\n",
"ax2.set_xticks([50, 100, 150])\n",
- "ax1.set_xlim([25,175])\n",
- "ax2.set_xlim([25,175])\n",
- "ax1.set_ylim([0,23])\n",
- "ax2.set_ylim([0,23])\n",
+ "ax1.set_xlim([25, 175])\n",
+ "ax2.set_xlim([25, 175])\n",
+ "ax1.set_ylim([0, 23])\n",
+ "ax2.set_ylim([0, 23])\n",
"ax1.set_title(\"Without P-cut\")\n",
"ax2.set_title(\"With P-cut 0.6\")\n",
"ax2.set_yticklabels([])\n",
- "ax2.legend([\"half, OOD\",\"half, Ref\",\"full, OOD\", \"full, Ref\"])\n",
+ "ax2.legend([\"half, OOD\", \"half, Ref\", \"full, OOD\", \"full, Ref\"])\n",
"\n",
"# add a big axis, hide frame\n",
"fig.add_subplot(111, frameon=False)\n",
"## hide tick and tick label of the big axis\n",
- "plt.tick_params(labelcolor='none', which='both', top=False, bottom=False, left=False, right=False)\n",
+ "plt.tick_params(labelcolor=\"none\", which=\"both\", top=False, bottom=False, left=False, right=False)\n",
"plt.xlabel(\"Iterations (thousands)\")\n",
- "plt.ylabel(\"Error (px)\")\n",
- "\n"
+ "plt.ylabel(\"Error (px)\")"
]
},
{
@@ -780,13 +825,16 @@
"import numpy as np\n",
"from scipy.stats import wilcoxon\n",
"\n",
- "p_value = np.empty((4,4))\n",
- "p_value[:]=np.NaN\n",
+ "p_value = np.empty((4, 4))\n",
+ "p_value[:] = np.NaN\n",
"\n",
- "for i in [0,1,2,3]:\n",
- " for j in [0,1,2,3]:\n",
- " if j<=i: continue\n",
- " _, p_value[i,j] = wilcoxon(x = error_distributions_pcut[i,-1,:], y = error_distributions_pcut[j,-1,:],nan_policy='omit')\n",
+ "for i in [0, 1, 2, 3]:\n",
+ " for j in [0, 1, 2, 3]:\n",
+ " if j <= i:\n",
+ " continue\n",
+ " _, p_value[i, j] = wilcoxon(\n",
+ " x=error_distributions_pcut[i, -1, :], y=error_distributions_pcut[j, -1, :], nan_policy=\"omit\"\n",
+ " )\n",
"\n",
"print(p_value)"
]
@@ -812,22 +860,26 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix = 'data_augm_00_base'\n",
- "Shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "model_prefix = \"data_augm_00_base\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# We need pandas for creatig a nice list to parse\n",
+ "import sys\n",
+ "\n",
"import pandas as pd\n",
"\n",
- "import sys\n",
- "sys.path.append('..') #my python file for this function is stored in the parent folder as I'm running this\n",
- "from getErrorDistribution import getErrorDistribution #import the getErrorDistribution function\n",
+ "sys.path.append(\"..\") # my python file for this function is stored in the parent folder as I'm running this\n",
"import numpy as np\n",
+ "from getErrorDistribution import getErrorDistribution # import the getErrorDistribution function\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# get test indices\n",
"test_inds = []\n",
@@ -838,38 +890,41 @@
"# this gives us the paths of our 27 test videos\n",
"test_paths = list(set([image_paths[i][1] for i in test_inds]))\n",
"\n",
- "#%% sorted so that the corresponding videos have the same index in three lists (one per camera)\n",
- "test_paths_cam1 = ['TS5-544-Cam1_2020-06-25_000099Track8_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000103Track3_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000104Track3_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000108Track6_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000123Track6_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000128Track2_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000134Track5_50_test'\n",
- " ]\n",
- "test_paths_cam2 = ['IL5-519-Cam2_2020-06-25_000099Track6_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000103Track3_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000104Track2_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000109Track1_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000124Track9_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000130Track2_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000136Track10_50_test'\n",
- " ]\n",
- "test_paths_cam3 = ['IL5-534-Cam3_2020-06-25_000095Track14_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000100Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000101Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000106Track3_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000122Track7_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000127Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000133Track9_50_test'\n",
- " ]\n",
- "\n",
- "nvideos = 7 # number of videos\n",
+ "# %% sorted so that the corresponding videos have the same index in three lists (one per camera)\n",
+ "test_paths_cam1 = [\n",
+ " \"TS5-544-Cam1_2020-06-25_000099Track8_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000103Track3_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000104Track3_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000108Track6_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000123Track6_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000128Track2_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000134Track5_50_test\",\n",
+ "]\n",
+ "test_paths_cam2 = [\n",
+ " \"IL5-519-Cam2_2020-06-25_000099Track6_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000103Track3_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000104Track2_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000109Track1_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000124Track9_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000130Track2_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000136Track10_50_test\",\n",
+ "]\n",
+ "test_paths_cam3 = [\n",
+ " \"IL5-534-Cam3_2020-06-25_000095Track14_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000100Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000101Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000106Track3_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000122Track7_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000127Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000133Track9_50_test\",\n",
+ "]\n",
+ "\n",
+ "nvideos = 7 # number of videos\n",
"\n",
"# get test frame indexes per camera\n",
- "test_inds_cam1 = [[],[],[],[],[],[],[]]\n",
- "test_inds_cam2 = [[],[],[],[],[],[],[]]\n",
- "test_inds_cam3 = [[],[],[],[],[],[],[]]\n",
+ "test_inds_cam1 = [[], [], [], [], [], [], []]\n",
+ "test_inds_cam2 = [[], [], [], [], [], [], []]\n",
+ "test_inds_cam3 = [[], [], [], [], [], [], []]\n",
"\n",
"for i, path in enumerate(image_paths):\n",
" for j in range(nvideos):\n",
@@ -882,67 +937,91 @@
"\n",
"nshuffles = len(Shuffles)\n",
"\n",
- "#pre-allocate matrixes for mean values and standard errors\n",
- "mean_cam1 = np.zeros([nshuffles,nvideos]) # shuffle x movie\n",
- "mean_cam2 = np.zeros([nshuffles,nvideos])\n",
- "mean_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "# pre-allocate matrixes for mean values and standard errors\n",
+ "mean_cam1 = np.zeros([nshuffles, nvideos]) # shuffle x movie\n",
+ "mean_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "mean_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "ste_cam1 = np.zeros([nshuffles,nvideos])\n",
- "ste_cam2 = np.zeros([nshuffles,nvideos])\n",
- "ste_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "ste_cam1 = np.zeros([nshuffles, nvideos])\n",
+ "ste_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "ste_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "meanPcut_cam1 = np.zeros([nshuffles,nvideos]) # shuffle x movie\n",
- "meanPcut_cam2 = np.zeros([nshuffles,nvideos])\n",
- "meanPcut_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "meanPcut_cam1 = np.zeros([nshuffles, nvideos]) # shuffle x movie\n",
+ "meanPcut_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "meanPcut_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "stePcut_cam1 = np.zeros([nshuffles,nvideos])\n",
- "stePcut_cam2 = np.zeros([nshuffles,nvideos])\n",
- "stePcut_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "stePcut_cam1 = np.zeros([nshuffles, nvideos])\n",
+ "stePcut_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "stePcut_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
"# %%\n",
"\n",
- "for i, shuffle in enumerate(Shuffles): \n",
+ "for i, shuffle in enumerate(Shuffles):\n",
" trainFractionIndex = i\n",
- " snapshot=-1\n",
- " (\n",
- " ErrorDistribution_all,\n",
- " _,\n",
- " _,\n",
- " ErrorDistributionPCutOff_all,\n",
- " _,\n",
- " _\n",
- " ) = getErrorDistribution(\n",
+ " snapshot = -1\n",
+ " (ErrorDistribution_all, _, _, ErrorDistributionPCutOff_all, _, _) = getErrorDistribution(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" snapindex=snapshot,\n",
- " trainFractionIndex = trainFractionIndex,\n",
- " modelprefix = model_prefix\n",
+ " trainFractionIndex=trainFractionIndex,\n",
+ " modelprefix=model_prefix,\n",
" )\n",
" for movie_number in range(7):\n",
- "\n",
- " meanPcut_cam1[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])\n",
- " stePcut_cam1[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam1[movie_number]][:].size**.5)\n",
- "\n",
- " meanPcut_cam2[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam2[movie_number]][:])\n",
- " stePcut_cam2[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam2[movie_number]][:].size**.5)\n",
- "\n",
- " meanPcut_cam3[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam3[movie_number]][:])\n",
- " stePcut_cam3[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam3[movie_number]][:].size**.5)\n",
- "\n",
- "fig, (ax1,ax2,ax3) = plt.subplots(3,1)\n",
+ " meanPcut_cam1[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam1[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam1[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ " meanPcut_cam2[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam2[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam2[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam2[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ " meanPcut_cam3[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam3[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam3[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam3[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ "fig, (ax1, ax2, ax3) = plt.subplots(3, 1)\n",
"fig.set_figheight(15)\n",
"fig.set_figwidth(10)\n",
"for i, shuffle in enumerate(Shuffles):\n",
- " \n",
" # to jitter the error bars to keep them from overlapping\n",
- " movie_number = list(range(1,8))\n",
- " movie_number = [x - 2/50 + shuffle/50 for x in movie_number]\n",
- " \n",
- " ax1.errorbar(movie_number,meanPcut_cam1[i,:], stePcut_cam1[i,:,])\n",
+ " movie_number = list(range(1, 8))\n",
+ " movie_number = [x - 2 / 50 + shuffle / 50 for x in movie_number]\n",
+ "\n",
+ " ax1.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam1[i, :],\n",
+ " stePcut_cam1[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
- " ax2.errorbar(movie_number,meanPcut_cam2[i,:], stePcut_cam2[i,:,])\n",
+ " ax2.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam2[i, :],\n",
+ " stePcut_cam2[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
- " ax3.errorbar(movie_number,meanPcut_cam3[i,:], stePcut_cam3[i,:,])\n",
+ " ax3.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam3[i, :],\n",
+ " stePcut_cam3[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
"ax1.set_ylim([0, 50])\n",
"ax2.set_ylim([0, 50])\n",
@@ -953,7 +1032,7 @@
"ax1.set_ylabel(\"Error (px\")\n",
"ax2.set_ylabel(\"Error (px\")\n",
"ax3.set_ylabel(\"Error (px\")\n",
- "ax1.legend([\"half, OOD\",\"half, Ref\",\"full, OOD\", \"full, Ref\"])"
+ "ax1.legend([\"half, OOD\", \"half, Ref\", \"full, OOD\", \"full, Ref\"])"
]
},
{
@@ -989,18 +1068,20 @@
"# sure deeplabcut is imported and the config_path defined\n",
"import deeplabcut\n",
"\n",
- "config_path = '/home/jusers/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
- "model_prefix = 'data_augm_00_base'\n",
+ "config_path = \"/home/jusers/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
+ "model_prefix = \"data_augm_00_base\"\n",
"\n",
"# we only want to plot the last snapshot (150k iterations)\n",
- "from deeplabcut.utils.auxiliaryfunctions import read_config, edit_config\n",
+ "from deeplabcut.utils.auxiliaryfunctions import edit_config, read_config\n",
"\n",
- "edit_config(config_path,{'snapshotindex':-1})\n",
+ "edit_config(config_path, {\"snapshotindex\": -1})\n",
"\n",
"shuffle = 3\n",
"trainingsetindex = 2\n",
"\n",
- "deeplabcut.evaluate_network(config_path, modelprefix = model_prefix, Shuffles = [shuffle], trainingsetindex=trainingsetindex, plotting=True)"
+ "deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex, plotting=True\n",
+ ")"
]
},
{
@@ -1048,46 +1129,50 @@
"# sure deeplabcut is imported and the config_path defined\n",
"import deeplabcut\n",
"\n",
- "config_path = '/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
"\n",
"# we also need the package os for folder manipulation\n",
"import os\n",
+ "\n",
"# and shutil for copying files\n",
"import shutil\n",
"\n",
- "#import tools for reading our config file\n",
+ "# import tools for reading our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import read_config\n",
"\n",
"# Number and name for our model folder\n",
- "model_number = 1 # CHANGE\n",
- "modelprefix_pre = 'data_augm'\n",
- "daug_str = 'fliplr' # CHANGE\n",
+ "model_number = 1 # CHANGE\n",
+ "modelprefix_pre = \"data_augm\"\n",
+ "daug_str = \"fliplr\" # CHANGE\n",
"\n",
"# Get config as dict and associated paths\n",
"cfg = read_config(config_path)\n",
- "project_path = cfg[\"project_path\"] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
+ "project_path = cfg[\n",
+ " \"project_path\"\n",
+ "] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
"training_datasets_path = os.path.join(project_path, \"training-datasets\")\n",
"\n",
"# Define shuffles\n",
- "shuffles = [1,2,3,4]\n",
+ "shuffles = [1, 2, 3, 4]\n",
"trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# Get train and test pose config file paths from base project, for each shuffle\n",
"list_base_train_pose_config_file_paths = []\n",
"list_base_test_pose_config_file_paths = []\n",
- "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices):\n",
- " base_train_pose_config_file_path_TEMP,\\\n",
- " base_test_pose_config_file_path_TEMP,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle_number,\n",
- " trainingsetindex=trainingsetindex) # base_train_pose_config_file\n",
+ "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " base_train_pose_config_file_path_TEMP, base_test_pose_config_file_path_TEMP, _ = (\n",
+ " deeplabcut.return_train_network_path(config_path, shuffle=shuffle_number, trainingsetindex=trainingsetindex)\n",
+ " ) # base_train_pose_config_file\n",
" list_base_train_pose_config_file_paths.append(base_train_pose_config_file_path_TEMP)\n",
" list_base_test_pose_config_file_paths.append(base_test_pose_config_file_path_TEMP)\n",
"\n",
"# Create subdirs for this augmentation method\n",
- "model_prefix = '_'.join([modelprefix_pre, \"{0:0=2d}\".format(model_number), daug_str]) # modelprefix_pre = aug_\n",
+ "model_prefix = \"_\".join([modelprefix_pre, f\"{model_number:0=2d}\", daug_str]) # modelprefix_pre = aug_\n",
"aug_project_path = os.path.join(project_path, model_prefix)\n",
- "aug_dlc_models = os.path.join(aug_project_path, \"dlc-models\", )\n",
+ "aug_dlc_models = os.path.join(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
"\n",
"# make the folder for this modelprefix\n",
"try:\n",
@@ -1097,25 +1182,20 @@
" print(\"Skipping this one as it already exists\")\n",
"\n",
"# Copy base train pose config file to the directory of this augmentation method\n",
- "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles,trainingsetindices)):\n",
- " one_train_pose_config_file_path,\\\n",
- " one_test_pose_config_file_path,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
- " \n",
+ "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles, trainingsetindices, strict=False)):\n",
+ " one_train_pose_config_file_path, one_test_pose_config_file_path, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
+ "\n",
" # make train and test directories for this subdir\n",
- " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
- " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
- " \n",
+ " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
+ " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
+ "\n",
" # copy test and train config from base project to this subdir\n",
" # copy base train config file\n",
- " shutil.copyfile(list_base_train_pose_config_file_paths[j],\n",
- " one_train_pose_config_file_path) \n",
+ " shutil.copyfile(list_base_train_pose_config_file_paths[j], one_train_pose_config_file_path)\n",
" # copy base test config file\n",
- " shutil.copyfile(list_base_test_pose_config_file_paths[j],\n",
- " one_test_pose_config_file_path)"
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
]
},
{
@@ -1168,32 +1248,35 @@
},
"outputs": [],
"source": [
- "#import tools for changing our config file\n",
+ "# import tools for changing our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import edit_config\n",
"\n",
- "model_prefix = 'data_augm_01_fliplr'\n",
+ "model_prefix = \"data_augm_01_fliplr\"\n",
"\n",
"## Initialise dict with additional edits to train config: optimizer\n",
"train_edits_dict = {}\n",
- "dict_optimizer = {'optimizer':'adam',\n",
- " 'batch_size': 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
- " 'multi_step': [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]]} # if no yaml file passed, initialise as an empty dict\n",
- "train_edits_dict.update({'optimizer': dict_optimizer['optimizer'], #'adam',\n",
- " 'batch_size': dict_optimizer['batch_size'],\n",
- " 'multi_step': dict_optimizer['multi_step']})\n",
+ "dict_optimizer = {\n",
+ " \"optimizer\": \"adam\",\n",
+ " \"batch_size\": 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
+ " \"multi_step\": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]],\n",
+ "} # if no yaml file passed, initialise as an empty dict\n",
+ "train_edits_dict.update(\n",
+ " {\n",
+ " \"optimizer\": dict_optimizer[\"optimizer\"], #'adam',\n",
+ " \"batch_size\": dict_optimizer[\"batch_size\"],\n",
+ " \"multi_step\": dict_optimizer[\"multi_step\"],\n",
+ " }\n",
+ ")\n",
"\n",
"# Augmentation edits\n",
"edits_dict = dict()\n",
"edits_dict[\"symmetric_pairs\"] = (0, 14), (1, 12), (2, 13), (3, 11), (4, 9), (5, 10)\n",
"edits_dict[\"fliplr\"] = True\n",
"\n",
- "for shuffle, trainingsetindex in zip(shuffles,trainingsetindices):\n",
- " one_train_pose_config_file_path,\\\n",
- " _,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " one_train_pose_config_file_path, _, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
"\n",
" edit_config(str(one_train_pose_config_file_path), edits_dict)\n",
" edit_config(str(one_train_pose_config_file_path), train_edits_dict)"
@@ -1218,27 +1301,28 @@
"outputs": [],
"source": [
"import deeplabcut\n",
+ "\n",
"# define config path and model prefix\n",
- "config_path='/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
- "model_prefix = 'data_augm_01_flipr'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
+ "model_prefix = \"data_augm_01_flipr\"\n",
"\n",
"# the computer I'm working on has several gpus, here I used the third one.\n",
- "gputouse=3\n",
+ "gputouse = 3\n",
"\n",
"# define shuffles and trainingsetindices\n",
- "shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# loop over shuffles and train each\n",
- "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices):\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
" deeplabcut.train_network(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" modelprefix=model_prefix,\n",
" gputouse=gputouse,\n",
" trainingsetindex=trainingsetindex,\n",
- " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
- " saveiters=50000\n",
+ " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
+ " saveiters=50000,\n",
" )"
]
},
@@ -1268,18 +1352,20 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix = 'data_augm_01_fliplr'\n",
- "Shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "model_prefix = \"data_augm_01_fliplr\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
- "#import tools for modifying our config file\n",
+ "# import tools for modifying our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import edit_config\n",
"\n",
"# make sure we are testing all snapshots\n",
- "edit_config(config_path,{'snapshotindex':'all'})\n",
+ "edit_config(config_path, {\"snapshotindex\": \"all\"})\n",
"\n",
- "for shuffle, trainingsetindex in zip(Shuffles,trainingsetindices):\n",
- " deeplabcut.evaluate_network(config_path, modelprefix = model_prefix, Shuffles = [shuffle], trainingsetindex=trainingsetindex)"
+ "for shuffle, trainingsetindex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex\n",
+ " )"
]
},
{
@@ -1293,23 +1379,27 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix_base = 'data_augm_00_base'\n",
- "model_prefix_augm = 'data_augm_01_fliplr'\n",
- "Shuffles = [4,3] # let's start with the refined un-augmented, i.e. shuffle 4\n",
- "trainingsetindices = [3,2]\n",
+ "model_prefix_base = \"data_augm_00_base\"\n",
+ "model_prefix_augm = \"data_augm_01_fliplr\"\n",
+ "Shuffles = [4, 3] # let's start with the refined un-augmented, i.e. shuffle 4\n",
+ "trainingsetindices = [3, 2]\n",
"\n",
"# We need pandas for creatig a nice list to parse\n",
+ "import sys\n",
+ "\n",
"import pandas as pd\n",
"\n",
- "import sys\n",
- "sys.path.append('..') #my python file for this function is stored in the parent folder as I'm running this\n",
- "from getErrorDistribution import getErrorDistribution #import the getErrorDistribution function\n",
+ "sys.path.append(\"..\") # my python file for this function is stored in the parent folder as I'm running this\n",
"import numpy as np\n",
+ "from getErrorDistribution import getErrorDistribution # import the getErrorDistribution function\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# get test indices\n",
"test_inds = []\n",
@@ -1319,39 +1409,39 @@
"\n",
"error_distributions_pcut = []\n",
"\n",
- "for shuffle, trainFractionIndex in zip(Shuffles,trainingsetindices):\n",
- " error_distributions_pcut_temp = []\n",
- " if shuffle == 4: model_prefix = model_prefix_base\n",
- " elif shuffle == 3: model_prefix = model_prefix_augm\n",
- " \n",
- " for snapshot in [0,1,2]: #we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
- " (\n",
- " _,\n",
- " _,\n",
- " _,\n",
- " ErrorDistributionPCutOff_all,\n",
- " _,\n",
- " _\n",
- " ) = getErrorDistribution(\n",
+ "for shuffle, trainFractionIndex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " error_distributions_pcut_temp = []\n",
+ " if shuffle == 4:\n",
+ " model_prefix = model_prefix_base\n",
+ " elif shuffle == 3:\n",
+ " model_prefix = model_prefix_augm\n",
+ "\n",
+ " for snapshot in [0, 1, 2]: # we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
+ " (_, _, _, ErrorDistributionPCutOff_all, _, _) = getErrorDistribution(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" snapindex=snapshot,\n",
- " trainFractionIndex = trainFractionIndex,\n",
- " modelprefix = model_prefix\n",
- " )\n",
- " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
- " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
+ " trainFractionIndex=trainFractionIndex,\n",
+ " modelprefix=model_prefix,\n",
+ " )\n",
+ " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
+ " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
"\n",
- "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]\n",
+ "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]\n",
"\n",
"import matplotlib.pyplot as plt\n",
+ "\n",
"plt.figure(figsize=(10, 5))\n",
- "for shuffle in [0,1]: #we start counting at 0, so for now, let's consider each index one less\n",
- " plt.errorbar(np.array([50,100,150])-1.5+shuffle,np.nanmean(error_distributions_pcut[shuffle,:],axis=1), np.nanstd(error_distributions_pcut[shuffle,:],axis=1)/len(test_inds)**.5)\n",
+ "for shuffle in [0, 1]: # we start counting at 0, so for now, let's consider each index one less\n",
+ " plt.errorbar(\n",
+ " np.array([50, 100, 150]) - 1.5 + shuffle,\n",
+ " np.nanmean(error_distributions_pcut[shuffle, :], axis=1),\n",
+ " np.nanstd(error_distributions_pcut[shuffle, :], axis=1) / len(test_inds) ** 0.5,\n",
+ " )\n",
"\n",
"plt.xticks([50, 100, 150])\n",
- "plt.xlim([25,175])\n",
- "plt.ylim([0,10])\n",
+ "plt.xlim([25, 175])\n",
+ "plt.ylim([0, 10])\n",
"plt.title(\"Error with P-cut 0.6, comparing baseline to fliplr and 180 degrees rotation augmented\")\n",
"\n",
"plt.legend([\"Full, ref, baseline\", \"Full, OOD, fliplr\"])\n",
@@ -1390,51 +1480,54 @@
},
"outputs": [],
"source": [
- "\n",
"# in case we restarted the kernel or something, let's make\n",
"# sure deeplabcut is imported and the config_path defined\n",
"import deeplabcut\n",
"\n",
- "config_path = '/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
"\n",
"# we also need the package os for folder manipulation\n",
"import os\n",
+ "\n",
"# and shutil for copying files\n",
"import shutil\n",
"\n",
- "#import tools for reading our config file\n",
+ "# import tools for reading our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import read_config\n",
"\n",
"# Number and name for our model folder\n",
- "model_number = 3 # CHANGE\n",
- "modelprefix_pre = 'data_augm'\n",
- "daug_str = 'max_rotate' # CHANGE\n",
+ "model_number = 3 # CHANGE\n",
+ "modelprefix_pre = \"data_augm\"\n",
+ "daug_str = \"max_rotate\" # CHANGE\n",
"\n",
"# Get config as dict and associated paths\n",
"cfg = read_config(config_path)\n",
- "project_path = cfg[\"project_path\"] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
+ "project_path = cfg[\n",
+ " \"project_path\"\n",
+ "] # or: os.path.dirname(config_path) #dlc_models_path = os.path.join(project_path, \"dlc-models\")\n",
"training_datasets_path = os.path.join(project_path, \"training-datasets\")\n",
"\n",
"# Define shuffles\n",
- "shuffles = [1,2,3,4]\n",
+ "shuffles = [1, 2, 3, 4]\n",
"trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# Get train and test pose config file paths from base project, for each shuffle\n",
"list_base_train_pose_config_file_paths = []\n",
"list_base_test_pose_config_file_paths = []\n",
- "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices):\n",
- " base_train_pose_config_file_path_TEMP,\\\n",
- " base_test_pose_config_file_path_TEMP,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle_number,\n",
- " trainingsetindex=trainingsetindex) # base_train_pose_config_file\n",
+ "for shuffle_number, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " base_train_pose_config_file_path_TEMP, base_test_pose_config_file_path_TEMP, _ = (\n",
+ " deeplabcut.return_train_network_path(config_path, shuffle=shuffle_number, trainingsetindex=trainingsetindex)\n",
+ " ) # base_train_pose_config_file\n",
" list_base_train_pose_config_file_paths.append(base_train_pose_config_file_path_TEMP)\n",
" list_base_test_pose_config_file_paths.append(base_test_pose_config_file_path_TEMP)\n",
"\n",
"# Create subdirs for this augmentation method\n",
- "model_prefix = '_'.join([modelprefix_pre, \"{0:0=2d}\".format(model_number), daug_str]) # modelprefix_pre = aug_\n",
+ "model_prefix = \"_\".join([modelprefix_pre, f\"{model_number:0=2d}\", daug_str]) # modelprefix_pre = aug_\n",
"aug_project_path = os.path.join(project_path, model_prefix)\n",
- "aug_dlc_models = os.path.join(aug_project_path, \"dlc-models\", )\n",
+ "aug_dlc_models = os.path.join(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
"\n",
"# make the folder for this modelprefix\n",
"try:\n",
@@ -1444,25 +1537,20 @@
" print(\"Skipping this one as it already exists\")\n",
"\n",
"# Copy base train pose config file to the directory of this augmentation method\n",
- "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles,trainingsetindices)):\n",
- " one_train_pose_config_file_path,\\\n",
- " one_test_pose_config_file_path,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
- " \n",
+ "for j, (shuffle, trainingsetindex) in enumerate(zip(shuffles, trainingsetindices, strict=False)):\n",
+ " one_train_pose_config_file_path, one_test_pose_config_file_path, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
+ "\n",
" # make train and test directories for this subdir\n",
- " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
- " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
- " \n",
+ " os.makedirs(str(os.path.dirname(one_train_pose_config_file_path))) # create parentdir 'train'\n",
+ " os.makedirs(str(os.path.dirname(one_test_pose_config_file_path))) # create parentdir 'test\n",
+ "\n",
" # copy test and train config from base project to this subdir\n",
" # copy base train config file\n",
- " shutil.copyfile(list_base_train_pose_config_file_paths[j],\n",
- " one_train_pose_config_file_path) \n",
+ " shutil.copyfile(list_base_train_pose_config_file_paths[j], one_train_pose_config_file_path)\n",
" # copy base test config file\n",
- " shutil.copyfile(list_base_test_pose_config_file_paths[j],\n",
- " one_test_pose_config_file_path)\n"
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
]
},
{
@@ -1475,19 +1563,25 @@
},
"outputs": [],
"source": [
- "#import tools for changing our config file\n",
+ "# import tools for changing our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import edit_config\n",
"\n",
- "model_prefix = 'data_augm_03_max_rotate'\n",
+ "model_prefix = \"data_augm_03_max_rotate\"\n",
"\n",
"## Initialise dict with additional edits to train config: optimizer\n",
"train_edits_dict = {}\n",
- "dict_optimizer = {'optimizer':'adam',\n",
- " 'batch_size': 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
- " 'multi_step': [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]]} # if no yaml file passed, initialise as an empty dict\n",
- "train_edits_dict.update({'optimizer': dict_optimizer['optimizer'], #'adam',\n",
- " 'batch_size': dict_optimizer['batch_size'],\n",
- " 'multi_step': dict_optimizer['multi_step']})\n",
+ "dict_optimizer = {\n",
+ " \"optimizer\": \"adam\",\n",
+ " \"batch_size\": 8, # the gpu I'm using has plenty of memory so batch size 8 makes sense\n",
+ " \"multi_step\": [[1e-4, 7500], [5 * 1e-5, 12000], [1e-5, 150000]],\n",
+ "} # if no yaml file passed, initialise as an empty dict\n",
+ "train_edits_dict.update(\n",
+ " {\n",
+ " \"optimizer\": dict_optimizer[\"optimizer\"], #'adam',\n",
+ " \"batch_size\": dict_optimizer[\"batch_size\"],\n",
+ " \"multi_step\": dict_optimizer[\"multi_step\"],\n",
+ " }\n",
+ ")\n",
"\n",
"# Augmentation edits\n",
"edits_dict = dict()\n",
@@ -1495,16 +1589,13 @@
"edits_dict[\"fliplr\"] = True\n",
"edits_dict[\"rotation\"] = 180\n",
"\n",
- "for shuffle, trainingsetindex in zip(shuffles,trainingsetindices):\n",
- " one_train_pose_config_file_path,\\\n",
- " _,\\\n",
- " _ = deeplabcut.return_train_network_path(config_path,\n",
- " shuffle=shuffle,\n",
- " trainingsetindex=trainingsetindex,\n",
- " modelprefix=model_prefix)\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
+ " one_train_pose_config_file_path, _, _ = deeplabcut.return_train_network_path(\n",
+ " config_path, shuffle=shuffle, trainingsetindex=trainingsetindex, modelprefix=model_prefix\n",
+ " )\n",
"\n",
" edit_config(str(one_train_pose_config_file_path), edits_dict)\n",
- " edit_config(str(one_train_pose_config_file_path), train_edits_dict)\n"
+ " edit_config(str(one_train_pose_config_file_path), train_edits_dict)"
]
},
{
@@ -1526,27 +1617,28 @@
"outputs": [],
"source": [
"import deeplabcut\n",
+ "\n",
"# define config path and model prefix\n",
- "config_path='/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml'\n",
- "model_prefix = 'data_augm_03_max_rotate'\n",
+ "config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
+ "model_prefix = \"data_augm_03_max_rotate\"\n",
"\n",
"# the computer I'm working on has several gpus, here I used the third one.\n",
- "gputouse=3\n",
+ "gputouse = 3\n",
"\n",
"# define shuffles and trainingsetindices\n",
- "shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
"# loop over shuffles and train each\n",
- "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices):\n",
+ "for shuffle, trainingsetindex in zip(shuffles, trainingsetindices, strict=False):\n",
" deeplabcut.train_network(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" modelprefix=model_prefix,\n",
" gputouse=gputouse,\n",
" trainingsetindex=trainingsetindex,\n",
- " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
- " saveiters=50000\n",
+ " max_snapshots_to_keep=3, # training for 150000 iterations so let's save 50, 100, and 150.\n",
+ " saveiters=50000,\n",
" )"
]
},
@@ -1564,18 +1656,20 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix = 'data_augm_03_max_rotate'\n",
- "Shuffles = [1,2,3,4]\n",
- "trainingsetindices = [0,1,2,3]\n",
+ "model_prefix = \"data_augm_03_max_rotate\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
"\n",
- "#import tools for modifying our config file\n",
+ "# import tools for modifying our config file\n",
"from deeplabcut.utils.auxiliaryfunctions import edit_config\n",
"\n",
"# make sure we are testing all snapshots\n",
- "edit_config(config_path,{'snapshotindex':'all'})\n",
+ "edit_config(config_path, {\"snapshotindex\": \"all\"})\n",
"\n",
- "for shuffle, trainingsetindex in zip(Shuffles,trainingsetindices):\n",
- " deeplabcut.evaluate_network(config_path, modelprefix = model_prefix, Shuffles = [shuffle], trainingsetindex=trainingsetindex, gputouse=3)"
+ "for shuffle, trainingsetindex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex, gputouse=3\n",
+ " )"
]
},
{
@@ -1595,23 +1689,27 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix_base = 'data_augm_00_base'\n",
- "model_prefix_augm = 'data_augm_03_max_rotate'\n",
- "Shuffles = [4,3] # let's start with the refined un-augmented, i.e. shuffle 4\n",
- "trainingsetindices = [3,2]\n",
+ "model_prefix_base = \"data_augm_00_base\"\n",
+ "model_prefix_augm = \"data_augm_03_max_rotate\"\n",
+ "Shuffles = [4, 3] # let's start with the refined un-augmented, i.e. shuffle 4\n",
+ "trainingsetindices = [3, 2]\n",
"\n",
"# We need pandas for creatig a nice list to parse\n",
+ "import sys\n",
+ "\n",
"import pandas as pd\n",
"\n",
- "import sys\n",
- "sys.path.append('..') #my python file for this function is stored in the parent folder as I'm running this\n",
- "from getErrorDistribution import getErrorDistribution #import the getErrorDistribution function\n",
+ "sys.path.append(\"..\") # my python file for this function is stored in the parent folder as I'm running this\n",
"import numpy as np\n",
+ "from getErrorDistribution import getErrorDistribution # import the getErrorDistribution function\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# get test indices\n",
"test_inds = []\n",
@@ -1621,30 +1719,25 @@
"\n",
"error_distributions_pcut = []\n",
"\n",
- "for shuffle, trainFractionIndex in zip(Shuffles,trainingsetindices):\n",
- " error_distributions_pcut_temp = []\n",
- " if shuffle == 4: model_prefix = model_prefix_base\n",
- " elif shuffle == 3: model_prefix = model_prefix_augm\n",
- " \n",
- " for snapshot in [0,1,2]: #we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
- " (\n",
- " _,\n",
- " _,\n",
- " _,\n",
- " ErrorDistributionPCutOff_all,\n",
- " _,\n",
- " _\n",
- " ) = getErrorDistribution(\n",
+ "for shuffle, trainFractionIndex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " error_distributions_pcut_temp = []\n",
+ " if shuffle == 4:\n",
+ " model_prefix = model_prefix_base\n",
+ " elif shuffle == 3:\n",
+ " model_prefix = model_prefix_augm\n",
+ "\n",
+ " for snapshot in [0, 1, 2]: # we saved three snapshots, one at 50k iteratinos, one at 100k, and one at 150k\n",
+ " (_, _, _, ErrorDistributionPCutOff_all, _, _) = getErrorDistribution(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" snapindex=snapshot,\n",
- " trainFractionIndex = trainFractionIndex,\n",
- " modelprefix = model_prefix\n",
- " )\n",
- " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
- " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
+ " trainFractionIndex=trainFractionIndex,\n",
+ " modelprefix=model_prefix,\n",
+ " )\n",
+ " error_distributions_pcut_temp.append(ErrorDistributionPCutOff_all.iloc[test_inds].values.flatten())\n",
+ " error_distributions_pcut.append(error_distributions_pcut_temp)\n",
"\n",
- "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]"
+ "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]"
]
},
{
@@ -1657,12 +1750,16 @@
"\n",
"plt.figure(figsize=(10, 5))\n",
"\n",
- "for shuffle in [0,1]: #we start counting at 0, so for now, let's consider each index one less\n",
- " plt.errorbar(np.array([50,100,150])-1.5+shuffle,np.nanmean(error_distributions_pcut[shuffle,:],axis=1), np.nanstd(error_distributions_pcut[shuffle,:],axis=1)/len(test_inds)**.5)\n",
+ "for shuffle in [0, 1]: # we start counting at 0, so for now, let's consider each index one less\n",
+ " plt.errorbar(\n",
+ " np.array([50, 100, 150]) - 1.5 + shuffle,\n",
+ " np.nanmean(error_distributions_pcut[shuffle, :], axis=1),\n",
+ " np.nanstd(error_distributions_pcut[shuffle, :], axis=1) / len(test_inds) ** 0.5,\n",
+ " )\n",
"\n",
"plt.xticks([50, 100, 150])\n",
- "plt.xlim([25,175])\n",
- "plt.ylim([0,10])\n",
+ "plt.xlim([25, 175])\n",
+ "plt.ylim([0, 10])\n",
"plt.title(\"Error with P-cut 0.6, comparing baseline to fliplr and 180 degrees rotation augmented\")\n",
"\n",
"plt.legend([\"Full, ref, baseline\", \"Full, OOD, fliplr_180_rotate\"])\n",
@@ -1697,23 +1794,27 @@
"import deeplabcut\n",
"\n",
"config_path = \"/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
- "model_prefix_base = 'data_augm_00_base'\n",
- "model_prefix_augm = 'data_augm_03_max_rotate'\n",
- "Shuffles = [4,3,3]\n",
- "trainingsetindices = [3,2,2]\n",
+ "model_prefix_base = \"data_augm_00_base\"\n",
+ "model_prefix_augm = \"data_augm_03_max_rotate\"\n",
+ "Shuffles = [4, 3, 3]\n",
+ "trainingsetindices = [3, 2, 2]\n",
"\n",
"# We need pandas for creatig a nice list to parse\n",
+ "import sys\n",
+ "\n",
"import pandas as pd\n",
"\n",
- "import sys\n",
- "sys.path.append('..') #my python file for this function is stored in the parent folder as I'm running this\n",
- "from getErrorDistribution import getErrorDistribution #import the getErrorDistribution function\n",
+ "sys.path.append(\"..\") # my python file for this function is stored in the parent folder as I'm running this\n",
"import numpy as np\n",
+ "from getErrorDistribution import getErrorDistribution # import the getErrorDistribution function\n",
"\n",
- "# Read the h5 file containing all the frames, (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
- "df = pd.read_hdf('/home/juser/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5')\n",
+ "# Read the h5 file containing all the frames:\n",
+ "# (project_folder/training-datasets/iteration-0/UnaufmentedDataSet_project_folder/CollectedData_LabelerName.h5)\n",
+ "df = pd.read_hdf(\n",
+ " \"/home/juser/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/training-datasets/iteration-0/UnaugmentedDataSet_bat_augmentation_austin_2020_bat_dataAug18/CollectedData_DLC.h5\"\n",
+ ")\n",
"\n",
- "image_paths = df.index.to_list() # turn dataframe into list\n",
+ "image_paths = df.index.to_list() # turn dataframe into list\n",
"\n",
"# get test indices\n",
"test_inds = []\n",
@@ -1724,38 +1825,41 @@
"# this gives us the paths of our 27 test videos\n",
"test_paths = list(set([image_paths[i][1] for i in test_inds]))\n",
"\n",
- "#%% sorted so that the corresponding videos have the same index in three lists (one per camera)\n",
- "test_paths_cam1 = ['TS5-544-Cam1_2020-06-25_000099Track8_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000103Track3_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000104Track3_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000108Track6_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000123Track6_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000128Track2_50_test',\n",
- " 'TS5-544-Cam1_2020-06-25_000134Track5_50_test'\n",
- " ]\n",
- "test_paths_cam2 = ['IL5-519-Cam2_2020-06-25_000099Track6_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000103Track3_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000104Track2_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000109Track1_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000124Track9_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000130Track2_50_test',\n",
- " 'IL5-519-Cam2_2020-06-25_000136Track10_50_test'\n",
- " ]\n",
- "test_paths_cam3 = ['IL5-534-Cam3_2020-06-25_000095Track14_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000100Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000101Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000106Track3_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000122Track7_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000127Track4_50_test',\n",
- " 'IL5-534-Cam3_2020-06-25_000133Track9_50_test'\n",
- " ]\n",
- "\n",
- "nvideos = 7 # number of videos\n",
+ "# %% sorted so that the corresponding videos have the same index in three lists (one per camera)\n",
+ "test_paths_cam1 = [\n",
+ " \"TS5-544-Cam1_2020-06-25_000099Track8_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000103Track3_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000104Track3_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000108Track6_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000123Track6_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000128Track2_50_test\",\n",
+ " \"TS5-544-Cam1_2020-06-25_000134Track5_50_test\",\n",
+ "]\n",
+ "test_paths_cam2 = [\n",
+ " \"IL5-519-Cam2_2020-06-25_000099Track6_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000103Track3_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000104Track2_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000109Track1_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000124Track9_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000130Track2_50_test\",\n",
+ " \"IL5-519-Cam2_2020-06-25_000136Track10_50_test\",\n",
+ "]\n",
+ "test_paths_cam3 = [\n",
+ " \"IL5-534-Cam3_2020-06-25_000095Track14_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000100Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000101Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000106Track3_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000122Track7_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000127Track4_50_test\",\n",
+ " \"IL5-534-Cam3_2020-06-25_000133Track9_50_test\",\n",
+ "]\n",
+ "\n",
+ "nvideos = 7 # number of videos\n",
"\n",
"# get test frame indexes per camera\n",
- "test_inds_cam1 = [[],[],[],[],[],[],[]]\n",
- "test_inds_cam2 = [[],[],[],[],[],[],[]]\n",
- "test_inds_cam3 = [[],[],[],[],[],[],[]]\n",
+ "test_inds_cam1 = [[], [], [], [], [], [], []]\n",
+ "test_inds_cam2 = [[], [], [], [], [], [], []]\n",
+ "test_inds_cam3 = [[], [], [], [], [], [], []]\n",
"\n",
"for i, path in enumerate(image_paths):\n",
" for j in range(nvideos):\n",
@@ -1768,70 +1872,96 @@
"\n",
"nshuffles = len(Shuffles)\n",
"\n",
- "#pre-allocate matrixes for mean values and standard errors\n",
- "mean_cam1 = np.zeros([nshuffles,nvideos]) # shuffle x movie\n",
- "mean_cam2 = np.zeros([nshuffles,nvideos])\n",
- "mean_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "# pre-allocate matrixes for mean values and standard errors\n",
+ "mean_cam1 = np.zeros([nshuffles, nvideos]) # shuffle x movie\n",
+ "mean_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "mean_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "ste_cam1 = np.zeros([nshuffles,nvideos])\n",
- "ste_cam2 = np.zeros([nshuffles,nvideos])\n",
- "ste_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "ste_cam1 = np.zeros([nshuffles, nvideos])\n",
+ "ste_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "ste_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "meanPcut_cam1 = np.zeros([nshuffles,nvideos]) # shuffle x movie\n",
- "meanPcut_cam2 = np.zeros([nshuffles,nvideos])\n",
- "meanPcut_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "meanPcut_cam1 = np.zeros([nshuffles, nvideos]) # shuffle x movie\n",
+ "meanPcut_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "meanPcut_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
- "stePcut_cam1 = np.zeros([nshuffles,nvideos])\n",
- "stePcut_cam2 = np.zeros([nshuffles,nvideos])\n",
- "stePcut_cam3 = np.zeros([nshuffles,nvideos])\n",
+ "stePcut_cam1 = np.zeros([nshuffles, nvideos])\n",
+ "stePcut_cam2 = np.zeros([nshuffles, nvideos])\n",
+ "stePcut_cam3 = np.zeros([nshuffles, nvideos])\n",
"\n",
"# %%\n",
"\n",
"for i, shuffle in enumerate(Shuffles):\n",
- " if shuffle == 4 or i == 2: model_prefix = model_prefix_base\n",
- " elif shuffle == 3: model_prefix = model_prefix_augm \n",
- "\n",
- " trainFractionIndex = shuffle-1\n",
- " snapshot=-1\n",
- " (\n",
- " ErrorDistribution_all,\n",
- " _,\n",
- " _,\n",
- " ErrorDistributionPCutOff_all,\n",
- " _,\n",
- " _\n",
- " ) = getErrorDistribution(\n",
+ " if shuffle == 4 or i == 2:\n",
+ " model_prefix = model_prefix_base\n",
+ " elif shuffle == 3:\n",
+ " model_prefix = model_prefix_augm\n",
+ "\n",
+ " trainFractionIndex = shuffle - 1\n",
+ " snapshot = -1\n",
+ " (ErrorDistribution_all, _, _, ErrorDistributionPCutOff_all, _, _) = getErrorDistribution(\n",
" config_path,\n",
" shuffle=shuffle,\n",
" snapindex=snapshot,\n",
- " trainFractionIndex = trainFractionIndex,\n",
- " modelprefix = model_prefix\n",
+ " trainFractionIndex=trainFractionIndex,\n",
+ " modelprefix=model_prefix,\n",
" )\n",
" for movie_number in range(7):\n",
- "\n",
- " meanPcut_cam1[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])\n",
- " stePcut_cam1[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam1[movie_number]][:].size**.5)\n",
- "\n",
- " meanPcut_cam2[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam2[movie_number]][:])\n",
- " stePcut_cam2[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam2[movie_number]][:].size**.5)\n",
- "\n",
- " meanPcut_cam3[i,movie_number] = np.nanmean(ErrorDistributionPCutOff_all.values[test_inds_cam3[movie_number]][:])\n",
- " stePcut_cam3[i,movie_number] = np.nanstd(ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:])/(ErrorDistribution_all.values[test_inds_cam3[movie_number]][:].size**.5)\n",
- "\n",
- "fig, (ax1,ax2,ax3) = plt.subplots(3,1)\n",
+ " meanPcut_cam1[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam1[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam1[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ " meanPcut_cam2[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam2[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam2[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam2[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ " meanPcut_cam3[i, movie_number] = np.nanmean(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam3[movie_number]][:]\n",
+ " )\n",
+ " stePcut_cam3[i, movie_number] = np.nanstd(\n",
+ " ErrorDistributionPCutOff_all.values[test_inds_cam1[movie_number]][:]\n",
+ " ) / (ErrorDistribution_all.values[test_inds_cam3[movie_number]][:].size ** 0.5)\n",
+ "\n",
+ "fig, (ax1, ax2, ax3) = plt.subplots(3, 1)\n",
"fig.set_figheight(15)\n",
"fig.set_figwidth(10)\n",
"for i, shuffle in enumerate(Shuffles):\n",
- " \n",
" # to jitter the error bars to keep them from overlapping\n",
- " movie_number = list(range(1,8))\n",
- " movie_number = [x - 2/50 + shuffle/50 for x in movie_number]\n",
- " \n",
- " ax1.errorbar(movie_number,meanPcut_cam1[i,:], stePcut_cam1[i,:,])\n",
+ " movie_number = list(range(1, 8))\n",
+ " movie_number = [x - 2 / 50 + shuffle / 50 for x in movie_number]\n",
+ "\n",
+ " ax1.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam1[i, :],\n",
+ " stePcut_cam1[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
- " ax2.errorbar(movie_number,meanPcut_cam2[i,:], stePcut_cam2[i,:,])\n",
+ " ax2.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam2[i, :],\n",
+ " stePcut_cam2[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
- " ax3.errorbar(movie_number,meanPcut_cam3[i,:], stePcut_cam3[i,:,])\n",
+ " ax3.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam3[i, :],\n",
+ " stePcut_cam3[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
"\n",
"ax1.set_ylim([0, 50])\n",
"ax2.set_ylim([0, 50])\n",
@@ -1867,6 +1997,11 @@
],
"metadata": {
"celltoolbar": "Edit Metadata",
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
"display_name": "Python [conda env:DEEPLABCUT_newGUI] *",
"language": "python",
diff --git a/docs/recipes/installTips.md b/docs/recipes/installTips.md
index cf923f2cf1..1ddefa0ca7 100644
--- a/docs/recipes/installTips.md
+++ b/docs/recipes/installTips.md
@@ -1,15 +1,36 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(installation-tips)=
# Installation Tips
## How to use the latest updates directly from GitHub
-We often update the master deeplabcut code base on github, and then ~1 a month we push out a stable release on pypi. This is what most users turn to on a daily basis (i.e. pypi is where you get your `pip install deeplabcut` code from!
+We often update the master deeplabcut code base on GitHub, and then ~1 a month we push out a stable release on pypi. This is what most users turn to on a daily basis (i.e. pypi is where you get your `pip install deeplabcut` code from! But, sometimes we add things to the repo that are not yet integrated, or you might want to edit the code yourself. Here, we show you how to do this.
-But, sometimes we add things to the repo that are not yet integrated, or you might want to edit the code yourself. Here, we show you how to do this.
+### Method 1:
+
+If you want to *use* the latest, you can use pip and add the specific tags, such as `gui`, etc. by modifying and running:
+```
+pip install --upgrade 'git+https://github.com/deeplabcut/deeplabcut.git#egg=deeplabcut[gui]'
+```
+
+which will download and update deeplabcut, and any dependencies that don't match the new version. If you want to force upgrade all of the dependencies to the latest available versions, too, then use the additional `--upgrade-strategy eager`, i.e.:
+
+```
+pip install --upgrade --upgrade-strategy eager 'git+https://github.com/deeplabcut/deeplabcut.git#egg=deeplabcut[gui]'
+```
+
+### Method 2:
+
+If you want to be able to *edit* the source code of DeepLabCut, i.e., maybe add a feature or fix a 🐛, then you need to "clone" the source code:
**Step 1:**
-- git clone the repo into a folder on your computer:
+- git clone the repo into a folder on your computer:
- click on this green button and copy the link:
@@ -23,7 +44,7 @@ But, sometimes we add things to the repo that are not yet integrated, or you mig

-- Now, when you start `ipython` or `pythonw` (mac users), and `import deeplabcut` you are importing the folder "deeplabcut" - so any changes you make, or any changes we made before adding it to the pip package, are here.
+- Now, when you start `ipython` and `import deeplabcut` you are importing the folder "deeplabcut" - so any changes you make, or any changes we made before adding it to the pip package, are here.
- You can also check which deeplabcut you are importing by running: `deeplabcut.__file__`
@@ -39,21 +60,11 @@ If you make changes, you can also then utilize our test scripts. Run the desired
i.e., for example:
```
-python testscript_multianimal.py
-```
-
-### Quick pull and install from the github repository
-
-If you just want to install the latest pre-release without editing, you can activate your anaconda env, and run
-
-```
-pip install --upgrade git+https://github.com/deeplabcut/deeplabcut.git
-```
+# Testing with the PyTorch engine
+python testscript_pytorch_multi_animal.py
-which will download and update deeplabcut, and any dependencies that don't match the new version. If you want to force upgrade all of the dependencies to the latest available versions, too, then run
-
-```
-pip install --upgrade --upgrade-strategy eager git+https://github.com/deeplabcut/deeplabcut.git
+# Testing with the TensorFlow engine
+python testscript_tensorflow_multi_animal.py
```
@@ -242,6 +253,7 @@ Share images, automate workflows, and more with a free Docker ID:
For more examples and ideas, visit:
https://docs.docker.com/get-started/
```
+
### Next, Anaconda!
Click here to get the ubuntu/linux package: https://www.anaconda.com/products/individual#linux
@@ -282,6 +294,11 @@ Follow prompts!
## Troubleshooting: Note, if you get a failed build due to wxPython (note, this does not happen on Ubuntu 18, 16, etc), i.e.:
+```{warning}
+DeepLabCut no longer uses `wxpython` for its GUI - if you're getting such an error,
+you're likely installing an old version of DeepLabCut.
+```
+
```python
ERROR: Command errored out with exit status 1: /home/mackenzie/anaconda3/envs/DLC-GPU/bin/python -u -c 'import io, os, sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-0jsmkrr1/wxpython_aeff462b2060421a9cf65df55f63a126/setup.py'"'"'; __file__='"'"'/tmp/pip-install-0jsmkrr1/wxpython_aeff462b2060421a9cf65df55f63a126/setup.py'"'"';f = getattr(tokenize, '"'"'open'"'"', open)(__file__) if os.path.exists(__file__) else io.StringIO('"'"'from setuptools import setup; setup()'"'"');code = f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' install --record /tmp/pip-record-pzy9q5u2/install-record.txt --single-version-externally-managed --compile --install-headers /home/mackenzie/anaconda3/envs/DLC-GPU/include/python3.7m/wxpython Check the logs for full command output.
@@ -312,11 +329,11 @@ Activate! `conda activate DEEPLABCUT` and then run: `conda install -c conda-forg
Then run `python -m deeplabcut` which launches the DLC GUI.
-## DeepLabCut MacOS M1 and M2 chip installation environment instructions:
+## DeepLabCut MacOS M-chip installation environment instructions:
-This only assumes you have anaconda installed.
-
-Use the `DEEPLABCUT_M1.yaml` conda file if you have an Macbok with an M1 or M2 chip, and follow these steps:
+This only assumes you have anaconda installed. Use the `DEEPLABCUT_M1.yaml` conda file
+if you have a newer MacBook (with an M1, M2, M3, M4 chip or more later), and follow
+these steps:
(1) git clone the deeplabcut cut repo:
@@ -329,17 +346,24 @@ git clone https://github.com/DeepLabCut/DeepLabCut.git
(3) Then, run:
```bash
-conda env create -f DEEPLABCUT_M1.yaml
+conda env create -f DEEPLABCUT.yaml
```
(4) Finally, activate your environment and to launch DLC with the GUI
```bash
-conda activate DEEPLABCUT_M1
+conda activate DEEPLABCUT
python -m deeplabcut
```
-The GUI will open. Of course, you can also run DeepLabCut in headless mode.
+The GUI will open. Of course, you can also run DeepLabCut in headless mode.
+
+If **you want to use the TensorFlow engine**, you'll need to install the `apple_mchips`
+extra with DeepLabCut. You can do so by running:
+
+```bash
+pip install deeplabcut[apple_mchips]
+```
## How to confirm that your GPU is being used by DeepLabCut
@@ -347,7 +371,7 @@ During training and analysis steps, DeepLabCut does not use the GPU processor he
**On Windows**:
-(1) Open the task manager. If it looks like the image below, click on "More Details"
+(1) Open the task manager. If it looks like the image below, click on "More Details"

@@ -355,15 +379,16 @@ During training and analysis steps, DeepLabCut does not use the GPU processor he

-(3) Click on the **Performance** tab. On that page, click on the small arrow under GPU (it might start as **3D**, and change it to **CUDA**.
+(3) Click on the **Performance** tab. On that page, click on the small arrow under GPU (it might start as **3D**, and change it to **CUDA**.
-(4) During training, you should see the **Dedicated GPU memory usage** increase to near maximum, and you should see some activity in the **CUDA** graph. The graph below is the activity while running `testscript.py`.
+(4) During training, you should see the **Dedicated GPU memory usage** increase to near maximum, and you should see some activity in the **CUDA** graph. The graph below is the activity while running `testscript_tensorflow_single_animal.py`.

(5) If you don't see activity there during training, then your GPU is likely not installed correctly for DeepLabCut. Return to the installation instructions, and be sure you installed CUDA 11+, and ran `conda install cudnn -c conda-forge` after installing DeepLabCut.
-## How to install DeepLabCut for Intel and AMD GPUs on Windows
+## How to install DeepLabCut for Intel and AMD GPUs on Windows for the TensorFlow engine
+
If you are on Windows 10/11 and have a DirectX 12 compatible GPU from any vendor (AMD, Intel, or Nvidia), you utilise GPU acceleration for inference, with an installation that is consistent between devices. This method uses [Tensorflow-directml](https://github.com/microsoft/tensorflow-directml) which uses DirectML instead of Cuda for ML training and inference.
To check the DirectX version of your installed GPU, type in dxdiag into windows search and select the run command. In system information, the bottom item of the list shows your DirectX version. In addition to this ensure your standard GPU drivers are up-to-date. Updating drivers by any official means (Nvidia Geforce experience, AMD radeon software, direct from the vendor website) is fine.
diff --git a/docs/recipes/io.md b/docs/recipes/io.md
index e97238628b..66d101dce7 100644
--- a/docs/recipes/io.md
+++ b/docs/recipes/io.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2022-04-11'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Input/output manipulations with DeepLabCut
## Analyzing very large videos in chunks
@@ -15,12 +21,12 @@ clips = vid.split(n_splits=10)
deeplabcut.analyze_videos(config_path, clips, ext)
```
-## Tips on video re-encoding and preprocessing
+## Tips on video re-encoding and preprocessing
-While moving videos between computers or from your computer to cloud storage you can encounter issues with `analyze_videos` or `create_labeled_video` due to video corruption.
-The issue can present itself during those steps and you have to carefully review the traceback. Sometimes it might look like the videos were analyzed but in fact analysis stopped right before the end of the video (corruption of the metadata when more indices are assigned than there are actual frames in a video).
+While moving videos between computers or from your computer to cloud storage you can encounter issues with `analyze_videos` or `create_labeled_video` due to video corruption.
+The issue can present itself during those steps and you have to carefully review the traceback. Sometimes it might look like the videos were analyzed but in fact analysis stopped right before the end of the video (corruption of the metadata when more indices are assigned than there are actual frames in a video).
To tackle this issue, the easiest solution might be to re-encode the video, this will not only help with corruption but can also – if you choose so – compress the video without perceivable loss of quality. Common package used for video processing is FFmpeg which you can use from the terminal inside your DEEPLABCUT environment (without going into iPython).
-There are number of video codecs that can be used to re-encode your video and if you want to keep the video in the same container (`.avi`, `.mp4`, `.ts` etc.) you should check which codec allows encoding to a certain container. For instance, for `.avi` it will be MJPEG and for `.mp4` H264 and H265.
+There are number of video codecs that can be used to re-encode your video and if you want to keep the video in the same container (`.avi`, `.mp4`, `.ts` etc.) you should check which codec allows encoding to a certain container. For instance, for `.avi` it will be MJPEG and for `.mp4` H264 and H265.
To re-encode your video, simply use:
```
ffmpeg -i "path_to_video" -c:v codec_name "output_path"
@@ -35,14 +41,14 @@ For `.avi` files you want to change the codec and the quality metric, since `crf
```
ffmpeg -i "path_to_video" -c:v mjpeg -q:v 10 "output_path"
```
-`-q:v` is a quality metric with values ranging from 1 to 31 with reasonable values being around 10.
+`-q:v` is a quality metric with values ranging from 1 to 31 with reasonable values being around 10.
If you want to compress all your recordings for easier storage or moving to cloud storage, you can use a for loop that will go through all videos in a directory that are in a certain container. Let’s say we want to transcode our `.avi` videos to `.mp4` and make them smaller without quality loss. Note, that the loop has be run from inside the folder the videos are in:
```
-for %i in (*.avi) do ffmpeg -i "%i" -c:v libx265 -preset fast -crf 18 "%~ni.mp4"
+for %i in (*.avi) do ffmpeg -i "%i" -c:v libx265 -preset fast -crf 18 "%~ni.mp4"
```
This command will re-encode all of your videos into an `.mp4` container and save them with the same name as the original (without overwriting them).
-Additionally, ffmpeg allows you to also crop or rescale the videos for possible improvement in inference speed further down the line in DLC workflow. To either crop or rescale you need to use
-`-filter:v` parameter after which you’d add either `"crop=Xsize:Ysize:Xstart:Ystart"` for cropping or
+Additionally, ffmpeg allows you to also crop or rescale the videos for possible improvement in inference speed further down the line in DLC workflow. To either crop or rescale you need to use
+`-filter:v` parameter after which you’d add either `"crop=Xsize:Ysize:Xstart:Ystart"` for cropping or
`"scale=Xsize:Ysize"` for rescale. Note that when using “scale” the values how be a result of integer division of the original video size. If you want to keep the aspect ratio, you can simply set either X or Y to `-1` and only give one of the or you can use `“scale=iw/2:ih/2”` which will simply make the video 2 times smaller in both dimensions. For instance, if you have a videos at 1920x1080 resolution and want to rescale it to 960x540 for faster inference while also reencoding from `.avi` and doing some compression in a loop, the command would be something like this:
```
for %i in (*.avi) do ffmpeg -i "%i" -c:v libx265 -preset fast -crf 18 -filter:v "scale= iw/2:ih/2" "%~ni.mp4"
diff --git a/docs/recipes/nn.md b/docs/recipes/nn.md
index 75b44e315f..b002dd6b14 100644
--- a/docs/recipes/nn.md
+++ b/docs/recipes/nn.md
@@ -1,15 +1,22 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+(tf-training-tips-and-tricks)=
# Model training tips & tricks
-## Limiting a GPU's memory consumption
+## TensorFlow Engine: Limiting a GPU's memory consumption
-All GPU memory is allocated to training by default, preventing
+With TensorFlow, all GPU memory is allocated to training by default, preventing
other Tensorflow processes from being run on the same machine.
-A flexible solution to limiting memory usage is to call `deeplabcut.train(..., allow_growth=True)`,
-which dynamically grows the GPU memory region as it is needed.
-Another, stricter option is to explicitly cap GPU usage to only a fraction
-of the available memory. For example, allocating a maximum of 1/4 of the total
-memory could be done as follows:
+A flexible solution to limiting memory usage is to call
+`deeplabcut.train(..., allow_growth=True)`, which dynamically grows the GPU memory
+region as it is needed. Another, stricter option is to explicitly cap GPU usage to only
+a fraction of the available memory. For example, allocating a maximum of 1/4 of the
+total memory could be done as follows:
```python
import tensorflow as tf
@@ -18,6 +25,7 @@ gpu_options = tf.GPUOptions(per_process_gpu_memory_fraction=0.25)
sess = tf.Session(config=tf.ConfigProto(gpu_options=gpu_options))
```
+(tf-custom-image-augmentation)=
## Using custom image augmentation
Image augmentation is the process of artificially expanding the training set
@@ -25,89 +33,104 @@ by applying various transformations to images (e.g., rotation or rescaling)
in order to make models more robust and more accurate (read our
[primer](https://www.sciencedirect.com/science/article/pii/S0896627320307170) for
more information). Although data augmentation is automatically accomplished
-by DeepLabCut, default values (see the augmentation variables in the
-[default pose_cfg.yaml](https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml#L23-L74) file)
-can be readily overwritten prior to training.
+by DeepLabCut, default values can be readily overwritten prior to training. See the
+augmentation variables defined in the:
-Another option we discuss is a different data-efficient approach based on a method called active learning. See this [this blog post](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/recipes/nn.md#using-custom-image-augmentation) for further details.
+- PyTorch Engine: [docs for the `pytorch_config.yaml` file](dlc3-pytorch-config)
+- TensorFlow Engine: [default pose_cfg.yaml file](
+https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml#L23-L74)
-When you `create_training_dataset` [you have several options](https://github.com/DeepLabCut/DeepLabCut/wiki/DOCSTRINGS#create_training_dataset) on what types of augmentation to use.
-```python
-deeplabcut.create_training_dataset(configpath, augmenter_type='imgaug')
-```
-
-When you do this (i.e. pass `augmenter_type`) what underlying files you are calling are these:
-https://github.com/DeepLabCut/DeepLabCut/tree/master/deeplabcut/pose_estimation_tensorflow/datasets
-You can look at what types of augmentation are available to you (or edit those files to add more). Moreover, you can add more options to the pose_cfg.yaml file. Here is a simple script you can modify and run to automatically edit the correct pose_cfg.yaml to add more augmentation to the `imgaug` loader (or open it and edit yourself).
-
-But, you can add more:
+For the single-animal TensorFlow models, [you have several options](
+https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#f-create-training-dataset-s-and-selection-of-your-neural-network)
+for image augmentation when calling `create_training_dataset`
-```python
-import deeplabcut
-
-train_pose_config, _ = deeplabcut.return_train_network_path(config_path)
-augs = {
- "gaussian_noise": True,
- "elastic_transform": True,
- "rotation": 180,
- "covering": True,
- "motion_blur": True,
-}
-deeplabcut.auxiliaryfunctions.edit_config(
- train_pose_config,
- augs,
-)
-```
-An in-depth tutorial on image augmentation and training hyperparameters can be found [here](https://deeplabcut.github.io/DeepLabCut/docs/recipes/pose_cfg_file_breakdown.html).
+An in-depth tutorial on image augmentation and training hyperparameters can be found [
+here](
+https://deeplabcut.github.io/DeepLabCut/docs/recipes/pose_cfg_file_breakdown.html).
## Evaluating intermediate (and all) snapshots
-The latest snapshot stored during training may not necessarily be the one that yields the highest performance. Therefore, you should analyze ALL snapshots, and select the best. Put 'all' in the snapshots section of the config.yaml to do this.
+The latest snapshot stored during training may not necessarily be the one that yields
+the highest performance. Therefore, you should analyze ALL snapshots, and select the
+best. Put 'all' in the snapshots section of the `config.yaml` to do this.
+(what-neural-network-should-i-use)=
## What neural network should I use? (Trade offs, speed performance, and considerations)
-### With the release of even more network options, you now have to decide what to use! This additionally flexibility is hopefully helpful, but we want to give you some guidance on where to start.
+You always select the network type when you create a training data set: i.e., standard
+dlc: `deeplabcut.create_training_dataset(config, net_type=resnet_50)` , or maDLC:
+`deeplabcut.create_multianimaltraining_dataset(config, net_type=dlcrnet_ms5)`. There is
+nothing else you should change.
+### PyTorch Engine
-**TL;DR - your best performance for most everything is ResNet-50; MobileNetV2-1 is much faster, needs less memory on your GPU to train and nearly as accurate.**
+The different architectures available are described in the [PyTorch model architectures
+](dlc3-architectures) page.
-You always select the network type when you create a training data set: i.e., standard dlc: `deeplabcut.create_training_dataset(config, net_type=resnet_50)` , or maDLC: `deeplabcut.create_multianimaltraining_dataset(config, net_type=dlcrnet_ms5)`. There is nothing else you should change.
+### TensorFlow Engine
+With the release of even more network options, you now have to decide what to use! This
+additionally flexibility is hopefully helpful, but we want to give you some guidance on
+where to start.
+
+**TL;DR - your best performance for most everything is ResNet-50; MobileNetV2-1 is much
+faster, needs less memory on your GPU to train and nearly as accurate.**
***
-## ResNets:
+### ResNets:
-In Mathis et al. 2018 we benchmarked three networks: **ResNet-50, ResNet-101, and ResNet-101ws**. For ALL lab applications, ResNet-50 was enough. For all the demo videos on [www.deeplabcut.org](http://www.mousemotorlab.org/deeplabcut) the backbones are ResNet-50's. Thus, we recommend making this your go-to workhorse for data analysis. Here is a figure from the paper, see panel "B" (they are all within a few pixels of each other on the open-field dataset):
+In Mathis et al. 2018 we benchmarked three networks: **ResNet-50, ResNet-101, and
+ResNet-101ws**. For ALL lab applications, ResNet-50 was enough. For all the demo videos
+on [www.deeplabcut.org](http://www.mousemotorlab.org/deeplabcut) the backbones are
+ResNet-50's. Thus, we recommend making this your go-to workhorse for data analysis. Here
+is a figure from the paper, see panel "B" (they are all within a few pixels of each
+other on the open-field dataset):
-This is also one of the main result figures, generated with ResNet-50. BLUE is training - RED is testing - BLACK is our best human-level performance, and 10 pixels is the width of the mouse nose -so anything under that is good performance for us on this task!
+This is also one of the main result figures, generated with ResNet-50. BLUE is
+training - RED is testing - BLACK is our best human-level performance, and 10 pixels is
+the width - of the mouse nose -so anything under that is good performance for us on this
+task!
+
-Here are also some speed stats for analyzing videos with ResNet-50, see https://www.biorxiv.org/content/early/2018/10/30/457242 for more details:
+Here are also some speed stats for analyzing videos with ResNet-50, see
+https://www.biorxiv.org/content/early/2018/10/30/457242 for more details:
-**So, why use a ResNet-101 or even 152?** if you have a much more challenging problem, like multiple humans dancing, this is a good option. You should then also set `intermediate_supervision=True` for best performance in the pose_config.yaml of that shuffle folder ( before you train). Note, for ResNet-50 this does NOT help, and can hurt.
+**So, why use a ResNet-101 or even 152?** if you have a much more challenging problem,
+like multiple humans dancing, this is a good option. You should then also set
+`intermediate_supervision=True` for best performance in the `pose_config.yaml` of that
+shuffle folder (before you train). Note, for ResNet-50 this does NOT help, and can
+hurt.
-## When should I use a MobileNet?
+### When should I use a MobileNet?
-MobileNets are fast to run, fast to train, more memory efficient, and faster for analysis (inference) - e.g. on CPUs they are 4 times faster, on GPUs up to 2x! So, if you don't have a GPU (or a GPU with little memory), and don't want to use Google COLAB, etc, then these are a great starting point.
+MobileNets are fast to run, fast to train, more memory efficient, and faster for
+analysis (inference) - e.g. on CPUs they are 4 times faster, on GPUs up to 2x! So, if
+you don't have a GPU (or a GPU with little memory), and don't want to use Google COLAB,
+etc, then these are a great starting point.
-They are smaller/shallower networks though, so you don't want to be pushing in very large images. So, be sure to use `deeplabcut.DownSampleVideo` on your data (which is frankly never a bad idea).
+They are smaller/shallower networks though, so you don't want to be pushing in very
+large images. So, be sure to use `deeplabcut.DownSampleVideo` on your data (which is
+frankly never a bad idea).
-Additionally, these are good options for running on "live" videos, i.e. if you want to give real-time feedback in an experiment, you can run a video around a smaller cropped area, and run this rather fast!
+Additionally, these are good options for running on "live" videos, i.e. if you want to
+give real-time feedback in an experiment, you can run a video around a smaller cropped
+area, and run this rather fast!
**So, how fast are they?**
-Here are comparisons of 4 MobileNetV2 variants to ResNet-50 and ResNet-101 (darkest red):
-read more here: https://arxiv.org/abs/1909.11229
+Here are comparisons of 4 MobileNetV2 variants to ResNet-50 and ResNet-101 (darkest
+red - read more here: https://arxiv.org/abs/1909.11229)
@@ -117,14 +140,26 @@ read more here: https://arxiv.org/abs/1909.11229
-## When should I use an EfficientNet?
+### When should I use an EfficientNet?
-Built with inverse residual blocks like MobileNets, but more powerful than ResNets, due to optimal depth/width/resolution scaling, [EfficientNet](https://arxiv.org/abs/1905.11946) are an excellent choice if you want speed and performance. They do require more careful handling though! Especially for small datasets, you will need to tune the batch size and learning rates. So, we suggest these for more advanced users, or those willing to run experiments to find the best settings. Here is the speed comparison, and for performance see our latest work at: http://horse10.deeplabcut.org
+Built with inverse residual blocks like MobileNets, but more powerful than ResNets, due
+to optimal depth/width/resolution scaling, [EfficientNet](
+https://arxiv.org/abs/1905.11946) are an excellent choice if you want speed and
+performance. They do require more careful handling though! Especially for small
+datasets, you will need to tune the batch size and learning rates. So, we suggest these
+for more advanced users, or those willing to run experiments to find the best settings.
+Here is the speed comparison, and for performance see our latest work at:
+http://horse10.deeplabcut.org
-## How can I compare them?
+### How can I compare them?
-Great question! So, the best way to do this is to use the **same** test/train split (that is generated in create_training_dataset) with different models. Here, as of 2.1+, we have a **new** function that lets you do this easily. Instead of using `create_training_dataset` you will run `create_training_model_comparison` (see the docstrings by `deeplabcut.create_training_model_comparison?` or run the Project Manager GUI - `deeplabcut.launch_dlc()`- for assistance.
+Great question! So, the best way to do this is to use the **same** test/train split (
+that is generated in create_training_dataset) with different models. Here, as of 2.1+,
+we have a **new** function that lets you do this easily. Instead of using
+`create_training_dataset` you will run `create_training_model_comparison` (see the
+docstrings by `deeplabcut.create_training_model_comparison?` or run the Project Manager
+GUI - `deeplabcut.launch_dlc()`- for assistance.
diff --git a/docs/recipes/pose_cfg_file_breakdown.md b/docs/recipes/pose_cfg_file_breakdown.md
index 2e980edc25..881ccb6f4d 100644
--- a/docs/recipes/pose_cfg_file_breakdown.md
+++ b/docs/recipes/pose_cfg_file_breakdown.md
@@ -1,12 +1,23 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# The `pose_cfg.yaml` Guideline Handbook
+::::{warning}
+The following is specific to Tensorflow-based models. To read the equivalent explanations for Pytorch-based models,
+click [here](dlc3-pytorch-config)
+::::
+
👋 Hello! Mabuhay! Hola! This recipe was written by the [2023 DLC AI Residents](https://www.deeplabcutairesidency.org/)!
When you train, evaluate, and run inference with a neural network there are hyperparatmeters you must consider. While DLC attempts to set the "globally good for everyone" parameters, you might want to change them. Therefore, in this recipe we will review the pose config parameters related to neural network models' and the related data augmentation!
# 1. What is the *pose_cfg.yml* file?
-- The `pose_cfg.yaml` file offers easy access to a range of training parameters that the user may want or have to adjust depending on the used dataset and task.
+- The `pose_cfg.yaml` file offers easy access to a range of training parameters that the user may want or have to adjust depending on the used dataset and task.
- You will find the file in the dlc-models > test and train sub-directories. There is also a button in the GUI to directly open this file.
- This recipe is aimed at giving an average user an intuition on those hyperparameters and situations in which addressing them can be useful.
@@ -35,11 +46,11 @@ When you train, evaluate, and run inference with a neural network there are hype
- [References](#references)
-## 2.1 Training Hyperparameters
+## 2.1 Training Hyperparameters
### 2.1.A `max_input_size` and `min_input_size`
-The default values are `1500` and `64`, respectively.
+The default values are `1500` and `64`, respectively.
💡Pro-tip:💡
- change `max_input_size` when the resolution of the video is higher than 1500x1500 or when `scale_jitter_up` will possibly go over that value
@@ -64,11 +75,11 @@ In both cases, you can increase the batchsize up to the limit of your GPU memory
___________________________________________________________________________________
-Values mentioned above and the augmentation parameters are often intuitive, and knowing our own data, we are able to decide on what will and won't be beneficial. Unfortunately, not all hyperparameters are this simple or intuitive. Two parameters that might require some tuning on challenging datasets are `pafwidth` and `pos_dist_thresh`.
+Values mentioned above and the augmentation parameters are often intuitive, and knowing our own data, we are able to decide on what will and won't be beneficial. Unfortunately, not all hyperparameters are this simple or intuitive. Two parameters that might require some tuning on challenging datasets are `pafwidth` and `pos_dist_thresh`.
### 2.1.D `pos_dist_thresh`
-The default value is `17`. It's the size of a window within which detections are considered positive training samples, meaning they tell the model that it's going in the right direction.
+The default value is `17`. It's the size of a window within which detections are considered positive training samples, meaning they tell the model that it's going in the right direction.
### 2.1.E `pafwidth`
@@ -78,7 +89,7 @@ The default value is `20`. PAF stands for part affinity fields. It is a method o
## 2.2 Data augmentation parameters
In the simplest form, we can think of data augmentation as something similar to imagination or dreaming. Humans imagine different scenarios based on experience, ultimately allowing us to better understand our world. [2, 3, 4](#references)
-Similarly, we train our models to different types of "imagined" scenarios, which we limit to the foreseeable ones, so we ultimately get a robust model that can more likely handle new data and scenes.
+Similarly, we train our models to different types of "imagined" scenarios, which we limit to the foreseeable ones, so we ultimately get a robust model that can more likely handle new data and scenes.
Classes of data augmentations, characterized by their nature, are given by:
- [**Geometric transformations**](#geometric)
@@ -122,7 +133,7 @@ During training, each image is randomly scaled within the range `[scale_jitter_l
### 2.1.2 `rotation`
-*Rotation augmentations* are done by rotating the image right or left on an axis between $1^{\circ}$ and $359^{\circ}$. The safety of rotation augmentations is heavily determined by the rotation degree parameter. Slight rotations such as between $+1^{\circ}$ and $+20^{\circ}$ or $-1^{\circ}$ to $-20^{\circ}$ is generally an acceptable range. Keep in mind that as the rotation degree increases, the precision of the label placement can decrease
+*Rotation augmentations* are done by rotating the image right or left on an axis between $1^{\circ}$ and $359^{\circ}$. The safety of rotation augmentations is heavily determined by the rotation degree parameter. Slight rotations such as between $+1^{\circ}$ and $+20^{\circ}$ or $-1^{\circ}$ to $-20^{\circ}$ is generally an acceptable range. Keep in mind that as the rotation degree increases, the precision of the label placement can decrease
The image below, retrieved from [2](#ref2), illustrates the difference between the different rotation degrees.
@@ -131,11 +142,11 @@ The image below, retrieved from [2](#ref2), illustrates the difference between t
During training, each image is rotated $+/-$ the `rotation` degree parameter set. By default, this parameter is set to `25`, which means that the images are augmented with a $+25^{\circ}$ rotation of itself and a $-25^{\circ}$ degree rotation of itself. Should you want to opt out of this augmentation, set the rotation value to `False`.
💡Pro-tips:💡
-- ⭐If you have labelled all the possible rotations of your animal/s, keeping the **default** value **unchanged** is **enough** ✅
+- ⭐If you have labelled all the possible rotations of your animal/s, keeping the **default** value **unchanged** is **enough** ✅
- However, you may want to adjust this parameter if you want your model to:
- - handle new data with new rotations of the animal subjects
- - handle the possibly unlabelled rotations of your minimally-labeled data
+ - handle new data with new rotations of the animal subjects
+ - handle the possibly unlabelled rotations of your minimally-labeled data
- But as a consequence, the more you increase the rotation degree, the more the original keypoint labels may not be preserved
@@ -143,7 +154,7 @@ During training, each image is rotated $+/-$ the `rotation` degree parameter set
This parameter in the DLC module is given by the percentage of sampled data to be augmented from your training data. The default value is set to `0.4` or $40\%$. This means that there is a $40\%$ chance that images within the current batch will be rotated.
💡Pro-tip:💡
-- ⭐ Generally, keeping the **default** value **unchanged** is **enough** ✅
+- ⭐ Generally, keeping the **default** value **unchanged** is **enough** ✅
### 2.2.4 `fliplr` (or a horizontal flip)
@@ -162,48 +173,48 @@ By default, this parameter is set to `False` especially on poses with mirror sym
### 2.2.5 `crop_size`
- Cropping consists of removing unwanted pixels from the image, thus selecting a part of the image and discarding the rest, reducing the size of the input.
+ Cropping consists of removing unwanted pixels from the image, thus selecting a part of the image and discarding the rest, reducing the size of the input.
In DeepLabCut *pose_config.yaml* file, by default, `crop_size` is set to (`400,400`), width, and height, respectively. This means it will cut out parts of an image of this size.
💡Pro-tip:💡
- If your images are very large, you could consider increasing the crop size. However, be aware that you'll need a strong GPU, or you will hit memory errors!
- - If your images are very small, you could consider decreasing the crop size.
+ - If your images are very small, you could consider decreasing the crop size.
### 2.2.6 `crop_ratio`
- Also, the number of frames to be cropped is defined by the variable `cropratio`, which is set to `0.4` by default. That means that there is a $40\%$ the images within the current batch will be cropped. By default, this value works well.
+ Also, the number of frames to be cropped is defined by the variable `cropratio`, which is set to `0.4` by default. That means that there is a $40\%$ the images within the current batch will be cropped. By default, this value works well.
### 2.2.7 `max_shift`
The crop shift between each cropped image is defined by `max_shift` variable, which explains the max relative shift to the position of the crop centre. By default is set to `0.4`, which means it will be displaced 40% max from the center to not apply identical cropping each time the same image is encountered during training - this is especially important for `density` and `hybrid` cropping methods.
- The image below is modified from
- [2](#references).
-
+ The image below is modified from
+ [2](#references).
+
### 2.2.8 `crop_sampling`
- Likewise, there are different cropping sampling methods (`crop_sampling`), we can use depending on how our image looks like.
+ Likewise, there are different cropping sampling methods (`crop_sampling`), we can use depending on how our image looks like.
💡Pro-tips💡
- - For highly crowded scenes, `hybrid` and `density` approaches will work best.
+ - For highly crowded scenes, `hybrid` and `density` approaches will work best.
- `uniform` will take out random parts of the image, disregarding the annotations completely
- 'keypoint' centers on a random keypoint and crops based on that location - might be best in preserving the whole animal (if reasonable `crop_size` is used)
- ### Kernel transformations
+ ### Kernel transformations
Kernel filters are very popular in image processing to sharpen and blur images. Intuitively, blurring an image might increase the motion blur resistance during testing. Otherwise, sharpening for data enhancement could result in capturing more detail on objects of interest.
### 2.2.9 `sharpening` and `sharpenratio`
- In DeepLabCut *pose_config.yaml* file, by default, `sharpening` is set to `False`, but if we want to use this type of data augmentation, we can set it `True` and specify a value for `sharpenratio`, which by default is set to `0.3`. Blurring is not defined in the *pose_config.yaml*, but if the user finds it convenient, it can be added to the data augmentation pipeline.
+ In DeepLabCut *pose_config.yaml* file, by default, `sharpening` is set to `False`, but if we want to use this type of data augmentation, we can set it `True` and specify a value for `sharpenratio`, which by default is set to `0.3`. Blurring is not defined in the *pose_config.yaml*, but if the user finds it convenient, it can be added to the data augmentation pipeline.
+
+ The image below is modified from
+ [2](#references).
- The image below is modified from
- [2](#references).
-
@@ -211,7 +222,7 @@ By default, this parameter is set to `False` especially on poses with mirror sym
Concerning sharpness, we have an additional parameter, `edge` enhancement, which enhances the edge contrast of an image to improve its apparent sharpness. Likewise, by default, this parameter is set `False`, but if you want to include it, you just need to set it `True`.
-# References
+# References
Cao, Z., Simon, T., Wei, S. E., & Sheikh, Y. (2017). Realtime multi-person 2d pose estimation using part affinity fields. In Proceedings of the IEEE conference on Computer Vision and Pattern Recognition (pp. 7291-7299).https://openaccess.thecvf.com/content_cvpr_2017/html/Cao_Realtime_Multi-Person_2D_CVPR_2017_paper.html
Mathis, A., Schneider, S., Lauer, J., & Mathis, M. W. (2020). A Primer on Motion Capture with Deep Learning: Principles, Pitfalls, and Perspectives. In Neuron (Vol. 108, Issue 1, pp. 44-65). https://doi.org/10.1016/j.neuron.2020.09.017
diff --git a/docs/recipes/post.md b/docs/recipes/post.md
index cbcb78c673..1ebdf09ec5 100644
--- a/docs/recipes/post.md
+++ b/docs/recipes/post.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2022-06-08'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Some data processing recipes!
## Flagging frames with abnormal bodypart distances
diff --git a/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md b/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md
index 31710c3a2d..99d754c731 100644
--- a/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md
+++ b/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md
@@ -1,3 +1,9 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
# Publishing Notebooks into the Main DLC Cookbook
### Your Recipe Guide to Contributing to the DLC Cookbook
@@ -5,7 +11,7 @@
Hey there, DLC enthusiast! 🌟 Ready to sprinkle your magic into the main DLC cookbook? Whether you're introducing a zesty new dish or giving an old one a twist, this guide's got your back. We'll walk you through how to publish a new notebook or spice up an existing one in the DLC cookbook. Let's get cooking! 🍲📘
## Preliminary Checks
-#### Check Existing Recipes or Tutorials
+### Check Existing Recipes or Tutorials
- **Search and Review**: Before you start writing a new recipe, go through the existing DLC Jupyter book to ensure there isn't a tutorial or recipe that covers the topic you have in mind.
- **Expand Existing Content**: If your content is related to an existing topic, like I/O manipulations, consider expanding or refining that section instead of creating an entirely new recipe. This ensures that the Jupyter book remains concise and that related information is found in one place.
- **Locate and Review**: Navigate to the particular recipe or tutorial you wish to update in the DLC Jupyter book.
@@ -16,9 +22,9 @@ Hey there, DLC enthusiast! 🌟 Ready to sprinkle your magic into the main DLC c
## Structure of a Recipe
When crafting your recipe, adhere to the following structure:
- **Introduction**: Begin with an introductory paragraph that highlights the importance and relevance of the recipe. This sets the stage and gives readers context.
-
+
- **Examples/Workflow**: Provide step-by-step instructions or a workflow, supported by examples. This makes it easy for readers to understand and follow along.
-
+
- **Conclusion**: Conclude with a summary or highlight the key takeaways of your recipe. You can also provide references or further reading.
@@ -27,7 +33,7 @@ Now, let's dive into the process of contributing your content to the DLC Jupyter
1. **Set-up your local environment.** You need `deeplabcut[docs]` installed:
You can do this by running the following command:
- ```
+ ```
pip install deeplabcut[docs]
```
@@ -57,9 +63,9 @@ This command installs DeepLabCut along with the dependencies required to build t
- **Craft with Care:** Remember, your notebook will be a reference for many. Begin with an engaging introduction, followed by well-structured content, and wrap it up with a conclusion.
- **Interactive Elements:** One of the strengths of Jupyter notebooks is the ability to combine code, visuals, and narrative. Use interactive plots, widgets, or any other tools that enhance the content and make it engaging.
- **Save Regularly:** Jupyter auto-saves your work, but it's a good habit to manually save your notebook frequently, especially after making significant changes.
- - **Naming Convention:** Name your notebook in a way that reflects its content and is consistent with other notebook titles in the DLC Jupyter book. This makes it easier for readers to understand the topic at a glance.
- - **Updating an existing notebook**
- - Navigate to the location of the existing recipe within the directory:
+ - **Naming Convention:** Name your notebook in a way that reflects its content and is consistent with other notebook titles in the DLC Jupyter book. This makes it easier for readers to understand the topic at a glance.
+ - **Updating an existing notebook**
+ - Navigate to the location of the existing recipe within the directory:
```
[YOUR_REPO_DIRECTORY]/docs/recipes/
```
@@ -76,15 +82,15 @@ This command installs DeepLabCut along with the dependencies required to build t
- Navigate to the appropriate directory where the Jupyter notebooks are stored for the Jupyter book.
- Add your Jupyter notebook (.ipynb file) to this directory.
-
+
To copy via terminal:
-
+
- Unix-based OS users
-
+
```
cp [YOUR_NOTEBOOK_FILENAME].ipynb [YOUR_REPO_DIRECTORY]/docs/recipes
```
-
+
- WinOS users:
```
copy new_recipe.ipynb [YOUR_REPO_DIRECTORY]\docs\recipes
@@ -94,7 +100,7 @@ This command installs DeepLabCut along with the dependencies required to build t
8. **Update `[YOUR_REPO_DIRECTORY]/_toc.yml`** by adding under the *Tutorials & Cookbook* section a **new line** containing the path to your notebook. This creates a link to your notebook on the main DLC book sidebar.
* For example:
- ```
+ ```
- file: docs/recipes/[YOUR_NOTEBOOK_FILENAME]
```
@@ -109,7 +115,7 @@ This command installs DeepLabCut along with the dependencies required to build t
10. **Commit your changes:**
When everything is a-okay, commit your changes to your branch. If not, edit your file and go to back to step 1.
-
+
```
git add [YOUR_NOTEBOOK_FILENAME]
git commit -m "Added a new notebook about [YOUR_TOPIC]"
@@ -133,7 +139,7 @@ This command installs DeepLabCut along with the dependencies required to build t
14. **🎉PR Approval:🎉** Once your PR is approved, the maintainers will merge it into the main repository. Your notebook will then be a part of the DeepLabCut Jupyter book! Yay!
-Remember to always check the [DLC CONTRIBUTING guidelines](https://github.com/DeepLabCut/DeepLabCut/blob/main/CONTRIBUTING.md).
+Remember to always check the [DLC contributing guidelines](https://github.com/DeepLabCut/DeepLabCut/blob/main/CONTRIBUTING.md).
## Wrap-Up 🎉
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 905a95ae6f..04228cb56e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,8 +1,14 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(dev-roadmap)=
## A development roadmap for DeepLabCut
-:loudspeaker: :hourglass_flowing_sand: :construction:
+📢 ⏳ 🚧
**General Enhancements:**
- [ ] DeepLabCut PyTorch & Model Zoo --> DLC 3.0 🔥
@@ -34,8 +40,8 @@
- [X] DeepLabCut-live! published in eLife
**DeepLabCut Model Zoo: a collection of pretrained models for plug-in-play DLC and community crowd-sourcing.**
-- [X] BETA release with 2.1.8b0: http://www.mousemotorlab.org/dlc-modelzoo
-- [X] full release with 2.1.8.1 http://www.mousemotorlab.org/dlc-modelzoo
+- [X] BETA release with 2.1.8b0: https://www.mackenziemathislab.org/deeplabcut
+- [X] full release with 2.1.8.1 https://www.mackenziemathislab.org/deeplabcut
- [X] Manuscript forthcoming! --> see arXiv https://arxiv.org/abs/2203.07436
- [X] new models added; horse, cheetah
- [X] TopView_Mouse model
diff --git a/docs/standardDeepLabCut_UserGuide.md b/docs/standardDeepLabCut_UserGuide.md
index 0fa6ba0e27..59985ea3bb 100644
--- a/docs/standardDeepLabCut_UserGuide.md
+++ b/docs/standardDeepLabCut_UserGuide.md
@@ -1,7 +1,14 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
(single-animal-userguide)=
# DeepLabCut User Guide (for single animal projects)
-This document covers single/standard DeepLabCut use. If you have a complicated multi-animal scenario (i.e., they look the same), then please see our [maDLC user guide](multi-animal-userguide).
+This document covers single/standard DeepLabCut use. If you have a complicated multi-animal scenario (i.e., they look
+the same), then please see our [maDLC user guide](multi-animal-userguide).
To get started, you can use the GUI, or the terminal. See below.
@@ -11,7 +18,12 @@ To get started, you can use the GUI, or the terminal. See below.
**GUI:**
-To begin, navigate to Aanaconda Prompt Terminal and right-click to "open as admin "(Windows), or simply launch "Terminal" (unix/MacOS) on your computer. We assume you have DeepLabCut installed (if not, see Install docs!). Next, launch your conda env (i.e., for example `conda activate DEEPLABCUT`). Then, simply run ``python -m deeplabcut``. The below functions are available to you in an easy-to-use graphical user interface. While most functionality is available, advanced users might want the additional flexibility that command line interface offers. Read more below.
+To begin, navigate to Anaconda Prompt Terminal and right-click to "open as admin "(Windows), or simply launch
+"Terminal" (unix/MacOS) on your computer. We assume you have DeepLabCut installed (if not, see
+[install docs](how-to-install)!). Next, launch your conda env (i.e., for example `conda activate DEEPLABCUT`). Then,
+simply run `python -m deeplabcut`. The below functions are available to you in an easy-to-use graphical user interface.
+While most functionality is available, advanced users might want the additional flexibility that command line interface
+offers. Read more below.
```{Hint}
🚨 If you use Windows, please always open the terminal with administrator privileges! Right click, and "run as administrator".
```
@@ -20,11 +32,19 @@ To begin, navigate to Aanaconda Prompt Terminal and right-click to "open as admi
-As a reminder, the core functions are described in our [Nature Protocols](https://www.nature.com/articles/s41596-019-0176-0) paper (published at the time of 2.0.6). Additional functions and features are continually added to the package. Thus, we recommend you read over the protocol and then please look at the following documentation and the doctrings. Thanks for using DeepLabCut!
+As a reminder, the core functions are described in our
+[Nature Protocols paper](https://www.nature.com/articles/s41596-019-0176-0) (published at the time of 2.0.6).
+Additional functions and features are continually added to the package. Thus, we recommend you read over the protocol
+and then please look at the following documentation and the doctrings. Thanks for using DeepLabCut!
## DeepLabCut in the Terminal/Command line interface:
-To begin, navigate to Aanaconda Prompt Terminal and right-click to "open as admin "(Windows), or simply launch "Terminal" (unix/MacOS) on your computer. We assume you have DeepLabCut installed (if not, see Install docs!). Next, launch your conda env (i.e., for example `conda activate DEEPLABCUT`) and then type `ipython`. Then type `import deeplabcut`.
+To begin, navigate to Anaconda Prompt Terminal and right-click to "open as admin "(Windows), or simply launch
+"Terminal" (unix/MacOS) on your computer. We assume you have DeepLabCut installed (if not, see Install docs!). Next,
+launch your conda env (i.e., for example `conda activate DEEPLABCUT`) and then type `ipython`. Then type:
+```python
+import deeplabcut
+```
```{Hint}
🚨 If you use Windows, please always open the terminal with administrator privileges! Right click, and "run as administrator".
@@ -32,50 +52,91 @@ To begin, navigate to Aanaconda Prompt Terminal and right-click to "open as admi
### (A) Create a New Project
-The function **create\_new\_project** creates a new project directory, required subdirectories, and a basic project configuration file. Each project is identified by the name of the project (e.g. Reaching), name of the experimenter (e.g. YourName), as well as the date at creation.
+The function `create_new_project` creates a new project directory, required subdirectories, and a basic project
+configuration file. Each project is identified by the name of the project (e.g. Reaching), name of the experimenter
+(e.g. YourName), as well as the date at creation.
-Thus, this function requires the user to input the name of the project, the name of the experimenter, and the full path of the videos that are (initially) used to create the training dataset.
+Thus, this function requires the user to input the name of the project, the name of the experimenter, and the full
+path of the videos that are (initially) used to create the training dataset.
-Optional arguments specify the working directory, where the project directory will be created, and if the user wants to copy the videos (to the project directory). If the optional argument working\_directory is unspecified, the project directory is created in the current working directory, and if copy\_videos is unspecified symbolic links for the videos are created in the videos directory. Each symbolic link creates a reference to a video and thus eliminates the need to copy the entire video to the video directory (if the videos remain at the original location).
+Optional arguments specify the working directory, where the project directory will be created, and if the user wants
+to copy the videos (to the project directory). If the optional argument `working_directory` is unspecified, the
+project directory is created in the current working directory, and if `copy_videos` is unspecified symbolic links
+for the videos are created in the videos directory. Each symbolic link creates a reference to a video and thus
+eliminates the need to copy the entire video to the video directory (if the videos remain at the original location).
```python
-deeplabcut.create_new_project('Name of the project', 'Name of the experimenter', ['Full path of video 1', 'Full path of video2', 'Full path of video3'], working_directory='Full path of the working directory', copy_videos=True/False, multianimal=True/False)
+deeplabcut.create_new_project(
+ "Name of the project",
+ "Name of the experimenter",
+ ["Full path of video 1", "Full path of video2", "Full path of video3"],
+ working_directory="Full path of the working directory",
+ copy_videos=True/False,
+ multianimal=False
+)
```
**Important path formatting note**
-Windows users, you must input paths as: ``r'C:\Users\computername\Videos\reachingvideo1.avi' `` or
-
-`` 'C:\\Users\\computername\\Videos\\reachingvideo1.avi'``
-
- (TIP: you can also place ``config_path`` in front of ``deeplabcut.create_new_project`` to create a variable that holds the path to the config.yaml file, i.e. ``config_path=deeplabcut.create_new_project(...)``)
-
-
-This set of arguments will create a project directory with the name **Name of the project+name of the experimenter+date of creation of the project** in the **Working directory** and creates the symbolic links to videos in the **videos** directory. The project directory will have subdirectories: **dlc-models**, **labeled-data**, **training-datasets**, and **videos**. All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be curated in separation from other projects. The purpose of the subdirectories is as follows:
-
-**dlc-models:** This directory contains the subdirectories *test* and *train*, each of which holds the meta information with regard to the parameters of the feature detectors in configuration files. The configuration files are YAML files, a common human-readable data serialization language. These files can be opened and edited with standard text editors. The subdirectory *train* will store checkpoints (called snapshots in TensorFlow) during training of the model. These snapshots allow the user to reload the trained model without re-training it, or to pick-up training from a particular saved checkpoint, in case the training was interrupted.
-
-**labeled-data:** This directory will store the frames used to create the training dataset. Frames from different videos are stored in separate subdirectories. Each frame has a filename related to the temporal index within the corresponding video, which allows the user to trace every frame back to its origin.
-
-**training-datasets:** This directory will contain the training dataset used to train the network and metadata, which contains information about how the training dataset was created.
-
-**videos:** Directory of video links or videos. When **copy\_videos** is set to ``False``, this directory contains symbolic links to the videos. If it is set to ``True`` then the videos will be copied to this directory. The default is ``False``. Additionally, if the user wants to add new videos to the project at any stage, the function **add\_new\_videos** can be used. This will update the list of videos in the project's configuration file.
+Windows users, you must input paths as: `r'C:\Users\computername\Videos\reachingvideo1.avi'` or
+` 'C:\\Users\\computername\\Videos\\reachingvideo1.avi'`
+
+TIP: you can also place `config_path` in front of `deeplabcut.create_new_project` to create a variable that holds
+the path to the config.yaml file, i.e. `config_path=deeplabcut.create_new_project(...)`
+
+This set of arguments will create a project directory with the name
+**++** in the **Working directory** and
+creates the symbolic links to videos in the **videos** directory. The project directory will have subdirectories:
+**dlc-models**, **dlc-models-pytorch**, **labeled-data**, **training-datasets**, and **videos**. All the outputs
+generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be
+curated in separation from other projects. The purpose of the subdirectories is as follows:
+
+**dlc-models** and **dlc-models-pytorch** have a similar structure; the first contains files for the TensorFlow engine
+while the second contains files for the PyTorch engine. At the top level in these directories, there are directories
+referring to different iterations of label refinement (see below): **iteration-0**, **iteration-1**, etc.
+The iteration directories store shuffle directories, where each shuffle directory stores model data related to a
+particular experiment: trained and tested on a particular training and testing sets, and with a particular model
+architecture. Each shuffle directory contains the subdirectories *test* and *train*, each of which holds the meta
+information with regard to the parameters of the feature detectors in configuration files. The configuration files are
+YAML files, a common human-readable data serialization language. These files can be opened and edited with standard text
+editors. The subdirectory *train* will store checkpoints (called snapshots) during training of the model. These
+snapshots allow the user to reload the trained model without re-training it, or to pick-up training from a particular
+saved checkpoint, in case the training was interrupted.
+
+**labeled-data:** This directory will store the frames used to create the training dataset. Frames from different videos
+are stored in separate subdirectories. Each frame has a filename related to the temporal index within the corresponding
+video, which allows the user to trace every frame back to its origin.
+
+**training-datasets:** This directory will contain the training dataset used to train the network and metadata, which
+contains information about how the training dataset was created.
+
+**videos:** Directory of video links or videos. When **copy\_videos** is set to `False`, this directory contains
+symbolic links to the videos. If it is set to `True` then the videos will be copied to this directory. The default is
+`False`. Additionally, if the user wants to add new videos to the project at any stage, the function
+**add\_new\_videos** can be used. This will update the list of videos in the project's configuration file.
```python
-deeplabcut.add_new_videos('Full path of the project configuration file*', ['full path of video 4', 'full path of video 5'], copy_videos=True/False)
+deeplabcut.add_new_videos(
+ "Full path of the project configuration file*",
+ ["full path of video 4", "full path of video 5"],
+ copy_videos=True/False
+)
```
-*Please note, *Full path of the project configuration file* will be referenced as ``config_path`` throughout this protocol.
+*Please note, *Full path of the project configuration file* will be referenced as `config_path` throughout this
+protocol.
-The project directory also contains the main configuration file called *config.yaml*. The *config.yaml* file contains many important parameters of the project. A complete list of parameters including their description can be found in Box1.
+The project directory also contains the main configuration file called *config.yaml*. The *config.yaml* file contains
+many important parameters of the project. A complete list of parameters including their description can be found in
+Box1.
-The ``create_new_project`` step writes the following parameters to the configuration file: *Task*, *scorer*, *date*, *project\_path* as well as a list of videos *video\_sets*. The first three parameters should **not** be changed. The list of videos can be changed by adding new videos or manually removing videos.
+The `create_new_project` step writes the following parameters to the configuration file: *Task*, *scorer*, *date*,
+*project\_path* as well as a list of videos *video\_sets*. The first three parameters should **not** be changed. The
+list of videos can be changed by adding new videos or manually removing videos.
-
-
-
+
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -87,34 +148,61 @@ The ``create_new_project`` step writes the following parameters to the configura
-Next, open the **config.yaml** file, which was created during **create\_new\_project**. You can edit this file in any text editor. Familiarize yourself with the meaning of the parameters (Box 1). You can edit various parameters, in particular you **must add the list of *bodyparts* (or points of interest)** that you want to track. You can also set the *colormap* here that is used for all downstream steps (can also be edited at anytime), like labeling GUIs, videos, etc. Here any [matplotlib colormaps](https://matplotlib.org/tutorials/colors/colormaps.html) will do!
+Next, open the **config.yaml** file, which was created during **create\_new\_project**. You can edit this file in any
+text editor. Familiarize yourself with the meaning of the parameters (Box 1). You can edit various parameters, in
+particular you **must add the list of *bodyparts* (or points of interest)** that you want to track. You can also set the
+*colormap* here that is used for all downstream steps (can also be edited at anytime), like labeling GUIs, videos, etc.
+Here any [matplotlib colormaps](https://matplotlib.org/tutorials/colors/colormaps.html) will do!
Please DO NOT have spaces in the names of bodyparts.
**bodyparts:** are the bodyparts of each individual (in the above list).
- ### (C) Data Selection (extract frames)
-
-**CRITICAL:** A good training dataset should consist of a sufficient number of frames that capture the breadth of the behavior. This ideally implies to select the frames from different (behavioral) sessions, different lighting and different animals, if those vary substantially (to train an invariant, robust feature detector). Thus for creating a robust network that you can reuse in the laboratory, a good training dataset should reflect the diversity of the behavior with respect to postures, luminance conditions, background conditions, animal identities,etc. of the data that will be analyzed. For the simple lab behaviors comprising mouse reaching, open-field behavior and fly behavior, 100−200 frames gave good results [Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y). However, depending on the required accuracy, the nature of behavior, the video quality (e.g. motion blur, bad lighting) and the context, more or less frames might be necessary to create a good network. Ultimately, in order to scale up the analysis to large collections of videos with perhaps unexpected conditions, one can also refine the data set in an adaptive way (see refinement below).
-
-The function `extract_frames` extracts frames from all the videos in the project configuration file in order to create a training dataset. The extracted frames from all the videos are stored in a separate subdirectory named after the video file’s name under the ‘labeled-data’. This function also has various parameters that might be useful based on the user’s need.
+ ### (C) Select Frames to Label
+
+**CRITICAL:** A good training dataset should consist of a sufficient number of frames that capture the breadth of the
+behavior. This ideally implies to select the frames from different (behavioral) sessions, different lighting and
+different animals, if those vary substantially (to train an invariant, robust feature detector). Thus for creating a
+robust network that you can reuse in the laboratory, a good training dataset should reflect the diversity of the
+behavior with respect to postures, luminance conditions, background conditions, animal identities,etc. of the data that
+will be analyzed. For the simple lab behaviors comprising mouse reaching, open-field behavior and fly behavior, 100−200
+frames gave good results [Mathis et al, 2018](https://www.nature.com/articles/s41593-018-0209-y). However, depending on
+the required accuracy, the nature of behavior, the video quality (e.g. motion blur, bad lighting) and the context, more
+or less frames might be necessary to create a good network. Ultimately, in order to scale up the analysis to large
+collections of videos with perhaps unexpected conditions, one can also refine the data set in an adaptive way (see
+refinement below).
+
+The function `extract_frames` extracts frames from all the videos in the project configuration file in order to create
+a training dataset. The extracted frames from all the videos are stored in a separate subdirectory named after the video
+file’s name under the ‘labeled-data’. This function also has various parameters that might be useful based on the user’s
+need.
```python
-deeplabcut.extract_frames(config_path, mode='automatic/manual', algo='uniform/kmeans', userfeedback=False, crop=True/False)
+deeplabcut.extract_frames(
+ config_path,
+ mode="automatic/manual",
+ algo="uniform/kmeans",
+ crop=True/False,
+ userfeedback=False
+)
```
**CRITICAL POINT:** It is advisable to keep the frame size small, as large frames increase the training and
inference time. The cropping parameters for each video can be provided in the config.yaml file (and see below).
-When running the function extract_frames, if the parameter crop=True, then you will be asked to draw a box within the GUI (and this is written to the config.yaml file).
-
-`userfeedback` allows the user to check which videos they wish to extract frames from. In this way, if you added more videos to the config.yaml file it does not, by default, extract frames (again) from every video. If you wish to disable this question, set `userfeedback = True`.
-
-The provided function either selects frames from the videos that are randomly sampled from a uniform distribution (uniform), by clustering based on visual appearance (k-means), or by manual selection. Random
-selection of frames works best for behaviors where the postures vary across the whole video. However, some behaviors
-might be sparse, as in the case of reaching where the reach and pull are very fast and the mouse is not moving much
-between trials (thus, we have the default set to True, as this is best for most use-cases we encounter). In such a case, the function that allows selecting frames based on k-means derived quantization would
-be useful. If the user chooses to use k-means as a method to cluster the frames, then this function downsamples the
-video and clusters the frames using k-means, where each frame is treated as a vector. Frames from different clusters
-are then selected. This procedure makes sure that the frames look different. However, on large and long videos, this
-code is slow due to computational complexity.
+When running the function extract_frames, if the parameter crop=True, then you will be asked to draw a box within the
+GUI (and this is written to the config.yaml file).
+
+`userfeedback` allows the user to specify which videos they wish to extract frames from. When set to `"True"`, a dialog
+will be initiated, where the user is asked for each video if (additional/any) frames from this video should be
+extracted. Use this, e.g. if you have already labeled some folders and want to extract data for new videos.
+
+The provided function either selects frames from the videos that are randomly sampled from a uniform distribution
+(uniform), by clustering based on visual appearance (k-means), or by manual selection. Random uniform selection of
+frames works best for behaviors where the postures vary across the whole video. However, some behaviors might be sparse,
+as in the case of reaching where the reach and pull are very fast and the mouse is not moving much between trials. In
+such a case, the function that allows selecting frames based on k-means derived quantization would be useful. If the
+user chooses to use k-means as a method to cluster the frames, then this function downsamples the video and clusters the
+frames using k-means, where each frame is treated as a vector. Frames from different clusters are then selected. This
+procedure makes sure that the frames look different. However, on large and long videos, this code is slow due to
+computational complexity.
**CRITICAL POINT:** It is advisable to extract frames from a period of the video that contains interesting
behaviors, and not extract the frames across the whole video. This can be achieved by using the start and stop
@@ -122,19 +210,22 @@ parameters in the config.yaml file. Also, the user can change the number of fram
the numframes2extract in the config.yaml file.
However, picking frames is highly dependent on the data and the behavior being studied. Therefore, it is hard to
-provide all purpose code that extracts frames to create a good training dataset for every behavior and animal. If the user feels specific frames are lacking, they can extract hand selected frames of interest using the interactive GUI
+provide all purpose code that extracts frames to create a good training dataset for every behavior and animal. If the
+user feels specific frames are lacking, they can extract hand selected frames of interest using the interactive GUI
provided along with the toolbox. This can be launched by using:
```python
-deeplabcut.extract_frames(config_path, 'manual')
+deeplabcut.extract_frames(config_path, "manual")
```
The user can use the *Load Video* button to load one of the videos in the project configuration file, use the scroll
-bar to navigate across the video and *Grab a Frame* (or a range of frames, as of version 2.0.5) to extract the frame(s). The user can also look at the extracted frames and e.g. delete frames (from the directory) that are too similar before reloading the set and then manually annotating them.
+bar to navigate across the video and *Grab a Frame* (or a range of frames, as of version 2.0.5) to extract the frame(s).
+The user can also look at the extracted frames and e.g. delete frames (from the directory) that are too similar before
+reloading the set and then manually annotating them.
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -144,43 +235,42 @@ bar to navigate across the video and *Grab a Frame* (or a range of frames, as of
### (D) Label Frames
-The toolbox provides a function **label_frames** which helps the user to easily label all the extracted frames using
-an interactive graphical user interface (GUI). The user should have already named the body parts to label (points of
-interest) in the project’s configuration file by providing a list. The following command invokes the labeling toolbox.
+The toolbox provides a function **label_frames** which helps the user to easily label
+all the extracted frames using an interactive graphical user interface (GUI). The user
+should have already named the bodyparts to label (points of interest) in the
+project’s configuration file by providing a list. The following command invokes the
+napari-deeplabcut labelling GUI. Checkout the [napari-deeplabcut docs](file:napari-gui-landing) for
+more information about the labelling workflow.
+
```python
deeplabcut.label_frames(config_path)
```
-The user needs to use the *Load Frames* button to select the directory which stores the extracted frames from one of
-the videos. Subsequently, the user can use one of the radio buttons (top right) to select a body part to label. RIGHT click to add the label. Left click to drag the label, if needed. If you label a part accidentally, you can use the middle button on your mouse to delete! If you cannot see a body part in the frame, skip over the label! Please see the ``HELP`` button for more user instructions. This auto-advances once you labeled the first body part. You can also advance to the next frame by clicking on the RIGHT arrow on your keyboard (and go to a previous frame with LEFT arrow).
-Each label will be plotted as a dot in a unique color.
-The user is free to move around the body part and once satisfied with its position, can select another radio button
-(in the top right) to switch to the respective body part (it otherwise auto-advances). The user can skip a body part if it is not visible. Once all the visible body parts are labeled, then the user can use ‘Next Frame’ to load the following frame. The user needs to save the labels after all the frames from one of the videos are labeled by clicking the save button at the bottom right. Saving the labels will create a labeled dataset for each video in a hierarchical data file format (HDF) in the
-subdirectory corresponding to the particular video in **labeled-data**. You can save at any intermediate step (even without closing the GUI, just hit save) and you return to labeling a dataset by reloading it!
+[🎥 DEMO](https://youtu.be/hsA9IB5r73E)
+
+HOT KEYS IN THE Labeling GUI (also see "help" in GUI):
+
+```
+Ctrl + C: Copy labels from previous frame.
+Keyboard arrows: advance frames.
+Delete key: delete label.
+```
+
+
**CRITICAL POINT:** It is advisable to **consistently label similar spots** (e.g., on a wrist that is very large, try
to label the same location). In general, invisible or occluded points should not be labeled by the user. They can
simply be skipped by not applying the label anywhere on the frame.
OPTIONAL: In the event of adding more labels to the existing labeled dataset, the user need to append the new
-labels to the bodyparts in the config.yaml file. Thereafter, the user can call the function **label_frames**. As of 2.0.5+: then a box will pop up and ask the user if they wish to display all parts, or only add in the new labels. Saving the labels after all the images are labelled will append the new labels to the existing labeled dataset.
-
-HOT KEYS IN THE Labeling GUI (also see "help" in GUI):
-```
-Ctrl + C: Copy labels from previous frame. With multi-animal DLC, only the keypoints of the animal currently selected are duplicated.
-Keyboard arrows: advance frames
-delete key: delete label
-```
+labels to the bodyparts in the config.yaml file. Thereafter, the user can call the function **label_frames**. As of
+2.0.5+: then a box will pop up and ask the user if they wish to display all parts, or only add in the new labels.
+Saving the labels after all the images are labelled will append the new labels to the existing labeled dataset.
-#### API Docs
-````{admonition} Click the button to see API Docs
-:class: dropdown
-```{eval-rst}
-.. include:: ./api/deeplabcut.label_frames.rst
-```
-````
+For more information, checkout the [napari-deeplabcut docs](file:napari-gui-landing) for
+more information about the labelling workflow.
-### (E) Check Annotated Frames
+### (E) Check Annotated Frames
OPTIONAL: Checking if the labels were created and stored correctly is beneficial for training, since labeling
is one of the most critical parts for creating the training dataset. The DeepLabCut toolbox provides a function
@@ -189,9 +279,12 @@ is one of the most critical parts for creating the training dataset. The DeepLab
deeplabcut.check_labels(config_path, visualizeindividuals=True/False)
```
-For each video directory in labeled-data this function creates a subdirectory with **labeled** as a suffix. Those directories contain the frames plotted with the annotated body parts. The user can double check if the body parts are labeled correctly. If they are not correct, the user can reload the frames (i.e. `deeplabcut.label_frames`), move them around, and click save again.
+For each video directory in labeled-data this function creates a subdirectory with **labeled** as a suffix. Those
+directories contain the frames plotted with the annotated body parts. The user can double check if the body parts are
+labeled correctly. If they are not correct, the user can reload the frames (i.e. `deeplabcut.label_frames`), move them
+around, and click save again.
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -199,97 +292,228 @@ For each video directory in labeled-data this function creates a subdirectory wi
```
````
-### (F) Create Training Dataset(s)
+(create-training-dataset)=
+### (F) Create Training Dataset
-**CRITICAL POINT:** Only run this step **where** you are going to train the network. If you label on your laptop but move your project folder to Google Colab or AWS, lab server, etc, then run the step below on that platform! If you labeled on a Windows machine but train on Linux, this is fine as of 2.0.4 onwards it will be done automatically (it saves file sets as both Linux and Windows for you).
+**CRITICAL POINT:** Only run this step **where** you are going to train the network. If you label on your laptop but
+move your project folder to Google Colab or AWS, lab server, etc, then run the step below on that platform! If you
+labeled on a Windows machine but train on Linux, this is fine as of 2.0.4 onwards it will be done automatically (it
+saves file sets as both Linux and Windows for you).
-- If you move your project folder, you must **only** change the `project_path` in the main config.yaml file - that's it - no need to change the video paths, etc! Your project is fully portable.
+- If you move your project folder, you must only change the `project_path` (which is done automatically) in the main
+config.yaml file - that's it - no need to change the video paths, etc! Your project is fully portable.
-- If you run this on the cloud, before importing `deeplabcut` you need to suppress GUIs. As you can see in our [demo notebooks]((https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/COLAB_DEMO_mouse_openfield.ipynb) for running DLC training, evaluation, and novel video analysis on the Cloud, you must first suppress GUIs - server computers don't have a screen you can interact with. So, before you launch ipython, run `export DLClight=True` (see more tips in the full PDF user-guide).
+- Be aware you select your neural network backbone at this stage. As of DLC3+ we support PyTorch (and TensorFlow, but
+this will be phased out).
-**OVERVIEW:** This function combines the labeled datasets from all the videos and splits them to create train and test datasets. The training data will be used to train the network, while the test data set will be used for evaluating the network. The function **create_training_dataset** performs those steps.
+**OVERVIEW:** This function combines the labeled datasets from all the videos and splits them to create train and test
+datasets. The training data will be used to train the network, while the test data set will be used for evaluating the
+network.
```python
-deeplabcut.create_training_dataset(config_path, augmenter_type='imgaug')
+deeplabcut.create_training_dataset(config_path)
```
-- OPTIONAL: If the user wishes to benchmark the performance of the DeepLabCut, they can create multiple
-training datasets by specifying an integer value to the `num_shuffles`; see the docstring for more details.
-
-- Each iteration of the creation of a training dataset will create a ``.mat`` file, which is used by the feature detectors,
-and a ``.pickle`` file that contains the meta information about the training dataset. This also creates two subdirectories
-within **dlc-models** called ``test`` and ``train``, and these each have a configuration file called pose_cfg.yaml.
-Specifically, the user can edit the **pose_cfg.yaml** within the **train** subdirectory before starting the training. These
-configuration files contain meta information with regard to the parameters of the feature detectors. Key parameters
-are listed in Box 2.
-
-- At this step, the ImageNet pre-trained networks (i.e. ResNet-50, ResNet-101 and ResNet-152, etc) weights will be downloaded. If they do not download (you will see this downloading in the terminal, then you may not have permission to do so (something we have seen with some Windows users - see the **[docs for more help!](https://deeplabcut.github.io/DeepLabCut/docs/recipes/nn.html)**).
-
-**CRITICAL POINT:** At this step, for **create_training_dataset** you select the network you want to use, and any additional data augmentation (beyond our defaults). You can set ``net_type`` and ``augmenter_type`` when you call the function.
-
-**DATA AUGMENTATION:** At this stage you can also decide what type of augmentation to use. The default loaders work well for most all tasks (as shown on www.deeplabcut.org), but there are many options, more data augmentation, intermediate supervision, etc. Please look at the [**pose_cfg.yaml**](https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml) file for a full list of parameters **you might want to change before running this step.** There are several data loaders that can be used. For example, you can use the default loader (introduced and described in the Nature Protocols paper), [TensorPack](https://github.com/tensorpack/tensorpack) for data augmentation (currently this is easiest on Linux only), or [imgaug](https://imgaug.readthedocs.io/en/latest/). We recommend `imgaug`. You can set this by passing:``` deeplabcut.create_training_dataset(config_path, augmenter_type='imgaug') ```
+- OPTIONAL: If the user wishes to benchmark the performance of the DeepLabCut, they can create multiple training
+datasets by specifying an integer value to the `num_shuffles`; see the docstring for more details.
-The differences of the loaders are as follows:
-- `imgaug`: a lot of augmentation possibilities, efficient code for target map creation & batch sizes >1 supported. You can set the parameters such as the `batch_size` in the `pose_cfg.yaml` file for the model you are training. This is the recommended DEFAULT!
-- `crop_scale`: our standard DLC 2.0 introduced in Nature Protocols variant (scaling, auto-crop augmentation)
-- `tensorpack`: a lot of augmentation possibilities, multi CPU support for fast processing, target maps are created less efficiently than in imgaug, does not allow batch size>1
-- `deterministic`: only useful for testing, freezes numpy seed; otherwise like default.
+The function creates a new shuffle(s) directory in the **dlc-models-pytorch** directory
+(**dlc-models** if using Tensorflow), in the current "iteration" directory.
+The `train` and `test` directories each have a configuration file
+(**pytorch_config.yaml** in **train** and **pose_cfg.yaml** in **test** for Pytorch models,
+**pose_cfg.yaml** in **train** and **test** for Tensorflow models).
+Specifically, the user can edit the **pytorch_config.yaml** (or **pose_cfg.yaml**) within the **train** subdirectory
+before starting the training. These configuration files contain meta information with regard to the parameters
+of the feature detectors. For more information about the **pytorch_config.yaml** file, see [here](dlc3-pytorch-config)
+(for TensorFlow-based models, see key parameters
+[here](https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml)).
-Alternatively, you can set the loader (as well as other training parameters) in the **pose_cfg.yaml** file of the model that you want to train. Note, to get details on the options, look at the default file: [**pose_cfg.yaml**](https://github.com/DeepLabCut/DeepLabCut/blob/master/deeplabcut/pose_cfg.yaml).
+**CRITICAL POINT:** At this step, for **create_training_dataset** you select the network you want to use, and any
+additional data augmentation (beyond our defaults). You can set `net_type`, `detector_type` (if using a detector)
+and `augmenter_type` when you call the function.
-**MODEL COMPARISON:** You can also test several models by creating the same test/train split for different networks. You can easily do this in the Project Manager GUI, or use the function ``deeplabcut.create_training_model_comparison``.
+- Networks: ImageNet pre-trained networks OR SuperAnimal pre-trained networks weights will be downloaded, as you
+select. You can decide to do transfer-learning (recommended) or "fine-tune" both the backbone and the decoder head. We
+suggest seeing our [dedicated documentation on models](dlc3-architectures) for more information (
+or the [this page on selecting models](what-neural-network-should-i-use) for the TensorFlow engine).
-Please also consult the following page on selecting models: https://deeplabcut.github.io/DeepLabCut/docs/recipes/nn.html#what-neural-network-should-i-use-trade-offs-speed-performance-and-considerations
-
- See Box 2 on how to specify **which network is loaded for training (including your own network, etc):**
+```{Hint}
+🚨 If they do not download (you will see this downloading in the terminal), then you may not have permission to do
+so - be sure to open your terminal "as an admin" (This is only something we have seen with some Windows users - see
+the **[docs for more help!](tf-training-tips-and-tricks)**).
+```
+
+**DATA AUGMENTATION:** At this stage you can also decide what type of augmentation to
+use. Once you've called `create_training_dataset`, you can edit the
+[**pytorch_config.yaml**](dlc3-pytorch-config) file that was created (or for the
+TensorFlow engine, the [**pose_cfg.yaml**](
+https://github.com/DeepLabCut/DeepLabCut/blob/main/deeplabcut/pose_cfg.yaml) file).
+
+- PyTorch Engine: [Albumentations](https://albumentations.ai/docs/) is used for data
+augmentation. Look at the [**pytorch_config.yaml**](dlc3-pytorch-config) for more
+information about image augmentation options.
+- TensorFlow Engine: The default augmentation works well for most tasks (as shown on
+www.deeplabcut.org), but there are many options, more data augmentation, intermediate
+supervision, etc. Here are the available loaders:
+ - `imgaug`: a lot of augmentation possibilities, efficient code for target map creation & batch sizes >1 supported.
+ You can set the parameters such as the `batch_size` in the `pose_cfg.yaml` file for the model you are training. This
+ is the recommended default!
+ - `crop_scale`: our standard DLC 2.0 introduced in Nature Protocols variant (scaling, auto-crop augmentation)
+ - `tensorpack`: a lot of augmentation possibilities, multi CPU support for fast processing, target maps are created
+ less efficiently than in imgaug, does not allow batch size>1
+ - `deterministic`: only useful for testing, freezes numpy seed; otherwise like default.
+
+**MODEL COMPARISON**: You can also test several models by creating the same train/test
+split for different networks.
+You can easily do this in the Project Manager GUI (by selecting the "Use an existing
+data split" option), which also lets you compare PyTorch and TensorFlow models.
+
+````{versionadded} 3.0.0
+You can now create new shuffles using the same train/test split as
+existing shuffles with `create_training_dataset_from_existing_split`. This allows you to
+compare model performance (between different architectures or when using different
+training hyper-parameters) as the shuffles were trained on the same data, and evaluated
+on the same test data!
+
+Example usage - creating 3 new shuffles (with indices 10, 11 and 12) for a ResNet 50
+pose estimation model, using the same data split as was used for shuffle 0:
-
-
-
+```python
+deeplabcut.create_training_dataset_from_existing_split(
+ config_path,
+ from_shuffle=0,
+ shuffles=[10, 11, 12],
+ net_type="resnet_50",
+)
+```
+````
-#### API Docs for deeplabcut.create_training_dataset
-````{admonition} Click the button to see API Docs
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset
:class: dropdown
```{eval-rst}
.. include:: ./api/deeplabcut.create_training_dataset.rst
```
````
-#### API Docs for deeplabcut.create_training_model_comparison
-````{admonition} Click the button to see API Docs
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_model_comparison
:class: dropdown
```{eval-rst}
.. include:: ./api/deeplabcut.create_training_model_comparison.rst
```
````
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset_from_existing_split
+:class: dropdown
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_training_dataset_from_existing_split.rst
+```
+````
+
### (G) Train The Network
The function ‘train_network’ helps the user in training the network. It is used as follows:
```python
deeplabcut.train_network(config_path)
```
-The set of arguments in the function starts training the network for the dataset created for one specific shuffle. Note that you can change the loader (imgaug/default/etc) as well as other training parameters in the **pose_cfg.yaml** file of the model that you want to train (before you start training).
+The set of arguments in the function starts training the network for the dataset created
+for one specific shuffle. Note that you can change training parameters in the
+[**pytorch_config.yaml**](dlc3-pytorch-config) file (or **pose_cfg.yaml** for TensorFlow
+models) of the model that you want to train (before you start training).
-Example parameters that one can call:
-```python
-deeplabcut.train_network(config_path, shuffle=1, trainingsetindex=0, gputouse=None, max_snapshots_to_keep=5, autotune=False, displayiters=100, saveiters=15000, maxiters=30000, allow_growth=True)
-```
+At user specified iterations during training checkpoints are stored in the subdirectory
+*train* under the respective iteration & shuffle directory.
-By default, the pretrained networks are not in the DeepLabCut toolbox (as they are around 100MB each), but they get downloaded before you train. However, if not previously downloaded from the TensorFlow model weights, it will be downloaded and stored in a subdirectory *pre-trained* under the subdirectory *models* in *Pose_Estimation_Tensorflow*.
-At user specified iterations during training checkpoints are stored in the subdirectory *train* under the respective iteration directory.
+````{admonition} Tips on training models with the PyTorch Engine
+:class: dropdown
-If the user wishes to restart the training at a specific checkpoint they can specify the full path of the checkpoint to
-the variable ``init_weights`` in the **pose_cfg.yaml** file under the *train* subdirectory (see Box 2).
+Example parameters that one can call:
-**CRITICAL POINT:** It is recommended to train the ResNets or MobileNets for thousands of iterations until the loss plateaus (typically around **500,000**) if you use batch size 1. If you want to batch train, we recommend using Adam, see more here: https://deeplabcut.github.io/DeepLabCut/docs/recipes/nn.html#using-custom-image-augmentation.
+```python
+deeplabcut.train_network(
+ config_path,
+ shuffle=1,
+ trainingsetindex=0,
+ device="cuda:0",
+ max_snapshots_to_keep=5,
+ displayiters=100,
+ save_epochs=5,
+ epochs=200,
+)
+```
+
+Pytorch models in DeepLabCut 3.0 are trained for a set number of epochs, instead of a
+maximum number of iterations (which is what was used for TensorFlow models). An epoch
+is a single pass through the training dataset, which means your model has seen each
+training image exactly once. So if you have 64 training images for your network, an
+epoch is 64 iterations with batch size 1 (or 32 iterations with batch size 2, 16 with
+batch size 4, etc.).
+
+By default, the pretrained networks are not in the DeepLabCut toolbox (as they can be
+more than 100MB), but they get downloaded automatically before you train.
+
+If the user wishes to restart the training at a specific checkpoint they can specify the
+full path of the checkpoint to the variable ``resume_training_from`` in the [
+**pytorch_config.yaml**](
+dlc3-pytorch-config) file (checkout the "Restarting Training at a Specific Checkpoint"
+section of the docs) under the *train* subdirectory.
+
+**CRITICAL POINT:** It is recommended to train the networks **until the loss plateaus**
+(depending on the dataset, model architecture and training hyper-parameters this happens
+after 100 to 250 epochs of training).
+
+The variables ``display_iters`` and ``save_epochs`` in the [**pytorch_config.yaml**](
+dlc3-pytorch-config) file allows the user to alter how often the loss is displayed
+and how often the weights are stored. We suggest saving every 5 to 25 epochs.
+````
-The variables ``display_iters`` and ``save_iters`` in the **pose_cfg.yaml** file allows the user to alter how often the loss is displayed and how often the weights are stored.
+````{admonition} Tips on training models with the TensorFlow Engine
+:class: dropdown
-**maDeepLabCut CRITICAL POINT:** For multi-animal projects we are using not only different and new output layers, but also new data augmentation, optimization, learning rates, and batch training defaults. Thus, please use a lower ``save_iters`` and ``maxiters``. I.e. we suggest saving every 10K-15K iterations, and only training until 50K-100K iterations. We recommend you look closely at the loss to not overfit on your data. The bonus, training time is much less!!!
+Example parameters that one can call:
-#### API Docs
-````{admonition} Click the button to see API Docs
+```python
+deeplabcut.train_network(
+ config_path,
+ shuffle=1,
+ trainingsetindex=0,
+ gputouse=None,
+ max_snapshots_to_keep=5,
+ autotune=False,
+ displayiters=100,
+ saveiters=25000,
+ maxiters=300000,
+ allow_growth=True,
+)
+```
+
+By default, the pretrained networks are not in the DeepLabCut toolbox (as they are
+around 100MB each), but they get downloaded before you train. However, if not previously
+downloaded from the TensorFlow model weights, it will be downloaded and stored in a
+subdirectory *pre-trained* under the subdirectory *models* in
+*Pose_Estimation_Tensorflow*. At user specified iterations during training checkpoints
+are stored in the subdirectory *train* under the respective iteration directory.
+
+If the user wishes to restart the training at a specific checkpoint they can specify the
+full path of the checkpoint to the variable ``init_weights`` in the **pose_cfg.yaml**
+file under the *train* subdirectory (see Box 2).
+
+**CRITICAL POINT:** It is recommended to train the networks for thousands of iterations
+until the loss plateaus (typically around **500,000**) if you use batch size 1. If you
+want to batch train, we recommend using Adam,
+[see more here](tf-custom-image-augmentation).
+
+The variables ``display_iters`` and ``save_iters`` in the **pose_cfg.yaml** file allows
+the user to alter how often the loss is displayed and how often the weights are stored.
+
+**maDeepLabCut CRITICAL POINT:** For multi-animal projects we are using not only
+different and new output layers, but also new data augmentation, optimization, learning
+rates, and batch training defaults. Thus, please use a lower ``save_iters`` and
+``maxiters``. I.e. we suggest saving every 10K-15K iterations, and only training until
+50K-100K iterations. We recommend you look closely at the loss to not overfit on your
+data. The bonus, training time is much less!!!
+````
+
+````{admonition} Click the button to see API Docs for train_network
:class: dropdown
```{eval-rst}
.. include:: ./api/deeplabcut.train_network.rst
@@ -299,34 +523,50 @@ The variables ``display_iters`` and ``save_iters`` in the **pose_cfg.yaml** file
### (H) Evaluate the Trained Network
It is important to evaluate the performance of the trained network. This performance is measured by computing
-the mean average Euclidean error (MAE; which is proportional to the average root mean square error) between the
-manual labels and the ones predicted by DeepLabCut. The MAE is saved as a comma separated file and displayed
-for all pairs and only likely pairs (>p-cutoff). This helps to exclude, for example, occluded body parts. One of the
-strengths of DeepLabCut is that due to the probabilistic output of the scoremap, it can, if sufficiently trained, also
-reliably report if a body part is visible in a given frame. (see discussions of finger tips in reaching and the Drosophila
-legs during 3D behavior in [Mathis et al, 2018]). The evaluation results are computed by typing:
+the average root mean square error (RMSE) between the manual labels and the ones predicted by DeepLabCut.
+The RMSE is saved as a comma separated file and displayed for all pairs and only likely pairs (>p-cutoff).
+This helps to exclude, for example, occluded body parts. One of the strengths of DeepLabCut is that due to the
+probabilistic output of the scoremap, it can, if sufficiently trained, also reliably report if a body part is visible
+in a given frame. (see discussions of finger tips in reaching and the Drosophila legs during 3D behavior in
+[Mathis et al, 2018]). The evaluation results are computed by typing:
+
```python
-deeplabcut.evaluate_network(config_path,Shuffles=[1], plotting=True)
+deeplabcut.evaluate_network(config_path, Shuffles=[1], plotting=True)
```
-Setting ``plotting`` to true plots all the testing and training frames with the manual and predicted labels. The user
+
+Setting `plotting` to true plots all the testing and training frames with the manual and predicted labels. The user
should visually check the labeled test (and training) images that are created in the ‘evaluation-results’ directory.
Ideally, DeepLabCut labeled unseen (test images) according to the user’s required accuracy, and the average train
-and test errors are comparable (good generalization). What (numerically) comprises an acceptable MAE depends on
-many factors (including the size of the tracked body parts, the labeling variability, etc.). Note that the test error can
-also be larger than the training error due to human variability (in labeling, see Figure 2 in Mathis et al, Nature Neuroscience 2018).
+and test errors are comparable (good generalization). What (numerically) comprises an acceptable RMSE depends on
+many factors (including the size of the tracked body parts, the labeling variability, etc.). Note that the test error
+can also be larger than the training error due to human variability (in labeling, see Figure 2 in Mathis et al, Nature
+Neuroscience 2018).
**Optional parameters:**
-```
- Shuffles: list, optional -List of integers specifying the shuffle indices of the training dataset. The default is [1]
- plotting: bool, optional -Plots the predictions on the train and test images. The default is `False`; if provided it must be either `True` or `False`
+- `Shuffles: list, optional` - List of integers specifying the shuffle indices of the training dataset.
+The default is [1]
- show_errors: bool, optional -Display train and test errors. The default is `True`
+- `plotting: bool, optional` - Plots the predictions on the train and test images. The default is `False`;
+if provided it must be either `True` or `False`
- comparisonbodyparts: list of bodyparts, Default is all -The average error will be computed for those body parts only (Has to be a subset of the body parts).
+- `show_errors: bool, optional` - Display train and test errors. The default is `True`
- gputouse: int, optional -Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not have a GPU, put None. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
-```
+- `comparisonbodyparts: list of bodyparts, Default is all` - The average error will be computed for those body parts
+only (Has to be a subset of the body parts).
+
+- `gputouse: int, optional` - Natural number indicating the number of your GPU (see number in nvidia-smi). If you do not
+have a GPU, put None. See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+
+- `pcutoff: float | list[float] | dict[str, float], optional`
+(Only applicable when using the PyTorch engine. For TensorFlow, set `pcutoff` in the `config.yaml` file.)
+Specifies the cutoff value(s) used to compute evaluation metrics.
+ - If `None` (default), the cutoff will be loaded from the project configuration.
+ - To apply a single cutoff value to all bodyparts, provide a `float`.
+ - To specify different cutoffs per bodypart, provide either:
+ - A `list[float]`: one value per bodypart, with an additional value for each unique bodypart if applicable.
+ - A `dict[str, float]`: where keys are bodypart names and values are the corresponding cutoff values.
+If a bodypart is not included in the provided dictionary, a default `pcutoff` of `0.6` will be used for that bodypart.
The plots can be customized by editing the **config.yaml** file (i.e., the colormap, scale, marker size (dotsize), and
transparency of labels (alphavalue) can be modified). By default each body part is plotted in a different color
@@ -335,9 +575,10 @@ plotted as plus (‘+’), DeepLabCut’s predictions either as ‘.’ (for con
’x’ for (likelihood <= `pcutoff`).
The evaluation results for each shuffle of the training dataset are stored in a unique subdirectory in a newly created
-directory ‘evaluation-results’ in the project directory. The user can visually inspect if the distance between the labeled
-and the predicted body parts are acceptable. In the event of benchmarking with different shuffles of same training
-dataset, the user can provide multiple shuffle indices to evaluate the corresponding network.
+directory ‘evaluation-results-pytorch’ (‘evaluation-results’ for tensorflow models) in the project directory.
+The user can visually inspect if the distance between the labeled and the predicted body parts are acceptable.
+In the event of benchmarking with different shuffles of same training dataset, the user can provide multiple shuffle
+indices to evaluate the corresponding network.
Note that with multi-animal projects additional distance statistics aggregated over animals or bodyparts are also stored
in that directory. This aims at providing a finer quantitative evaluation of multi-animal prediction performance
before animal tracking. If the generalization is not sufficient, the user might want to:
@@ -356,7 +597,7 @@ deeplabcut.extract_save_all_maps(config_path, shuffle=shuffle, Indices=[0, 5])
```
you can drop "Indices" to run this on all training/testing images (this is slow!)
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -364,26 +605,42 @@ you can drop "Indices" to run this on all training/testing images (this is slow!
```
````
-### (I) Novel Video Analysis:
+### (I) Analyze new Videos
-The trained network can be used to analyze new videos. The user needs to first choose a checkpoint with the best
-evaluation results for analyzing the videos. In this case, the user can enter the corresponding index of the checkpoint
-to the variable snapshotindex in the config.yaml file. By default, the most recent checkpoint (i.e. last) is used for
-analyzing the video. Novel/new videos **DO NOT have to be in the config file!** You can analyze new videos anytime by simply using the following line of code:
+The trained network can be used to analyze new videos. Novel/new videos **DO NOT have to be in the config file!**.
+You can analyze new videos anytime by simply using the following line of code:
```python
-deeplabcut.analyze_videos(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi'], save_as_csv=True)
+deeplabcut.analyze_videos(
+ config_path, ["fullpath/analysis/project/videos/reachingvideo1.avi"],
+ save_as_csv=True
+)
```
There are several other optional inputs, such as:
```python
-deeplabcut.analyze_videos(config_path, videos, videotype='avi', shuffle=1, trainingsetindex=0, gputouse=None, save_as_csv=False, destfolder=None, dynamic=(True, .5, 10))
-```
-The labels are stored in a [MultiIndex Pandas Array](http://pandas.pydata.org), which contains the name of the network, body part name, (x, y) label position in pixels, and the likelihood for each frame per body part. These
-arrays are stored in an efficient Hierarchical Data Format (HDF) in the same directory, where the video is stored.
-However, if the flag ``save_as_csv`` is set to ``True``, the data can also be exported in comma-separated values format
-(.csv), which in turn can be imported in many programs, such as MATLAB, R, Prism, etc.; This flag is set to ``False``
-by default. You can also set a destination folder (``destfolder``) for the output files by passing a path of the folder you wish to write to.
-
-#### API Docs
+deeplabcut.analyze_videos(
+ config_path,
+ videos,
+ videotype="avi",
+ shuffle=1,
+ trainingsetindex=0,
+ gputouse=None,
+ save_as_csv=False,
+ destfolder=None,
+ dynamic=(True, .5, 10)
+)
+```
+The user can choose a checkpoint for analyzing the videos. For this, the user can enter the corresponding index of the
+checkpoint to the variable snapshotindex in the config.yaml file. By default, the most recent checkpoint (i.e. last) is
+used for analyzing the video.
+The labels are stored in a MultiIndex [Pandas](http://pandas.pydata.org) Array, which contains the name of the network,
+body part name, (x, y) label position in pixels, and the likelihood for each frame per body part. These arrays are
+stored in an efficient Hierarchical Data Format (HDF) in the same directory, where the video is stored.
+However, if the flag `save_as_csv` is set to `True`, the data can also be exported in comma-separated values format
+(.csv), which in turn can be imported in many programs, such as MATLAB, R, Prism, etc.; This flag is set to `False`
+by default. You can also set a destination folder (`destfolder`) for the output files by passing a path of the folder
+you wish to write to.
+
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -393,28 +650,58 @@ by default. You can also set a destination folder (``destfolder``) for the outpu
### Novel Video Analysis: extra features
-#### Dynamic-cropping of videos:
+### Dynamic-cropping of videos:
-As of 2.1+ we have a dynamic cropping option. Namely, if you have large frames and the animal/object occupies a smaller fraction, you can crop around your animal/object to make processing speeds faster. For example, if you have a large open field experiment but only track the mouse, this will speed up your analysis (also helpful for real-time applications). To use this simply add ``dynamic=(True,.5,10)`` when you call ``analyze_videos``.
+As of 2.1+ we have a dynamic cropping option. Namely, if you have large frames and the animal/object occupies a smaller
+fraction, you can crop around your animal/object to make processing speeds faster. For example, if you have a large open
+field experiment but only track the mouse, this will speed up your analysis (also helpful for real-time applications).
+To use this simply add `dynamic=(True,.5,10)` when you call `analyze_videos`.
```python
dynamic: triple containing (state, detectiontreshold, margin)
- If the state is true, then dynamic cropping will be performed. That means that if an object is detected (i.e., any body part > detectiontreshold), then object boundaries are computed according to the smallest/largest x position and smallest/largest y position of all body parts. This window is expanded by the margin and from then on only the posture within this crop is analyzed (until the object is lost; i.e., detectiontreshold),
+ then object boundaries are computed according to the smallest/largest x position and
+ smallest/largest y position of all body parts. This window is expanded by the margin
+ and from then on only the posture within this crop is analyzed (until the object is lost;
+ i.e., < detectiontreshold). The current position is utilized for updating the crop window
+ for the next frame (this is why the margin is important and should be set large enough
+ given the movement of the animal).
```
-### (J) Filter pose data data (RECOMMENDED!):
+### (J) Filter Pose Data
You can also filter the predictions with a median filter (default) or with a [SARIMAX model](https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html), if you wish. This creates a new .h5 file with the ending *_filtered* that you can use in create_labeled_data and/or plot trajectories.
```python
-deeplabcut.filterpredictions(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi'])
+deeplabcut.filterpredictions(
+ config_path,
+ ["fullpath/analysis/project/videos/reachingvideo1.avi"]
+)
```
An example call:
- ```python
-deeplabcut.filterpredictions(config_path,['fullpath/analysis/project/videos'], videotype='.mp4',filtertype= 'arima',ARdegree=5,MAdegree=2)
- ```
+```python
+deeplabcut.filterpredictions(
+ config_path,
+ ["fullpath/analysis/project/videos"],
+ videotype=".mp4",
+ filtertype="arima",
+ ARdegree=5,
+ MAdegree=2
+)
+```
Here are parameters you can modify and pass:
```python
-deeplabcut.filterpredictions(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi'], shuffle=1, trainingsetindex=0, comparisonbodyparts='all', filtertype='arima', p_bound=0.01, ARdegree=3, MAdegree=1, alpha=0.01)
+deeplabcut.filterpredictions(
+ config_path,
+ ["fullpath/analysis/project/videos/reachingvideo1.avi"],
+ shuffle=1,
+ trainingsetindex=0,
+ filtertype="arima",
+ p_bound=0.01,
+ ARdegree=3,
+ MAdegree=1,
+ alpha=0.01
+)
```
Here is an example of how this can be applied to a video:
@@ -422,7 +709,7 @@ deeplabcut.filterpredictions(config_path, ['fullpath/analysis/project/videos/rea
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -430,7 +717,7 @@ deeplabcut.filterpredictions(config_path, ['fullpath/analysis/project/videos/rea
```
````
-### (K) Plot Trajectories:
+### (K) Plot Trajectories
The plotting components of this toolbox utilizes matplotlib. Therefore, these plots can easily be customized by
the end user. We also provide a function to plot the trajectory of the extracted poses across the analyzed video, which
@@ -440,14 +727,18 @@ can be called by typing:
deeplabcut.plot_trajectories(config_path, [‘fullpath/analysis/project/videos/reachingvideo1.avi’])
```
-It creates a folder called ``plot-poses`` (in the directory of the video). The plots display the coordinates of body parts vs. time, likelihoods vs time, the x- vs. y- coordinate of the body parts, as well as histograms of consecutive coordinate differences. These plots help the user to quickly assess the tracking performance for a video. Ideally, the likelihood stays high and the histogram of consecutive coordinate differences has values close to zero (i.e. no jumps in body part detections across frames). Here are example plot outputs on a demo video (left):
+It creates a folder called `plot-poses` (in the directory of the video). The plots display the coordinates of body parts
+vs. time, likelihoods vs time, the x- vs. y- coordinate of the body parts, as well as histograms of consecutive
+coordinate differences. These plots help the user to quickly assess the tracking performance for a video. Ideally, the
+likelihood stays high and the histogram of consecutive coordinate differences has values close to zero (i.e. no jumps in
+body part detections across frames). Here are example plot outputs on a demo video (left):
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -455,53 +746,90 @@ It creates a folder called ``plot-poses`` (in the directory of the video). The p
```
````
-### (L) Create Labeled Videos:
+### (L) Create Labeled Videos
Additionally, the toolbox provides a function to create labeled videos based on the extracted poses by plotting the
-labels on top of the frame and creating a video. There are two modes to create videos: FAST and SLOW (but higher quality!). If you want to create high-quality videos, please add ``save_frames=True``. One can use the command as follows to create multiple labeled videos:
+labels on top of the frame and creating a video. There are two modes to create videos: FAST and SLOW (but higher
+quality!). One can use the command as follows to create multiple labeled videos:
```python
-deeplabcut.create_labeled_video(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi','fullpath/analysis/project/videos/reachingvideo2.avi'], save_frames = True/False)
-```
- Optionally, if you want to use the filtered data for a video or directory of filtered videos pass ``filtered=True``, i.e.:
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/analysis/project/videos/reachingvideo1.avi",
+ "fullpath/analysis/project/videos/reachingvideo2.avi"],
+ save_frames = True/False
+)
+```
+ Optionally, if you want to use the filtered data for a video or directory of filtered videos pass `filtered=True`,
+ i.e.:
```python
-deeplabcut.create_labeled_video(config_path, ['fullpath/afolderofvideos'], videotype='.mp4', filtered=True)
-```
-You can also optionally add a skeleton to connect points and/or add a history of points for visualization. To set the "trailing points" you need to pass ``trailpoints``:
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ filtered=True
+)
+```
+You can also optionally add a skeleton to connect points and/or add a history of points for visualization. To set the
+"trailing points" you need to pass `trailpoints`:
```python
-deeplabcut.create_labeled_video(config_path, ['fullpath/afolderofvideos'], videotype='.mp4', trailpoints=10)
-```
-To draw a skeleton, you need to first define the pairs of connected nodes (in the ``config.yaml`` file) and set the skeleton color (in the ``config.yaml`` file). There is also a GUI to help you do this, use by calling `deeplabcut.SkeletonBuilder(config+path)`!
-
-Here is how the ``config.yaml`` additions/edits should look (for example, on the Openfield demo data we provide):
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ trailpoints=10
+)
+```
+To draw a skeleton, you need to first define the pairs of connected nodes (in the `config.yaml` file) and set the
+skeleton color (in the `config.yaml` file). There is also a GUI to help you do this, use by calling
+`deeplabcut.SkeletonBuilder(configpath)`!
+
+Here is how the `config.yaml` additions/edits should look (for example, on the Openfield demo data we provide):
```python
# Plotting configuration
-skeleton: [['snout', 'leftear'], ['snout', 'rightear'], ['leftear', 'tailbase'], ['leftear', 'rightear'], ['rightear','tailbase']]
+skeleton:
+ - ["snout", "leftear"]
+ - ["snout", "rightear"]
+ - ["leftear", "tailbase"]
+ - ["leftear", "rightear"]
+ - ["rightear", "tailbase"]
skeleton_color: white
pcutoff: 0.4
dotsize: 4
alphavalue: 0.5
colormap: jet
```
-Then pass ``draw_skeleton=True`` with the command:
+Then pass `draw_skeleton=True` with the command:
```python
-deeplabcut.create_labeled_video(config_path,['fullpath/afolderofvideos'], videotype='.mp4', draw_skeleton = True)
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ draw_skeleton=True
+)
```
-**NEW** as of 2.2b8: You can create a video with only the "dots" plotted, i.e., in the [style of Johansson](https://link.springer.com/article/10.1007/BF00309043), by passing `keypoints_only=True`:
+**NEW** as of 2.2b8: You can create a video with only the "dots" plotted, i.e., in the
+[style of Johansson](https://link.springer.com/article/10.1007/BF00309043), by passing `keypoints_only=True`:
```python
-deeplabcut.create_labeled_video(config_path,['fullpath/afolderofvideos'], videotype='.mp4', keypoints_only=True)
+deeplabcut.create_labeled_video(
+ config_path,["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ keypoints_only=True
+)
```
-**PRO TIP:** that the **best quality videos** are created when ``save_frames=True`` is passed. Therefore, when ``trailpoints`` and ``draw_skeleton`` are used, we **highly** recommend you also pass ``save_frames=True``!
+**PRO TIP:** that the **best quality videos** are created when `fastmode=False` is passed. Therefore, when
+`trailpoints` and `draw_skeleton` are used, we **highly** recommend you also pass `fastmode=False`!
-This function has various other parameters, in particular the user can set the ``colormap``, the ``dotsize``, and ``alphavalue`` of the labels in **config.yaml** file.
+This function has various other parameters, in particular the user can set the `colormap`, the `dotsize`, and
+`alphavalue` of the labels in **config.yaml** file.
-#### API Docs
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -509,15 +837,25 @@ This function has various other parameters, in particular the user can set the `
```
````
-#### Extract "Skeleton" Features:
+### Extract "Skeleton" Features:
-NEW, as of 2.0.7+: You can save the "skeleton" that was applied in ``create_labeled_videos`` for more computations. Namely, it extracts length and orientation of each "bone" of the skeleton as defined in the **config.yaml** file. You can use the function by:
+NEW, as of 2.0.7+: You can save the "skeleton" that was applied in `create_labeled_videos` for more computations.
+Namely, it extracts length and orientation of each "bone" of the skeleton as defined in the **config.yaml** file. You
+can use the function by:
```python
-deeplabcut.analyzeskeleton(config, video, videotype='avi', shuffle=1, trainingsetindex=0, save_as_csv=False, destfolder=None)
-```
-
-#### API Docs
+deeplabcut.analyzeskeleton(
+ config,
+ video,
+ videotype="avi",
+ shuffle=1,
+ trainingsetindex=0,
+ save_as_csv=False,
+ destfolder=None
+)
+```
+
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -525,6 +863,7 @@ deeplabcut.analyzeskeleton(config, video, videotype='avi', shuffle=1, trainingse
```
````
+(active-learning)=
### (M) Optional Active Learning -> Network Refinement: Extract Outlier Frames
While DeepLabCut typically generalizes well across datasets, one might want to optimize its performance in various,
@@ -540,39 +879,48 @@ where the decoder might make large errors.
All this can be done for a specific video by typing (see other optional inputs below):
```python
-deeplabcut.extract_outlier_frames(config_path, ['videofile_path'])
+deeplabcut.extract_outlier_frames(config_path, ["videofile_path"])
```
We provide various frame-selection methods for this purpose. In particular
the user can set:
```
-outlieralgorithm: 'fitting', 'jump', or 'uncertain'``
+outlieralgorithm: "fitting", "jump", or "uncertain"
```
-• select frames if the likelihood of a particular or all body parts lies below *pbound* (note this could also be due to
-occlusions rather than errors); (``outlieralgorithm='uncertain'``), but also set ``p_bound``.
+• `outlieralgorithm="uncertain"`: select frames if the likelihood of a particular or all body parts lies below `p_bound`
+(note this could also be due to occlusions rather than errors).
-• select frames where a particular body part or all body parts jumped more than *\uf* pixels from the last frame (``outlieralgorithm='jump'``).
+• `outlieralgorithm="jump"`: select frames where a particular body part or all body parts jumped more than `epsilon`
+pixels from the last frame.
-• select frames if the predicted body part location deviates from a state-space model fit to the time series
-of individual body parts. Specifically, this method fits an Auto Regressive Integrated Moving Average (ARIMA)
-model to the time series for each body part. Thereby each body part detection with a likelihood smaller than
-pbound is treated as missing data. Putative outlier frames are then identified as time points, where the average body part estimates are at least *\uf* pixel away from the fits. The parameters of this method are *\uf*, *pbound*, the ARIMA parameters as well as the list of body parts to average over (can also be ``all``).
+• `outlieralgorithm="fitting"`: select frames if the predicted body part location deviates from a state-space model fit
+to the time series of individual body parts. Specifically, this method fits an Auto Regressive Integrated Moving Average
+(ARIMA) model to the time series for each body part. Thereby each body part detection with a likelihood smaller than
+`p_bound` is treated as missing data. Putative outlier frames are then identified as time points, where the average
+body part estimates are at least `epsilon` pixels away from the fits. The parameters of this method are `epsilon`,
+`p_bound`, the ARIMA parameters as well as the list of body parts to average over (can also be `all`).
-• manually select outlier frames based on visual inspection from the user (``outlieralgorithm='manual'``).
+• `outlieralgorithm="manual"`: manually select outlier frames based on visual inspection from the user.
As an example:
```python
-deeplabcut.extract_outlier_frames(config_path, ['videofile_path'], outlieralgorithm='manual')
+deeplabcut.extract_outlier_frames(config_path, ["videofile_path"], outlieralgorithm="manual")
```
In general, depending on the parameters, these methods might return much more frames than the user wants to
-extract (``numframes2pick``). Thus, this list is then used to select outlier frames either by randomly sampling from this
-list (``extractionalgorithm='uniform'``), by performing ``extractionalgorithm='k-means'`` clustering on the corresponding frames.
-
-In the automatic configuration, before the frame selection happens, the user is informed about the amount of frames satisfying the criteria and asked if the selection should proceed. This step allows the user to perhaps change the parameters of the frame-selection heuristics first (i.e. to make sure that not too many frames are qualified). The user can run the extract_outlier_frames iteratively, and (even) extract additional frames from the same video. Once enough outlier frames are extracted the refinement GUI can be used to adjust the labels based on user feedback (see below).
-
-#### API Docs
+extract (`numframes2pick`). Thus, this list is then used to select outlier frames either by randomly sampling from
+this list (`extractionalgorithm="uniform"`), by performing `extractionalgorithm="kmeans"` clustering on the
+corresponding frames.
+
+In the automatic configuration, before the frame selection happens, the user is informed about the amount of frames
+satisfying the criteria and asked if the selection should proceed. This step allows the user to perhaps change the
+parameters of the frame-selection heuristics first (i.e. to make sure that not too many frames are qualified). The user
+can run the `extract_outlier_frames` method iteratively, and (even) extract additional frames from the same video.
+Once enough outlier frames are extracted the refinement GUI can be used to adjust the labels based on user feedback
+(see below).
+
+### API Docs
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -604,7 +952,7 @@ deeplabcut.refine_labels(config_path)
```
This will launch a GUI where the user can refine the labels.
-Use the ‘Load Labels’ button to select one of the subdirectories, where the extracted frames are stored. Every label will be identified by a unique color. For better chances to identify the low-confidence labels, specify the threshold of the likelihood. This changes the body parts with likelihood below this threshold to appear as circles and the ones above as solid disks while retaining the same color scheme. Next, to adjust the position of the label, hover the mouse over the labels to identify the specific body part, left click and drag it to a different location. To delete a specific label, middle click on the label (once a label is deleted, it cannot be retrieved).
+Please refer to the [napari-deeplabcut docs](file:napari-gui-landing) for more information about the labelling workflow.
After correcting the labels for all the frames in each of the subdirectories, the users should merge the data set to
create a new dataset. In this step the iteration parameter in the config.yaml file is automatically updated.
@@ -613,15 +961,18 @@ deeplabcut.merge_datasets(config_path)
```
Once the dataset is merged, the user can test if the merging process was successful by plotting all the labels (Step E).
Next, with this expanded training set the user can now create a novel training set and train the network as described
-in Steps F and G. The training dataset will be stored in the same place as before but under a different ``iteration #``
-subdirectory, where the ``#`` is the new value of ``iteration`` variable stored in the project’s configuration file (this is
-automatically done).
+in Steps F and G. The training dataset will be stored in the same place as before but under a different `iteration-#`
+subdirectory, where the ``#`` is the new value of `iteration` variable stored in the project’s configuration file
+(this is automatically done).
-Now you can run ``create_training_dataset``, then ``train_network``, etc. If your original labels were adjusted at all, start from fresh weights (the typically recommended path anyhow), otherwise consider using your already trained network weights (see Box 2).
+Now you can run `create_training_dataset`, then `train_network`, etc. If your original labels were adjusted at all,
+start from fresh weights (the typically recommended path anyhow), otherwise consider using your already trained network
+weights (see Box 2).
-If after training the network generalizes well to the data, proceed to analyze new videos. Otherwise, consider labeling more data.
+If after training the network generalizes well to the data, proceed to analyze new videos. Otherwise, consider labeling
+more data.
-#### API Docs for deeplabcut.refine_labels
+### API Docs for deeplabcut.refine_labels
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -629,7 +980,7 @@ If after training the network generalizes well to the data, proceed to analyze n
```
````
-#### API Docs for deeplabcut.merge_datasets
+### API Docs for deeplabcut.merge_datasets
````{admonition} Click the button to see API Docs
:class: dropdown
```{eval-rst}
@@ -640,12 +991,15 @@ If after training the network generalizes well to the data, proceed to analyze n
### Jupyter Notebooks for Demonstration of the DeepLabCut Workflow
We also provide two Jupyter notebooks for using DeepLabCut on both a pre-labeled dataset, and on the end user’s
-own dataset. Firstly, we prepared an interactive Jupyter notebook called run_yourowndata.ipynb that can serve as a
-template for the user to develop a project. Furthermore, we provide a notebook for an already started project with
-labeled data. The example project, named as Reaching-Mackenzie-2018-08-30 consists of a project configuration file
-with default parameters and 20 images, which are cropped around the region of interest as an example dataset. These
-images are extracted from a video, which was recorded in a study of skilled motor control in mice. Some example
-labels for these images are also provided. See more details [here](https://github.com/DeepLabCut/DeepLabCut/blob/master/examples).
+own dataset. Firstly, we prepared an interactive Jupyter notebook called
+[Demo_yourowndata.ipynb](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/JUPYTER/Demo_yourowndata.ipynb)
+that can serve as a template for the user to develop a project. Furthermore, we provide a notebook for an already
+started project with labeled data. The example project, named as
+[Reaching-Mackenzie-2018-08-30](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/Reaching-Mackenzie-2018-08-30)
+consists of a project configuration file with default parameters and 20 images, which are cropped around the region of
+interest as an example dataset. These images are extracted from a video, which was recorded in a study of skilled motor
+control in mice. Some example labels for these images are also provided. See more details
+[here](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples).
## 3D Toolbox
diff --git a/examples/COLAB/COLAB_3miceDemo.ipynb b/examples/COLAB/COLAB_3miceDemo.ipynb
index 8b4b55452d..75c741ad40 100644
--- a/examples/COLAB/COLAB_3miceDemo.ipynb
+++ b/examples/COLAB/COLAB_3miceDemo.ipynb
@@ -16,40 +16,48 @@
"id": "TGChzLdc-lUJ"
},
"source": [
- "# DeepLabCut 2.2 Toolbox Demo on 3 mice data\n",
+ "# DeepLabCut MultiMouse Data Demo\n",
"\n",
"\n",
"https://github.com/DeepLabCut/DeepLabCut\n",
"\n",
- "### This notebook illustrates how to use COLAB for a multi-animal DeepLabCut (maDLC) Demo 3 mouse project:\n",
+ "Note: this Colab notebook was written to accompany the Nature Methods publication [_Multi-animal pose estimation, identification and tracking with DeepLabCut_](https://www.nature.com/articles/s41592-022-01443-0) with the TensorFlow engine. To learn about DeepLabCut 3.0+ and the PyTorch engine, you can check out our other notebooks (such as [`COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb`](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb)).\n",
+ "\n",
+ "## This notebook illustrates how to use COLAB for a multi-animal DeepLabCut (maDLC) Demo 3 mouse project:\n",
+ "\n",
"- load our mini-demo data that includes a pretrained model and unlabeled video.\n",
"- analyze a novel video.\n",
"- assemble animals and tracklets.\n",
"- create quality check plots and video.\n",
"\n",
- "### To create a full maDLC pipeline please see our full docs: https://deeplabcut.github.io/DeepLabCut/README.html\n",
+ "- To create a full maDLC pipeline please see our full docs: https://deeplabcut.github.io/DeepLabCut/README.html\n",
+ "\n",
"- Of interest is a full how-to for maDLC: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html\n",
- "- a quick guide to maDLC: https://deeplabcut.github.io/DeepLabCut/docs/tutorial.html\n",
- "- a demo COLAB for how to use maDLC on your own data: https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb\n",
+ "- a quick guide to maDLC: https://deeplabcut.github.io/DeepLabCut/docs/quick-start/tutorial_maDLC.html\n",
+ "- a demo COLAB for how to use maDLC on your own data: https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb\n",
"\n",
- "### To get started, please go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n"
+ "### To get started, please go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\""
]
},
{
"cell_type": "code",
"execution_count": null,
- "metadata": {
- "id": "HoNN2_0Z9rr_"
- },
+ "metadata": {},
"outputs": [],
"source": [
- "# Installs a CUDA version compatible with tensorflow on COLAB\n",
- "!apt update && apt install cuda-11-8\n",
- "\n",
- "# Install the latest DeepLabCut version:\n",
+ "# Install the correct (older) version of DeepLabCut\n",
"!pip install \"deeplabcut[tf]\""
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Important - Restart the Runtime for the updated packages to be imported!\n",
+ "\n",
+ "PLEASE, click \"restart runtime\" from the output above before proceeding!"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {
@@ -58,7 +66,7 @@
"source": [
"No information needs edited in the cells below, you can simply click run on each:\n",
"\n",
- "### Download our Demo Project from our server:"
+ "## Download our Demo Project from our server:"
]
},
{
@@ -70,21 +78,22 @@
"outputs": [],
"source": [
"# Download our demo project:\n",
- "import requests\n",
"from io import BytesIO\n",
"from zipfile import ZipFile\n",
"\n",
- "url_record = 'https://zenodo.org/api/records/7883589'\n",
+ "import requests\n",
+ "\n",
+ "url_record = \"https://zenodo.org/api/records/7883589\"\n",
"response = requests.get(url_record)\n",
"if response.status_code == 200:\n",
- " file = response.json()['files'][0]\n",
- " title = file['key']\n",
+ " file = response.json()[\"files\"][0]\n",
+ " title = file[\"key\"]\n",
" print(f\"Downloading {title}...\")\n",
- " with requests.get(file['links']['self'], stream=True) as r:\n",
+ " with requests.get(file[\"links\"][\"self\"], stream=True) as r:\n",
" with ZipFile(BytesIO(r.content)) as zf:\n",
- " zf.extractall(path='/content')\n",
+ " zf.extractall(path=\"/content\")\n",
"else:\n",
- " raise ValueError(f'The URL {url_record} could not be reached.')"
+ " raise ValueError(f\"The URL {url_record} could not be reached.\")"
]
},
{
@@ -104,14 +113,15 @@
},
"outputs": [],
"source": [
- "import deeplabcut as dlc\n",
"import os\n",
"\n",
+ "import deeplabcut as dlc\n",
+ "\n",
"project_path = \"/content/demo-me-2021-07-14\"\n",
"config_path = os.path.join(project_path, \"config.yaml\")\n",
"video = os.path.join(project_path, \"videos\", \"videocompressed1.mp4\")\n",
"\n",
- "dlc.analyze_videos(config_path,[video], shuffle=0, videotype=\"mp4\",auto_track=False )"
+ "dlc.analyze_videos(config_path, [video], shuffle=0, videotype=\"mp4\", auto_track=False)"
]
},
{
@@ -120,7 +130,7 @@
"id": "zmdSLRTOER00"
},
"source": [
- "### Next, you compute the local, spatio-temporal grouping and track body part assemblies frame-by-frame:"
+ "## Next, you compute the local, spatio-temporal grouping and track body part assemblies frame-by-frame:"
]
},
{
@@ -136,10 +146,14 @@
"dlc.convert_detections2tracklets(\n",
" config_path,\n",
" [video],\n",
- " videotype='mp4',\n",
+ " videotype=\"mp4\",\n",
" shuffle=0,\n",
" track_method=TRACK_METHOD,\n",
- " ignore_bodyparts=[\"tail1\", \"tail2\", \"tailend\"], # Some body parts can optionally be ignored during tracking for better assembly (but they are used later)\n",
+ " ignore_bodyparts=[\n",
+ " \"tail1\",\n",
+ " \"tail2\",\n",
+ " \"tailend\",\n",
+ " ], # Some body parts can optionally be ignored during tracking for better assembly (but they are used later)\n",
")"
]
},
@@ -149,7 +163,7 @@
"id": "nlpGe9obEvFa"
},
"source": [
- "### Reconstruct full animal trajectories (tracks from tracklets):"
+ "## Reconstruct full animal trajectories (tracks from tracklets):"
]
},
{
@@ -163,7 +177,7 @@
"dlc.stitch_tracklets(\n",
" config_path,\n",
" [video],\n",
- " videotype='mp4',\n",
+ " videotype=\"mp4\",\n",
" shuffle=0,\n",
" track_method=TRACK_METHOD,\n",
" n_tracks=3,\n",
@@ -187,17 +201,13 @@
},
"outputs": [],
"source": [
- "#Filter the predictions to remove small jitter, if desired:\n",
- "dlc.filterpredictions(config_path, \n",
- " [video], \n",
- " shuffle=0,\n",
- " videotype='mp4', \n",
- " track_method = TRACK_METHOD)\n",
+ "# Filter the predictions to remove small jitter, if desired:\n",
+ "dlc.filterpredictions(config_path, [video], shuffle=0, videotype=\"mp4\", track_method=TRACK_METHOD)\n",
"\n",
"dlc.create_labeled_video(\n",
" config_path,\n",
" [video],\n",
- " videotype='mp4',\n",
+ " videotype=\"mp4\",\n",
" shuffle=0,\n",
" color_by=\"individual\",\n",
" keypoints_only=False,\n",
@@ -222,7 +232,7 @@
"id": "n7GWMBJUA9x5"
},
"source": [
- "### Create Plots of your data:\n",
+ "## Create Plots of your data:\n",
"\n",
"> after running, you can look in \"videos\", \"plot-poses\" to check out the trajectories! (sometimes you need to click the folder refresh icon to see it). Within the folder, for example, see plotmus1.png to vide the bodyparts over time vs. pixel position.\n",
"\n"
@@ -236,7 +246,7 @@
},
"outputs": [],
"source": [
- "dlc.plot_trajectories(config_path, [video], shuffle=0,videotype='mp4', track_method=TRACK_METHOD)"
+ "dlc.plot_trajectories(config_path, [video], shuffle=0, videotype=\"mp4\", track_method=TRACK_METHOD)"
]
}
],
@@ -248,6 +258,11 @@
"name": "Copy of 3micedemo.ipynb",
"provenance": []
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-02-10",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
diff --git a/examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb b/examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb
new file mode 100644
index 0000000000..38460552ae
--- /dev/null
+++ b/examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb
@@ -0,0 +1,2388 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "87e3afb1",
+ "metadata": {
+ "id": "87e3afb1"
+ },
+ "source": [
+ "# DeepLabCut - Tutorial for BUCTD models\n",
+ "\n",
+ "\n",
+ " \n",
+ " \n",
+ "\n",
+ "**This tutorial introduces the use of [bottom-up conditioned top-down](https://openaccess.thecvf.com/content/ICCV2023/papers/Zhou_Rethinking_Pose_Estimation_in_Crowds_Overcoming_the_Detection_Information_Bottleneck_ICCV_2023_paper.pdf) pose estimation models (also named BUCTD or CTD) in DeepLabCut. This architecture is state-of-the-art in crowded images (when animals are interacting closely with one another), and carry the huge advantage that they can be used to directly track animals, removing the need for tracklet creation or stitching.**\n",
+ "\n",
+ "Some resources that can be useful:\n",
+ "\n",
+ "- The original paper: [Zhou, Stoffl, Mathis, Mathis. \"Rethinking Pose Estimation in Crowds: Overcoming the Detection Information Bottleneck and Ambiguity.\" Proceedings of the IEEE/CVF International Conference on Computer Vision (ICCV). 2023](https://openaccess.thecvf.com/content/ICCV2023/papers/Zhou_Rethinking_Pose_Estimation_in_Crowds_Overcoming_the_Detection_Information_Bottleneck_ICCV_2023_paper.pdf)\n",
+ "- Multi-animal user guide: [DeepLabCut's Documentation: User Guide for Multi-Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html)\n",
+ "\n",
+ "Note: In this notebook, we first train a BU model. Typically, you would already have a BU model, that is not performant enough. That's why you go for the BUCTD approach.\n",
+ "\n",
+ "## Introduction\n",
+ "\n",
+ "This notebook is an introduction to training and using CTD models in DeepLabCut, through the [maDLC Tri-Mouse Benchmark Dataset](https://zenodo.org/records/5851157) presented Lauer et al. 2022 (Nature Methods). For more information, you can check out the [DeepLabCut Benchmark Datasets](https://benchmark.deeplabcut.org/datasets.html).\n",
+ "\n",
+ "In this notebook, we'll\n",
+ "\n",
+ "- train an bottom-up model that can provide conditions for the CTD model\n",
+ "- evaluate the bottom-up model\n",
+ "- (optional/advanced) learn how the CTD model is trained with generative sampling\n",
+ "- train the CTD model\n",
+ "- evaluate the CTD model\n",
+ "- **(Nice feature of CTD models)** use the CTD model to track individuals\n",
+ "\n",
+ "Note: This notebook **can also be run locally**. However, using a GPU is recommended to train the models and run video inference. Just skip the _Installing DeepLabCut on COLAB_ section"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ad548ee1",
+ "metadata": {
+ "id": "ad548ee1"
+ },
+ "source": [
+ "### ⚠️⚠️ Change the Runtime type to use a GPU!⚠️⚠️\n",
+ "\n",
+ "First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2d41bf5e",
+ "metadata": {
+ "id": "2d41bf5e"
+ },
+ "source": [
+ "### Installing DeepLabCut on COLAB"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4428d550",
+ "metadata": {
+ "id": "4428d550"
+ },
+ "source": [
+ "Let's install the latest version of DeepLabCut, straight from GitHub."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "ae9ebeae",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000
+ },
+ "collapsed": true,
+ "executionInfo": {
+ "elapsed": 134753,
+ "status": "ok",
+ "timestamp": 1744357127034,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "ae9ebeae",
+ "outputId": "6643acbb-c848-42c3-b222-d9797a67249b"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Collecting deeplabcut\n",
+ " Cloning https://github.com/DeepLabCut/DeepLabCut.git (to revision lucas/buctd_v2) to /tmp/pip-install-p_2aupou/deeplabcut_38affa993eac4f18a4fcf05ba8f80e79\n",
+ " Running command git clone --filter=blob:none --quiet https://github.com/DeepLabCut/DeepLabCut.git /tmp/pip-install-p_2aupou/deeplabcut_38affa993eac4f18a4fcf05ba8f80e79\n",
+ " Running command git checkout -b lucas/buctd_v2 --track origin/lucas/buctd_v2\n",
+ " Switched to a new branch 'lucas/buctd_v2'\n",
+ " Branch 'lucas/buctd_v2' set up to track remote branch 'lucas/buctd_v2' from 'origin'.\n",
+ " Resolved https://github.com/DeepLabCut/DeepLabCut.git to commit 12cf3fa01b91bbc8c73c1efcecebf83815164da0\n",
+ " Installing build dependencies ... \u001b[?25l\u001b[?25hdone\n",
+ " Getting requirements to build wheel ... \u001b[?25l\u001b[?25hdone\n",
+ " Preparing metadata (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
+ "Collecting albumentations<=1.4.3 (from deeplabcut)\n",
+ " Downloading albumentations-1.4.3-py3-none-any.whl.metadata (37 kB)\n",
+ "Collecting dlclibrary>=0.0.7 (from deeplabcut)\n",
+ " Downloading dlclibrary-0.0.7-py3-none-any.whl.metadata (4.2 kB)\n",
+ "Requirement already satisfied: einops in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.8.1)\n",
+ "Collecting filterpy>=1.4.4 (from deeplabcut)\n",
+ " Downloading filterpy-1.4.5.zip (177 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m178.0/178.0 kB\u001b[0m \u001b[31m10.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n",
+ "Collecting ruamel.yaml>=0.15.0 (from deeplabcut)\n",
+ " Downloading ruamel.yaml-0.18.10-py3-none-any.whl.metadata (23 kB)\n",
+ "Collecting imgaug>=0.4.0 (from deeplabcut)\n",
+ " Downloading imgaug-0.4.0-py2.py3-none-any.whl.metadata (1.8 kB)\n",
+ "Requirement already satisfied: imageio-ffmpeg in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.6.0)\n",
+ "Requirement already satisfied: numba>=0.54 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.60.0)\n",
+ "Collecting matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3 (from deeplabcut)\n",
+ " Downloading matplotlib-3.8.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (5.8 kB)\n",
+ "Requirement already satisfied: networkx>=2.6 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (3.4.2)\n",
+ "Collecting numpy<2.0.0,>=1.18.5 (from deeplabcut)\n",
+ " Downloading numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (61 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m61.0/61.0 kB\u001b[0m \u001b[31m5.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hRequirement already satisfied: pandas!=1.5.0,>=1.0.1 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (2.2.2)\n",
+ "Requirement already satisfied: scikit-image>=0.17 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.25.2)\n",
+ "Requirement already satisfied: scikit-learn>=1.0 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (1.6.1)\n",
+ "Requirement already satisfied: scipy>=1.9 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (1.14.1)\n",
+ "Requirement already satisfied: statsmodels>=0.11 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.14.4)\n",
+ "Collecting tables==3.8.0 (from deeplabcut)\n",
+ " Downloading tables-3.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (2.2 kB)\n",
+ "Requirement already satisfied: timm in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (1.0.15)\n",
+ "Requirement already satisfied: torch>=2.0.0 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (2.6.0+cu124)\n",
+ "Requirement already satisfied: torchvision in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (0.21.0+cu124)\n",
+ "Requirement already satisfied: tqdm in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (4.67.1)\n",
+ "Requirement already satisfied: pycocotools in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (2.0.8)\n",
+ "Requirement already satisfied: pyyaml in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (6.0.2)\n",
+ "Requirement already satisfied: Pillow>=7.1 in /usr/local/lib/python3.11/dist-packages (from deeplabcut) (11.1.0)\n",
+ "Requirement already satisfied: cython>=0.29.21 in /usr/local/lib/python3.11/dist-packages (from tables==3.8.0->deeplabcut) (3.0.12)\n",
+ "Requirement already satisfied: numexpr>=2.6.2 in /usr/local/lib/python3.11/dist-packages (from tables==3.8.0->deeplabcut) (2.10.2)\n",
+ "Collecting blosc2~=2.0.0 (from tables==3.8.0->deeplabcut)\n",
+ " Downloading blosc2-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (11 kB)\n",
+ "Requirement already satisfied: packaging in /usr/local/lib/python3.11/dist-packages (from tables==3.8.0->deeplabcut) (24.2)\n",
+ "Requirement already satisfied: py-cpuinfo in /usr/local/lib/python3.11/dist-packages (from tables==3.8.0->deeplabcut) (9.0.0)\n",
+ "Requirement already satisfied: typing-extensions>=4.9.0 in /usr/local/lib/python3.11/dist-packages (from albumentations<=1.4.3->deeplabcut) (4.13.1)\n",
+ "Requirement already satisfied: opencv-python-headless>=4.9.0 in /usr/local/lib/python3.11/dist-packages (from albumentations<=1.4.3->deeplabcut) (4.11.0.86)\n",
+ "Requirement already satisfied: huggingface-hub in /usr/local/lib/python3.11/dist-packages (from dlclibrary>=0.0.7->deeplabcut) (0.30.1)\n",
+ "Requirement already satisfied: six in /usr/local/lib/python3.11/dist-packages (from imgaug>=0.4.0->deeplabcut) (1.17.0)\n",
+ "Requirement already satisfied: opencv-python in /usr/local/lib/python3.11/dist-packages (from imgaug>=0.4.0->deeplabcut) (4.11.0.86)\n",
+ "Requirement already satisfied: imageio in /usr/local/lib/python3.11/dist-packages (from imgaug>=0.4.0->deeplabcut) (2.37.0)\n",
+ "Requirement already satisfied: Shapely in /usr/local/lib/python3.11/dist-packages (from imgaug>=0.4.0->deeplabcut) (2.1.0)\n",
+ "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (1.3.1)\n",
+ "Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (0.12.1)\n",
+ "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (4.57.0)\n",
+ "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (1.4.8)\n",
+ "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (3.2.3)\n",
+ "Requirement already satisfied: python-dateutil>=2.7 in /usr/local/lib/python3.11/dist-packages (from matplotlib!=3.7.0,!=3.7.1,<3.9,>=3.3->deeplabcut) (2.8.2)\n",
+ "Requirement already satisfied: llvmlite<0.44,>=0.43.0dev0 in /usr/local/lib/python3.11/dist-packages (from numba>=0.54->deeplabcut) (0.43.0)\n",
+ "Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.11/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut) (2025.2)\n",
+ "Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.11/dist-packages (from pandas!=1.5.0,>=1.0.1->deeplabcut) (2025.2)\n",
+ "Collecting ruamel.yaml.clib>=0.2.7 (from ruamel.yaml>=0.15.0->deeplabcut)\n",
+ " Downloading ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (2.7 kB)\n",
+ "Requirement already satisfied: tifffile>=2022.8.12 in /usr/local/lib/python3.11/dist-packages (from scikit-image>=0.17->deeplabcut) (2025.3.30)\n",
+ "Requirement already satisfied: lazy-loader>=0.4 in /usr/local/lib/python3.11/dist-packages (from scikit-image>=0.17->deeplabcut) (0.4)\n",
+ "Requirement already satisfied: joblib>=1.2.0 in /usr/local/lib/python3.11/dist-packages (from scikit-learn>=1.0->deeplabcut) (1.4.2)\n",
+ "Requirement already satisfied: threadpoolctl>=3.1.0 in /usr/local/lib/python3.11/dist-packages (from scikit-learn>=1.0->deeplabcut) (3.6.0)\n",
+ "Requirement already satisfied: patsy>=0.5.6 in /usr/local/lib/python3.11/dist-packages (from statsmodels>=0.11->deeplabcut) (1.0.1)\n",
+ "Requirement already satisfied: filelock in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (3.18.0)\n",
+ "Requirement already satisfied: jinja2 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (3.1.6)\n",
+ "Requirement already satisfied: fsspec in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (2025.3.2)\n",
+ "Collecting nvidia-cuda-nvrtc-cu12==12.4.127 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Collecting nvidia-cuda-runtime-cu12==12.4.127 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Collecting nvidia-cuda-cupti-cu12==12.4.127 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
+ "Collecting nvidia-cudnn-cu12==9.1.0.70 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
+ "Collecting nvidia-cublas-cu12==12.4.5.8 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Collecting nvidia-cufft-cu12==11.2.1.3 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Collecting nvidia-curand-cu12==10.3.5.147 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Collecting nvidia-cusolver-cu12==11.6.1.9 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
+ "Collecting nvidia-cusparse-cu12==12.3.1.170 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl.metadata (1.6 kB)\n",
+ "Requirement already satisfied: nvidia-cusparselt-cu12==0.6.2 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (0.6.2)\n",
+ "Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (2.21.5)\n",
+ "Requirement already satisfied: nvidia-nvtx-cu12==12.4.127 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (12.4.127)\n",
+ "Collecting nvidia-nvjitlink-cu12==12.4.127 (from torch>=2.0.0->deeplabcut)\n",
+ " Downloading nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl.metadata (1.5 kB)\n",
+ "Requirement already satisfied: triton==3.2.0 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (3.2.0)\n",
+ "Requirement already satisfied: sympy==1.13.1 in /usr/local/lib/python3.11/dist-packages (from torch>=2.0.0->deeplabcut) (1.13.1)\n",
+ "Requirement already satisfied: mpmath<1.4,>=1.1.0 in /usr/local/lib/python3.11/dist-packages (from sympy==1.13.1->torch>=2.0.0->deeplabcut) (1.3.0)\n",
+ "Requirement already satisfied: safetensors in /usr/local/lib/python3.11/dist-packages (from timm->deeplabcut) (0.5.3)\n",
+ "Requirement already satisfied: msgpack in /usr/local/lib/python3.11/dist-packages (from blosc2~=2.0.0->tables==3.8.0->deeplabcut) (1.1.0)\n",
+ "Requirement already satisfied: requests in /usr/local/lib/python3.11/dist-packages (from huggingface-hub->dlclibrary>=0.0.7->deeplabcut) (2.32.3)\n",
+ "Requirement already satisfied: MarkupSafe>=2.0 in /usr/local/lib/python3.11/dist-packages (from jinja2->torch>=2.0.0->deeplabcut) (3.0.2)\n",
+ "Requirement already satisfied: charset-normalizer<4,>=2 in /usr/local/lib/python3.11/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.7->deeplabcut) (3.4.1)\n",
+ "Requirement already satisfied: idna<4,>=2.5 in /usr/local/lib/python3.11/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.7->deeplabcut) (3.10)\n",
+ "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/lib/python3.11/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.7->deeplabcut) (2.3.0)\n",
+ "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/lib/python3.11/dist-packages (from requests->huggingface-hub->dlclibrary>=0.0.7->deeplabcut) (2025.1.31)\n",
+ "Downloading tables-3.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (6.5 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m6.5/6.5 MB\u001b[0m \u001b[31m105.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading albumentations-1.4.3-py3-none-any.whl (137 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m137.0/137.0 kB\u001b[0m \u001b[31m13.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading dlclibrary-0.0.7-py3-none-any.whl (16 kB)\n",
+ "Downloading imgaug-0.4.0-py2.py3-none-any.whl (948 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m948.0/948.0 kB\u001b[0m \u001b[31m52.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading matplotlib-3.8.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (11.6 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.6/11.6 MB\u001b[0m \u001b[31m103.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (18.3 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m18.3/18.3 MB\u001b[0m \u001b[31m95.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading ruamel.yaml-0.18.10-py3-none-any.whl (117 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m117.7/117.7 kB\u001b[0m \u001b[31m11.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl (363.4 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m363.4/363.4 MB\u001b[0m \u001b[31m4.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (13.8 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m13.8/13.8 MB\u001b[0m \u001b[31m99.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (24.6 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m24.6/24.6 MB\u001b[0m \u001b[31m88.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (883 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m883.7/883.7 kB\u001b[0m \u001b[31m54.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl (664.8 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m664.8/664.8 MB\u001b[0m \u001b[31m1.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl (211.5 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m211.5/211.5 MB\u001b[0m \u001b[31m3.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl (56.3 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m56.3/56.3 MB\u001b[0m \u001b[31m22.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl (127.9 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m127.9/127.9 MB\u001b[0m \u001b[31m7.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl (207.5 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m207.5/207.5 MB\u001b[0m \u001b[31m5.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl (21.1 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m21.1/21.1 MB\u001b[0m \u001b[31m41.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading blosc2-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.9 MB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.9/3.9 MB\u001b[0m \u001b[31m91.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hDownloading ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (739 kB)\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m739.1/739.1 kB\u001b[0m \u001b[31m51.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25hBuilding wheels for collected packages: deeplabcut, filterpy\n",
+ " Building wheel for deeplabcut (pyproject.toml) ... \u001b[?25l\u001b[?25hdone\n",
+ " Created wheel for deeplabcut: filename=deeplabcut-3.0.0rc8-py3-none-any.whl size=2135196 sha256=5fed7cbc1c688b63dba5c4ec999ec8cd64ff183633f52f61dae83c99bef61086\n",
+ " Stored in directory: /tmp/pip-ephem-wheel-cache-mksbbfnl/wheels/f5/b8/31/9da4b9cf29c390764ce8fb3cda190fa42dce894367ddf37cc9\n",
+ " Building wheel for filterpy (setup.py) ... \u001b[?25l\u001b[?25hdone\n",
+ " Created wheel for filterpy: filename=filterpy-1.4.5-py3-none-any.whl size=110460 sha256=ba55d3e7bb10d2a01b1ad749dd6776e6fe11dc542fa55874879db636b2932478\n",
+ " Stored in directory: /root/.cache/pip/wheels/12/dc/3c/e12983eac132d00f82a20c6cbe7b42ce6e96190ef8fa2d15e1\n",
+ "Successfully built deeplabcut filterpy\n",
+ "Installing collected packages: ruamel.yaml.clib, nvidia-nvjitlink-cu12, nvidia-curand-cu12, nvidia-cufft-cu12, nvidia-cuda-runtime-cu12, nvidia-cuda-nvrtc-cu12, nvidia-cuda-cupti-cu12, nvidia-cublas-cu12, numpy, blosc2, ruamel.yaml, nvidia-cusparse-cu12, nvidia-cudnn-cu12, tables, nvidia-cusolver-cu12, matplotlib, dlclibrary, imgaug, filterpy, albumentations, deeplabcut\n",
+ " Attempting uninstall: nvidia-nvjitlink-cu12\n",
+ " Found existing installation: nvidia-nvjitlink-cu12 12.5.82\n",
+ " Uninstalling nvidia-nvjitlink-cu12-12.5.82:\n",
+ " Successfully uninstalled nvidia-nvjitlink-cu12-12.5.82\n",
+ " Attempting uninstall: nvidia-curand-cu12\n",
+ " Found existing installation: nvidia-curand-cu12 10.3.6.82\n",
+ " Uninstalling nvidia-curand-cu12-10.3.6.82:\n",
+ " Successfully uninstalled nvidia-curand-cu12-10.3.6.82\n",
+ " Attempting uninstall: nvidia-cufft-cu12\n",
+ " Found existing installation: nvidia-cufft-cu12 11.2.3.61\n",
+ " Uninstalling nvidia-cufft-cu12-11.2.3.61:\n",
+ " Successfully uninstalled nvidia-cufft-cu12-11.2.3.61\n",
+ " Attempting uninstall: nvidia-cuda-runtime-cu12\n",
+ " Found existing installation: nvidia-cuda-runtime-cu12 12.5.82\n",
+ " Uninstalling nvidia-cuda-runtime-cu12-12.5.82:\n",
+ " Successfully uninstalled nvidia-cuda-runtime-cu12-12.5.82\n",
+ " Attempting uninstall: nvidia-cuda-nvrtc-cu12\n",
+ " Found existing installation: nvidia-cuda-nvrtc-cu12 12.5.82\n",
+ " Uninstalling nvidia-cuda-nvrtc-cu12-12.5.82:\n",
+ " Successfully uninstalled nvidia-cuda-nvrtc-cu12-12.5.82\n",
+ " Attempting uninstall: nvidia-cuda-cupti-cu12\n",
+ " Found existing installation: nvidia-cuda-cupti-cu12 12.5.82\n",
+ " Uninstalling nvidia-cuda-cupti-cu12-12.5.82:\n",
+ " Successfully uninstalled nvidia-cuda-cupti-cu12-12.5.82\n",
+ " Attempting uninstall: nvidia-cublas-cu12\n",
+ " Found existing installation: nvidia-cublas-cu12 12.5.3.2\n",
+ " Uninstalling nvidia-cublas-cu12-12.5.3.2:\n",
+ " Successfully uninstalled nvidia-cublas-cu12-12.5.3.2\n",
+ " Attempting uninstall: numpy\n",
+ " Found existing installation: numpy 2.0.2\n",
+ " Uninstalling numpy-2.0.2:\n",
+ " Successfully uninstalled numpy-2.0.2\n",
+ " Attempting uninstall: blosc2\n",
+ " Found existing installation: blosc2 3.2.1\n",
+ " Uninstalling blosc2-3.2.1:\n",
+ " Successfully uninstalled blosc2-3.2.1\n",
+ " Attempting uninstall: nvidia-cusparse-cu12\n",
+ " Found existing installation: nvidia-cusparse-cu12 12.5.1.3\n",
+ " Uninstalling nvidia-cusparse-cu12-12.5.1.3:\n",
+ " Successfully uninstalled nvidia-cusparse-cu12-12.5.1.3\n",
+ " Attempting uninstall: nvidia-cudnn-cu12\n",
+ " Found existing installation: nvidia-cudnn-cu12 9.3.0.75\n",
+ " Uninstalling nvidia-cudnn-cu12-9.3.0.75:\n",
+ " Successfully uninstalled nvidia-cudnn-cu12-9.3.0.75\n",
+ " Attempting uninstall: tables\n",
+ " Found existing installation: tables 3.10.2\n",
+ " Uninstalling tables-3.10.2:\n",
+ " Successfully uninstalled tables-3.10.2\n",
+ " Attempting uninstall: nvidia-cusolver-cu12\n",
+ " Found existing installation: nvidia-cusolver-cu12 11.6.3.83\n",
+ " Uninstalling nvidia-cusolver-cu12-11.6.3.83:\n",
+ " Successfully uninstalled nvidia-cusolver-cu12-11.6.3.83\n",
+ " Attempting uninstall: matplotlib\n",
+ " Found existing installation: matplotlib 3.10.0\n",
+ " Uninstalling matplotlib-3.10.0:\n",
+ " Successfully uninstalled matplotlib-3.10.0\n",
+ " Attempting uninstall: albumentations\n",
+ " Found existing installation: albumentations 2.0.5\n",
+ " Uninstalling albumentations-2.0.5:\n",
+ " Successfully uninstalled albumentations-2.0.5\n",
+ "\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\n",
+ "thinc 8.3.6 requires numpy<3.0.0,>=2.0.0, but you have numpy 1.26.4 which is incompatible.\u001b[0m\u001b[31m\n",
+ "\u001b[0mSuccessfully installed albumentations-1.4.3 blosc2-2.0.0 deeplabcut-3.0.0rc8 dlclibrary-0.0.7 filterpy-1.4.5 imgaug-0.4.0 matplotlib-3.8.4 numpy-1.26.4 nvidia-cublas-cu12-12.4.5.8 nvidia-cuda-cupti-cu12-12.4.127 nvidia-cuda-nvrtc-cu12-12.4.127 nvidia-cuda-runtime-cu12-12.4.127 nvidia-cudnn-cu12-9.1.0.70 nvidia-cufft-cu12-11.2.1.3 nvidia-curand-cu12-10.3.5.147 nvidia-cusolver-cu12-11.6.1.9 nvidia-cusparse-cu12-12.3.1.170 nvidia-nvjitlink-cu12-12.4.127 ruamel.yaml-0.18.10 ruamel.yaml.clib-0.2.12 tables-3.8.0\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.colab-display-data+json": {
+ "id": "d5d69df769e143178b2cb062f2044022",
+ "pip_warning": {
+ "packages": [
+ "matplotlib",
+ "mpl_toolkits"
+ ]
+ }
+ }
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dd0a07bc",
+ "metadata": {
+ "id": "dd0a07bc"
+ },
+ "source": [
+ "**(Be sure to click \"RESTART RUNTIME\" if it is displayed above before moving on !)** You will see this button at the output of the cells above ^."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7c35856e",
+ "metadata": {
+ "id": "7c35856e"
+ },
+ "source": [
+ "### Imports"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "0d2ca689",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 30067,
+ "status": "ok",
+ "timestamp": 1744357191552,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "0d2ca689",
+ "outputId": "c1dcdaaa-0ec9-475b-bb5d-a3fa59712727"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Loading DLC 3.0.0rc8...\n",
+ "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import shutil\n",
+ "from io import BytesIO\n",
+ "from pathlib import Path\n",
+ "from zipfile import ZipFile\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "import requests\n",
+ "\n",
+ "import deeplabcut\n",
+ "import deeplabcut.pose_estimation_pytorch as dlc_torch\n",
+ "import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "36a04338",
+ "metadata": {
+ "id": "36a04338"
+ },
+ "source": [
+ "### Downloading the Tri-Mouse Dataset\n",
+ "\n",
+ "This cell downloads the Tri-Mouse dataset from Zenodo into the current working directory (or `cwd`), which should be the directory you launched the jupyter server from."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "8d1c71ab",
+ "metadata": {
+ "executionInfo": {
+ "elapsed": 14,
+ "status": "ok",
+ "timestamp": 1744357194674,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "8d1c71ab"
+ },
+ "outputs": [],
+ "source": [
+ "download_path = Path.cwd()\n",
+ "config = str(download_path / \"trimice-dlc-2021-06-22\" / \"config.yaml\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "784ed973",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 42401,
+ "status": "ok",
+ "timestamp": 1744357238563,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "784ed973",
+ "outputId": "fb682110-14b4-4a09-cdb6-6524f7433d11"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Downloading the tri-mouse dataset into /content\n",
+ "Downloading trimice-dlc-2021-06-22.zip...\n",
+ "Config path: /content/trimice-dlc-2021-06-22/config.yaml\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(f\"Downloading the tri-mouse dataset into {download_path}\")\n",
+ "\n",
+ "url_record = \"https://zenodo.org/api/records/5851157\"\n",
+ "response = requests.get(url_record)\n",
+ "if response.status_code == 200:\n",
+ " file = response.json()[\"files\"][0]\n",
+ " title = file[\"key\"]\n",
+ " print(f\"Downloading {title}...\")\n",
+ " with requests.get(file[\"links\"][\"self\"], stream=True) as r:\n",
+ " with ZipFile(BytesIO(r.content)) as zf:\n",
+ " zf.extractall(path=download_path)\n",
+ "else:\n",
+ " raise ValueError(f\"The URL {url_record} could not be reached.\")\n",
+ "\n",
+ "\n",
+ "# Check that the config was downloaded correctly\n",
+ "print(f\"Config path: {config}\")\n",
+ "if not Path(config).exists():\n",
+ " print(f\"Could not find config at {config}: check that the dataset was downloaded correctly!\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "414d1700",
+ "metadata": {
+ "id": "414d1700"
+ },
+ "source": [
+ "## Training a CTD Model in DeepLabCut\n",
+ "\n",
+ "BUCTD (or bottom-up conditioned top-down), as its name suggests, requires a bottom-up model to provide conditions (or **pose proposals**) for the CTD model to fix. So the first step in getting a CTD model that can be used to run inference is to train a bottom-up model to provide conditions!\n",
+ "\n",
+ "We'll also **ensure that we're training the bottom-up and CTD models on the same train/test splits!** This is important: if you're training the models on different training images and evaluating them on different test images, then their results aren't comparable!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "8e808c35",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 81,
+ "status": "ok",
+ "timestamp": 1744357248719,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "8e808c35",
+ "outputId": "1a7777ba-a4d5-4777-973f-02497878617d"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using 70% of the data in the training set.\n"
+ ]
+ }
+ ],
+ "source": [
+ "cfg = auxiliaryfunctions.read_config(config)\n",
+ "train_frac = cfg[\"TrainingFraction\"][0]\n",
+ "print(f\"Using {int(100 * train_frac)}% of the data in the training set.\")\n",
+ "\n",
+ "num_images = 112\n",
+ "train_images = int(train_frac * num_images)\n",
+ "\n",
+ "seed = 0\n",
+ "rng = np.random.default_rng(seed)\n",
+ "\n",
+ "train_indices = rng.choice(num_images, size=train_images, replace=False, shuffle=False).tolist()\n",
+ "test_indices = [idx for idx in range(num_images) if idx not in train_indices]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d2165546",
+ "metadata": {
+ "id": "d2165546"
+ },
+ "source": [
+ "### Training a BU Model\n",
+ "\n",
+ "We'll take the simplest approach possible here and train a ResNet pose estimation model. As the CTD model will be used to improve the predictions made by the BU model, we want something light and fast rather than something heavy and slow!\n",
+ "\n",
+ "We'll start by **creating the shuffle for the bottom-up model (with index 1) with the selected train/test split**."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "8329fce1",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 427,
+ "status": "ok",
+ "timestamp": 1744357252179,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "8329fce1",
+ "outputId": "643bddfa-da4d-49d0-e241-849500d06fc1"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n",
+ "You passed a split with the following fraction: 70%\n",
+ "Creating training data for: Shuffle: 1 TrainFraction: 0.7\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 78/78 [00:00<00:00, 1613.55it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "BU_SHUFFLE = 1\n",
+ "\n",
+ "deeplabcut.create_training_dataset(\n",
+ " config,\n",
+ " Shuffles=[BU_SHUFFLE],\n",
+ " trainIndices=[train_indices],\n",
+ " testIndices=[test_indices],\n",
+ " net_type=\"resnet_50\",\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " userfeedback=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9bb714ec",
+ "metadata": {
+ "id": "9bb714ec"
+ },
+ "source": [
+ "We can then train the model defined in the created bottom-up shuffle. To make running this notebook a bit quicker, we'll **only train the BU model for 100 epochs**. The model should still perform well enough, and as we're less interested in the BU model than the CTD model we'll save a bit of time and compute here. Training the model should **take 10 to 20 minutes**, depending on your CPU and GPU performance.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "4ec8e03c",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000,
+ "referenced_widgets": [
+ "2e74326b630a4d4fa879ec3a154eecb4",
+ "3880c2d826f440d485bd49b562c58a6f",
+ "4aea0735770d4196bfb9b036017f65b2",
+ "069424f702f446cb8737e94a28b18588",
+ "da21a61d52cd46d79d88bb458b1cda45",
+ "7e4bae5e01234b49a350583d99998956",
+ "affa8c3978fa4b118d1fd4b5c055d464",
+ "c4fc0083fdd24badafea3df30ff9b2a5",
+ "5e9c160affc945748cbbfe8f6b76df3f",
+ "6a24ded1648a40e0926f6a66d2501207",
+ "1d6e05a559e34b389ff57424ad7ce3d1"
+ ]
+ },
+ "executionInfo": {
+ "elapsed": 1182867,
+ "status": "ok",
+ "timestamp": 1744358458536,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "4ec8e03c",
+ "outputId": "59489e99-078c-4ef7-c98b-930c34cf82e2"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Training with configuration:\n",
+ "data:\n",
+ " bbox_margin: 20\n",
+ " colormode: RGB\n",
+ " inference:\n",
+ " normalize_images: True\n",
+ " train:\n",
+ " affine:\n",
+ " p: 0.5\n",
+ " rotation: 30\n",
+ " scaling: [0.5, 1.25]\n",
+ " translation: 0\n",
+ " crop_sampling:\n",
+ " width: 448\n",
+ " height: 448\n",
+ " max_shift: 0.1\n",
+ " method: hybrid\n",
+ " gaussian_noise: 12.75\n",
+ " motion_blur: True\n",
+ " normalize_images: True\n",
+ "device: auto\n",
+ "metadata:\n",
+ " project_path: /content/trimice-dlc-2021-06-22\n",
+ " pose_config_path: /content/trimice-dlc-2021-06-22/dlc-models-pytorch/iteration-0/trimiceJun22-trainset70shuffle1/train/pytorch_config.yaml\n",
+ " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n",
+ " unique_bodyparts: []\n",
+ " individuals: ['mus1', 'mus2', 'mus3']\n",
+ " with_identity: None\n",
+ "method: bu\n",
+ "model:\n",
+ " backbone:\n",
+ " type: ResNet\n",
+ " model_name: resnet50_gn\n",
+ " output_stride: 16\n",
+ " freeze_bn_stats: False\n",
+ " freeze_bn_weights: False\n",
+ " backbone_output_channels: 2048\n",
+ " heads:\n",
+ " bodypart:\n",
+ " type: DLCRNetHead\n",
+ " predictor:\n",
+ " type: PartAffinityFieldPredictor\n",
+ " num_animals: 3\n",
+ " num_multibodyparts: 12\n",
+ " num_uniquebodyparts: 0\n",
+ " nms_radius: 5\n",
+ " sigma: 1.0\n",
+ " locref_stdev: 7.2801\n",
+ " min_affinity: 0.05\n",
+ " graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n",
+ " edges_to_keep: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65]\n",
+ " apply_sigmoid: True\n",
+ " clip_scores: False\n",
+ " target_generator:\n",
+ " type: SequentialGenerator\n",
+ " generators: [{'type': 'HeatmapPlateauGenerator', 'num_heatmaps': 12, 'pos_dist_thresh': 17, 'heatmap_mode': 'KEYPOINT', 'gradient_masking': False, 'generate_locref': True, 'locref_std': 7.2801}, {'type': 'PartAffinityFieldGenerator', 'graph': [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]], 'width': 20}]\n",
+ " criterion:\n",
+ " heatmap:\n",
+ " type: WeightedBCECriterion\n",
+ " weight: 1.0\n",
+ " locref:\n",
+ " type: WeightedHuberCriterion\n",
+ " weight: 0.05\n",
+ " paf:\n",
+ " type: WeightedHuberCriterion\n",
+ " weight: 0.1\n",
+ " heatmap_config:\n",
+ " channels: [2048, 12]\n",
+ " kernel_size: [3]\n",
+ " strides: [2]\n",
+ " locref_config:\n",
+ " channels: [2048, 24]\n",
+ " kernel_size: [3]\n",
+ " strides: [2]\n",
+ " paf_config:\n",
+ " channels: [2048, 132]\n",
+ " kernel_size: [3]\n",
+ " strides: [2]\n",
+ " num_stages: 5\n",
+ "net_type: resnet_50\n",
+ "runner:\n",
+ " type: PoseTrainingRunner\n",
+ " gpus: None\n",
+ " key_metric: test.mAP\n",
+ " key_metric_asc: True\n",
+ " eval_interval: 10\n",
+ " optimizer:\n",
+ " type: AdamW\n",
+ " params:\n",
+ " lr: 0.0005\n",
+ " scheduler:\n",
+ " type: LRListScheduler\n",
+ " params:\n",
+ " lr_list: [[0.0001], [1e-05]]\n",
+ " milestones: [90, 120]\n",
+ " snapshots:\n",
+ " max_snapshots: 5\n",
+ " save_epochs: 25\n",
+ " save_optimizer_state: False\n",
+ "train_settings:\n",
+ " batch_size: 8\n",
+ " dataloader_workers: 0\n",
+ " dataloader_pin_memory: False\n",
+ " display_iters: 500\n",
+ " epochs: 100\n",
+ " seed: 42\n",
+ "Loading pretrained weights from Hugging Face hub (timm/resnet50_gn.a1h_in1k)\n",
+ "/usr/local/lib/python3.11/dist-packages/huggingface_hub/utils/_auth.py:94: UserWarning: \n",
+ "The secret `HF_TOKEN` does not exist in your Colab secrets.\n",
+ "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n",
+ "You will be able to reuse this secret in all of your notebooks.\n",
+ "Please note that authentication is recommended but still optional to access public models or datasets.\n",
+ " warnings.warn(\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "2e74326b630a4d4fa879ec3a154eecb4",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "model.safetensors: 0%| | 0.00/102M [00:00, ?B/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[timm/resnet50_gn.a1h_in1k] Safe alternative available for 'pytorch_model.bin' (as 'model.safetensors'). Loading weights using safetensors.\n",
+ "Data Transforms:\n",
+ " Training: Compose([\n",
+ " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (0.5, 1.25), 'y': (0.5, 1.25)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n",
+ " PadIfNeeded(always_apply=True, p=1.0, min_height=448, min_width=448, pad_height_divisor=None, pad_width_divisor=None, position=PositionType.CENTER, border_mode=0, value=None, mask_value=None),\n",
+ " KeypointAwareCrop(always_apply=True, p=1.0, width=448, height=448, max_shift=0.1, crop_sampling='hybrid'),\n",
+ " MotionBlur(always_apply=False, p=0.5, blur_limit=(3, 7), allow_shifted=True),\n",
+ " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n",
+ " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n",
+ "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n",
+ " Validation: Compose([\n",
+ " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n",
+ "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n",
+ "Using 78 images and 34 for testing\n",
+ "\n",
+ "Starting pose model training...\n",
+ "--------------------------------------------------\n",
+ "Epoch 1/100 (lr=0.0005), train loss 0.08699\n",
+ "Epoch 2/100 (lr=0.0005), train loss 0.02512\n",
+ "Epoch 3/100 (lr=0.0005), train loss 0.02266\n",
+ "Epoch 4/100 (lr=0.0005), train loss 0.02117\n",
+ "Epoch 5/100 (lr=0.0005), train loss 0.02038\n",
+ "Epoch 6/100 (lr=0.0005), train loss 0.01967\n",
+ "Epoch 7/100 (lr=0.0005), train loss 0.01796\n",
+ "Epoch 8/100 (lr=0.0005), train loss 0.01739\n",
+ "Epoch 9/100 (lr=0.0005), train loss 0.01615\n",
+ "Training for epoch 10 done, starting evaluation\n",
+ "Epoch 10/100 (lr=0.0005), train loss 0.01435, valid loss 0.01307\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 52.10\n",
+ " metrics/test.rmse_pcutoff: 42.00\n",
+ " metrics/test.mAP: 28.63\n",
+ " metrics/test.mAR: 37.16\n",
+ "Epoch 11/100 (lr=0.0005), train loss 0.01340\n",
+ "Epoch 12/100 (lr=0.0005), train loss 0.01192\n",
+ "Epoch 13/100 (lr=0.0005), train loss 0.01072\n",
+ "Epoch 14/100 (lr=0.0005), train loss 0.01012\n",
+ "Epoch 15/100 (lr=0.0005), train loss 0.00917\n",
+ "Epoch 16/100 (lr=0.0005), train loss 0.00888\n",
+ "Epoch 17/100 (lr=0.0005), train loss 0.00848\n",
+ "Epoch 18/100 (lr=0.0005), train loss 0.00810\n",
+ "Epoch 19/100 (lr=0.0005), train loss 0.00742\n",
+ "Training for epoch 20 done, starting evaluation\n",
+ "Epoch 20/100 (lr=0.0005), train loss 0.00693, valid loss 0.00716\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 31.48\n",
+ " metrics/test.rmse_pcutoff: 29.16\n",
+ " metrics/test.mAP: 56.98\n",
+ " metrics/test.mAR: 63.14\n",
+ "Epoch 21/100 (lr=0.0005), train loss 0.00636\n",
+ "Epoch 22/100 (lr=0.0005), train loss 0.00645\n",
+ "Epoch 23/100 (lr=0.0005), train loss 0.00596\n",
+ "Epoch 24/100 (lr=0.0005), train loss 0.00534\n",
+ "Epoch 25/100 (lr=0.0005), train loss 0.00545\n",
+ "Epoch 26/100 (lr=0.0005), train loss 0.00507\n",
+ "Epoch 27/100 (lr=0.0005), train loss 0.00502\n",
+ "Epoch 28/100 (lr=0.0005), train loss 0.00492\n",
+ "Epoch 29/100 (lr=0.0005), train loss 0.00456\n",
+ "Training for epoch 30 done, starting evaluation\n",
+ "Epoch 30/100 (lr=0.0005), train loss 0.00456, valid loss 0.00507\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 15.23\n",
+ " metrics/test.rmse_pcutoff: 11.35\n",
+ " metrics/test.mAP: 79.63\n",
+ " metrics/test.mAR: 83.04\n",
+ "Epoch 31/100 (lr=0.0005), train loss 0.00453\n",
+ "Epoch 32/100 (lr=0.0005), train loss 0.00434\n",
+ "Epoch 33/100 (lr=0.0005), train loss 0.00425\n",
+ "Epoch 34/100 (lr=0.0005), train loss 0.00434\n",
+ "Epoch 35/100 (lr=0.0005), train loss 0.00403\n",
+ "Epoch 36/100 (lr=0.0005), train loss 0.00402\n",
+ "Epoch 37/100 (lr=0.0005), train loss 0.00389\n",
+ "Epoch 38/100 (lr=0.0005), train loss 0.00377\n",
+ "Epoch 39/100 (lr=0.0005), train loss 0.00365\n",
+ "Training for epoch 40 done, starting evaluation\n",
+ "Epoch 40/100 (lr=0.0005), train loss 0.00368, valid loss 0.00457\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 9.55\n",
+ " metrics/test.rmse_pcutoff: 8.73\n",
+ " metrics/test.mAP: 84.75\n",
+ " metrics/test.mAR: 86.86\n",
+ "Epoch 41/100 (lr=0.0005), train loss 0.00369\n",
+ "Epoch 42/100 (lr=0.0005), train loss 0.00369\n",
+ "Epoch 43/100 (lr=0.0005), train loss 0.00350\n",
+ "Epoch 44/100 (lr=0.0005), train loss 0.00338\n",
+ "Epoch 45/100 (lr=0.0005), train loss 0.00321\n",
+ "Epoch 46/100 (lr=0.0005), train loss 0.00337\n",
+ "Epoch 47/100 (lr=0.0005), train loss 0.00328\n",
+ "Epoch 48/100 (lr=0.0005), train loss 0.00354\n",
+ "Epoch 49/100 (lr=0.0005), train loss 0.00315\n",
+ "Training for epoch 50 done, starting evaluation\n",
+ "Epoch 50/100 (lr=0.0005), train loss 0.00304, valid loss 0.00439\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 7.60\n",
+ " metrics/test.rmse_pcutoff: 6.95\n",
+ " metrics/test.mAP: 86.14\n",
+ " metrics/test.mAR: 88.43\n",
+ "Epoch 51/100 (lr=0.0005), train loss 0.00314\n",
+ "Epoch 52/100 (lr=0.0005), train loss 0.00312\n",
+ "Epoch 53/100 (lr=0.0005), train loss 0.00306\n",
+ "Epoch 54/100 (lr=0.0005), train loss 0.00308\n",
+ "Epoch 55/100 (lr=0.0005), train loss 0.00303\n",
+ "Epoch 56/100 (lr=0.0005), train loss 0.00313\n",
+ "Epoch 57/100 (lr=0.0005), train loss 0.00320\n",
+ "Epoch 58/100 (lr=0.0005), train loss 0.00286\n",
+ "Epoch 59/100 (lr=0.0005), train loss 0.00284\n",
+ "Training for epoch 60 done, starting evaluation\n",
+ "Epoch 60/100 (lr=0.0005), train loss 0.00284, valid loss 0.00423\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 8.43\n",
+ " metrics/test.rmse_pcutoff: 7.91\n",
+ " metrics/test.mAP: 84.27\n",
+ " metrics/test.mAR: 86.76\n",
+ "Epoch 61/100 (lr=0.0005), train loss 0.00271\n",
+ "Epoch 62/100 (lr=0.0005), train loss 0.00283\n",
+ "Epoch 63/100 (lr=0.0005), train loss 0.00293\n",
+ "Epoch 64/100 (lr=0.0005), train loss 0.00282\n",
+ "Epoch 65/100 (lr=0.0005), train loss 0.00273\n",
+ "Epoch 66/100 (lr=0.0005), train loss 0.00286\n",
+ "Epoch 67/100 (lr=0.0005), train loss 0.00299\n",
+ "Epoch 68/100 (lr=0.0005), train loss 0.00299\n",
+ "Epoch 69/100 (lr=0.0005), train loss 0.00260\n",
+ "Training for epoch 70 done, starting evaluation\n",
+ "Epoch 70/100 (lr=0.0005), train loss 0.00268, valid loss 0.00412\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 6.21\n",
+ " metrics/test.rmse_pcutoff: 5.85\n",
+ " metrics/test.mAP: 89.50\n",
+ " metrics/test.mAR: 90.88\n",
+ "Epoch 71/100 (lr=0.0005), train loss 0.00254\n",
+ "Epoch 72/100 (lr=0.0005), train loss 0.00270\n",
+ "Epoch 73/100 (lr=0.0005), train loss 0.00281\n",
+ "Epoch 74/100 (lr=0.0005), train loss 0.00269\n",
+ "Epoch 75/100 (lr=0.0005), train loss 0.00262\n",
+ "Epoch 76/100 (lr=0.0005), train loss 0.00273\n",
+ "Epoch 77/100 (lr=0.0005), train loss 0.00260\n",
+ "Epoch 78/100 (lr=0.0005), train loss 0.00250\n",
+ "Epoch 79/100 (lr=0.0005), train loss 0.00270\n",
+ "Training for epoch 80 done, starting evaluation\n",
+ "Epoch 80/100 (lr=0.0005), train loss 0.00259, valid loss 0.00393\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 5.40\n",
+ " metrics/test.rmse_pcutoff: 5.14\n",
+ " metrics/test.mAP: 91.43\n",
+ " metrics/test.mAR: 92.25\n",
+ "Epoch 81/100 (lr=0.0005), train loss 0.00278\n",
+ "Epoch 82/100 (lr=0.0005), train loss 0.00270\n",
+ "Epoch 83/100 (lr=0.0005), train loss 0.00266\n",
+ "Epoch 84/100 (lr=0.0005), train loss 0.00266\n",
+ "Epoch 85/100 (lr=0.0005), train loss 0.00257\n",
+ "Epoch 86/100 (lr=0.0005), train loss 0.00250\n",
+ "Epoch 87/100 (lr=0.0005), train loss 0.00270\n",
+ "Epoch 88/100 (lr=0.0005), train loss 0.00247\n",
+ "Epoch 89/100 (lr=0.0005), train loss 0.00220\n",
+ "Training for epoch 90 done, starting evaluation\n",
+ "Epoch 90/100 (lr=0.0001), train loss 0.00224, valid loss 0.00405\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 8.61\n",
+ " metrics/test.rmse_pcutoff: 8.10\n",
+ " metrics/test.mAP: 88.81\n",
+ " metrics/test.mAR: 90.20\n",
+ "Epoch 91/100 (lr=0.0001), train loss 0.00224\n",
+ "Epoch 92/100 (lr=0.0001), train loss 0.00227\n",
+ "Epoch 93/100 (lr=0.0001), train loss 0.00205\n",
+ "Epoch 94/100 (lr=0.0001), train loss 0.00229\n",
+ "Epoch 95/100 (lr=0.0001), train loss 0.00206\n",
+ "Epoch 96/100 (lr=0.0001), train loss 0.00214\n",
+ "Epoch 97/100 (lr=0.0001), train loss 0.00192\n",
+ "Epoch 98/100 (lr=0.0001), train loss 0.00197\n",
+ "Epoch 99/100 (lr=0.0001), train loss 0.00208\n",
+ "Training for epoch 100 done, starting evaluation\n",
+ "Epoch 100/100 (lr=0.0001), train loss 0.00187, valid loss 0.00378\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 4.67\n",
+ " metrics/test.rmse_pcutoff: 4.59\n",
+ " metrics/test.mAP: 91.37\n",
+ " metrics/test.mAR: 91.96\n"
+ ]
+ }
+ ],
+ "source": [
+ "deeplabcut.train_network(\n",
+ " config,\n",
+ " shuffle=BU_SHUFFLE,\n",
+ " epochs=100,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dd158943",
+ "metadata": {
+ "id": "dd158943"
+ },
+ "source": [
+ "And finally we evaluate it! If you trained for 100 epochs, you should get an mAP around 90, and RMSE around 4-5 pixels. When calling `evaluate_network`, the PAF graph is pruned (as described in [Lauer et al. 2022 (Nature Methods)](https://www.nature.com/articles/s41592-022-01443-0)) to boost performance."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "0a02cf7c",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 22610,
+ "status": "ok",
+ "timestamp": 1744358489662,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "0a02cf7c",
+ "outputId": "0548ab4e-ead0-4f73-8660-a56170f9c137"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 78/78 [00:05<00:00, 13.04it/s]\n",
+ "100%|██████████| 34/34 [00:05<00:00, 6.33it/s]\n",
+ "100%|██████████| 78/78 [00:05<00:00, 15.49it/s]\n",
+ "100%|██████████| 34/34 [00:02<00:00, 16.35it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Evaluation results for DLC_Resnet50_trimiceJun22shuffle1_snapshot_080-results.csv (pcutoff: 0.01):\n",
+ "train rmse 2.42\n",
+ "train rmse_pcutoff 2.42\n",
+ "train mAP 97.23\n",
+ "train mAR 97.52\n",
+ "test rmse 3.95\n",
+ "test rmse_pcutoff 3.95\n",
+ "test mAP 92.69\n",
+ "test mAR 93.04\n",
+ "Name: (0.7, 1, 80, -1, 0.01), dtype: float64\n"
+ ]
+ }
+ ],
+ "source": [
+ "deeplabcut.evaluate_network(config, Shuffles=[BU_SHUFFLE])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9742e6a8",
+ "metadata": {
+ "id": "9742e6a8"
+ },
+ "source": [
+ "### Training the CTD Model\n",
+ "\n",
+ "As for the BU model, we need to start by creating the shuffle for the CTD model. We'll use `create_training_dataset_from_existing_split` to create a shuffle with the same train/test split as the BU shuffle. You could equivalently call `create_training_dataset(..., trainIndices=[train_indices], testIndices=[test_indices], ...)` again, as done above for the BU shuffle.\n",
+ "\n",
+ "In this notebook, we'll use a preNet CTD architecture. You can check out the paper for more information on how preNet models are designed!\n",
+ "\n",
+ "We'll also specify which model we want to use to provide conditions with the `ctd_conditions` parameter. As is indicated in the docstring:\n",
+ "\n",
+ "```\n",
+ "ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None, default = None,\n",
+ " If using a conditional-top-down (CTD) net_type, this argument should be specified. It defines the\n",
+ " conditions that will be used with the CTD model. It can be either:\n",
+ " * A shuffle number (ctd_conditions: int), which must correspond to a bottom-up (BU) network type.\n",
+ " * A predictions file path (ctd_conditions: string | Path), which must correspond to a\n",
+ " .json or .h5 predictions file.\n",
+ " * A shuffle number and a particular snapshot (ctd_conditions: tuple[int, str] | tuple[int, int]),\n",
+ " which respectively correspond to a bottom-up (BU) network type and a particular snapshot name or\n",
+ " index.\n",
+ "```\n",
+ "\n",
+ "We'll use the index of the BU shuffle defined above, and the best snapshot that was saved (indicated through a -1). You can edit which model is used to provide conditions through the `pytorch_config` for the `CTD_SHUFFLE` (in this case shuffle `2`):\n",
+ "\n",
+ "```yaml\n",
+ "# Example: Loading the predictions for snapshot-250.pt of shuffle 1.\n",
+ "inference:\n",
+ " conditions:\n",
+ " shuffle: 1\n",
+ " snapshot: snapshot-250.pt\n",
+ "\n",
+ "# Example: Loading the predictions for the last snapshot of shuffle 1.\n",
+ "inference:\n",
+ " conditions:\n",
+ " shuffle: 1\n",
+ " snapshot_index: -1\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "0f75947d",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 267,
+ "status": "ok",
+ "timestamp": 1744358497035,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "0f75947d",
+ "outputId": "a99698bd-eb7d-4042-f72f-7ae7e1ed6a46",
+ "scrolled": true
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Utilizing the following graph: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5], [0, 6], [0, 7], [0, 8], [0, 9], [0, 10], [0, 11], [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 9], [1, 10], [1, 11], [2, 3], [2, 4], [2, 5], [2, 6], [2, 7], [2, 8], [2, 9], [2, 10], [2, 11], [3, 4], [3, 5], [3, 6], [3, 7], [3, 8], [3, 9], [3, 10], [3, 11], [4, 5], [4, 6], [4, 7], [4, 8], [4, 9], [4, 10], [4, 11], [5, 6], [5, 7], [5, 8], [5, 9], [5, 10], [5, 11], [6, 7], [6, 8], [6, 9], [6, 10], [6, 11], [7, 8], [7, 9], [7, 10], [7, 11], [8, 9], [8, 10], [8, 11], [9, 10], [9, 11], [10, 11]]\n",
+ "You passed a split with the following fraction: 70%\n",
+ "Creating training data for: Shuffle: 2 TrainFraction: 0.7\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 78/78 [00:00<00:00, 8165.62it/s]"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "CTD_SHUFFLE = 2\n",
+ "\n",
+ "deeplabcut.create_training_dataset_from_existing_split(\n",
+ " config,\n",
+ " from_shuffle=BU_SHUFFLE,\n",
+ " shuffles=[CTD_SHUFFLE],\n",
+ " net_type=\"ctd_coam_w32\",\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " ctd_conditions=(BU_SHUFFLE, -1),\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b2829415",
+ "metadata": {
+ "id": "b2829415"
+ },
+ "source": [
+ "#### (Optional/Advanced) Learning and visualizing generative sampling during training\n",
+ "\n",
+ "You can skip this section (and move on to _Training and Evaluating the CTD Model_) as it's simply to visualize how CTD models are trained, if you aren't interested in learning about it."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "odk3LbpOI0B3",
+ "metadata": {
+ "id": "odk3LbpOI0B3"
+ },
+ "source": [
+ "This section **uses some internal DeepLabCut functions which may seem a bit complicated if you're not used to using them; you can ignore most of the code and just read the text/comments and look at the outputs if you're more comfortable with that.**\n",
+ "\n",
+ "Conditional top-down models are trained using _generative sampling_, as introduced in PoseFix \\[1\\]. For every ground truth pose, we'll add some errors. The errors that can be introduced are:\n",
+ "\n",
+ "- Jitter error is defined as a small displacement from the GT keypoint.\n",
+ "- Swap error represents a confusion between the same or similar parts which belong to different persons.\n",
+ "- Inversion error occurs when a pose estimation model is confused between semantically similar parts that belong to the same instance.\n",
+ "- Miss error represents a large displacement from the GT keypoint position.\n",
+ "\n",
+ "It's important that \"enough\" generative sampling is applied (so the model can learn how to correct errors), but applying too much can be bad too! You want the model to learn to correct errors that are realistic (w.r.t. the task at hand), not just receive random points and have to learn by itself where the keypoints go. **The default parameters should work well on most datasets.**\n",
+ "\n",
+ "The way these keypoints are \"sampled\" can be visuallized below. We'll create a `dataset` (which is used by DeepLabCut for training) and sample some data from this dataset. You can see that every time we sample an image, we get different keypoint conditions that will be given to the model. This ensures that the model is well trained to deal with a variety of mistakes that can be made by the bottom up model. On the left side of the plots, you have an image with the ground truth keypoints annotated. On the right side of the plots, you have the pose conditions that the CTD model will receive and will be tasked with fixing.\n",
+ "\n",
+ "> \\[1\\]: Moon, Gyeongsik, Ju Yong Chang, and Kyoung Mu Lee. \"Posefix: Model-agnostic general human pose refinement network.\" Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition. 2019"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "2a43ec8e",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 989
+ },
+ "executionInfo": {
+ "elapsed": 2099,
+ "status": "ok",
+ "timestamp": 1744358505139,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "2a43ec8e",
+ "outputId": "892a8a22-db67-4e64-a441-667e90faeecd",
+ "scrolled": false
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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17zmNXjuc5+Ln//5nweQHkru9/u4+OKLcf755+Od73wnvvSlL+HP/uzPsHXrVrzmNa85oPT+5Cc/QbFYxOzsLLZt24bHPvaxaDabuPTSSzE/P4/LL78cz372s/HJT34Sz33ucwEAH/7wh3HppZfi+c9/Pn7jN34DKysr+OEPf4hvfetbeMlLXgIA2LZtGx796EcnEF9YWMC//Mu/4JJLLsG+ffvwhje84YDS63Idi7r55psBAPPz8wBWWXb55Zfj+c9/Pt70pjfhW9/6Ft7+9rfj2muvxWc+8xkAq42Vpz3taVhYWMDv/M7vYHZ2Frfeeis+/elPp8J+1atehY9+9KN4+ctfjksvvRS33HIL3vve9+I///M/ceWVVwZHBJrNJr785S/jiU98Ih784AePzMN6+f/1r38dn/70p/Ha174WU1NTePe7343nPe95uP3225Ny+NGPfpTk8S1veQt6vR7e/OY3Y/PmzZlpeeITn4hLL710aPoOT+NhtVotXHjhhbjpppvw+te/HieddBI+8YlP4GUvexn27NmD3/iN30i5//jHP47FxUW86lWvQhRF+JM/+RP8/M//PH7yk58k5fm85z0PP/7xj/Hrv/7rOPHEE3Hvvffi//2//4fbb799aBjadRAUu3Jr7969MYD4Oc95ztC53bt3x9u3b0/+ms1mcu6lL31pDCD+nd/5nZSfz372szGA+A//8A9Tx5///OfHURTFN910UxzHcXzLLbfEAOKPfOQjQ/ECiN/85jcnv9/85jfHAOJXvOIVKXfPfe5z4/n5+eT397///RhA/NrXvjbl7iUveclQmKP0iU98IgYQf/WrXx1Kx4tf/OIh9xdccEF8wQUXDB1/6UtfGp9wwgnJ7+3btwfTImX61re+NXX8kY98ZHzOOeeMTPMFF1wQn3rqqcn1uvbaa+NLL700BhA/61nPiuM4jt/whjfEAOJ///d/T/wtLi7GJ510UnziiSfG/X4/juM4/rmf+7n49NNPz4zvkksuiY877rh4x44dqeMvetGL4pmZmdT94nK5VvWRj3wkBhB/6Utfirdv3x7fcccd8d/93d/F8/Pzcb1ej++8886EZa985StTfn/zN38zBhB/5StfieM4jj/zmc/EAOLvfOc7wfj+/d//PQYQf+xjH0sd/8IXvmAeZ/3gBz+IAcS/8Ru/kStvefkfx6uc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OF784hfjlFNOSd7wEccxbrnlFnz84x9HoVAYmt9n6VnPehae9KQn4fd+7/dw66234qyzzsIXv/hF/OM//iPe8IY3pObjvfKVr8Qf//Ef45WvfCUe9ahH4d/+7d9www03HHA+zj77bLz4xS/G+9//fuzduxePfexj8eUvfxk33XTTAYeZV694xSvw53/+57j44otxySWX4N5778Vf/uVf4vTTT08mTQOrQ8annXYa/v7v/x4Pe9jDsGHDBpxxxhk444wzDnkaAeB3fud38H/+z//B05/+dFx66aXYsGEDLr/8ctxyyy341Kc+lQzjP+1pT8OWLVvwuMc9Dps3b8a1116L9773vXjmM5+ZzOf54z/+Y3z1q1/F+eefj1/91V/Faaedhl27duF73/sevvSlL2HXrl33S55crnHTWWedhZe+9KX40Ic+hD179uCCCy7At7/9bVx++eV4znOegyc96UkAgMsvvxzvf//78dznPhdbt27F4uIiPvzhD2N6ejppQF5wwQV41atehbe//e34/ve/j6c97Wkol8u48cYb8YlPfAL/83/+Tzz/+c8PpuWxj30s3ve+9+G1r30tTj311NQbPr72ta/hn/7pn/CHf/iHANbH/7y67LLL8IUvfAFPeMIT8NrXvjZpkJ1++ukjXzt69tlno1gs4h3veAf27t2LarWKJz/5yeaoxK/92q/hgx/8IF72spfhu9/9Lk488UR88pOfxJVXXol3vetduRe9iG644QY85SlPwQtf+EKcdtppKJVK+MxnPoNt27bhRS960brCcuXUYVljPAa66aab4te85jXxQx/60LhWq8X1ej0+9dRT41e/+tXx97///ZTbl770pfHExIQZzuLiYvzGN74xfsADHhCXy+X45JNPjv/0T/80HgwGKXfNZjO+5JJL4pmZmXhqaip+4QtfGN97773BrV62b9+e8i9bJtxyyy3JsVarFV966aXx/Px8PDExET/rWc+K77jjjoO61YtOh+hv//Zv44c85CFxpVKJzz777PiKK64Y2uoljuP4P/7jP+JzzjknrlQqqXSFylTiHaULLrhg5PYscRzHN998c/z85z8/np2djWu1WnzeeefFn/vc51JuPvjBD8ZPfOIT4/n5+bharcZbt26Nf+u3fiveu3dvyt22bdvi173udfHxxx8fl8vleMuWLfFTnvKU+EMf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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAn8AAAFECAYAAABWG1gIAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9efhsWVXfj7/W2sM5VfX53KGbpgHFpmlmUPABAXEAFeEriIIgAmoYIwikxaDG6C8BHIIoKgFEAR+BRIgGEYwEwYA4Jg5IcAKUGRRpupu+9zNUnXP23mv9/ji3r1y6gWbs9KVez3Pv89Spc6r2qU/td62999rvJe7ubNmyZcuWLVu2bPmCQK/tBmzZsmXLli1btmz5/LEN/rZs2bJly5YtW76A2AZ/W7Zs2bJly5YtX0Bsg78tW7Zs2bJly5YvILbB35YtW7Zs2bJlyxcQ2+Bvy5YtW7Zs2bLlC4ht8Ldly5YtW7Zs2fIFxDb427Jly5YtW7Zs+QJiG/xt2bJly5YtW7Z8AbEN/rZcBRHhqU996rXdjE/IIx7xCHZ2dq7tZmzZsmXL//M89alPRUTOOHaTm9yERzziEdfo+nvc4x7c4x73+Ow3bMu1xjb4+zR5z3vewxOf+ERucYtbsFwuWS6X3OY2t+EJT3gCf/3Xf31tN+9zyj3ucQ9E5JP++0wDyPV6zVOf+lR+//d//7PS7o/mY+/hnHPO4Su+4iv4lV/5Fczss/5+W7Zs+dR58YtffEY/7fueW9ziFjzxiU/kkksuubab93F5y1vewnd913dx4xvfmK7rOOecc7jnPe/Ji170Ilpr13bzrpa3vvWtPPWpT+W9733vtd2ULZ8H4rXdgOsir371q/mO7/gOYox853d+J7e//e1RVd7+9rfzm7/5m/ziL/4i73nPe7jggguu7aZ+TvjRH/1RHvOYx5x+/Bd/8Rc8+9nP5kd+5Ee49a1vffr4l33Zl31G77Ner3na054G8DkZdX7xF38xT3/60wG49NJL+S//5b/w6Ec/mn/4h3/gp37qpz7r77dly5ZPjx/7sR/jwgsvZBgG/viP/5hf/MVf5DWveQ1/+7d/y3K5vLabdwa//Mu/zOMe9zjOP/98vvu7v5ub3/zm7O/v84Y3vIFHP/rR/PM//zM/8iM/cm03k7//+79H9V/mf9761rfytKc9jXvc4x7c5CY3OePc3/3d3/08t27L55pt8Pcp8q53vYuHPOQhXHDBBbzhDW/ghje84RnPP+MZz+B5z3veGZ3q6jg8PGS1Wn0um/o54xu/8RvPeNz3Pc9+9rP5xm/8xk8YpP2/ds9Hjx7lu77ru04/fuxjH8stb3lLnvvc5/LjP/7jpJSuxdZt2bLlSr7pm76JO93pTgA85jGP4dxzz+Xnfu7n+K3f+i0e+tCHXsut+xf+9E//lMc97nF85Vd+Ja95zWvY3d09/dyTnvQk3vSmN/G3f/u312IL/4Wu667xuTnnz2FLtlwbbJd9P0V++qd/msPDQ170ohddJfADiDFy8cUXc+Mb3/j0sSvz0971rndxn/vch93dXb7zO78TmAOiJz/5yaeXB255y1vyzGc+E3c/ff173/teRIQXv/jFV3m/j11evTK3453vfCePeMQjOHbsGEePHuWRj3wk6/X6jGvHceT7v//7Oe+889jd3eVbvuVb+Md//MfP8BM6sx1vfetbedjDHsbx48f56q/+auDj54884hGPOD3ifO9738t5550HwNOe9rSPu5T8T//0T9z//vdnZ2eH8847jx/4gR/4tJdVlssld73rXTk8POTSSy8F4N3vfjff/u3fzjnnnHP6+f/5P//nVa59znOew21ve1uWyyXHjx/nTne6Ey972cuu0tZHPepRnH/++XRdx21ve1t+5Vd+5dNq65YtX8h8/dd/PTCn3wDUWvnxH/9xLrroIrqu4yY3uQk/8iM/wjiOZ1z3pje9iXvf+95c73rXY7FYcOGFF/KoRz3qjHPMjGc961nc9ra3pe97zj//fB772MdyxRVXfNJ2XalVL33pS88I/K7kTne60xl5dtdE/2HW+Sc+8Ym86lWv4na3u91p/Xjta197lff44z/+Y77iK76Cvu+56KKLeP7zn3+1bf3onL8Xv/jFfPu3fzsAX/d1X3dab69Mubk6zf7whz/Mox/9aM4//3z6vuf2t789L3nJS84458rfrmc+85m84AUvOP33+Yqv+Ar+4i/+4oxzP/ShD/HIRz6SL/7iL6brOm54wxvyrd/6rdtl6M8R25m/T5FXv/rV3OxmN+Mud7nLp3RdrZV73/vefPVXfzXPfOYzWS6XuDvf8i3fwhvf+EYe/ehHc4c73IHXve51/OAP/iD/9E//xM///M9/2u188IMfzIUXXsjTn/503vzmN/PLv/zLXP/61+cZz3jG6XMe85jH8Ku/+qs87GEP4253uxu/93u/x33ve99P+z2vjm//9m/n5je/Of/pP/2nqwjaJ+K8887jF3/xF/ne7/1eHvCAB/Bt3/ZtwJlLya017n3ve3OXu9yFZz7zmbz+9a/nZ3/2Z7nooov43u/93k+rve9+97sJIXDs2DEuueQS7na3u7Fer7n44os599xzeclLXsK3fMu38Bu/8Rs84AEPAOCFL3whF198MQ960IP4vu/7PoZh4K//+q/5sz/7Mx72sIcBcMkll3DXu971tIifd955/M7v/A6PfvSj2dvb40lPetKn1d4tW74Qede73gXAueeeC8xa9pKXvIQHPehBPPnJT+bP/uzPePrTn87b3vY2XvnKVwJzsHKve92L8847jx/+4R/m2LFjvPe97+U3f/M3z3jtxz72sbz4xS/mkY98JBdffDHvec97eO5zn8v//b//lz/5kz/5uCsC6/WaN7zhDXzt134tX/IlX/JJ7+FT1f8//uM/5jd/8zd5/OMfz+7uLs9+9rN54AMfyPvf//7Tn8Pf/M3fnL7Hpz71qdRaecpTnsL555//CdvytV/7tVx88cVXSd/56DSej2az2XCPe9yDd77znTzxiU/kwgsv5OUvfzmPeMQjOHHiBN/3fd93xvkve9nL2N/f57GPfSwiwk//9E/zbd/2bbz73e8+/Xk+8IEP5O/+7u/4N//m33CTm9yED3/4w/yv//W/eP/733+VZegtnwV8yzXm5MmTDvj973//qzx3xRVX+KWXXnr633q9Pv3cwx/+cAf8h3/4h8+45lWvepUD/hM/8RNnHH/Qgx7kIuLvfOc73d39Pe95jwP+ohe96CrvC/hTnvKU04+f8pSnOOCPetSjzjjvAQ94gJ977rmnH7/lLW9xwB//+Mefcd7DHvawq7zmJ+PlL3+5A/7GN77xKu146EMfepXz7373u/vd7373qxx/+MMf7hdccMHpx5deeunHbcuVn+mP/diPnXH8y7/8y/2Od7zjJ23z3e9+d7/VrW51+u/1tre9zS+++GIH/H73u5+7uz/pSU9ywP/oj/7o9HX7+/t+4YUX+k1uchNvrbm7+7d+67f6bW9720/4fo9+9KP9hje8oV922WVnHH/IQx7iR48ePeP7smXLlpkXvehFDvjrX/96v/TSS/0DH/iA/9qv/Zqfe+65vlgs/B//8R9Pa9ljHvOYM679gR/4AQf8937v99zd/ZWvfKUD/hd/8Rcf9/3+6I/+yAF/6Utfesbx1772tVd7/KP5q7/6Kwf8+77v+67RvV1T/XefdT7nfMaxK9/vOc95zulj97///b3ve3/f+953+thb3/pWDyH4x/7cX3DBBf7whz/89OOr0/Er+VjNftaznuWA/+qv/urpY9M0+Vd+5Vf6zs6O7+3tufu//Hade+65/pGPfOT0ub/1W7/lgP/2b/+2u8+/n4D/zM/8zCf6yLZ8Ftku+34K7O3tAVytxcg97nEPzjvvvNP/fuEXfuEq53zsbNRrXvMaQghcfPHFZxx/8pOfjLvzO7/zO592Wx/3uMed8fhrvuZruPzyy0/fw2te8xqAq7z3Z3sG6mPb8dnm6u7z3e9+9zW69u1vf/vpv9etb31rnvOc53Df+9739FLsa17zGu585zufXq6G+W//Pd/zPbz3ve/lrW99KwDHjh3jH//xH6+yjHEl7s4rXvEK7ne/++HuXHbZZaf/3fve9+bkyZO8+c1v/nRuf8uWLwjuec97ct5553HjG9+YhzzkIezs7PDKV76SL/qiLzqtZf/23/7bM6558pOfDHA6TePYsWPAvHpTSrna93n5y1/O0aNH+cZv/MYz+ukd73hHdnZ2eOMb3/hx23iltl7dcu/V8anq/z3veU8uuuii04+/7Mu+jCNHjpzWu9Yar3vd67j//e9/xszjrW99a+5973tfozZdU17zmtdwgxvc4Ix8y5QSF198MQcHB/zBH/zBGed/x3d8B8ePHz/9+Gu+5msATrd9sViQc+b3f//3r9Hy+pbPnO2y76fAlZ364ODgKs89//nPZ39/n0suueSMTQRXEmPki7/4i8849r73vY8b3ehGVxGLK6fa3/e+933abf3YZYcrO94VV1zBkSNHeN/73oeqniEmALe85S0/7fe8Oi688MLP6ut9NH3fn84LvJLjx49fY/G4yU1uwgtf+MLTFhI3v/nNuf71r3/6+fe9731Xu7z/0X+f293udvy7f/fveP3rX8+d73xnbnazm3Gve92Lhz3sYXzVV30VMO8kPnHiBC94wQt4wQtecLVt+fCHP3yN2rxlyxciv/ALv8AtbnELYoycf/753PKWtzy9qe5KLbvZzW52xjU3uMENOHbs2Gkdvfvd784DH/hAnva0p/HzP//z3OMe9+D+978/D3vYw05vfnjHO97ByZMnz9CBj+YT9dMjR44AsL+/f43u6VPV/6tbSv5ovbv00kvZbDbc/OY3v8p5t7zlLU8HyZ8N3ve+93Hzm9/8Khsbr2nbP/r3CObNJ894xjN48pOfzPnnn89d73pXvvmbv5l/9a/+FTe4wQ0+a+3e8i9sg79PgaNHj3LDG97wandrXRkkfLzk1K7rPukO4I/Hx5pzXskn2tgQQrja4/4p5N19NlgsFlc5JiJX245PdaPGx7vHa8pqteKe97znZ/QaMAve3//93/PqV7+a1772tbziFa/gec97Hv/xP/5Hnva0p532Dfyu7/ouHv7wh1/ta3ymtjhbtpzN3PnOdz692/fj8fF08qOf/43f+A3+9E//lN/+7d/mda97HY961KP42Z/9Wf70T/+UnZ0dzIzrX//6vPSlL73a1/jYweZHc7Ob3YwYI3/zN3/zyW/o0+D/FU3/dLgmbX/Sk57E/e53P171qlfxute9jv/wH/4DT3/60/m93/s9vvzLv/zz1dQvGLbLvp8i973vfXnnO9/Jn//5n3/Gr3XBBRfwwQ9+8Cojxbe//e2nn4d/GSWdOHHijPM+k5nBCy64ADM7nTh9JX//93//ab/mNeX48eNXuRe46v18MjH/XHPBBRdc7efxsX8fmAPJ7/iO7+BFL3oR73//+7nvfe/LT/7kTzIMw+nd1K017nnPe17tv48307Bly5ZPzJVa9o53vOOM45dccgknTpy4it/qXe96V37yJ3+SN73pTbz0pS/l7/7u7/i1X/s1AC666CIuv/xyvuqrvupq++ntb3/7j9uO5XLJ13/91/OHf/iHfOADH7hG7b4m+n9NOe+881gsFlf5HOCa6fqnorcXXHAB73jHO65iiP/ptv1KLrroIp785Cfzu7/7u/zt3/4t0zTxsz/7s5/Wa235xGyDv0+RH/qhH2K5XPKoRz3qah3mP5VR2H3ucx9aazz3uc894/jP//zPIyJ80zd9EzAvJ1zvetfjD//wD88473nPe96ncQczV772s5/97DOOP+tZz/q0X/OactFFF/H2t7/9tJ0KwF/91V/xJ3/yJ2ecd6V569UFip8P7nOf+/Dnf/7n/J//839OHzs8POQFL3gBN7nJTbjNbW4DwOWXX37GdTlnbnOb2+DulFIIIfDABz6QV7ziFVc7a/zRn8OWLVs+Ne5zn/sAV9Wun/u5nwM47WBwxRVXXEWf73CHOwCctoR58IMfTGuNH//xH7/K+9RaP6kWPeUpT8Hd+e7v/u6rTQ/6y7/8y9N2KNdU/68pIQTufe9786pXvYr3v//9p4+/7W1v43Wve90nvf5KD9Zrorf3uc99+NCHPsSv//qvnz5Wa+U5z3kOOzs73P3ud/+U2r5erxmG4YxjF110Ebu7u1ex69ny2WG77PspcvOb35yXvexlPPShD+WWt7zl6Qof7s573vMeXvayl6GqV8nvuzrud7/78XVf93X86I/+KO9973u5/e1vz+/+7u/yW7/1WzzpSU86Ix/vMY95DD/1Uz/FYx7zGO50pzvxh3/4h/zDP/zDp30fd7jDHXjoQx/K8573PE6ePMnd7nY33vCGN/DOd77z037Na8qjHvUofu7nfo573/vePPrRj+bDH/4wv/RLv8Rtb3vb00nTMC8Z3+Y2t+HXf/3XucUtbsE555zD7W53O253u9t9ztsI8MM//MP8t//23/imb/omLr74Ys455xxe8pKX8J73vIdXvOIVp5fx73Wve3GDG9yAr/qqr+L888/nbW97G8997nO5733vezqf56d+6qd44xvfyF3uchf+9b/+19zmNrfhIx/5CG9+85t5/etfz0c+8pHPyz1t2XK2cfvb356HP/zhvOAFL+DEiRPc/e5358///M95yUtewv3vf3++7uu+DoCXvOQlPO95z+MBD3gAF110Efv7+7zwhS/kyJEjpwPIu9/97jz2sY/l6U9/Om95y1u4173uRUqJd7zjHbz85S/nP//n/8yDHvSgj9uWu93tbvzCL/wCj3/847nVrW51RoWP3//93+d//I//wU/8xE8An5r+X1Oe9rSn8drXvpav+Zqv4fGPf/zpgOy2t73tJy07eoc73IEQAs94xjM4efIkXdfx9V//9Ve7KvE93/M9PP/5z+cRj3gEf/mXf8lNbnITfuM3foM/+ZM/4VnPetY13vRyJf/wD//AN3zDN/DgBz+Y29zmNsQYeeUrX8kll1zCQx7ykE/ptbZcQ66VPcZnAe985zv9e7/3e/1mN7uZ933vi8XCb3WrW/njHvc4f8tb3nLGuQ9/+MN9tVpd7evs7+/793//9/uNbnQjTyn5zW9+c/+Zn/kZN7Mzzluv1/7oRz/ajx496ru7u/7gBz/YP/zhD39cq5dLL730jOuvtEx4z3vec/rYZrPxiy++2M8991xfrVZ+v/vdzz/wgQ98Vq1ePrYdV/Krv/qrftOb3tRzzn6HO9zBX/e6113F6sXd/X//7//td7zjHT3nfEa7Pt5neuX7fjLufve7f1J7Fnf3d73rXf6gBz3Ijx075n3f+53vfGd/9atffcY5z3/+8/1rv/Zr/dxzz/Wu6/yiiy7yH/zBH/STJ0+ecd4ll1ziT3jCE/zGN76xp5T8Bje4gX/DN3yDv+AFL/ik7diy5QuRK3XrE9mzuLuXUvxpT3uaX3jhhZ5S8hvf+Mb+7//9v/dhGE6f8+Y3v9kf+tCH+pd8yZd413V+/etf37/5m7/Z3/SmN13l9V7wghf4He94R18sFr67u+tf+qVf6j/0Qz/kH/zgB69Ru//yL//SH/awh53W9ePHj/s3fMM3+Ete8pLTFlHu11z/AX/CE55wlff5WLsWd/c/+IM/OK2ZN73pTf2XfumXrlYXr+7aF77whX7Tm970tDXMlZp+dfZcl1xyiT/ykY/0613vep5z9i/90i+9ih3ZlVYvV2fh8tF6ftlll/kTnvAEv9WtbuWr1cqPHj3qd7nLXfy///f/fpXrtnx2EPfrQLboli1btmzZsmXLls8K25y/LVu2bNmyZcuWLyC2wd+WLVu2bNmyZcsXENvgb8uWLVu2bNmy5QuIbfC3ZcuWLVu2bNnyBcQ2+NuyZcuWLVu2bPkCYhv8bdmyZcuWLVu2fAGxDf62bNmyZcuWLVu+gLjGFT7+fz/4uM9lOz7rHL/hjeny9ZkkotnIsWGtMo2FRTF2JoNjysnNSPJKd9gYj3X4kUSowIFQh0RTR+SAk2HJuVPHOX3DdhsnxkQLI4lGKBFrAfWB0kbMIzksuKLs0NmEeYMusbtSYjD2quOLieHEDuVgYnmk0h0LTAY+LVlqJbaAryNd3RDaES5bHaJjpSzXRFfCFElaIRT2XTlZIgt1bnAk8IHLPsjRL7mAo+/PDGtn6A7RPuCpxxzERrQbaecoF/5z4p9XxhDAGpRaqOsALFiKcrQMiBfqciT0h6xLx6Z1HPWeI76g1UZNSu2NIa7ZFGfHJ+gWDCcXxJNrwvGGdUfQtiRdUfHxkLaa6GSHvRuMDNXZCR07+5WybpSQaR4YN4XmG/pFRdYLzjta+NDewNHu+hyesw/V6UJEoyFe6GqjR2ldpIXE3hSoU8PKDv98UNg5PORY3ODdxEe08pFWaZcXpg9u+EDeo7N9aBP+wQ7LE0EmSjP2Q8Q0stifEBqWIxYSdMLO2NgLwmaqUAc0GtEzy5YJez0cO0HtJiqCm+JjpMSGMRH2jGkDYTzK8tjIh1nz7ne8k/VmH0tL3AXqAD5ADOARRoW2AQJHYmRNoeZT9TUrSBESiW4ZuOmtb8Q33+Oe12Iv/NT5iZ/5pWu7CZ9Ttjq61dGtjm519HPNNdHRs7a8W5MR5IAdE2SjNE+ICCuE1uAjNnL8Cuiudy6xBJB9ujYwXREYfcmIMqUKNrGUjn4VOUwZnRYM8YDF/h5D66kcpRPF+or1itXEeFI5KB3rY5U8rBmLEsYNe9XJXeCo9LTxXHJx1gHKumdsh2h3gl3ZoGOPFQEr7DGx6XvGvR0W3UTez4xhjQYlkcnV2W2Qk5JXTh07zjt+I+RScDtJ20RqTChCaiPRhWAdYX8HOzjkA3kgbHpaNeLxHhkqLUVCF4jeUYZd3NeMZY82LFkdF5YlEScj++Ws0xFGFMZKT4S+UH1FWy+IyeiuH7AWCMEJZiyORtIU2QwHnFxu2N3fkMsxogdqFtoRp+lE1Ug42uiKQXX2u0OG6pRbr/CPDPQnE74wxmOK94HQAm0whjFSyg7NCl4LYbcwjmuOk5AjPRudUK/s1EKqcHA95VKE8s8VmQZ2PwSjVqpUaI5MkUjEg9NHo3YBFae3kdp14CNj2WHpK5a6YhM3WLfm8IqRsDrJWBQVYxUcGQOtOgnB+8jmWMPMGUNBFgM7HzbO7Za0sTGuJ9wLgqGuhElpCM0DxorAIQc1YQjZAuA0NwynhoKKQ7nmhdq3bLk6tjq61dGtjp6dOnrWBn9p6skesM4oycAm0mRoq1R1JPekfpeNBTblAFXwKYMEyIYEQ62BRMiZfDhyEFYcrC4nboR1t0M46HELjIuCSiUcGGGErI6tKuaVEgL9sXOo04APh0itlGlD9YwtG7k2gkcmVdjs4LHhwbGl4FrBChKd5eIQlxGdlhzRhEQhGoSx0DERXNDaM26E2AtXuLKTd6nHnNwHpK+4FcpkDO6QIRGIwwpdBo7mil1hiCj9qlClIMMhbgHzilPQRaZECFYoNXFZPoeGQWggMJbCUAqpBKJOpFyxWhkx+mqsJ2czTaSwRz0GtR5jPQ3k1vBe8dQIhyNUJ64ES44l8D6gHtndrNg/sU+b9pg80beIDQuYEtIq0gpg9FOlVJiscFIXLMd9zokBU4EUMDtKKSCbCasbdmLjRmQm6WirSh0rGx+JOROJ6DjiVNoykbxHRsNbwyYYspCkkXwkWGWpDQ3C5mhgPSa0renUaTVDBRVlyB1jVmIZWaRKPHqC44eJabHLIq+JMjBeUfApAIqJI2YEjEZBAIvgdSJjEJSmYCb4qX9tApu2xXu2fGZsdXSro1sdPTt19KwN/ohGOBqJYlCgmlJCxVIjhEyuQqEjFCMwkgAJgQlHvBIqLExoItRoTApdHkkjEBXRxiofglc2Wqk1EjyhMWCpEaNxZC/jIoSpYbFgGSoJgqBthB5kAJ2cRMA8Mq0Dk0VkAeFIwrseNpmse9Q2Yk0QMyavrEskTBnRiEVDzSA2/GRAl3u0mOm6impPq0JtICgpRUIISDRaKEhy2jpgAjBSpkCzSC5G9kYLioUFYgE/NGrnTGUkdImhCiLQddBb4sgQaSIMwagK7gFfC61zOi8kHJOeEiKLAFqc6M7oRthUwsaRILhBMQXr6KvSh540VSaUlFcEyWhwmjtTrbTq0CILd2QcUZ8QH4mH5xJ1xPuIVyjVMASiE7OQGFmlSl0mTqyvx+SXMmaBHMF6zAOqlVwjuSglCjU2hJFNW1DaAgnC6CPOSPbCuB9oiw4fDK1C1ztBhBoi5gpNkSLQlNYSWhbEJgwpcU5/nE06QZMDJirioDjgp/4HJyPSgEDDwZS5UqYjBCAgKni6tjrflrOGrY5udXSro2clZ23wZ6lSOie2RBgVq07VhsVAzD2iAVsXIkJRZyNGVxxQUEUFogpVwShob7S8IY1KSUr1DVMUaA0rDTVBQkRECebUCpjQ+kKl4OIogrhAiJg6bo2K0lQRF6IqURWvBl5BneKZ0jbsjkDuKNkwayATTQ1PC1QT+ISPE+aRZhO9VUISQqwQFPeEtIiqIFGRCKElRnGiGC2A+4h7xasgHkDmMZIIxBDAlWZKrQ3LTqYibYljiBqxZfoGUyxMONbAMCYECOxEJWtAQiao0GojlkbpCqKgG0dSQLNSglBdEFeCKN6U1iCXRMmGKBAUFUUbNBOqRUpVGBotRTZpYJGMtow0C7gbI07BSA6qcupvLaQuo1PGuz2krWkOMhlSFFCSQJCJAWGUiKXKYAldK9aN1BowXWJxxEejScLEiNIhYaB4YxKFYBgVKYrXSHUjNefSvlDakj4mdvoVe8sDihekNKIBASaFWJWmkeAjiUhNQgOgIeK4GYKQXOnPzgHrls8jWx3d6uhWR6+t3ve55awN/gDKVLGmiOusRSKIBaRlmiciGzCnZqGYozguOieIAqaCBCUKc0d3hyhEnGLO5Aks4XUiYWiYvzhihptCbhSboAoppFNLCo42oYR5hKaWMQ1ENVKC0AV805AIJpnRIJYRWoBuiYWB1irBIQXDtOGAmqEVigZ0WQmTknJH9PlLHRVQx8xprYII6goWCO4gI80qaCARQAMuicECSkNdMAHRNmc0p4TgqALSUK/4fCZNHFEnGpgDvSCqSBSaKogg2mhaiQWsd1QbqINGPAWaBAwQMYpU3NZob6RNZGxzx3fpEAtEM9QcOfUD4NGQlCFlunjIWgUf5jGfqeE+d3NRR0MmhBXaC3Es6CKiJwVFwQuNCmLUACaVhkJMoIFYnNQKpRSchKfMFJwsa1KpFBU0KMUjm+KU4qQsiBbUBVCSNkIwPiKBqEJPIS8W5H6FlBHzhpmgIrie6rAuJIOI0GLEqeAOZqg46k42pavXQqfbctax1dGtjm519OzjLA7+FKaJWg2hR1XnidwmqDjVoEWnDROuc8etMSJEcDAznHl6P6BIy4gkPE5kq4xFwTNiirohMuLz5DE4BBOIgh6CtEAWJdg8DU8VWoJcfe6nvRCDIbnhIYIpqhk0E+pE14QpzssZlPnLqx6JrhQM13mk7THiMSB9pF3SiBIJgBRDDNwbUts8WkrgeRYzcVAviCktZZJHEGUSp+IEA9wxlTmnxxp4j2F4GhEpSDA8ToxkWlA0OT7Nn94yzx1MAhSFQgMmPBstRNQ6XBoEx11prrgpooqLMVoh2AZdOrI+SjCQME/fazNibXhzUEOz4FFRDSw9kuqGsM7I5LQEitDhJGlIUBodOUe6hbIYPsQQlbEt6AN4KDQpOEaRhFoANWKqqIPLhMRGpEKG1sNEQ6USN0pngrZCHSOlhDmXJHDqx61hYnTBMVVkXBAWBfWR2GW6bkHadAzWmAy0CWZgoaGTowYFm0fipwybRARVJ2hDBbBrodttOcvY6uhWR7c6ejZy9gZ/Ye7cjhGoRIsEYZ7SlQkJzhQmyjTQtY6mkRojwSNi7ZRoCcGdVAMeOixFMiNuI1oT4k70OUm3iUKbR4FiggDaKbFF1BNaAoicOtdpnnCB4I4y4WoUr6dENdFih0lAWiGkwJDmpREtjdA7QRKguEBRoWkAUUKqkATPmUkGYgrI2jGD4goYYgatMjkEnWjag2USDQmBUBM0J3qZR0EYEgWLC6p1FG/ECTwrHue2I6A0puyIRjQwL+d4YKGO64SIAXPnc3dC51ifiPQ4ExqMAkw+52gklIpSp0BAacK8g27e8I9IQw1o8z2JGFEVyQtaayyaEYqQWyDqvKQR2rxbrrf5m9FiT9cpFGjpBEMamZZLQqk0gxaUJhNUB5vtELQZYYp4A1NHVAmdUeMGq4anSFsrURtSjBY6clggariUOW/I55mRRKKYs6yKs0dQISVltegZhiW1NcZaqW2eCZkTxZ1JFHenFZtFSwOiBmogzgSM117v23K2sNXRrY5udfSs5KwN/qopISyIWYlVSHUefVoSPDaaKxKd4EJyKJKo6Nzx1UAd0YKo0krEorCJlSOtsPaKtUzwEcFxmUdzlI646QgyMmqlFSHmgIae6gI253QQHfGOKQmZDUE3TKaMDRY6IFGpuVLagGwm6JVUGyOVHCZyEFRAg6AaqC5MDYI0FtOAq+OLHTblUmpa0DfBBaYUsOTENoFXhtaxQiAHgi0I7IMbIRgNQ1tj4XM6rEnFUuRwvcKnSB8rvUfMExMBa4ZbZbNo5BbJZV7ecBesOdob6oYXJVtEzRnqRI2Z4MYiwETggMLkjb4J2Z3gDWmGh55ad5GloQSkOTE0qp0S/1gRNaxlkndM0wGtq3R5SR4CiQrWaAJmkWYR0UjIaU7qHRb06ShxecgiNNqlAVnPSdgERW3CBbxE2AjNE1WEQKMGx9WRBqHCJAmPiU4mYs7kNC9FiAvVK5tmFBNAKOJM5uR0SLEKaUFKlZ3djtpW1Gmires88yKGWQAalpSugFidZ0Gi4CIYgrvjCsPZ6VCw5fPIVke3OrrV0Wu3D36uOHuDv3VmxYIUFWg0bQSEIIE2NQ5b4PiRo6x2N5SyYbmXTtkDgJc58I8YQaFox6iNYCPBJqTOnkOd+jzy1Qbi9NazkgUWR0p2pqHR01PyDl5HxDYolTgZnSlT1yg5zCOvagSdk6a1M6Di64F0uE+SHZI5Yxa6ZEhL1GCEKOCR2oxiI0t34jixigsup+LrnsEDnQldBEmFKRSoFWrEJYIdpSOQrFK7hMiIhAJJwROhKKHMwkFZUxCONEjnBJa10WzeiWYCm3xqlmCclw9SEMrkDNpYHG2Yj4zWcF+w9DlJVySSpgNChdETh6Y4gQioDUjbkFwJ/RFKPEKUy2avptCTXSgSaZ2Rk5HNmDaZsWVibQzLDksJHR1VI6ijSZkkMNRIIpI6wdLIwVSZiqBVCF5JsWOKI9VGShOSBCZzSoOlKNIZNRbCYBQUmxqJhhehTRtYNVoRwjISxambNVbnDtdLgB7oGt3hes4D6hyVc3ErJKss6Ch9z7AbmCZnLIqr4VFJtTBmwVpDrCFRQAJuijVQN8hg4VrtglvOArY6utXRrY5eq13wc8ZZG/ylEPE2f5nMDBEIfSN2hSFCLQP1g5lyvcj6I+cRMnjYw2zCfM5SqZ5Y1sBucJpNkBrrobFWR3aV1i8xEcZS0AlSmNCjxmhOOCjsEhlqx3ozkOuaRRC0W2BLw9eV5ImiiWoDSSZEFR0zuUZaHykhYS5MU8+izxzNG0KrjBIYaiA2JSokme0VUnM8B06sEvv7e0hckTWCK5ta8UNIqYesTEunc6Nb95D3mc6bEF3CkGijkDyRSTQTileojijEIwusHwmyZNp5P5PvEEtPmoSpJXoiuqrE4kgTam8wBNaHHUEOGdsabGLlO5y7iaz7gWlQLq2KdT3nxgBaaDoSrZIK0DLH6pKT6zVDP9HGNe3cnh0VjvtEJaJ1SZ4qSSbWq4mdskueYEyR4chIkB4riouSlHnmYBTq5DQp8+7A5YJz1ruc2CQ8HlAXBWkTeTTyBK0qm6wcqBDaiOeKxx3MnEWBvjpBHNfE/mRkaXireMuYwUgjtDjbRHRC7TrGIXK0HdKys1edwTIBg9qIJLrFgsVygv2RyQwt47zqVeZlqiCOLgQPGakRH0eaNSTN1mpbtnwmbHV0q6NbHb22e+HnhrP0tmBZN0i3g4kTJiVYIFAQNZQO1yvY4xysGCeGjp3+kJ0SmYaMuyKhUaWy54Z0hVVxJuu5pK6JbYUwEG0C7VDvsRC4vMJiGNlRZwxHaDtKyo1uLMSTI9MyY0c78hgQ+TA1b3CBZE7QiHgiWAdRSG6sJFOOXp9uSqxXmRALaxuZDjrs0DBmj6uuM1bR0a4jHa7YvwKOb4T1sKSXhh4qkwHLSkxC9DSPrGRi00Y2hxPHcgdZEVaEaNjo+GC419kkVAJ2OLJT9+h65WDvkG65ixwVShBcAp0MjFNj3wtHGhAz6wTLdWF3aAy+JNcFIpU92WM1HqOwYLMMhLpG1wMalbBUbOrZAOW40ylcetnlrGo/55D0AQtCyZFQQEalTXAoc3L4smxIB4k9OYesA0FHToxOVeNIrQQzDho0dxYYiUBflNoyV3zxeSw/8F7UO3zPsANnCBPrBQSF5TQxHqwhQN45Tug76mbDlHeRnY6qh8ThMo7ViEok0Jjc2cQFLUxU32eKFeszQSsDK4wvIq7fw66WuV1FTglbz248Qls3xlSRpmQzNgJhhHmMXGHsQAyt61MeZYKaEsZt6e4tnxlbHd3q6FZHz04dPWuDv9YrXTgEacSVk6PiKbJP5mAsLEpHv1qyqRPL85WVKqMZU5kIBRbBWa6MTUxcXjpWrZJcONbtsDcEjmsjVKOEQuwcpDGMjaEJvUYWoVBXiaM1cbJfcuAgtkDXHXucoPaJnXZAX2DNiin3HOkz1leKF7IFskcITg2VsQx4GzindUisnFwVphopojiV6oWwVuo0EMK5aLekJmiDIscHFjsGEdooTIcNcWdxxDiaDrk8HyUeZrroFD9BFWPKHY1MKBmpHU0LUxfYxCXZDzjSNWABdYM2o/kuQziKbpzdssH6DcLEsWmJ9jvk1QmmNEE7itZdpjbQFvscntfYGQt+WWPoVgzLjqVP9HWABG25A5phZeSDyHQIG9bUUfAJfN2QVtBOkCVYFITAZhmY2gmmtk+ONyCtC9EHilTGYNBBJ8ZCIqsuE9hjbY5cGjhM5zOmS+hkQ7+YmLqGrAuFTOyUhLNuE8UO8E7JB04YN8jhmoUX2F0xycQJP0DzkjJG6sZY9ok+93gzyjpRRVHdx6eTNOm5YlC0GDGOSF6QYo+WhHeBqeuZNifY5JMw1lOeVHPeSmxABy0INimUeYdfzWepQdWWzxtbHd3q6FZHz04dPWuDv4CRg1LiksEjpTUWrbGSivqI4aSyz4LApjuJDRDHQO+Ka8OoTFNF6kQYC9Mi0DY9ujSONKPLC0pwUnXCARR3mvdMkjhZG8tYkXXl0CrszMnH4WCf/uAjKIlN16h1l3FnosZK3kzsTYa1xNIjWUZaN1D6jskay9ohU2XdEkd2M8fTwNrXOAZjpNYlo0DYWVGnzO4NRvp/OuCYdOyHHQ7VMNsABc1Cp4bamhO2gwy7rOpJ6uYc9toGaR06QtSJuOoQA4aJRThg0U/zDrNzOsrJgWnvUjTu0odMtzkkDUAe2aeyrhFHIQwMgyB2hNhHTA+pU2E8CjealJgTaw0sQ6N5mc1g08jKoe73tNSjyw2XnSicPO+ARenYHTJpJzF0G5gqnTuLEilj5MAVWw307ShHNyMtf4iTuWdE0RwJEpjGMC8zLQbiYoPnnniipzARamFxZAnpGMOHF3B5o/iEZGMSqDsL2gjsNbpVQ45MWBuYSsBrB4cnqTuQxxV2GAgLp89OPygyrBiBpg3ViVUzYlowDE5YHZDKArFEKhN+OLKhwlJI50J8b4U2G72G0KBdD7gcqw1vwlztUkihEnODfJYmq2z5vLHV0a2ObnX07NTRszb4S8OStuwQ9XmXFh2jG1YqRkJ3QfeUuluJm8bkkdYr0QrRDSRBWyEWWGbHhgGZHO+FUQp4RKtg4tA1aIKUhmoiBmURnJYiDCAbYzUqtQSG2mixIrni2pHGSCiGVCFSka4QfUWZdinDSEuV5SKRp8owOYcyETZ1NspUZxGUEAPrHFExdtzYqHH+B8/lRHPW3RXURSECMgo+RcyEqsZgS2rZYbk7cCJXkh8SomOpgIA3pSKINGJydBNpBYaSGD2Sck8/rNHakLDH2EFJiXgYyUPEckeTHTQ4VoEp0MrshbViQ2eZ6krcnKSEjhgSy1Pb7JsoZYowJPoSqKFjLynXi1cQpGOjyjg1fBQ6FyQ4BWUMPdJHiiuNwpLI2gboG1KW6GT0NHYoDP3IEDesx548rtjZX2PmXLJaEMeeroxkM3KoNJnQpqgFOptHyaWDEDvMG9I6sErxE2hwNAY8KMs6ziNUVzw7yQJKwieYNsZOHzjRBnwZaQdCtwNQGZvhQUEjGVguRxa7sN6v8047hdRO0MKcoDwbrpZ5Vx0BrUt06K69DrjlrGCro1sd3ero2amjZ23wN2Whph6rjU6c0M8eTVKd2IwyNjwYJVfCBPM+9wkJE+IBJ2AiBCoLlMEauryMdYto5xSdCDUiKoSgJANipcWBGCONQGtQq9I0kEQAx8XoUmMhidYZbaiMCCyc2CCHRNKIaaI0odiAWGFzqEy54ZrBFZOOGvJcXL0GQg0EJnaSIN0h67XRYqDkAt6gKnLKP0sFUEG0w1yoreK9c7ipJHUSTgOaCkolOXQqaMqMY0Jaxq3OSdJtibnMtRtjQXqnRdBidDqLayQwxExhQspsCdH3htTGgQWiK973NA+4NtQUpgxVCKESslFD4PrRie0ImyMdsRg+JTQEcgxIqhT0dCmjvmZEJloKtLZCYiFFI1fI1RCHrDtUzxRbk3KjHkkkM44slIPLFiATKY90YaLuFUgdhEprGwJC1kBqlWLzTINawpl9uZpEmsHYNawJdYTWFPE4f265UrXgMv/mrbMQ6pLoDiGgOn+3ajAml7mc1m6Pbk5iqnhzbGfEW8BrgOZAm+0yMFoNeG3XYg/ccjaw1dGtjm519OzU0bM3+OsEAvjguBWIhgaBTtFT3kaymDCtpO6UJUFzkgkOFJmXLKIZUgKCUBP45GSPSLchCOAd0mZxQwtKRVpiJOJmUJfYQiBXvBk+OmGR0S4TfWQMc+1HDY5qQDxjMtfU9DTXXvS1U9YT09JYZuiLYjYn8jafEHOCOeGUlz5hwxU6oroCX+BDQaXOxaqTzPUfFdTmjnFoI8eqsI5h9pYyp9KQ0IixkpuizRARgmVSMpIpjBNJehqJsTiCYZ1hK0cnR8SJMjupBzGMub5icBg1wDQXcp9yT8wZb4liI8HqXHczgKVGjQHNHUdi4FCWEJk/ZweRjEiYS/MABEHqMNsqqDAZ1M2SmNbEBWTm8lTFhWbdqX38w2mPMeszfXba/pLWDeSF0G2c0YXiRqVRVEma6c1RrwSrs3dVDEBHscTUKjKm2dO0G/Gq2KRMyPy9kEbMYNUJRUmp4drPdUVDRHyegYheiKaksKTbPUa89CO0oHRToYrNZrg4crpouSHmQMH9LLWm3/J5Y6ujWx3d6ujZqaNnbfCnCqk1vBixNaJUvBNKzNQQyQlcBW0ToetoE+Q2lwQqVIpMiDOPNpqhXabaUXqdyJMQgRBnk8lCYKLiblidyxRVLSBGDE5UCF2llQoWsZypWYiTItqRKug0QTpV1qY1XOb+JMHIDaI0fFCWyemKYw5NlUkFl0pMcx3IdYbJnWaVFhqh7KDMZRQlKSZCc5sd3asi6lQZUU/QgW8Cbk7ACOIEBxWn0QDHu0aMA33tGQWImWaRYgUtyliNpIDMybKawUMjtoo3IUUHcw5aJJeCVYEUSGYUjzQz8AHy7LjeZC6Anqi0nDGUcNiYIkhqBDG8CtQwF1kPjte5vmaWzDgarSyRQdBcgXmJqSmUNtFaI5HhMBLqRE2K6IKdhaOLjloS62UiHm2M04i74nFBI8wVDKRhCq6OxUIzo256KCNalmgpqM/1MkmzuNapoVVJKSNaMHF6a2xipU6zSauEQBYIVelrxDXAcsW62+WysiETsQmogstcPQDm74WKny62vmXLZ8JWR7c6utXRs1NHz9rgL9HIRRkszP5FZlDmOpOIz1PjcUGuVyBhF50ty5nEGNXn4ttNGUXRPBH6Skcmp0hIG9wDQRVTKKa0FhnbXOqmTwHRAjiia0KN6DQSMJIGtBZqg8EhNqGvzLUY1TFGQnPUIiVFWhC8GXGV6KbAYkzUokw60pqCGEJDqFhMbCJIS+SkbHIljSMZg9ThIeCl4WNhMog4qoU+KlMcCNrTusZYhWCBRERtXkLwZCCKhREbnEk3pNDTpsAQAjVBahNefM6dKKDBiHHAs1DqXPzcdS7jFGokNmi+pLcD2tSQUFFRCB0emO+tOqUUbNPwZSTXyLifZxuDZSVoQcNccB51RIwaE+aB3ShspOFppOlABZSEyjy2xwaCN6JH2jrS906JBSuJftkh0w6H0xHCOBB3QfYL0hSVQCuN5pAtUkSQGFGtlHFNHAsyCpbqnL9UBVkqdAGsYePscJ8JtG4k94WSnRQqtsl4SVi0UyNSSO7zsk1Q9LxjXPGBQ0ZRmiTwioeKhbnuKIAHwcPZK1pbPn9sdXSro1sdPTt19KwN/mqD3iB7w6LQUp6Tiq3SWYVcqW6ERUKHgFikMNHMUZm/mHMZ7UANmcbAgg2DKrE6dcjkNOepLHBqENwVM6eLBdPKNArFDwnlHMx0LtMThDoMVBXEK1I3RFtgXaJ0jTZWWq24CEima87ksIwBJDOaILHRMIJGoijNjI0PiAnjsKCmwK52KHuUUMmiKBHzgInjAqM55k7nEfJRmC4nq9FaB8XmUkA6l7ppwWjZiR4Qm6fbW4VcAxYF707ljExrUvkXt/qGQjVCa7h1c96OQTZj0ZS6hL4JilMS0I2YOa06LjKP9KwhpVJDgGkiecA0EKVjqhOIY0EgNlwnqhfGXol9wFpAFoKPc55JckUJ+Kn6ljFOaDMolUkbcbmgR1gPDcuKrXbwTUHXha4tiC2x1jXOiNSJjSdSyIhELGZireRNnWufqnIYDzGcRsRxImU2L42KR+a2mpO9Z7MQdqcGg80zF1qZfMKD0EclqBDNSOcsCZcs2GwOwBUh4C5oaqANaW1OREfmSvNbtnwGbHV0q6NbHT07dfSsDf58atSwwVSpSUHrPG0fFJMAtRFsDw8BmRoiCVcnuYMLbgFCJCSBCOsAYTOwyR07EzAKU0mETtAOLChRIjoaPjbG3GhrYSdncgkUVao0fAJhiXdCPtxHysSgPW6RFgHtMHfqaLTRWLnSohN0xD2yXhq9OjrJ3E4Bk3nnuqqhY6F0wnjS0WOnEkPkVB1DsTn/JUCoDZqRx8x+MeKekFdr3PKp0fhcm1IE3AJWfRZ0X9DJBomJWkfUKx0Nj05OThyUaQqMCIMmrEI3rRGXuW6jOKkKucHl12ukYaJYJmlEGZjCqfyLZijzZsHQC6XLsJ842JFT4uNEMyYFpKIU1BrqSsQpQbnMIsdjT/HLyYtEtkioieZKE8MwhEDxiO9MVFUWsqTlzZwzg7KjK4ZwDIv7SD+BbOZdeW7ENlI7JYSIa6MFJcUV2SqehZ08zktKEhmCUXxeUtLOaVRKq1Tv2FhHS4ExXU73kUCfK0ErU624KjUlmgYOm2EoR845yuafTsI0gMz1hzwEJBpucqqslhFavTa74JazgK2ObnV0q6Nnp46etcFflhGPiU3q0GQsrJAmwenxbOwPhujI4ooejwUJkS6OQME801A8VjQXUj617LFRuqbEUMjZGKow+lxoWi1BcEqEaTQmafQqxNBThoHJ5tFFcKMtM9oJjEtEMy0oxSo6COSIp54wNGTTcIQuGkOeYK1EbTgdVQJV5yLZGoUkC8QjzZTVpGAbSt/Y0UItEQ/QkuBBUc/0TWDTSGkgDAfACC2Rw4RrQFHEA6UptRo2FMwr/W4DC5TQgIq4EOpATBOSndb3GDIXAKeyLjA5RFeWsRAxUoyoOl2ZaCxRliwOhRoNW0wEhSgRTLBgkBqZFaUPtF2FYSQMBVNHohC9kUudR3CSSA57EpnU5jyiZaNbTNShUYpDi4g6URwJiRYWaJyQcQ9Nx1lqZGMbmhdWItRFT73eAYvDRjsJpczLHQtpFHNcCskHppzZ38nsHuyhHkghEHyuz1mL4mJIEJgHl1ADHlbUTunaQIlCjjaLlihYorTG1OostL4gMHHsRj2XH6yohxvcB5CIN5nzfIqDAbEhUq7FHrjlbGCro1sd3ero2amjZ23w12isuoBEQWksHVQTo8wjIz02MKwX5LKktX1SToTY8GY0EYoE3CuMhpNIY0T7RiuBTVvSayAu5t1hOkVC16jqDKtIzEIeFTuWuWIa8VQJHGGpPdodcqh78+6qHSVqoPMJnRoy9tRDo0QlL5TV0qlU1iUzDMouE3lTGTuoMaJuRBVCTIgl2kEjbI4iEvHrV3RlSGuMxRDTOZ0jKN4STBEtaza5IkXIu5kWjjFKJFnFSqS2TMHxsEFkTlzWVBjHjmkqhNBjKZPUkFIw21BDpC0ju0nYOax0Q6PmQFNlFYRkMCSYzFhtKmMU4qiUsWJtRQuBtthAEEIJeFVcetRXDHnDsQIHfgWwQtIO0RwtE80irqec6bOx0wZiFDai+O4usAGNVAKTKERjlYSVTbRixLWy65HiBRRijRx2MJ5byavM7sGKsW6oU6MNa2IstKBYVeo00gUjxDnpuKZGMBhChzNhJDBDa51vPkQic3J2lYiWnnRwgiwdLAqbZNS6pNJojFRveIvk1jGGPdJixc4NrsfeBz+Ebebi9Uynpi4MwGlq1HRt9sAtZwNbHd3q6FZHr80e+LnjrA3+1mtnJyvROjZmmE70nSOpMpA4budTGAjHnIPJqb3itiTEAZeK+AQ14TVTDiZiMWy1h+n1yNYzqtAxEEKjCjQRtDVyqsQFxNq4jEISJ3TzyEVLQKbEblDqkczUGYONhCrzNnRrOMYYKi0kRl3QtOeKdeHopORc8K7HYyO2NpcMEkVLJDTARgIfYeg7dtqCzZRmf65QEIv4ELGNYG6IGHokcKIJ7TCSqEhKtIUhPbP/UZ2INpCbIzlhK6eRWAWj6zJTasRNw9cTozqeF8RFoKfHB8XXcESg9c4oGUhM1ojVWI7OsJOIbfagSuEKNhylTRmxOXckMIEohIyPE+rOGDa4RMawQEuH10az2QcrOHRVEG1MDsGFPnTUfIRYLodqQEeTTK2FVmchXzZDzJjyiqqKL4WyzhAq6fiEj0KmsTgxschGOaJIWlPXhakpk/XUdWC3Vb5YnMGvj7GPS50T3TuF3plGwQ6NLm5YLkZ6M/RkY+/QaaZM2ij9yOQZqhNdUFViaDSZsASqlSMnj3C9PHLoYC7gzEnawRGdl7EEAT87E5W3fP7Y6uhWR7c6enbq6Fkb/OXUUdWoOsBi9pY6MJAR8mSYGMUPWY7nsC5CyIEgEZOKykSwkWoTQ1OkJspmojtI7J4zcFJOsFmsyBJZFCVWwQ3WSbANLJrjllhpR0sbDhocqSAIYx8JaixrQ6IgKZJlzieREBERaoBqStkIDAVNE5ybuMw6joiRNh0+CG5KjUqKSi9ODgsOuj28wuJEwjtom44QHQ1KrZk2RNBGOgYslyxONOruNNfBxAnasLFRzdEMSY24rzRPeM6E1qFHN8R9n400U6ZJxq2j2ezYXzdpNipdCmPtqFLIw8A0zQKfQ0T6HisVIWLqlKi0nQmkEodGbEptiUkE6Sa6xT764UBZGK1EFnsjVho19oRuSQgRYYNZQS2x0sy6RW7UC+PljnuPHkJXKymMTBFMIgenzGXD4BzkfSQeoexVLCvL1IjeGDwx2FGO+QHFjckaZZqok0PnKIavjerQjipRdZ5BOBxoRxJZAsmVoI55IYyCh54xVkwnjtjAvkRGq4SxsjOsmSRQU4+ETMIJNIpmNiUQOuM83+FDYYnhmBmYkaITo8/O9RLQdtZ27y2fJ7Y6utXRrY6enTp6dt4VYHmi5onUOV3ssNphhwE5gFQqfVpzsEzEcJJ0sENd2Owj5WAy7/Kpmuecg1awnQ3h5IJNMFpyug3k4HMuQh3nwVCNhL5jwpkGJeOkHNiNc66wd4UUCj5UDjegkslVUZ9d1U0yUQwfhOAD0SdaWZLqinSsYm3BlAPduESnATgkx4nUCRaVZpkjwxE+fGTFOp1kij1CJUyRlgVJzjI2QjNknJC6oacDVmzSPmkNakKvC5pD9ZEYnbALVibsYEDLDvROrSOJRpwKdQoUHA2NNDkpNqx3Rg+Izbk7rdTZ3sB8zp2JHT1HOEgb2nCMqoWiI6lEkmeiRnIzFnWijSOeleYT/VpZyMTYjiP9AIuGhYA6iCXEFuQBUqwM5x1y2CKlGPsmLCSRTInWWOiE5cImN4ZgdG5EF9x7Yq5UGxBA2i6RwGp1kk2Xid2SHPaQMlJEKD4QsxJJqAnr4RBPA5p7mq+IGyH4BtMJ1wSaqQ1srESptCNQu0SQQm6NcX2MaZhmY9xaSOqolLlUVF4TQyM3hx3leucd4UQZOdxUVBIRaLVQaGgC07PTmX7L54+tjm51dKujZ6eOnrXBH3VBsCVqE0wVKZlgDt1AyZWaEn3MHJoTzxNCdUJdYBqpumFgoNaBXHty2mVa9Hg4pF2vozuZSVqxWHAJ86jCjGhOM8Ej5OU+kjLNEl0cqKK0HAgyu5fX5YhOBehxCkIhph7qvHU+xolgIChDTHge6W2DDQuIBduplOJIEFQDqYEwEXLHcYOhayymxloahzS0ZlJrKCOmlSaCWOaIrTgQWAwLSjcSJBNIEBoERUmgjtKQEdY2zaaYyQkeOJBK9UjSjtBP1MkIOWBBsGkuiD0yoKmRS4+2DtPK2A3UlNkvAesCGlbE0vBSqc3JzVCEIU1sdI2no/Q7PeTZClYW+6Q0IjlQYwekuZzU5GyKQAns7A8c+gGhnEtbOi0VahQEYxmNTMJbxxSclhvNG+IfIcgOUhuuMPlsfppzxo5G+qr0myV2+QaXDW6BEkEXNickt4ZPe0i6EXnIqO9TdaKaUCeQychmpAZWVrTU0CPO3smBGDPaG34IWoE44rmgpdFvILKhhiXrVaI/eZLji+uzd2SDc5I2Vdzn6gABQS0S2lmarLLl88dWR7+gddRK4IND4FJ1liFz47Te6uhZwlkb/JkpoweKBYIJyWyuMb6AIuChck7N7O8bvkgshkr1yhQihbmWpTFguYBUPAXGkAgaUYlYpzQ24ELQDomnagC6ENwIqpTkjMOESMVbxlvAVNEW6HYDZWxYETzNSwgqFW+NpI3ikcETfexJXaVWIWtEa6FwiDGbfCZ60EwTo9VDpBndgTIFYYXjCocO7sYEjLHhCZJEllNGc0fdTHRtSQqHVFnQFLzGOfm1VgRDa8A9MGqm8w3doieUDDIgKRA7IWRjWhtDCmjKKBGdGqFtcJ2Q1NBuLo1k3SGaDrD1AimN6NDVDrM517Y4aHBKVqbcE3JmUqVXZd0SkhshNEBpFmnSgRgeB2pnLKaOeJioFESVwFxmymLDg7NOETSSWiI0Z9ANWjqajBSDbAqx4rGgKngLTDuBRYss2GU/VWKs9JMQm5KjzEtNIvOPkM0i5nFAO6GzhIpSUEQaps6mBRidbhKYIqlExm4griJWIp7aLLIekGLU1qii1BZYtUjKS1aL4wzjwFAPaKWRgKyBYI63s9Ofasvnj62OXvd1FHX+Yeq5fOo44h0Xil0jHX3LsR1++9gXcSKeCn6OwpFW+Na9S7iNn9zq6HWcszb4wwo1DqSQZ/uAU50AetwaIe6RdEUaN+ztr+hawVeHkDu0KMnnHWQhOJSTqFSmcg7xikLzSEs9Ukak2FwJMCjuDoyEaljZZVQntEOKL9B1h0en5QpWye1UImkB054SG42KeMWnyFSUYolYFK0VW/SUuCLqIXUEc6cXoxchSKREZ2qBMjmdCNISU5/pZC4oVGTeRVeSYjkQXMnNGReVPDhjDBybAgddm0eblvFxrouoCs0DlTnxu7eC9h2uPasY2PQj42IktUIysAKhBKKDFiXWFVM22BnRLoJ0uCYWYWQ37+B7BWkVNBAl4xiTRogVYqALHanOpqHSIhtJLKLRklBdaCXRPEFqhDCQQ0OSsfEerz2SBR2AVonRICoehbGriIEcQmBDDruM4ejszN91pMVcvFM1UoaC5J7U79AFIcQVXRhmQS4RCYEpGKaZ0B9By4SJUzTQa6CbvWZpMdGAqiODj0QKcVjR2YqujhxKRJcBKQEvgk0y10fNFauZYBUdFI+JCeNIWjJqT9OBSQwzh2YoBWe6NnvglrOBrY5ep3X0LYc9r/lwx179l00Lu8F44PkbvuQIH1dH/26R+a87X3KVr8OeRv7rsS/iX13e+FI/2OrodZizNvjLDY56Y2GBVjJDKzSvczHyBhI6NiRUnMg+IRZScDIDVRpVJ4RGbh1KQsYNoRluRvKEpxEJiiTFTaBNUDdU2dBEiO0IfpBY5Y5SVxQvuA1zj3ZBNokQYQrCUAStlbSEEhOHVVARFlS6zUkmc9LiKK1WYs0oAZOGxJESG5SBtHbEjYMgyOIkIpnLSk9OBc0DwSFXWA2NJhXbiYw5ME2F3eWGyzdhzrmYNlSLTFIhzbkmCZ87QINla4Sq7FGJeWQ3zBaf6zGwsI7dEIglouvGWCq1BLqQMTpayVjNQGBKOwgdq7Zgs9hAsdkWQhrK7MzfMkiuRArdYabFwKYKsgQLkU1aglewRrBCREjeQ0kcdJXJIy5zUnjzjNQBoiFZiKlCE0ZTPCmhZXRldK7YNJB2O0SOYIdOGQujBnJ3hFghLZyjaeSkryhpnGuYtolSJyxVOhdaMvokkBfUIpT1xOiGWSO7kDywxKjZQNdMpaNFobkyTU5sRmgF1YkgTk+C8SjuxmHMHLRDqq6QTljEyKCJFhrmjdEcdyNydo5Yt3z+2OrodVdH33Yy8Gsf7K/yN91vwos/uOTBceT25+hVdFQRXrW80XyyyJkXi4A7/+PYDbn94T+Qtzp6neWsDf4sNnToEesJBLoAkwvVGsoGqrLfJqbUOGJOXR7ClKkaaSEg3YIcCkrF1j3T+gZ0i8TQN/J6xNd7WBQi3ampYYG2oHaRmp2+JdwEGWcLgebQmtG70q8KpUDrezwmYjASyrJUBGdMGddI8kpbGkmUrowcCLR0AtOItEzQCJpoDmojvTm5F/y4MB6OhCETC9giodpwGWlSQJ1kwFBBhE0URCcsZMaN0iYhJCFGh+i0CIum6FiZkrOZEr2MLOwkkImlY6VCUCVKDwnW2SglImXehSZN0RpmPydvWAdD6ZGq0CpdMswNJRNzQhKoGOI9rrCJQlsq0jmcbBxIR+dKVgVtWBuopliKeGcMYY9EppMeaY6FSvKMi+GtwpiIZDxEmgriC9ohHA2FoTvE3edC5wihc6IoUjpWeowxGEPYZ7OcGJdON0CcIopSpRJgNpxdFSwE2qZRp7l4fbBKCIAI1QJKz6QdsUsUr8TpMlTDbCIbjS4qKQrNhGGEUCtSK9YZx4+M+H5g2jlOLA0tleaVBiBKknAt9sAtZwNbHb1u6iieec2Hlqf+ih8TwCGA89oPZi48aiw+RkffEXc4ET5BnpsIJ2LmXdJz63Hc6uh1lLM2+CNkRJZYmG0HJAxknTB1DiXSDYWlFkKtLPqBddylxYCO45w7EFe0heLxgKwN0YozEUrjIDoSI1kgFSMyQXKqZmqKhH5EbC5fM46JYWfErGMxZlIwNqkQ4khkgdZM9oL5xKiVVS8cITBMxqYovrNkakpqRkoRb404VFzAu4ghVFGmJKwJSFqwe0UmVmX0yro1RFZ0YkQJYCOlNApOnBrukegFlR1iLuwFQQS6CNmcMhUOJZDCikWEliZa7yRtYCO1bLDpCFl6shoenKmrjKZInE1b3eZi7L5WpB9ZJCeWBXWaGIaRVPfojyhJK1UzRCFpoTbBLBEVqlVW2SjDCrNL6IYK0mPz6ShQylxrU9U5Eo/TMzHmNWldoC1RCYgucTUIBS+GNMVCoCOx6k9QS8DyDoGe2ASVRgyVro0citDCkmU6ysHRK+jTEeroDHaSUje0wlzcPGXqGKmWyLnOlYNaJBfAIpbnuqXHq3PChXUeCXEPm3ZJrvOyR1exBK4Z8YjUSlmtCUWIhxGNiTgEtAmLxQ6L8ZBp3MMMmoIlp2HXdi/ccl1nq6PXSR39+/WCk+UT+dMJe1X40BUHXKjxDB3d82sWFhyGFfi01dHrKGdt8Ce1m3dxaSFlI0SniWIuDK3SDoHFwCLMIx7dGH2MuAm12L94J2WhFENCoaAEy0y+Bxh4wN0Ip6aGTQyVjtgibRgZi5J7KFmw/UQoTosjjYZWxWvBFGKrpFYpAaYKHpzWKgj0QWCCQzb041FMlrQwIDqPpvFZLElO9sZBPWCqK45mIbZG3alEXRIsItohUQhiaGuoOnqgLGLHOkViH0g20kZhMKEGIS4ifRRSUmwVqNbYOTR0nZm645hXjIA7aBNq2UclzX5RYZ/eAlYTLRZUC1ILYooheF/pYqbfPwYGqhPmztgCjYwBLhG1RCjKslZGdw5TJE4QvUJVmmeIEKShFEoz4rRA+4G90DgaIdcRSx2WIjUFSnBo4EPFTVkuekJn7DcQC4gmJDQCTmqJpkreqVh2VihH2GFdN6Q20XRgkyuDRGSzmCsIqDOqoh7IeSSHhm8y45RoPs3+X6J4gX4DNQtHEWJaMAhYawQxggbcBKtKFyKugX3L9F7ZHBxlJx2SkrJaLGnjLubCxIQEo/rZuVyx5fPHVkevmzp6snzsbN/Vs6nK4mN09Ng1tDbZUWcjcauj11HO2uDPdR7GjCpM6mQxVBSVTK+CJadEI40N75WhOIriqcOtoa0g1hhdmDyylIFhfQRNleSKqIEGJg8IjSjTvDtt4zAGvGVShZALPTrnM8RCC4d0k2Cto4jgPiHJ0KwsKmit1LqkSSLLSKgbTBNJjJQb06RI7eYk6qZYiJATIpAGiOEyTgZjNyghZcIysLSCVqNIw0Ogt0yuFafMCc5dR1+NQVYsMEZxSnNcldBFuugkGRgDBBw9bJQpMORMsg600TpoGpHSkEmRpSNaCZMQcfYDaIA8RtSh9hOGsVCIecEBjckcEcMGY2pzlZ2YDRXDJyNox0YHnEwLPcGd1ByROgdzwdDmJIwWR0bdIU82F0IPisYw17hsTvCEqmC50mYzdw7HRK1GiBGZJugKkh2fAlVmy4TUBI/C0V1lcxgoNVPziihKzDBGYX9qdAaxzuWPooCLMHWOUGljpdaGibKahKkIrCI5bPDRCKIkAtkAKYxuTE1pmpFRSAJHFd7VDiBPSEisFgtsXDGME9M0gVx1sWfLlk+VrY5eN3V0Ea5ZwLJKEfd2ho5e6CNH28RJTVfN+QNw55hVblrWoFsdva5y1gZ/oS/oslExilZMGtEDQYVF1+HSoAm9rZnyDk6imRJgdijFaerUoNQexrFQSkHCQIiJRYBJOoonxApCgwpzNSPBO5mXHZuQYiEtJ2gV9UacFox01OyoVEoGi0qa5gRrdUFRsEAzo2VjJQFPjebA2qEG8IQEUDUsCK0Ju4vMNGVCbEx5vhXxigRDREAUF5m33VuHJ2HMTtecoTWSNKJXzBQt8+iW1qgUECcEp2aBVSL6bJmg0ogJUlKUJerK6E61QGlODCMxgREwERQjhDqX3jEYZM3oRsXppLKoDS0y1w+VQK+O6QEbUYoWZBogJUZnzikRp4oDSrSEq2PLCJslq3FN6J0mkWCVbIXkgArqSguBKo5v1qwloARi6WnDIW6K9gHx2buKSUlDo7kTUmB3GSibI0xBCZOQbQ0y0MpERKF2jNJwIFSZR/VRiARsKIQyEX1Ba4eUkjnRDcRTpYayB6pXzEcGcTapp7WOfhrousJ67BjsgLE6MU6koPS5o+8SmwreIkp3LfbALWcDWx29burohavK0bjgZFWuPnxxjsTGBbvlanX0/vuX85KjNwD3MwPAU7Ng33F4CSuvWx29DnPWBn9Eo6VCbpWMYx6RlghtHkVomBhKTxeNUjsWrdFPFXFhAAZ1DIEKiLOnC9JqRARCKASH0AQzISAkCQQxTKAhuMAQA9XnkXPOkW6EMEbaqIwoGjazqWVIuEGhgxBJONkLzUBrInmj7TS8NUqXkDjBMEGZvfDcndYgJWe32wXpEJ8oodBthE1NhC7goWF1AoOcQbTDBxCptEUjeMNshMmgCR4cR2kqIAFrUFTJyVn0ieWmYwxgVLS2U0KbiOKIGWsPrNUINDqcmIWqwkggitLaxJR6Nr5HtFl8RdtsfJogiQBKwwldYjMZOa8pBskcl0YVoWjECWQLKMoYK1FBywYrhiwCFgekFAKGBmjBqKVDrCNj2LjGs9BCg2FELUBZ0jRisTHJXM4o724YXWG9y3I1Me1PbFJlPawZx0g3QLKRroODLtCaUd0Q1/n7JwkNSosTtYxUrSwVrqCj+kDZ7UlrwwejNphU2SShxkwmof2AJWPvRGblPV3eUG1ELNOnzO5qyVAHxkPDOTsTlbd8Htnq6HVSR1Ub33KjPf7r+48x+/N8dAA4B3D/3w326DzidlUdvcM40Q4u4dU71+PER4UJx7zy4MN/5o715Gy5stXR6yxnbfBXXRGPqIFVQ1xI4nTBaVMAr0wB1rqitAjSKMlOBRACqjRRgik0R8OSLhywHBI1OJOBuJGpSOeErMTqFKs0A/PElJWYJ/I8UMRCBquMrTHFSgoVNGKxJ7aKtbkotTOhVdEpESao0dBdoaMRRWGxJIqjISJhFh0pgHc0MXZWhf3LMyzXeOmY3HEz1OdE62SCJoUFWCuEkvBVQ8Zpdm4VJcSGUxhtHpUHs1PFwXtoa5oOxKSwSPNGCqtgSq0RGMi1EGNkfeXNV6ULzMIGqBSGsdGio9nJJqybUTyChlMFxhvRNxSMWnfoVDgoDeuXLPYyIRjeVaI2FMjuCEJD0TKiIQGZsYIjeIhzRrMbdYTW5iTnYEKwuYzU0CqExiJmQhLKQimdUAukusGTAguWUUCM9cFl9GViMTWGw8CE4ssjDFQ2KdJNha5EWheZRJGxoaVgqmz6hK4LXerJZoTDxqFFfF2hFVAB79EWSSiLNkGOYIFV3KdZJgXHW0KboMHJOZN1yWSHNB2vtf635exgq6PXXR296XHhQbrP7/7Tir3yLwHM0QjfeKMT3Ox6inwCHb3D1LjT4Tt5X9vlZOlZpMZN/SRZjJbyVkev45y1wZ+2gpVAaXPuiTCBF9wNaRlNx1ioUVYLBKVEh1RRtdluwHye5heQpvRToKs9YkpLIyZ+ehkABatOMWfMUCwSicRayDGgamAbrCWaBKbkaBdJHVSdsBKxKoQyhy4eB8wiYgGiYWFCJKJDoJOENyF6Q4JhQDKjE0fU2RsjR8JE1wxvI5YSfTmkGniMpNSR3QFDJ2M1KTbNSzI6JdSYl0WDYzHQVCkGxXTeDdZXYhHYGPvqkBuijSCNqNPsxVTmBOIaFZWGBqGWjIZKEJvzhpjm/LsyoTowVGGcAkYkhbkQurjTvNHMoBhxVbHWGDrI2tCQwU4lekslRCAK2XvaIYRQibkwaUCYa04aSpVIrT6XHmIDU0esC4LuYzWx02cyRqOCC2oQKsQWsRaIdPQuDCyIfaTbifR7gSiFoa+0RUQPC4V9pBm9O2ig4UgYEQwhEaWjOlzeNrhAHzI71QkyUruJKUXcM7EGfKwYAx4zozdkOYDPsySLOs+MDASqZ5CMxjUez86alFs+f2x19Lqto1+2HPjSiza85zBxsgg7LLjFuZWNHzLozjXS0ZuFA2Ld56QGTA22OnpWcNYGf6HOflAtQ1AhVsHNmLxgbmQ7Ttc21D6w8sohHaoKoeI0xH3upGHeUdV7gbKkiFPDBkcxnW1FYgtoEZzGmJSiHX2DUBPdkGE14rLG3TA6Qg5op7ROoBV0M0DJKIKGgKSMqOI2l7GJeoiEQJBCZxFrhgdwlFDnGo0aYQg9/3/2/i7Wtm3L74N+rbXe+xhjzvWx9/m6t1wuO04Zx4SUAziRIMIBCXgiDxEPPJA3HoAnBEggIQRPRAiJV4IEEkpeQEQCIiBIicQLkUNQIB8OSYgdl8tVdevWveecvfdaa845xugfrfEwdtlOyte+qXLOvbVr/aXztPdZa+45Rv+N1kdv7f9fuzD5mTQHyRNNhEkqUA6jY80gnSGd2JX7Cnsy5EOl3S+wKaWCILSieJbD6dwVG47fKjqDlMLug6ntB+g1AQNLN7wJY9wRahSpmGaGF/poaK+IDfzU8ST4dqVKZx/gPhA60kF9MNTYTQ8DWV/Zqx/fWdvZHdQm1BWpoBJHczqHCenYoBRj5QnrM5TEFIF7p3E8EAxHvaKjkOzoezHJJJRIg67OoEFzchiqie6QOxSvdJz5VDjHPevUeJkHN3MagxQ7eX0i2sKY5ABOtyOiyiCGkgIknbjReFAoulC2xDgnfBK6JEZLh/9qBD0HJoHTsD2hU0fUURIhx/dkSTmfjugs7FNtVX7Vd6VXjn4aHP0jU+OPzoNUndZeOfrK0U+4+Nt7QkKwXDELBPAuh8mvd1YfnLvjUyL3C5O8xXoh+ga9og5hhofiPRDrDFnYU8XUGW2hqR9Zly5ICNhH+0wHiYAyo+3IFGzmdBsgQlFhyMZugxJBaZ2xK8yGTgLzGZOALOzbzGQvSHbkTZCuAQY9J0ZVGO3jLlfo5vR90ONEn420voU2jhtbZ7rMdBdSOFgijv+TODn2o871+x0dmeaBqjHCGHF4NE0pkK5sz4NejDgvzNszpW6MtNB0ZviO0jAFSUqajTodcUYSFR879Xajf+x56bUzjcZaDU+QdYU+6LsyhtIt0SclTU6hsa6dbg8sbHgrMHdcGl36EWTZha5gMVBxSk18mw8LABkCRfDoyOhkN5JNSDEsBSI7TsKK8KGuTDZwOzMiYaNiKogJSRxtFd32YwJRlIgH6nlwPQ9uA8btSk/G8pzQUWDqDBoxwPW4Nzw6LlBKMLWJ+aTk62AdhZ4M1UaqzqgfJwZFSWSKB6ZKrTPZGtWU0IwqGI1FBvleSaUg+mn2qrzqu9MrR185+srRT5Ojn2zxd/FARmK5HjvJHoGIoqIYiWueSToISzw9beiyorowRoKR0TCqDlY2xDqbCDonVq08TEfmonvCuGFS6bPhJpQG2hq1dNJdor9sjD64dSVEKSeH4cRYsV4x0ePVfTgeiWg7kR+QOWHaERrJEsU2drtj3RPahZwhpk4LkFGYzEnpSuwnUn1mszOP0z1wIyGoTViaEJRcDa2VkYNnywx/Qb9YsHVimwdj6WgHHQulTyRpqK7000ByorfMLZQvrgLmDANXpXmQVbFUyKmTTsYe91xfLiS+Rdhpa3C7JOoteCzPlDhzuWX66Wjy5qUfD4opEf0FamdsJ8Sdml+Y399TPh+Qd0aPwwIgHy25QzpjBO6JySYu44a8mfF3Tr5tXPyengvJO7MHyQpjLnS74ZvgSUmT8PytofuN6A+keMM9KxEbnoyUgyTBapltbyQJLD8yPXROl2fmd8YYwrPek2wmb4GvF7xMDM2I7Jh3kk60dEdrO7KufPvlG96MwfWuYS1gX+l1hS4UMqDkq3M/OR/mzPX7K3Nd8MvKUDALokGviluGaSFNvzva6VWv+g+iV46+cvSVo58mRz/Z4u+NDE71gsiJnCeSOEM6VSFGI8vXPDXhdH8iPXyPSw9iq0jvdFHIE6diPCahp533747Mw1+43vMuZZDKdB+kbIx+YveZPjqTVd5OGdWFdenEmiFWzjYQzYQlWq7cqpJvAbsjdWbKhZTgYspeC3qD5IKk4LRlvAanED70Bj7DSCRTZhFGVnZxpAafS3A6f8X84cplv9IfElLukXzEHMlYIXf22Rij8Ggd7/dkc6hXahS260TPAzndcAl8m5hZ0OkA3UTiUV+o9wmpE7YfFiojT9zLzpg6a6tseqPrRGaglx3frkfcztuJEgJqPP3Gzu3RafuOt4G2QEsnyk6ic78F93Xj/TQT3tjiV6n7wsMCy+XGeBH2NOOlUES4c8F9ZpTgohPcjCVdmC3hU+AqSJ9JPdDxTL+uvIw7Zj2R83u+ud2jvrBL5pxunHSHrhR9ITSTx8IcM8agzZWef4k1nsgPcO6PzH2B/YXz+g3f+sSPyo10n7G0IPtgDiWPTFRnjBuXNfHFrLQPP6CROSVn8if20WmSGemEqSLe2foL5/sT+dsLnB+58MLjfScu4LdMiDKS4nFmTm/46vNP06LgVd+dXjn6ytFXjn6aHP1ki78WiszKZ+dK0sbegro7Sxecgp4hPyZ0Utq2Qx2EVISFxIQMwbaGF6XfT+SS+fxbJ95+w31diO0OSYr5jRE7PYxpnzmvx6tmvxvkNbEXP4439o51Q2tQPyg+fU7KL5xPO1uZuW6OvWw0KdwvlZwESc552nhfTtz6H+GLl294wwd8GYzzTDdl9AKjYAQi0KTxg/pMnlf+KPc8tWd8q5RTQc7KmDo+OvMWyFq4SOGeTD1Deg4s35BzI7WZaSvoHMT9jRid1p2tQymNN3eF1hfW2smtsoizzYkXy8e02wBqIu0Vub1we195vwV7a1i6kR+eqWOwbjO3zUF2dArE7rA1U97dIHdeUuaDd3YffHlzSI+Mi9Me79l10MqV3XbwYBkZi4Sg3D1c2dfE+yv0HExpZtkDWzrjIbiMQn86w2WmLBDzj7ltxtlO3JaVUQfVJ8IElc7sJ6a1sBdhLFdS6liauHqDVljG97g9Z3r8Nv3tPfezUX7wgXPaiQS7DhQnVmhrZawJYSI+23nXK+fvd/i2cttP3MY9acnkVNDg6GfqisU9T+8H8/KWS7vw5a64Toh3xnnQ0oz3mbLCee/Ml09zx/qq706vHH3l6CtHP02OfrLF35Ird8vh2aNeaWNnb8FOIS+JXJ1nCc79HXc6sHKH9BM5C+RGxbmOI+BvfjqhcuF2nrHuMGbsvMGsWDaUxDauNK30JOCNWhZmrbByHFXoHXrXoLww9juWKEzyhm3b2ccLxo5rYZ+cNA3UDHOldyiPhq9X6jzzIHfcfOW2DjxlzJWsE2k+IQm2D8+k+zP23NjOgb5k4jQhCUbvNHeyZuacqBKcWiWtzrhU0mOGeuaxXhg2GNoY3RlZqSWwrXGymVsR6oB73xEVWGbCJqRCSo1aE/VqWG8k7dRceT/9mJd2o18z7Wth/dUbLWVG/xbpg1s5Mikt3UCDVG+4K+/Pv0Q/JebxxC+vgvHEfXrg4lfmzzcWcZZhR29RQHUl+5X92zOXhyfu0h1xfsCulUqQaiMFpBy0u0I/QYpG8Uwi8RlPvNeEnRKkwXELGOu5sIqQqjO70YeTwjlfArZgGxMnzny/K+8+VN7JmXoKpCbGvqEmJD0Tdx+nHOdK0ice5MLyFby7vaVbJTyzmBwTdLWRGYQGw5URM0Mqbd7JT52SEpaUFyo1lOhOalfCVz6kQcrf+1kvw1f9AdcrR//gczRciHIinRWNxnb9Cnnl6B96jn6yxV+cEi2M97sQoeAJm2AuRkqgtcFwnirsxTkvDbk5wwLmgUgw3RJjL9zMkAzb6vyCLXwbMxPPhyVBM9KuzLsxxGkBI4yeGnfXjox7IjsyFdwOp/llVGJ9z8iCd2OpEyUJfQ60GMwz1YxYg/US3K+Vz3yGrLQa1DzR2wl2xfJO5Bttc9QmytRYnr9Fngsv58YpF9hnhjYU57Qp5oZPghejhxJlQL0gzNiiWDrR+7FDdRdUMk2dnhuCYLEwX4WmUAikCHt2Wu1MvmGiDMncUnCTjeeXK+3ZqLvywo1vLXiug7v9ax6ic6mD9aWAV+xjs/fT+Rf5wS/9Z+nTw3FBHf41u/EfXf9N/mPjPcaNGgKnGc1C2MbOfDyUVPmiPB3HGn3grPi2IVOiWrBpIL4RdYfNYUqUe+GWX3hpj6Rm9GUjEeR2ZDzua2KkhMrMGIWpXwBBN5hRro8gDL765pG1/gjdXrD7F+IW6Djh3nHdaDkxpsBCyTXT96OfKNc7Jr1R2VhSwiKxD2d1aCFHznFcOZ1PPLWV8yR436n9ntUD707WhpoT52DuxuPt05xSe9V3p1eO/sHmKG2l93YMyKSC3898nTufPZ7webxy9A8xRz/Z4k/9zPDETsOiU1TICl064hVy5/xy5mUDfasEFRC8Q9+droZ2O0bgtZPqfCRORCLTKE0QyhFy7k6zzG4DpGFhSE+sOPgNUYVxJddBSenIpqWj+0oeisSMMZOjw254mtAIvO/sFuw2yC8zqwYah4eUheIimCaSON6dtm3Yovg+k6eOURlzYNWx4ag5Mie8xxFvJMAq9BTc5pmN4LF32p7xbkRUQtthTxATTQ3dOqTEtO74yajToJVMlwWNSq5OGx/wWol6pttg3VduraL9Rt8usMFZlBid53Xj+drY9D3VOuli1Ie/h29/+R/5Xde02cJfuPsHqE//T/7Y/oG7mojq+FTQDLV0RgkmPeMuvLxPLG+Uh9bZBdpxqEP4sb2NNgh3+tbZwhlvTgw35rZjTx3KEeA++0BXYS9KuR8kS7T1kRjH75PRWaJCcurbwnmcefw68Hpij/VwoZfK2KDfBM92HGG0RG4z1z0Yp06+BS07a+xHmkIKrBnaChFCEyf2HRln8ptKd+VSN+TZSB6IDtQg1PAhhH7Xq+5Vn5peOfoHl6NpE/be2aOjPrgzIbU78t1G8BW3tnPeH145+oeUo59s8Scj6OJ4DDLHhNoI6C2OyTCbibzz0BSXDh10cECoZ7CCS2FIIK2SZSItg2cP1De0C+aNMPCsND1G+/NHh3MbDUMQYI/lo8nlhUC45gID5pqQnnA13ByNhLiShkMfxNZRExg7bZyoRckGwwdig5wKZjMRgUcF3QlmsDMpvYcGkYJhgTUFgm6DYaDdkN5p0VFL5GR0Nm67oauQETQJPRyvO5DRmBF2LCAlkJPTELyC1UHugsfMilGlI7Fj+4qsK9ttJ/adbQw8ghiDdQ/GdmGtwmobrW3EDrd/8D//8SL++3ZccnhP/bunX2H51f8L436htc71XCjzkSoeUkmpk/uJfVx40jsmATf96LP10ctKgp4FSYbsThsKe2ZbA4vKUP1rxqSY4BYUV5aR8AzdHCXTVUi9crptR+j745nsn3N3Edax0WKlS0MMkiji4C2h3Uitg1cCQecG18HQmb038IaKkS1jJHxA90yMC6mcSarsOxCdKXdSEiIpzZzmgyBR06dpUfCq704/K46WAX9s+4vc9Wd2veNr/WW2OL9y9KfkKHvgG9QOEYMSg1BBXhp3L3eMs3ObZ9ZTZdyfXjn6h5Cjn2zxZ22DESAJ6B+9mISGEpqYRqHdfcPn+cx131AzXIxmRkhBY8JJDK3kcSTEdHWqdc7uDBJ9CE4g6kz4kas4ApPDIykTaMkgxsgzapWhHachKow8MyyT6GgaDDIlBNdG69AjI71jN4UcSHFUgwgoMdAIdBgxHPVgZBhJWWLQimP149i6JYYaMBAfGEpyhXBirohm5rp+BHYQyUkqiIKEfczmHIcVQ+nkGLRkWBbEZ6wNdLuiGa4FtqqMMiAujMuVtq20W2Nrh69WS4OXUdnrjuK4NWKHPoT65o8Sy5uffGFF6OWe365G/PhbXraVZUuc7hSrGe0Lkzl3EdjcgcwmRxiSjYriKCCqqCqhoClBWlj6oA1ht8w2dRKDwkePMncyjrdgRENjoHI6dr4aFJMjT9TOTPEF5d1Gqe8g3YgRMBKIE8WJ4UgVtDltuuKjUPZgDGVUQyXwUAaGiiIS4J3wgRuEbcim6Do4iwAbCSPIdIQgDuNVie9gpb3qU9bPgqN/8vKv8Q+9/z9w5x/+2ue46Rv+5c/+MX7tzX/8laM/BUf7qIzRoQXi0HB2GYx3znZzzp+vyOOJer3h6+MrR/8QcvSTLf7SCNIQumVMhKQDlwBRPBkyFEsTqRjTC4QJ9Zxo9js7FTnir0UwS7gJYwxK2ZlCWcug7Sc0hByNrJ3giHTscmRFujY8CYWdlkFTYD5Yxg4xQcn0JOjozKOBKJqMKoOIhJRE2R2tBTsLpoE5qBphoL3BEHwcn7OT6QazV96bcJcSjI4hKIaGHlFHceQwiiiaK6UN0ksl24mKwHS4vA+MY0tuyDBUBq4dPGi6wJ4Oh35fCeuQYbOG4BDOPla2trGPnRadCjBgdGcbnTF2NE3YWPEqjCZEevipru+Ld/T9j5m2M+ercT0Z6TSz3N/xMKCdhHIeWLvRdOHIF2gE4GGkEeQYRIaeD8f+KTolBZeU6WVH/PAgI0BaMFJjH43UnUwm1EnRiAIxG8lh3oTTcmK9vyM9G2aCVaGPRLegp50u9XjgpYGcKn4p6KrcutC7UJbjHh1+vOkMH0jvuDfISsRO6wZ0Fg9uHVrriIIWIWuQw8nyacYSveq703fN0b97/Vf4c9/+U7/rcyz+gX/4m/8FMv3X+cFnf+qVo387jgJanbE7YySGgOvOGIXt+kTNJ/y0403Y2/5zydF5mfmrX155Vz6wTTv2g4nxytG/Y/pki7/IGU0cObGSDuduG2QTQhTXnakm/L4x5cSIYE8KSZDWkBjA8TNcBJ8G9OAszuKJ57LTfKGgiB32AEOCCDn+fgpGGvQYRAxCHKFj3Zm70y2I6ciRVB/kPkh0moEZlAI4lJYIBsmchCAB0YEUyGiIByF2RPy4MsIpaZAaxHQmxk7Gj14GN1yEof0YvTfDJZNoaM9Ii+OGUIhkdBIBRDt6byIaGhXRgaRB2pW6CcHxvXko2hppBOsWrBdnWwe99wN2Ab46o+3gG6k7XgqagsEgekMv7/lpllp7+S3ev/yYaT2zvRh5Sth85v7OmS+QPnemR+F2eqLPSk6NmEFkRntCxsCGE5aRIpgcjesQiAxS9ONtgHcExyJw7YzupGbIJASNFI2WIPLvXMfEXDL57o6cH5h1Y8hGEwgEkUCtMZKzibCkTE4JJxjeMAzJcXyOFsBgmOM5aGuwOIgMPBe8ZVwaHhnvA4mBeJBNKJqw9Gk2Kr/qu9N3yVHVwZ/95p8BPg4r/A0SIIA/+6N/mh++/e+jrxz9W3M0+tGTOIw+DHQcRacoLVWerxl5vrKIs9b2c8fRf3f9S/zf9J/j8v0X+P5xD6Sb8fivfUX64f0rR/8O6JMt/vbZ0Hlg6nS3j7FBibkovSpCJfagT5VpKUQLdGRUOyod4/Cr6pYZKpDq0ahs6bixxPDsdDhe63siwpAO2QKdGiIKvbCSyH2gnoguxMj0BSYg10SMwghHhjCqIJqYNI58TMu4Njod8kQAYz9G5/HAGPQMNQm0fmQnWnB/hTonhgUldjQGiBLqdOsMFUyFUTN7qdydF7ztxDQTASGJyJmIYzw+NKN+9LBICqxUUgepMyZHX4v3glal90HbYb8J/SboZqTRaPugXga1NsQruSWaVzAhShwNKt/8Jbi+J05vkH9/zx9HbiTXb+E3/wI3nVjp7GSKZrQ4t7PQLpVtv/H5c2V/s/FmCs7nRno7o5IoVXAf7BNIVk7mmK1Uh20XRK5MYcTgaCiXhCYhaUA1IhLDHKIjPojG0SScBjIb1pVUJpblji2/cE2NHgMlyH4kEzQfVAxZT0xJiEko1dE48kA1BokG6vgEbRI8jBLt6D8qwnqbqGqoKaTG8B2io93wZPgnGkv0qu9O3yVH326/ynk8/cTPIsC5f+Dzp1/n6/InXjn6t+BodGeIEAKaOmadrMqGQSh+6ZRvrvidsBk/Vxz9N9u/zT+9/TO/6/r3ZfDtf/qHvPmXnLsfpleO/j71yRZ/mzsqgubOaA2pmZMWTpHIXWAoH8Ioz8cxbUvG6AUVMO1HD8U4di+ajOiVSRaevDClwMY9EhMjHLqQUDIFs8DTiqOkyKAnknfUhSGnw+ldgh7K/TYxr5k+Kf2cuOxCYGhVzCquwbYkxIW9D0Qhp0BbZzSlx/GG0XOg6qRxNM9WGVga6PaM0xkSWFa0AB+jjhxhDJjXxPMXxomGX2bGfMeugWVnKY6GchWliXPqFckNicaOM+bEeMyUWhEGqzk7E71dkQDVhIxC2Y9emGsf1L0xLoPhhTSEsd3AQLNS54WxddL/5/+I/Ln/2mE49TcUgBGOIOR/8Z+i34zNhFA/fpco9M5N3tFReixc5m/Y+y+wlyuf1yDGA2ory1iYrBBRKNzIa5CngkamxwQyOIsyykaIIvXMsIKNo8ndkzMGSFcYOw6QEnpWOFVSC5LCMhVezgveOt4viO+oO1ZBqyCRGCPg4YVznvETtPUKl0RYJ6yCOIKRiqGPhrzsnGuwZWfNE6bG3AZNK1uCwZHnWYfTWv9ZLb9XfSL6LjlatutP9ZmmvlOnu1eO/i05mkm9MtFA+3EKRWa0QBQeJznekK7LzxVH3ZR/5vn//je/8B9f/z7//d9w/5tfkl45+vvSJ1v8LX3wZlcqiSqNpIO8D9JuzOqsy2B+eYPJNzy1C+PNTDLQoXgznDj6Ozyz9Yr5GwhlX4X97YU+grDM5M7sztBgtydIjraFfBHsTSeiMfuFfq7cSNSb4aPTrxM7Snuu5MlJb5WbBn1xig9YO1GE8n3o307k9zdyr/T8RJCZYyY7xBDWkYHErP2wWBhO8RWZzujmaFEMYVwaNTZqGWg5k/dMHVdCFWkvbHefUcrOnSVSKKdmmBkijT427hTezxO8gASkb79lOgs2Z8autO1C6WdsnLA+0dLGNu/odeDTTohwi8YqF+bbYJPjaGhdZ9YasF6xrRL/7r+Bb/8k+g/9o8jd279+Ua/viX/xf0+7/Vu8pAXdDdNgW2CbAOlI3WgJ6h5cx2D7zd9kW96xXoz5+RFLj1h6ID/eUe4n7hnoarzYl5y2Si7BZXGk3ZGujuQdLYOmnWtTzuUDmoV9+5JpOMUGMcE2z2zuSH/BThvlviPTW+Iu+L4ED6edp7oSL42xO46RUOo8KI8VbspjdbYt0eWBi+zsrkQMdD9274sEWjuI8nlrPO0ByxOSBbVM7svx5wNycqax/YxW36s+FX2XHPX09m/7eQCup0SV2ytH/3YcrUJtMxE7rpWROluqnCLRotO/d8fLU/m54ui/oz/iabz85Isv4KdB//4N/avplaO/D32yxV9MjfXhBVy5vyaKw7Zc+HbeWC6G//aX2Jtv0Xvlq5ev+PBBaG8+YGRkvydWw1PFlyuhGz7d8xsCbzN8vSbu1uCUKnKv9JPS2YnYSHsh7RD3Qn3zPdK7jbqd2K6Nl8kJLXy2GmU/ws9vc8PnDlWx4ggrthm6fYa0RwpX1vSB+r0L9HZMOnlhWIbckdLIfSZd75DeSHfP9PiSxY6jB4kKoxAy4efB0EA2J70veJr4the+92Hl5bN75h8Ap0xkoafGizbKlnl4CdK7jas58sfu8RTE8xXhe9xJZ3/e+PpD4b1MJD2T7T23+T11fkbLM/29cfvG2GMjVyH3B9I02NKV3a+sQ9G2UaUiBUoO4sO/hf+f/23sq+8TbzPt9kx8/Re53mApgyRCP18ZYyB7wVuiTs5IzvJ8Jq47a/kx95fEh+mRd39X8HZX5vE5kr8gbSeWl4lHZvpnDzwuE6YbLd0xcuZ9vmGbIA4WwtJn9BYMv8dmmB8aWRLEibobcu1Yf0drV+z0GdM0ePhqkLYLv72t9NuGbonreqZKQAlyF862EN9W1uWZ8sUD4hMqH0jJqTHRxh0jAmj0EPLzQpmf+KtfveVeXtj2znUu+FC6Hg3maXMA+vzJLu9XfUf6Ljn64y/+FNdv33BqH35Xzx8cPX83feQv9z+Om71y9KfhqBjSTkzi+P0T2oHovL8Nlvrzx9HL7S/9VPflqoUmp1eO/j70af6rAH4g3JVEPCz42dhbxauSrvcMnZHv7VRNfLhVHua/yhwn7n6sxKrEfCXeBLoIg4ldM6m+cNqMJwq574x5Yasdfwdj6sQ0yOMz7nphSpWrL2zbO7Z5oF+fieUNp7ShtjO+gA99YtmvFH2h2T1ZziyycW2J8XbGvwdRf0S7DNbLmbKc2LLy1FZOdXC6D3ou6B4wVphX8i5Mo/Jbl98k3Sdq3MFwwm9UOuMaJEvMRUgPxvPLyrlMyHVlnM9IOLcanPzCshouM9d58OFxYwJKn0h6ZX+54XZiv+t8WI4J5xTBL7RMahvXtLKXJ6bnjfWDcRuN0XZOa4DCs+2styszO8jCXJ8gD9bs7LcFa4lFGlkb2zd/kfc/vlDGPdYFicxihf52ptWO1RWVje47VCX6zObGNym4e3lhywnxO+Jp55tLQfszZVq5vzmP58I1f8XafoHv3S5sTbFx5YtfCk7xC0z5gXyakJG55pU4G59NgetgDeXcgknvWWrDp878cMbGA0965t6u3D//Ot++ecf7yJRvPsfixlSfuF5gbwLLlR9+sTJ++3v8kXPmxXZun2/UH5/R/S3TDHfTM6ovVBp733n80rFfhLffLGz2gak+Ukcipkq2Qd+NthvDhPtx/lmvwlf9Qdd3zNE///Yf5b/443+S3/FO/h39jtnGv/TwX2HJyytH/4Nw1DobwfOLfuTo+nPL0bd2/1Pdlm92kM1fOfr70Cdb/I0vjDoJsl3Zh7Cr0tPR2DsPRcxZy8QfuSq9gT8M2qSst6BTyTh5y4yYWWcQF05kpphBnfV0YZ6dqRdEEqTA0hOY8pLObMsEe2K0juSdJTdaV172B3pJmFXmU+NNesS2wrU1rvkOdyNtO72+0NZKWQunrxzdEuobX9yt3J4n+tOMFgXtEEqEcTVjLcokv0WRP4HqO2I6MRBqb7RQhijXFtio5Hni+x745yfi1zL33wuUyiaZbRy7IHrjbMrdEtTbhdtlUM8zj9YZ+wtP8YjlmfnzjviN9dKpt415m5FLpX544bxBSzMf0o7HxplK98rtecPCiCTgQlo70VYyiUrlpXeGwTKfsfVEsReKXSB9D39XaTLoW0LbzGAHqSx5Zb4PtGfSdiK+MB63lfXDDHsFX+mz8Pws7El4Pr3w7umZ3/I3fHZ64OEXfp3rb/5x7u5+wJenb5jWO7qeufti4TSdmZLw4Icb/buciTzxOD8yPT+xvr9wu8tYHSzV8Mdfova3xMsPSPYDbvLCJgK6Y8XZivHFk3OdfpMP7xNvHwvJO3u+odqQcEZt9DGwEcwBzxLk3xrktx84X058mJ9ZXxL7quTUOZfK9MbYmXjS9rNehq/6A67vmqPfnH+FP2//GP/JH/9fOf0Nwx9Xe8v/6/xf5scPf5r0ytFPlqP/kadf4YEHnnn+m9+QAWlLyLczaH3l6O9Dn27xZ8qmzpSVFBMxjonbIgO1QfSE7jeKfsY2LZy3YOwV84QXI0onfCfWjdFh05U8gs/vM09yRBKVVQkR3CCp4apsKjQ3ogWhIN3I5xPmO304lhLJBicbOMpNjXxyImDxnfG009cdKYmsj5A7+jIoaaFa59nuGKcToxS0D6Y6mGIg6uwJqir5/nu0bYWnBTkJzFdkchbP2Cg4hVUzGw0tKwq004Xa3jCSHBN6ZUXTYZ1gq9BcsDnxIHds80r2mTYJuwnTCJxEU8GHsWNcT0/gHbkpjCe6Q98h9gEU+jRoXwxuqzLtQYiTT4MxnOFKHhndYW9HDFJelO3lERnQ/FvSfGJuCZkSoob0cgyHWBxO+XPheXrhl1x5MqfWnS4ZTcbiilWhSma7OcvLt8QiuDSuv7Whb36bx28XOBnnt3ekh8+Zrw94Up6tc9UGbWG57ei8sp2/YMyCmpCtM6uia4DBfQoeY6aNmZsZTApDj2SDbNSy8/A8864bIs64S+hjQm4d9YrZQAb4bjiP7NrRemNsF/It4y8L7aQk2ykdtBcUOXpo/NM0J33Vd6efBUd/482f4Vfvf4XPrz9iiReu+YEfxZ9k0oXplaOfNkcX47/k/wj/u8v/9nffjB9x9ov/zi8gRYn5laO/H32yxV9XxXsBTYRmwiE3YXZjFKF68GXcsT/coCttM7ruhDVcJ7wlpGViv9L7jkshnRJQKQLSlsP5XjumA3XBPBHm7Lrj/cwcwr03NpzejyzJpR/Gm54VT0qPjSidnjIK6BshXQ8TqpEGIYX95swC2hJbG9y3QbJBIxgoq0Lkncyg6Fs8KZE+oJtRWybSmTS3Y0oN8AgsBlodhrJV4W5esH0hTTfaNKHZGezsQBuJbMFsjq2G1ML4uOu+24O4wghBZ8eHkHyQVqVtGfQGFkdcTjqsC0af2cmEBW+acuvOqiteB9aVodBcEQaw0nrnJAtxmbg7J55ixXrFmjFwMEfUwRRUyJuC3FgnaDnT3zWICU9OFses4+oMnxGZgIa3C5enDb2c0O3Gai+824QfnpVov8XnL5/zd/mf5o2eWeYTZgUrHbHBiGckhEmMHIXsg8iOaeJ0V1k+v2Pa35KomE9QN3pfaW0/nPTHieIf6EWwzdHf8RHDkd4Op/pSGCPI0x0mHWrQpivj9hl5BCM5vXR0N3QTwsCK/8zW36s+Df0sOfqDuz+O+5k54O3e2MYrR/8gcvT9pqx9Za7A2mmuPOjyEzn6901/hv+qFf7Zl/8TT/7X3/6e/I6/5we/glwSL9P1laO/T32yxV82ZdIJCTliboaj48gjxAamMzIS1RzWhvoN5HAB13GY8foIBoJMhjSIolg3TCfomVEHyBEVpHa4vttH1/e+ddIkWOrQhRYJDUFwRgaxwpQ6JRo+ghChi1AmUP/o1SQV00wPGHsjqIg7qQUqHVfFMdAJELoq2hulBiefuRXBRdGRsA7Ex1gmESbpWDgRCYuZxSom9fiOPIga+DB8GGJH9M7eC4WEjsOgs5YMkgnJhDesdTwNVCrzi+E+4VnJKQ6TWFNGcppWPAapO0s4FxeGQaCMDl22IwpJGp4AG8jHMfzrcOgTtifUwS1ABVdBzNGI4+cMRbRx+3BG2oZnR0cc/l74YfxpA6xDSTg7e9ux6Z4+rnz9Sxt/8e//wDj9dcvppf1L/LmnP8efqX8GFuW2OMtqZK+kLuQoWOYwbp0NMShWWN6cOG933LUXxnb4iY1wxuh4FxBjbpkwZep+2AwkGCTojuIkEXTA1jf2SMy1Yw1Eg9QcFz0c9i3Q3Ini+PRpWhS86rvTK0dfOfr74ejVlLYr87cv3N0E3uyIQ+Et6f7uJ3L0z57/DH92+VP82vpXeOYFPDM9fcm79I4fn3/4ytG/A/pki78kgpGgd6AeIDBjWODWQIXWnFoNq4PRdzzPhAgqjsoRYYPCKWXyGEgEsimSEmINF2gRDA9IgukR4VP2wOuOTjBSRdUJccaww4gzJyhg5sQGYzfwj8ahoRCJznFjJyo6O+0Gqh0bxtAjZbN5EKYkNWBiBETbkR4QZ9yELDBiEM2RfmQcolAEUh+0NB03gYDPL/RWjtxK+TgpZgmVhodTQyHL8aq9CsRMjESkIHDEHbNGaOBJjyinDJEdEaPoREwr7pWxD6QDtjM0GGEghmsjpKEC7gIkFCO2ji8XPnjjJGc8Cz6cQIiQI9QbRZIwZqHGYX/QPgxyUWrpyBAco4thDtaAHJATWJBS4ClYf/EDH/5Tv9tzbE0r/3z655Hn4Ff8V1ASo05EV8IET3L4SaVBHkLPQo7M3amzPxrXF2N7L3RzmIJpCDqc9aP1C7EwizOAIe1IzxRFxDENNA1aDFafKe2F+SbcrKJDGD0RJqCdyINuTo9PM5boVd+dXjn6ytHfK0eJG7ss9KGwVVJ7QrWTSSQXtAant/4TOZpS8Pf6n6BPwlNtfGgr7ZWjf8f0yRZ/ThAe5HFEr4Ue0TlVj7hmox6u4lumowyHMD0WKcfOVhiowCKKt8BHYowGNghbYUp4H3QfRCvMmshNKPs4TEGH09MRKbR7Y0igklFXVA8obsMY/VhEkhrdC94nhhuIM1JHTlA3OEUiIfR0JF2EOFIgNDgoJySF3QzVQJoy2WDThvsRxG0miCm/E1cYSSAGuwd5asRLQrojCVIxJCkhiX10hu40cWbRw6z4euxAwxphDanB7E5TpaaPMT4BLcBDECtYFHITeg+kFqpu9BR4VyyOB4aYo2rQld4MWkY66Nzo5kRK+NQZW6X3whiGECQTNNlhpB2NWAfeBzYlhjoJP5LlRdEB1gejJ0Y3RlJyTtQQnv/s7fhyfkLG1L9w+vP88ssf45E7Rkx0N/ycYLKPO0bB98HAKBTOpbKfhfOSeM4f/452TiWwEN4NoDhlJIg4Hkgx8Dg+gEsgNrBlkJsS1ehSSWR0VEzyER0XinBkaFI5vvhXver3oVeOvnL098LR47jZCW/Ix0GaOo43j7HecM9oKJqce06vHP0Z6JMt/np0lM4kIFroCZo4DYgopL4z6ZGFuKWZZjfIncxMuIF0SA4oBujDxLrNDK1Icao2yMduyVvCR0FTQT4ejUgWqhsyZhaU6I4jx5ppjWwDkQkfyhiOupBi4AL7xwhxDSdw0Jk975wtk61RI8CdnB2ZjHCl14ChpJxZUdK5U54EUyeXTnfFBbQMSINeDY8J12BYZqyO5gnNCR8fF4qAqB9u+WRyXNBWUXuENEhrp04cC2oEGcXa4dyu3jCvRBPGZtCCjjAiHYkoNbCWeZYJZCVroAE6BI+M5IwiaHVaEyQpqSeWu4SMjumO0anjcNhXkSNTsjnikFJlF4ETDDH6riTppOyYNJSAgDESuwMR3MvC/nnHz3+LxS5wyzf+8v6X+TP2d+MlsYswkTALVAdhibUMwLBQpqScp8zdaWY+zdzWFR8HKBEjiuLuJK9H/5MoHkcfEpo+PoCdZIpqQRjInPE4Q72QhY8wPnJBYxyu/7l/mrFEr/ru9MrRV47+XjjqPePTxCwfB2ks0/N8XINwWDeyXNC5s9h45ejPQJ9s8adtHPFCMuOih2t5DGwcuZIyhATMxfCR8EkZYxw7GQySM7ricVgd9HNGLBERSOr4quCCkEhkckyIGiN1qgUjjhvS7QFvQN+PY5DUQRqlZ0wnhiuuA+RodBY/bn4XQ+PoVRi14NYYOUgTcAP3jkhHIyGeSQJeBp6EQiLPDd+CIJgKIEod6djEuKPJSDEDNzabiajM6Uy6T+z1ACk08HG40A8jN8OHEPcGuWF9hSVjJOahpAzug07DvJFkMFSQLkxNuO5Oa040xUxxDXqbmfV6vO7XRO9ORfCPbw6MwzIicoL1jqwDrhXdnNJhjDjeLgik0dF1/3h9C8kK0xTU5tRNmRG0O5KPh0OI4g6hHVCWMTPf/XSv+H/c3vPN7cJZjoeVjkJpg5wbNs5c58x0mzEaSWHOM6fzHffnSr3txK1x60rI4GQ7WyQyN4YbqkoSPzJEoxxh8DcnKIzJQW7k5UzTO2Ia+AjMIQl0OmFOwsgx/4ezuF71h0avHH3l6O+Fo3k2tvOZlCpaDXoiUsFTxjTovfG83fCXhk68cvRnoE+2+EsjM8K4DKUGjAhMhBJKBDStRL+jToXlaSXFiVtriFVGdjygbYIMIyj0gAcVWgjIlXmHrglJx3HB0E5d/DgCcWfZFemCPO7sY+JoY+jH9JAkWMD37dhJzjM6G+veac0IIBCsB5Ns1PGeKUHbE9ES4gNPyvCM7XHA+SyYdvYePKZ7Wmusix+7Fi3HkcYIYpuIMGSCZNAjuF03luL0LOgMchVsHL0PETtSO4HR7e3hJ6UQ1kiPG6dmjAaqAcvhi+QreIEhEHUw5k7SRImVrTsjFmReeE43yhOM4WyxkJkI2xk2YBoQgYxAtLLticZM3K4EM9cXJafBkvw4PgIG4JZgVsbTmelkTHFhG3L00TioKsOEEKeIUNywGJw0uEOBny7Kx2vhnSRCCveSmVSYNJAmlLoT80SsGZ8amkAik5aZu/PC9bZx6Y30EkzthsfK6pkqnSaJpAOLYHSHfmQB5y0hHL1Peb6Q/MyadyiF3cHqwLxhUek+YF+QtfyHt8Be9YdCrxx95ejvhaN6vnKWmUhKeCIPAypZ4VETNiX2dLyJs/7K0Z+FPtniL9IDXSeSXDERJIOw02vD/cxXZWP/8g1bzyRO3I9vGdNGk2CQEZ3QuRPjCIUuzVm3xvmcuI6viHbDtGCiR7ZkzlBh1spyn8hl5ikEYqPblcxOpEGdCzEyMTViq+wl4Q8zbVbGtpFuHV0yY5vgminnwlyeue0TOt0jw/GyH8HpLmQVJBl7CGu9HTft5Qk5PyC6slXFkjBGBRnkeaAO1pw8hOGdh1XJDxPzxUn5iqyFsExKgdBoubLrGat33NXBfFWqCPXsxJrpuzOmjWIzdpuYWyNyInlh3p3NF8abzrTs3N021ltwqwl5s2PNeHr3eHh4lSt1BD0ClwrWKBmmTVh9J5j47FK43CnjPtAuTLIj6jTNuGRCgqFB+SrY6wO/EYlzu1L0cOWXHAzrNAcRYRFBayFNE7JUpg8TdjPGMn53zx9AwNQnvjc+h5NiD8JEI2dD0kQwYcvOZ1256kZV0OVMdmEujdPnJx7azlhfuOmN6y60+Uv0/MwYN/xc8XRPumZojd6Pnei0BJEap6Lc8gP12w/coiDnyiITpA69ot2xYbQ6GOunaU76qu9Orxx95ejvhaOaEmnbkadjAMREkK5odtok6J2w5GNy+ZWjPxt9ssXfGB1djxH0NIFpQeRETYMI46lnposy7Svj/ltqWZkfFNuCvncgMZPxDTa9Md09Yb3z8vx9pqak6S1+3mnWsFYp7UoHLrnQozAtoDfn5IXwoL9xombS1dgm511amIvzed3o/Uc8bcIUwuMpENu5CWw5cTVn6V/Spk6Zr4cvlghqg6QJJRO7Mo3KKVe2/Q473xGzobcJuf8R2+jEbphkpCTSOTNZ4GNHnjbO9zfe9j9Jm36MfyisKsg8UBNGNTYfZH/HHYPr9+5pdWIbM+ctEeGEdto1cf0QJB/cfOLmM96fuLP3mO3Ee2UdZ/ra6Ncricr50rmVG2+XgtXGoJGmzuydaJ3JAxvCmoPRwPyeW1LS/AOm+85JE3WfuLniTOSWWG4d6xd4845v3n9B95lSOnMEfRdab8QymJKT1qCtxvZVQbRg9R3y2284/6v3PP9DH/hJGVN//49/hdP8hlNfWNZ05EGeB2oN2Qr1/MilOeY7viUmN970ik6GP2QulxkpC4OZm6zIfmGeE89yx2N/4bzD2IO1x2G4OgtbdvpmTDETS4N44TNbuQ1BfaUnYdgD0RSiM52Dh7efZizRq747vXL0laO/V45uD8pUzoQqu3bcFXnuvGXnTUqcpjfEfHrl6M9In2zxd1eu3C2ZSDMjKUkga6ZMhfeyc9sUe9t4/s3g8y/fkm6PpJsg9ZnbqGxx9FMs86DK4DZm4uzMfsfl/gOPWyPtg20YNy9kc85rh1tCTpBOVyIb8r7S71Z6K5AK/kaAiYd2Y8oTKT/QeCa1nUkmeghxDbQ5y7QjtvPwGxdO9wn7bGLrg2BH80DGYPSB2EzOM9acZbkiJzjZiX0r4F8yP+zYfKHXyrYHt2rMomS5J76YuZs3vv3h4P3Xb3hzemLMK2lA3zP7CFxg+MLz/sBogbx9T9wb++0L+vWFwTPhg/Qs8C4zJYeT8XJbqPFMs5WaX5ier5S98Xwa3LRSv83ke6V/dqH+eKX7zrCNeQNuGVCYO2Ue1O1ziATnb3lz/QoeG8OfuQvlPmX6HDRdYRH49o79r8CbfGG5/4y43GjacTshCqUrZQi7OVu6Ye8S0zVo31e0NJa/+MuUUXj/Z79hnP+6wWdaM7/0l36Jz+6N5e0L0k/cxg5vTtTPEuM+KGtje1Huh9POyjrgvQtluoNJERHKtLGcTjx/9kCeLky3TNpeaHqj2cSlO107bQ5ElJwHXjZuMdh0Qr+9UMOQ2z1x7jz5C4gxmWC5sltwZeEUwZ/4WS3AV30SeuXoK0d/rxydr7+MTp2oHwh3ZLkj6z3by8KPc+V7X/yIt/dfvHL0Z6RPtvhbipAnQa2jqvgwemu0sZGn7ZgeexGm+XPm9y9cW2EAyILqzNkz7sYtAmLHQggR0vdWJGfih3es2xN9XJlUOWWhmHGLlRfZsPUR/TDzZIXHOhgNcggPOiHTRDVn2B3WhfO+0ceEW+Y8NWpeDm+oaaPfdbavhKcEX6SBUjExJM0MmRkhWB6MeaPlYPxwYfoysffK23ShjYXrnunjnhyNO3OiJ2RPLF6JXWHKDDfK8oFWOoslJpRWjJ4UF0PWzl28Y86J9+sjy3Xhw/ItYp37cY9H8DRVti8TXzwL92sGL3TOVG2U8+C5bqzSueuJz6vwfoU0brx/vKM83lO+vXB5/8I6AvLAfEO2IJYzDw/f0t8o8f6B+IVGm+7IOhFLxXZhbsZcoN/v1P7Chyx8YZnP9Ylv04yUjXEX5JYpfaJYkEpHJIg+MH0huLL758T66zz+K8r5L0zUPxHwmRFtov+4M06Vrz+r9P7CV198zv0M9w+VvEHdTzCfiRwMnTDggcSmh92ADuPUNx7HmRYrmx4Pjv3DD/lRe6F9ntFYmEfG66D4TrZjcu3lJRjNYWl89rzw2+kt5bNB80G/KrJW3Do5JyYRsE7c/YwX4av+wOuVo68c/f1wtDwpmGAnJduFmhpPfmOp9+iPv3zl6M9Qn2zxtxej54U8lLY6vTlDg1gM44HFE/Ii3L76hi9eEpoHTeLolVAY5oyP7vRFDPWduGbavrJ4xhdoY0IlU5KBBhsQkZl2QXSwPFxwNWgDGUqflBcTdGuk2SllZ8yJfjasDWxA2RdU7gFHWuUaM/2rO2a54GRiC6YkuHRGvxJ2NA4nDZaifPOl0xqMfKW2BxJwpuFJES2k7qg7veyMSXF7pF8c9Qvn3OndqF0hZZLB/b4xxiAk44tzGwOXwYOtzA0+BKzbjncncrCnwYcps2yF5IrnYE8J8Zm8FnIdVGvc9MY8w9O2sF0auV/w2GmzHw3VAo7ikWkVlMrt6cYjCbNMbhe0zPT8SFMhDSWrkKm06BjOYOWHl8otF6YKtI1doduZ0o3Ydtw6yxzsoyM/vsfuJsq6U896OPn/FaX9GnTpRA/qdGPUd0QunKfGZ60dE3b3C5oSp74yesGicb1PLK1CroBirWLamd5m7rczt33mNl3Yll+k3X4D+eDICa7pipgjruxS6KZ0CXgZ1ItyOxXKU0f1iXS3M+eBcpjUBjD6oA9n6z/d8MqrXvWT9MrRV47+vjlqQluVtvW/xtH9laM/c32yxd/woJPobvQatM0/xtFAkQkdSi8VGe94On2J9071Y9TdCJB6uK2HI24QJzwFdqlInpFtMIUgkjBXwtthg4Bg5YxpkLxRTNhGwT2o43CFPy2VfRYiHHNDvJD9hmqnqSJjI0QJMvMzpDkQP9MDPDm9KJ1gV2d4J/uxIx9AlhWzM9WVOBk2HGNAGB5CFQ7ncjkibeY62CNjdqN6QUyYRiVpQ02JBtqVlo1bNnQZlLqz7uB2RBHZELp0vF8Q7zQ9k/MJ629Z3LnbBpfbFcik3Bk+aMOIe+M6gtIHdqmst0FKRi6DOgYNw8OO3VfK2KqwGKJCGUAIEk6WxD/wZeXLefCjFvyL44HPB0y7M06JddhfMz81AqGCg3ig7oeZ6WSse8FCSbnR9pneOsM65AIKPSBqsDytvCzPPMXXfHavLNMDUxT0nLhOK6YZFSc3ZY+GRyWZEWWgZmSZmW/C8s1KYuf5fKGsZ6a80fTw1goTYk/ErsQQ0pyIsmA3pyfQUslzx+2MFEEmR9WR5sgNNJw7/TQzKV/13emVo394OGqSKWnGLFFVscdHPh/xytFPlKOfbPEnLeM7xAAsH7GNo6GtsUhD+sz6EGjMfMAxjAghIeQItAdKMIbiJFoudL0wAd4ntCdIgUswRkd8xwSczMf4x8OIsyZyP5ziXRU4bBC6GbHDtA9CA1FQF1wySmPIODIqW1B6UHLm2TsSBsmQ4HCQl4HoEY209SAFpClTbieKBWLGCMF3OUwuJXA1QhQdA2Ewa+LqYJrwRRh1oABDsEiQB3UajCxkACo1EtE62neyZYYooylLU3J0kjZuKgwSBVAZSJ7QPrCtsrejv0TTILmh2vAUiBrJhLAOMfABQx10YpoWsgnu7TAkJfgvfPnMf/dP/Ziv5r8+kfXjP535n/+F7/P/+LXMVQtndyAx1Ag2LDaKgCbFQ3APehdEdrpmbLqhtdF2RTLMVlELJAGR2Dfj5cOVF/vAB5vJz3eYTricwIxl6YRn7lLwosJQMOkMDZJmllD8FCz395RtJtt7SlFGOnanIhnUcRyqoM2QrpglzulG7U5edjwroGAQKoAgIqiChJBdv8sl96pPUK8c/cPB0QzMmiiasZzI6ihBqQ3Lrxz9FPXJFn/eE14dUSXNeizg5ug2yBLUfdCSMefP2J/XY1Oicriyh5IQxBUiGGY0UyQgpkJqmZjlyHEcA4/DTyhFIgyq7ORIhGbG1Q4glSCnTkSj9cCHIBH0XvEMFoaODBTUroRUXJQmCUZmLA4VyiZEKCCUCFoSNDmi4/C0soWEcOpGGoOumZsF3uMw5jQhsuKuZAaSGmWHD915e1d4KUL1ibEPCmCqSBa0NIoFeT38vnJSxv4xkkgVjZkcCzoaOQbERsXZ0o7PgdwLJhlrGeH4/VvdyLoTFoyTgw7GsZXGVdEAiX4YT42FNC0kXw83fQn+c9+78j/9ld/+Xdf+i6nxP/sHf4P/oX3OP/1XT8xThw1eAgaCYYiDuhKhBAMfB/zdgiobxEofM8ZMboA0hjotBmhibBsvzy+8yxkrGUmDB/uCpXwGJ6eKMSYwOaKNeqv4UIQJo5KmyvxonLeJz94XRoKLGxiMMeHeiREQQfQgboEsnSV1xoB8CjaUGI2Q34mlAnEnkh9xUONv5lXzqlf99Hrl6KfP0RAhSyZboWTDipA1k8X4cH9B8oRsvHL0E9MnW/wNd8ICSYGXTsrjgIbAPgrVHdVMkoC247kdgeXEcaN5OsbDZUfscFC/awW/ExZxbqlDq6RwRhKiT7grsWzIvGMtox/zAUc6+l/UneSB2IT1TJHOyEEfgrVEhEHuRApUFe1GQ/Gp8Jx3chfmgNvmR15hCQg/sijNSJOCCvnScFb2MdFRenFGcVSEIsduuoszhaBtY6+DbJ0ihympBwjj2N1aQiShAbPXj+HfCVBqEpzD+DXcsXB8dAJniBDmWIY2TYidSHo9vg8MvQgtOhoNcqPtR1g7Majd6J4JPXal1gORMyVntL5nzI8k2/jv/emvCY5Enr9RKuAB/62/94l/9q8+8KGBtAH9iIsiKd2hNWO4glb+Exq8kYn3VfmLCqMLPRx3Z6sL4p2dRgbO2glfeXqGxR2djFQSs52Zl8p4XAhLPCsseWceSu352B2PI2w+ykY5Gw93D1xPV57iRjxv5NiJmolRkOioDbxACkfouCmqHZnOtHZl8o/u+s3R5qg4WBBJP+5oX/Wq37teOfppc1RtR5IiqoiClIA5UDGKJHJk6u3EQuP5b8NR10qKShsTthlDIV45+nOrT7b40wgkK5EH4YO2H/mTIeAtkHqYPg5pmBUGO25C5DgMQHsnIsDGkV94C0bnOB6YnN52tB8e8oHgoqgFk0FmQkLpZZAWIyrkSEfPhQaBIl0plok4Fnnm2EHuEmxJUCuAEk2owIaQskA2Yg28HbBSKxiJpIkpHKsNaTc+zAOqwN1E00TkHYsOzbERiAoWRqqNp8l5aEofTqpH4aSmqBwNw6BIzzQZtCJsNaPhqCZ6E/oYiDSyOGKZSDBEkBzMPvCrMcXE7jdCB/sceBWmGvSWwRLelKiD8JWondBMz4FFY6ogOZimhBQj5Mw/+MWV7839J19/ge8vnX/g+zv/3K9lUq+kPojkCEJFcTf+4aT8t093fKV/fYF/HW/4J2j8C9JBBvsYSAfvx9FPlcaRHb6jFbzMZD0xpQVbJ+I6wx3MPSF7Iwkgx260uZDEsJKJZWa+v+fN2/c8jzO6OhoXRhS0J4RANGBWigRmO11OaB94LAyuiAORcYLGjrsjIuQ0kdInu7xf9R3plaOfNkeLOVbGMQ2rwlDBfUJdyGNwJ5mvNaG5M170J3KUAB2GREZCKPkC/fhZIvHK0Z9DfZr/KiDhZB2MrowK3UFDUHeK32h2YljDamemsLocfQE5EzGo4XQUiYV5dU51ELmSm9HMEE9IErp1qlSGNAJllsSpZ3Q4azTGNOAmmCrdjN0T1pzwjujpaGgugSZDo6MGmx6wSh2mEli9YXWiZVhdkOnYeXqkIxNTFCFwF3QLmjTIM15nYmoMz+goDFcqTpGBatA3o3eQM1i/55IVk054x3XQI4iIY5fdBzWC9GYitYzIhRSZanbkVnboPjM0YdlBQPpA841pcnobmIN1sBjsuaMvgvVyxP/4oI+ByYZJBze8BxHgBne5k7PSY6YN46s5fqr74DRVxtbRceyzwwc0Jwb8Z0T4x8+Ff/9P+hzlf1wm/vFN+BdSY8gL4kYZCl24VUM2hxhc5Ur+8TtignZu+NPg0ZQ7F9JYGCMxSscmZwJyHL00XTK1nDg9dPbvKem3T2TrbL0QOhBdIZxQRXIipY5pZ4uG+EK9HhYHd31gSfBsdDF6NZILeWSkf5o71ld9d3rl6KfNUVPFIh3ejckwhRiODqFYRdxQEz70ztjWvylHvQ1CB5ELbWTSqMxTpwfYDpGUUcYrR3/O9MkWfyEDRgW/Z4wEY8NGp4Sgs7DNDV0Gy63SRoE4onqsJtBgyGBHaPuJvncKG3o/Q4XYBjYlxlSg7OTUEQ8cYQ/Q5pwIsicqFZlgt/GxHbqQHBQjdSFKRnVG1Ki60rSjLjAM64PzvCO5QT0RSdnbjSLCnCbchT0C8c6wwc2CqkoKYxkGRXEupJ6wSJgk+qSM4hTdsS2xZehJuE0ZWwriF6zWYyctSnOjjU7aOkUTsxeyB0uGa0+MvKBR0a50VRoN9kGKRB5C6zPMZ7Re4KwsY8Gj09t6TMflo08kWeOcnZGgWkMuHYuEz4WaHY3MvGeuDnFX+XZMP9V98O0lkUcDPRq0PYzUBqe+8d95+/bjsfG/99xYRfAI/htT4c83h9SwfBwHDDnsAqQF7ok+bjxHZX/ekZfKyRohwqMmJHa2+YFQp8SVeSRSL4gYQzN5yryRyjoSd9MJ3y+0faL6SrNjek4MLA/EDPNMxEZeJj5cV8wz2Su5NawkqiiBYR1cnJ2f/Gb0Va/6afTK0U+bo6RCxBnv58OPTxtKBVWqZVoN5q6kqsQYv4ujcz8sbPYEfhaiZvTF8GFoXNHa6DFDFuyVoz9X+mSLvyKJiMJIBbFE9EqNwTChpELaLsc5vzXG+XB5X3rAJiANKRvLEFYJti+V6o15OXNrF+xS8XLYH6QxcxoGN6NlZyw3XFcus9BtwapyXpT+UulpkJeFLIU6lcMGQBLJJ9iDKzM1bqgqlgydBuGd57IQUQEnHHpTXDpKwwKGGbs5L75zjcJp36i5MHXDpgXVYKJTxmCEsON0nFl36umBeqpYDL70C89rwFAKM4pRaayxIcWYy4lBsMsNkxnHIBY8KXZXKflGvwb5xTCCCaX7RIszWR9Y5qCPwqiZB3fi/J7LTXhaG00GS3ZWcTZxxIJTMmQqjDDqJfhcCpJnShj/zsv3+Xr/EZ+X+rt6/uA4cvlmTfzau4XzPNOnD2z7RlszMgp/X058afYT7x8V4UuBv3cr/OsqxNQxdVyd1DsiO7d8D7cjfujxXfCyCD+QhMs996fvM70NNBLSJpxglQ5TIaGklHnQifU2ePv8yPs/nrC/8iN6gw9tYtOgpw1sJ2kHP5H3mZKuUJ6Q08KXz8cCHj0QAtNAGqQ18Lmj9tO9HX3Vq36SXjn6aXN01jvUDJdg1URHmaLB7lzXRBovlN740oSn+f53cTR/PEbXdDRaDxVKnPjAjVMI/Q7YFb0YUV45+vOkT7b4uyvBFIUYwbCNmAJ0RsWRdqFcdvryhrZ/xnh7QaWxdkVwVAfJB6k5eTxTNVFGwl4uSIIuBZOEeiPazoiO9QySyC6IGpeUWfIK6qR4oOXGrQ/STbm3matucOrMBrYqPhKuQW7QqhPqsBgvyx2pCtVW1rVz7ve4z2xZyakx2Gk6cA2mLfBqUCfuzeknATKzXDl5p6jiZpQQxjbRGQyFh6fBuE9s/ULzxKhnvCkag1GEaUrMc4KSUWmELnz79cb8CDpD74nRBpM3Zj3hZ6VPRh2Ov//AqT7T8xlXRypIHvjDwoUrdq1I6pShNL+xScfL/ZG3GQ2jkwy4A/3iiu9f8flLEA+D//Wv/t38D/70/+/orfkbCsCj0Rr+if/vl3zTNiQtNDNyH8xd0Zh4tJ/u1n+YGrdxpQUIQvkInJITCw03sAjWl5Uf/nDi/f6C1d/m7d0vkZa3vPEXlpdEK49w7lh2PIKGMincTW940l/k/ouveXr5DNbGvFW6N1pP4MqwxGpKl4Z44uadJVeYjHZ3IrwzwvCWkeHU/YbUzvn6yS7vV31HeuXop89RdsOTHG/+KNAKvW3YuHKu7/mA8q6tf1OONhVWa1gE53ZimwzJjS05qT2yq8AMaWx02V45+nOkT/NfBawDQhW3jSJBlhkkg9Tjdb9NvGTj6dYop4TUhe3NYKlwvzfyHmw4njP3e6L0E8/9ibs3CTm/w9odN5QtNhRnypk8lGlVxrRg9UyaX7jWlWcCSkHahq9XnpOCOC/bC/tspP5IvwZbaoxkKBPFjXkN4gLX8g6X4FEqvdwY6ULJhVkKMhJXMjc1il55WCu/XRJThivvuUOZRkLVYDhancRAbBDmhGV4gfnt17zIZ5THgYRSLw1dByVlNCVaBLIrze/wi/JmuqJeaZugURDPJHHQhc06QjAhmM6YNla70AlqUp4TXHThXTzQ7z8wrZnQK+39wt0+cVJloDQTNhqyGjnNjN258xfGdEJX589f7/mf6J/kv/knfp0vS/1r1/6brfC/+be+5J9vyrwpuyn6PEhxh4qjvfNUf7pb/2u/kLeOJME1EyaMSHgVxn6ljgd6ecFfKtOWSQH7qfHt+m+il1/mdP4eiz5hfiKRP3qegeLkvLGNe8b9zJTesn/vc3y7Eulb0m2Ha+Cb4Rl8qexJKC58r0z86KWx50a+NnIrVE2MFOhcITW8Jlr7NHtVXvXd6ZWjnz5H3XeGCRE7Q4zwQq8JmtK9cPviwvyXy9+UoyFKSZkRK/t2wVToHry5drYx8DRB2hixk3d95ejPkT7Z4k9zJk2KNKeMzilnTGb2MbH1ihWnFeN62ojbzsIjZokiyrDjP8c5VUPr4Hq6sW+wRuez84mTdHru7C5Ey2h3prEhsqJx9Ek8jR0d03Gz5gV5e0J8JcUTfWQmDI2jL2F+OPGQlQuF7o1hjV2Cu+fB7Tqz3D0xL8qeFuq6s+0NDOaRKB00OiIN08bDfaGnnS/2Apdg6IlYgph3hE6LxBZCzxurNc7jke16BGtLX4kx0CVjVujD6RHMCGcJdulglZO8YeKJNRdcM70Nxsg0hLtl56TCh0vmSQpM90AmnzbOc6IVY/u60C6dp7RTyxXbjTE/EF1IW0NjQyaQZPhwpth5uiTemlLuhW4wfOZffv6j/L//wi/yJ++/5TFdedeDv/D+jm2v/OCpMs8b1/df0yXIUeghjEn4Vwt8HcHn/O6ePwCP4JsIfnV1psic00RLTtTAnlaGrKwiaJqpkfAIpG9crvDDmyO/VTk/7Lx8v1HmC48TJHtz5JpqwpIQ+Yb4e+ZT4roJZ1vQk/Hu3eC5BiMGqjs2dubrIOeZyxfG+uuDuzHYz0rWDHamhGFUwo9mblcY0/qdr7tXfVp65egfDo4WM6LDRqfKC3K/Qxd+vA9+8K39RI5qUUpZIDKjdtw35rsX6iYUT5xawkb56ONorxz9OdInW/xt+0qWDWahj8yqgcjK5sEmht0nOsZjHdTvN+pN4aLUAWZQTBFgaCDu1Iux+4nJjOsKZdnp+0qEHEcXi+PieFX8ltExYQLZlVvqdEkk5sMTKgzPhwUBKIsKixutOl2FGEJug2TOmEDGwjyUe5z5MkFUmkLdOy06yTKSCp2Ee2GZMmyK2Ew/A42jOXrYMT2GYCg+zqTris87p7VjZ6XjMAb0gQWHAWvoMc01CdVnFnbSZeKdF26SSWKcXbARqG7I3ulhFO/cCdzEaMlYLFFkYUzCpjs3q/R3wj6UWz8MV5c7QRbYPBAgN6V3CNnZx0wh0aVSJTG0Iwx0BP/G1xOtD8Qbo3e22tg+7AzLJLtHfWNIo2tn6MDN+V/2E/+j9IBH/HsKQI/jd/+vXhq9F9YFQoSpGRM72HrYKTBRQojN0NEYZWc/JdJaeX7+hqdfP/PYEtcvvyK+yCwuJBKSFNcB14RWxbJz/qzxS09v+LX4gpR3cnkitIFXYhfkNpjXmSZO76BnZ1w6lAfEHXwcXmGScIVkwlR+uqGYV73qJ+mVo3+4OCqtov32kaPpb8tRNSdFwtpCJ1P6leyF5sY8BM8BNeHxytGfN32yxd/wAXFMOomn4ywfGP2jP9Eo7A9wd0nEfKY2QVenyxG4bQQWUM0IMXKtTCm4iwXXSm8F80wRkEkhK12F3hTp0/Fn3hBrWFS8dZCCkCg6UXNGIqENknREhL0mqoKoY93RBr0YUuKjl1JF6Id3VUq4OR6dkR1PTutKaYK50PWeqjtJBJF2WBgERAgQ5OGwFrBBVqf4BH3/GN0Eg4/mrpHJIngKVoVijUkq2jK7L4QFkw6m4niCuUOPzGgG5sgdcMv8/9n7u1jL1i6/D/qN8XzMOddae++q8/Xa3Z22HeRgLOzECSRRBIqEREgkhJBAQcICLhASAkGUOy4QQhEXiFtbGIkLIIibiEtQYiAmKCgJwkmcGH+Etml3u93vxzmnqvbHWnPO53nGGFzMstt2q+1Wu8953aX9l+qipF17r1V7Pr81xpxj/P+6DYR2bFvNibo403kw/ziRxkzzndYaquBpsMtg2IAQBkIlaOzsArQz1gSbjXVyxDq2r/i2IWb4UK7RWPpgy4mshVSvKA0xh4Bhyr+xbVAS/93lxFd/U/H3jTt/Ytv4N71hyYieoDdEMrtCJMhLhZ6w6MhuOINeM94G6XHiSd/z7TRzWS7U+69IpqQxSDkOO4UIhjh5GpS7iewnHt5cWT7cs6xX+ja4jhc2nK4QarwYpCtQhDgFfLOQS8alQQwICDmGqbUkUv40H1e86vvTK0dfOfp342jfHOjEpIRPgNAUam8MyXhxzF45+vebPtniL6fK7Ip+jGsJPbpDNaW4Y3uDLxPpLjhvhUcXtP5aDmK4EKqkUo6MwKlz6oM5d2wKWJ1JKiklXDMax+ZXKNRkoBsprXgWTg3MdjxDrolJg912kjjqigXseqy8C4andrjZW8bH4URuOtjiBhOYL4gKUR2TBGIQdnSiuhL9nlEzo33L/ZiRkrB8GHiGyxFh0w9/JZEZZWcrMymeka3gkRi1Hxe/p2MbTME7XIqRRjDuCkvLKJ2JQVSnS0avM1r8mOmg0aWjDpVKWMcJSk3Ml0y5Kvl0IvYrpUNbG9b88Geic6BTsKpoFLp1vs3CfV8Y1+1jsLlhoxHbRuw7DMcxnml8ls70eKJHIsrOyYOc8rE5tirejH/Db/ybZecPMfGVGL8cnV+4doJM146bk9dKzYpVZe0Z5Yyc4ohT6h3JTq4OU2dsG/ldocvK9fLEY79y5xunMZCmZG+kULwWYg6oO3eXRHvM2L1x/zZzvSb2R6XdoNmRWdoXZbTOKTtTOGyZvrzByoA4vLcO362BY0g4YvtP8wi+6hPQK0dfOfqb4WjPHUnOkIRmpTHYhyDRXjn696k+3eJPM1MoaKYnxdKgC9jIyC6IrFzWCfFgX48h3Mh6VP2WGJ7xKuQCU1E+2B2nx47dv9C0UssGkpBUyKrAOLrJoqTaiY+zFvgDc23swxhMJCoRjbzdyNVA/MjB7IUYg1qEYRyu6EnwkI+B3gX2iaSHuae50BBGqog4WQalOBEDDaOuV/YdehjJCySQLIQr3jk2xi4dWwu3M2zcuB8rGnesKTA62YCjwaMMZRqCWqU1YdTCVDcY/XBnz0oUxylH1E8eiEPdd4gbPs2kXqhNDpPQvFC1Uu5mwp4RL+icYTSKC2cTOgdsRw32rVLWGy86KHpl1MFYd/qzEeJHduOu9F3YU2doI+SBcnuhyYW1KbVVaoW5dHJ2NoXmBvvGf8COs9AnY5lO+GaMvWF9EAiyXNglo6bMPfB9Q0pQSmWoklJDfae1KziUK8RLx7cnXq6PzC+fcZF0RF2V4zGIDCHlwv3aGCiRMpdUDtf9M+SRqNeEDSMkof0C8oxKJT+eWL8qPK2Nu2RMfhjmdhdyNqbRkfXT9Kd61fenV46+cvQ3y9GybwiZjYU8BTrN2CtH/77VJ1v8RXa2DEgQcRg9aj6CtUWdwFk259mEye/AXygS9JIZFNIoFBUmG+QxmPNMYeB0zn0Cm7iF0lSRbGQbxA2wzDodjvUpC080iu0kCfpw1tbQqpAMZLC40OtCS8LcN0adSTqRu5Fd8UmQa4fTjj4mot6Rp2O2LW0Jk4xM+egYfYX+gFyUn//mkeuY2HQi5cSujRGH8WkuibRAUaBsjHxG1g88ywlNN8yNGA4pobOTsiAdLCojFsY8U8fKdHFsdHRU1KGMlTwVVnN28hF9ZMLUII8OrbB74DlgKeS7e+ptRR9h6oezOqWyNWXzgaUdqZ0w2OLKm3blqRbGdoaSENsQGxyfNGAmbAS3BKYXvt127u7usRcn7wl2YfeBeTsea+jMbmf2trHkwSydmna2MTEIdiloTdQEkgzrSnKj9I6EM9dKJFjLQLqQeqKo4XPhpe/EGpyunfnpPfv5Df1e4byQy4TYTNA43TlIJa+DqX3B54vxXCttyYxe8L0TmyGRuKUFhsGcOH0+EXYjS3z0CgtcOR5TzAnxxvh0j/ervie9cvSVo68c/TQ5+mm+KwCD/pyItByDxBhWOjE51SpZChadXhL5diKzU7eNroIlSLWRkxMi7ID4RqobH9rAs3Fxp1iwST/mFdwpVhFRzBPDBE/BZLDvZ3wMhnXUjdYVPc1ISowtYTEoZUOnwS5OioXcJ5JBzsEtDXJ5Ir85s22FXRUpDdGdJQyY6GPC+x0aNzYb7LNzzYG6otP+cd5F0RBKEqackVtQrPPMoEzO1StDVsqmSM8klCygtdPTERI+eyYvTvUXZptZy8SYE7oVzo8VyYFJxzyjFUwTt346uvIK1vPH7jxxHjNdb3x9noht4HtlWEBXMp0oRsexFuQ60NvKYhu+TUS/ENLR1I8ufEuwQ80gVXnXhexB7wOpxrSD98EwoffpiF2aBMnBvCoFwbmnbAXzQcqZYhlzw3Kj9BvnmBAV9jTQKJQ1cdOd22ycNJFyJXC6P/PB3jLWD/zk2wvn+o7PHhasg9/u2E8zNt9TNbFvzigNk8Ld+UZ7s3D+8DlvvOHdiecNN8e2nfCVTqcslSuFuSWKOJoM6pGdqi40d7YJzg8/7UP4qt/xeuXoK0dfOfpJ6pMt/oY5HSFpoHkgcQR4+1UID0a/0FjJ8wu5vVBGpczCkkDSIFJHBCLVw4ByfSGWZ1IrBDvuQcqVWYUWiqVC0YmkExaZvA+IwUUNsrP3TLYjdHwMZ9szOgQzMElMI+M+EHeSO0Jmr8KQo2vL48K6TDQzdDRKCrQKWIAFU1NqgyjBkgaPOnEbZ0qtaA7CQT/6ImWEMo5Dvndh1xunGswkzCZkcXQyFCNCiR7HrXU3ynLECKlN+FbQuZLShuwb+DGIfVKDutMSyCiUsxPJ6H3ge5BXQ3ZjW+9432/cyorNivaPG38qFGskBLVybAJq4nrKpK3QksPoEBnLCXcHO37HNQTdlGTGm8n4djQQKDJIE0hMFJlwhdAgIRQJNpRZgpwVuVagkzHUE3Y7keyFMg8oBSdDOM9y4wkIM0Qc04SQOaeNNBUQaOsL79eJZT1xuZ645BOnYuS8kSXwraEPhlwGvg5KFs6XN9xWYdZEkYaXjV0Hk3VImbIb6WyMZGSEbI6EE3KE3bcBKQUn/Kd9DF/1O1yvHH3l6CtHP02OfrLFn1qiqKK5obUhAWnPh3u5KtGckoTed0wbLhnNhn7sMq0XXBKeM1o6aMZ1ovrCyMLAGdOEqROmOAkLJXEMN9cYbDvEKbGcOrM4MQRPgvZAi9NQIjtVQdYj83DWYGiwl4EpyFDUElZnVgOmnTIaRCFkpovirlQfzHknSubkzk96Yk4T3oMoSvIjTidheM2sOtDSGD2xdZimRLk4ywcYOehSsCFIDLQHeQDSGQjZNkrKhA3yengtDR30kzHpmbIJKQy3jG0TeCfhtFFxO95XU4FJ4RrUZoQKtji0DrscHlmSyCjOQJoxbKYvJ6jgzw1ZlWGK01DrZBswMjTlq6z4Kejjnvo86OUZ8mE3AcejmGSZ6M4uiTVmZt9o8kzIPdzAc4asSO/sGXZ1JlEmV7CdvnA8ajDBfWDuTCq4J1If6H7H0MbT0878zrm7JOa58kUbPPgzNQp7rpy+Ndaf6bjPFCp3J+VlSdRslGKkU0Liidx2rAz6BppfGHsleSYZuCQQpSS4xKCEMtnyUz2Dr/qdr1eOvnL0laOfJkc/2eIvUylRER8kUyBwESLLccHON7I5IxZqMpoYUjdSyui+oH3CRRgYeJBKIuyOMmU2DKuO5kIYDDPCla4G0amhyKnT3sO0K55nanJaCloKkgzmxXkyQ4pTWydvAw+wKejaiTBqz0xW2TnRilH3IKVEpHq4o9sRZyTmhHTG3FGZ2a+DPCbKeTC2TrEg6yDTD18qz2ySaLKjVKqcYDR0UyIcLB15QyjiG6r98KnywdaVRQdaBTMlDeiRsbygYgxrNALbZlQF750+FrIYYX7kO2pBrXKen/i8Ku+G0sRgDtg70YOQRMiMG9h4ofkzeR9cPwtkZG6pU2JDBSI5Hs7oSgQojbtW+GFu5O1MacEuiZ44cuCiozZwCoEiEtQhhCduKSHlysQdK4Ux35jmJyI7g4y2Y0tPEEJmaoCMRsjxvQtK9MLYG3usNBLjcaJ+c+N8fmGZn8gJyrxQk2J1QtcE1xlJg3pSFknctyu358rLeqaMK/n6jN2UmgOrIN1Ju+MFmkJOiczxWpJ3UiT804ykfNX3qFeOvnL0laM/1SP4nemTLf42b9RkzAJiGVdlJGWII6zUuTOeZnJ6g+oj0gu9ZHyqqGYUQcSR2mneQAY+ZuqUqetKJFiGoW3CLTMkCFU2D1wb8wRzBV0H+yh4OtPV6fEC2nArpOho5cijRPGstJyBzpSgJkixExOMLmhkqoCq0rJjbIhnigg5BaaKRePaOnc247cNqU6MTMhA8iDpjFpFtnw41lehVsEjIy9Cm+sBLhmkFCR3zPTIzyWgCKNNbM1xKrMcj4HoCbKzyaAvoG2hxE5MA+kJBhTi+NpNWUR41oUsD0R+xAnEDtd2n5ym0D9+3yQJq05tK7qd6fuZ0TpJB5H8sF9IgieI4UdXegu8PzK1TD85loA8oQrFG3l0TJUhmbQ5qW0fXfAfKPrCtEBPDdOGqPL7ftd/krvyFc+Pz/y1218gqxIZah9EjOMuSBYcJ3PCy2CVKzYUWwP9kJhPxhe10+XnuT1U9GHlPAd2WTilwhqJc27Y5JwumfObzHQ7fNN4GfQxuNxOxGnjPJ14J9+i6S2qzqSGWHBjsEUn9Z22KfDlT/kkvup3sl45+srR3w6Oug5+7xd/iNPlLc/bM3/lr/0CTZySXjn609InW/ytzyvLFy8kyVhXRBQySBijG52JVCbEJm6nOxg7KTJHACAgGyqDFIEkSKVy9olry9gwphcl0iCbcDJlr46PAMmMJPjeeEBYW6OlQHSC0hB5IY0g2T2m63FRU8hzJ4uwSKKNY5PL0zgAF40Rb/CUcXlhHolQgSJoOcwoN+ngCdgInJJvjK0RJ2G0AxYqiSGw+2AzhZJY5p3cD+PWMe9UzQxXQoJMJ4uxj8KuhemUOeWK7gP3zjYZ4o6iREo0/9jVdSEio7VzV4UtO0+3wGoldogRGJCXE5FvUJTcZmiD7tAwdhu4bZDAS6fmhfzo1Jdg33dObcVTwUJozRluOB0RQ/uMkEllZolB5ERPGdEJIUg4tQpWM8NgXO2YG5oV60qNjM9BSsIf/vyf4p/7Q/8tHk5f/I1r68Mf+ZY/+R/+7/j//fhPUzB6mZgjkxuYNDYKL4tziUCfgtGu9FB2hOdceElvmcpnyJ2g7swxKOPMcgP1ldIDjZk8PTBfOpe7F277I/sYPLtT64Jb0NLOyQcpNwKImBBOqN4YstHSJ3u8X/U96ZWjrxz9e+XoH/yZ/zT/zB/8ozwsn/+N6+rDH/yW//Of+d/zCz/8fzHt9srRn4I+zXcFhCTEHZOgy7EqX11Iopg5a1m4Px9moSwZ6S/UIah1Yih4ECWwZOwG95tQp8DtxrMrY0yQM2jgqUN1RGAegrtg1ZFakGliylDrlYiOWoZUsVCURHJjBtK0MDRwW3Ec/IjjMcnsW8drIPNGy07aAtcDFKEDkmEKtiUqiqadqzqTVrx2kgapzcARXeYSRDaKDqZdOFfoa8Zmxz1hJCYxThihR/eomlAtkATNSrggmvB5Pja5xkA3xbJSTPFqbNlJATXvVBRpgfWdIQ41k33QJ8PSRNJHPMFgwQyyrVjc6BJ4nslubKeC6EbdBp70cIhPTmpK34/XTeoQsFWjz2/wN4n5dmXEMZAsApIrlsCG4rcdtw7lgmWjuBAo1834T/zcf4p//h//F3/dtfWwvOWf/yf+Rf6P/9Yf4698+/8gjfNxl0MbMaD5IMrOthUu10D3QY8bL3Ll2+WZu8t7zg/3zLc3uHbWO6VrgzkTovASTGViOQfLfmV+mZmeT0z3GztGTzP+/A7Ld/TaEQYMwVpnDCeHUHIhfZrG9K/6HvXK0VeO/r1w9Oc//0f5r/6j/4Nfd109zG/5r/+T/wL/h3/nj/GLf/VPk+f9laPfsz7Z4m9aKqcyQWR6BKL9eLeaKF7Ys1Hnhb0YaXfSUEIbkZ1ImchCVMOzUXohAtowGBunNDHqMfugEXj4EcFjgZojDWxSNCd0AemZ4f3Y8mqZbZqQAjkq1Y0sQUPZXIg4br1HzpATxTu1z3QVUt1AFdcjL1PFiPjrIFKkBLRgzsqHqfJ5UxyDKXAE9Yx8zLGtEseA65hZ9PDCetp2bIZikMfxPSFT6vGzsgWKg8CITPagZqe70EeCcMIaUSZ0cUbArRdKGmiBqW+M2NlDyfvC4i9UDSJX4nz4NXGVj8Peh5FnhJNRumb8bJQOqcKmFbYNeid5ZmLCRqabYh8jeijQdGJeX5iDI09SnD2MzQQb+XB1L5mCIzSSCetieBP+2T/83wRA/qbot+Pvx0zPP/OP/FH+V//av0VpQOpYNYJCCuWNJ24jMHWmZJgMXvrOu6cn5g8/prxZKEvlUmZ4E0Tf0VOQJDFPGXdjAy6rcNHEc0xclxNiV/LmbCcQnxjSj7symjD0GHaPICelxidKrVd9b3rl6CtHf6sc3Rbnv/CP/DeA35ih/8V/+I/yx3/5P8B6pr5y9HvVJ1v8lTmR83zEC8kG80bMRkQl+Ykl7aR2gnKjPwm1LbTTwGqQ4LAxCEdCOGumJSWedxqFN6lg2VDZSMFxu16CSE5Ux02RbYahVAarZdSgiiMSuAWqQkhGotK18RxHkPYUlaKHSapXZWqDqQgfCLob2Y7DnOWwHeghjDgex5QIYu3UOSNlZr/tWEtIcsIbRCCSyCHk7qhlrFa2sZFnY+xX0jJx8gkadBXIlZKVmo7Im9QTWoRNYE4DYUdiQjQx5sGkhi4TqGJbIrpg9rGrz+AeSFJOKVFK4V4TX2eh+YLEjRIr6JENOSQTvZPsyiiV03gPTekc30OiYkNQdXLtqAjWwCyjGSZbGS0TRZmAjGPWjtkSUQaKaiaFo9zIYbiD5MJ/7Gf/IR7OX/yG15eI8nD+nJ/94o/w9a/+WbCNPmBoJouS68KUOhSHGoxSuSXnZVz55vlr5P2FNH/Gaaosw5nN0VuHWinpTC0b89h5yMo+Va7TwvMYdFmZl8w3TJzWDRpQC71mzIM6HCGOnM2Uvq/j9qpPVK8cfeXob5Wjv/fLf5g3p78zQ9+cPuf3fvkf55ff/UWE9srR71GfbPHnUbj2guhAJ8gTaDJk3I64oHxP++Y9uazscg9LZS4ThBEtsF3JzEQNLEDlxOAd/eFz2uhHlzs5GoZ3JeXAlsCmQjzP8KKsMbgTZ8wb87wzidGrkjH6mtm9IkAU6NnR7IgnFmDeofWgm9KtE1nZmxKSSCcQB90S4QmNhDrobowPgn9VuG/C1QUflcu2M+GMedCKM7qQulD3BGpcL/e8KS9MlvBVUFdKdLILPS6oL2QaRkdqpz5MeN8pAc8N3AY5dSQbdVlImlibkEaiDEclEyhbU9rqyFhZJkg6mB8Tn6fAbiu39QiC3yNoLbFbofuNFzburFKeD1i+7IObQSkV6sxgw/2KSCeYsDEzHozSK/ayEZ7x7KwjYVHwBLUU6Eq7PhP+iJ0zl8j0ChdmPp9+c86ep/vP2V4ShNIs2HpQdPC+Fj6TleUFohXWIpQQuhvb9cqHb98xlQ/UXtBp5sv7QnJjlwvnPBFUSsBp3rn/auPBBi+/svOTlrCv3vDy42dmg+orui1ohV4at0tnk4xMEw+L/N3fwKte9XfQK0dfOfpb5eiX029uSWI532NbYQt75ej3qE+2+BslyFMmkYhdkL2QqpGSE124Pjh1DbbrTqLT087DVhAvdBRJgucgih0O7bcVnQvcjLk2Vj+z9begDZErjI4/GdISdruwRvBw98RNO7UIXQu3UHIaXNJgE6W9ZHBD8s55KphWRh90MWpzlj6wNFj7mTfnQi6Z0Y3xWAAjq1OY8JFAV3J9pt03bNwTd4/cfXbh/Xu4FUOzMitMwEhgS6aVRK7K0hP67ZnbZ2ekvTBNxpKhipIZDLvRb5nwE5fPnvG00y2T98RCR+46PQW3VRlboOmJWo3pXLmOjHllXhJy6vAcvDzC875z70qSM19NK+P0gd4V18TihkYj03kZidutMPwdj+dK6SfO8xPDNk7jCqnSPWGt4APG4vhp5WzGNi/Yy4lxfaSdjBQDF0FzcJEXSjLGAs1mXtaFD8PI9x3/MHiW22/qOnvXfkSfG6lkli1z6pn3gH8w3DsxT/h8j2RB2jPXnzTiuaNNGV55OQ9uXfkwO/fnzym34y4JVVEmIi5I65zn4OIr3yzvuf3oV/lc3+Pyhlo37Gkjbcq5CqYZy8eW5fTpHu9XfU965egrR3+rHP3m23e/qWvs2/ETNnlhmvyVo9+jPs13BSxxJY8NrcI4G7coDArZVyZryDedVn5AKxufrcJtKmxkCMW04wzUlLwtTAI5biz+M7x72mhfblzvjOnxSm2OFcEyqDk1NfLbr4ld0D7RcuImA5dMTfmARh+MzZj6t4xTpabE1Jz3oxM3O4KyEbIq5yU43XdWKRRNoApLg+uVaJBmw8tCM6Gpog9w2zbu16B+CD5XxZaZsTWeOqgkSlF0VmKaqbuy9RttuTBj9Gr0VvGu1DqwsrKmyq0oojd6dNJt5iYnWl6ZPzhFMvFloHdG/uA8r4VhlaU79BuzCNkztm5EbKR758uu7HGBBtdeybcztQ52y8R+Q1pjXkGsEMWZ/Yyld8i3O/q2sESiC0RWXAddB30HRzl35WTvebSVt3qhnldMMtd1oqWEV2doMNsgG0iqTG+c+XGhD2FOif/v7c/x4fYND8tnx4bj36YI53H7lh/+8M9zf4OmiQ9VGOlGwpjWDPnMiwvknewJvxruK64Lzz3zy+Nr9Knx5unGF/MfIF/+AT7XnWg7+zQzUmLKhXMtjPvM9Svn818R3qXBWCc+SCa0Uu+v1BclP1dKDri/0XWl50/2eL/qe9IrR185+lvl6I/f/QKP6zfcz39nhv7Kh3+PpS/8k9fMfc78sgp/Ot2QV45+p/o03xUgy5n6kMneGbvBELokJGdkHmBwH4PJlN2vbP2B85S5hODDWIezM9gVTCr22cRyNX6UH/m9NXOxKybHIUBgCqWK0kvhtsxE7uit4dNGmhZ0use7sN+emawwSkfijC4LXg1fEyXNyMkZm9H2hk9QT3ek2zGbQhzbWJKCZZlI0ei20RuoVGrM3OSOy7hnLD+EyAzb6KOjQ8ipQFaMjKyZWQZSb2xivLlUpD+T04I8nBiSeGz7kYW4CzUNplNDcuLlFpT6NTFmEGO+Gpc1c8mFsQibZLCNITe4E7IWRk9YmbEo6GhUeeRl7jzcLazfVt4tdyAb53gk7StIQMkkNVp2Qq58/lz5esmoXhn5gXjvjLHRFidESTIxk8l0Vp+Z95mnciGVnVN0ah6wK6MlyAnLF2pOMJTr9oTeOfUJTrmTfOX/9h/+Cf4r/8T/mAj/W+AV4YDwr/35fxk/b2z+Bp9mTqeBbiB9J8+ZuBywt9U4NWPJcVwvbUIelVu+8n678K5lvp12+Kvfki8Ly1zRmsjY4Zm1ZDgJd+uJ6ZufIVHJH77mtCh9Vfblgk5CimBgNM+Mq5AC+OyndgRf9QnolaOvHP2tcnTJjT/17/2v+S//U/+j35Ch/9c//y/zT6c7/oXTma/k12brfuITf2K78u8Qrxz9jvTJFn+9v5Bu71ADOmQmqGc2uWMbmfv8zM0b3e+Q83ve6ODUYQ/YcUwr2fXI+ksrtT2wdWHJD3ww8GTMdaEkgbgSFrQ6E7UwDxjeCdvRPsOWSHojSKgFyY3l5ORzIubOFo2hmUSmyoyeV2RqiBotKanODOu47NQmlOSUnBBZiMgYM51EsyubBzF37vyObQKNmUohFyPlwAt0yVhLhO8MD9A7XHdEGlcGfktILmg24jTwGiQppLqw50S/rZyeOjUbTRZeRmbZhJwnRjJOQ8lFmXLBTRkpKKsg1zOXWdmnG3vbyPvOPFXOF7jzzv5NZ9NOmoycoGNs5txa4rztFFtoK6QPnfLFSq9nVM4sQzBfaQbXkagK5hWlEPeVzz4sSH5mPyszlbwn2IxtDF5yJyZBV0Fa43xKvIvGZBP/0S/++/wr/j/nn/0j/x0ell8bXH5a3/Gv/vn/Lb/wk/83IkqPFWxFtk7sQvGZVoNWOgw4DyNLJtJEKYqVna1/Tf164du2cDq/483XP8f9bPhboU8Jk4TTWWQglpDxOf7WuH9jXH8IX+uETU9M3dB2xjTRToMig6JACpb6Uzt+r/pE9MrRV47+vXD0+tf+fez/+b/gP/+P/bf/Fq/Up+09f/LP/m/4/Ns/w/9svv91190XIvxPlgv/U7nxp145+p3oky3+0lCupiBKlIJGJtng1AeDhJQL47kx+T0pKSA8hdN6IokwJSdrYvRKNoMBqz/zlWa+6UqOe0aueHZ0TCQgFcgpKLdj/b2XjE5n+rNhm5GzEHVmLQWPjaEdwqAlsk2IJMw7OTXy4gyH8TxItXNLV4oonJS0K6xCr5WRCzqC3DbClEyBt+8I7mgEsxpiGRsJG46MRCpKUgEx2jVRp0xvK091QgVSb1QNUhGUTMofuzYP7HkQuaGRIVck5sPrKUOdEqSNkwZdZ0avVHd2NcwD6TemJETqDDHKmtGrM9czb8qVNU6saWFMh+dWeKePHW9GTImrb0itBAqjY8WPjTBJtJTZsjMiCBd8VqYmtLFBC4YktmZAwy0TJuxDCQwtTrpkltbY3FizEyEsfeIXf/zv8sf/9T/N77v8Ie6mL9nsA7/0k7/AywjiDMWVpEEmsFDWpIgPiiWkwxadQFjFGb5T7ciaHCZcLWH1kZtV1v6OPb7kSTsVpZRCkULaKmUMLsORNvPt8obp/h2n287WoGIknDUmWhSSDooYEgZ8moPKr/r+9MrRV47+vXL0F77+M/zlP/nf42d+1z/EvXzJur7wo6e/iJnzLz3cE4D+bVYwKoJH8N+fFv7tfuX2ytHfdn2yxZ+kmZZPjCEI9fDr8Z1qjZzuaTIT+T0X73Q9sYuwZ0H2REWYshPawcBzYutBnG6c9wvPlph7Zcfp6lTJiBhmRidQC3QUpAJmHIE+gipohT3P+HDEO/rXt7kU0E6LIBGUlBga3Joi3Vm8Y/mMnTJdwHchHFQGGobSGSVTa2HxwV6NbonagpQGkQURQVCkCWQj8iBMGXkwTNkn4d4SWQclBqllBAERJA28O2ktlLtgEOylIANqDrR8zIb0Agiyd2zLbPOMlcaYN6p1RCHCjtgnV7wFU79Dx4amG5oWwga9d3aHkJ0lN5Iv0I2advqUSeZMetgSBA4kSjpSBVwGac5su+Nb8OwrditYAAKmx9cHCe1H8ea1IslpZiw9yJOT0xFsHy78pfd/AYm/wJISZSSmPfGSgamRMuSsx+95QGdnskruwSiDkWYiJpoEThw5ljg7jRRXNsus4yeE/yyDSrJMHZC0EHoiz0ptgjBxnt5QTnfUqbHsj2wS4AMdjZBCTIpXYURm2Cfasr7qe9MrR185+tvDUeFXvv5LdPsFJGBJiT8ila/+Dg7KKsJXIvxjLfFvp/WVo7/N+nSLvxLMOdMjYWTIMIKPXYYTLrQ5k+zKWivicvg2SaKaoiPw3NEyoEAZV3ye2FYhhTLtQY9xmJnmAOFYhUcOp3CBlJW+D5ImXCshQqjgSUiSKD1wVzwpkg1JA1HBDYIgZaNOisbgrWeepeBDsJSR+a8Dq6PqUJWRMmmC037mJVdmj8OQtAh5cZII7DCGMRg4YFXwNAgS8wiWLgQgbuBgKFFAxMEUNFO0Hu77GtTcURQLZe+BR8YSTD4QYCsZdJBqRvQIJfcmhChSlHERslVCKlYSpErygoxB9GPAeSazrRkRKLGyUsE7s3Xc5cgZFcgIoQohqAl73LgfCZZGrBfUKxadIY6mQPJxV6B7It8KkpQSULfGyMFeHEbCxozR0WzsCCZHYgEDbBKsGJYSMQQJR3WgI7DhqIABRKa6UHQgHqBOMOgfjJfLztO7J56/uLJs9x/vGjqUoGhC8hk7Ke1u4/4qnF8mkiaWJTOewcwxdlwHrhOQkL/uffGqV/096JWjrxz9rjh6Sb+5O2pvHDReOfrbrU+2+Ms6OKsxpnJ0eDgWzghH+4qMj17kvh9h1WNGA5IEGiADEEVyBpyTfmDE59xiIww0Mrk4VgcqnRRBpiCmZBOojk2JfQiaDMcZUlEEJyhJkVvGImGayOEU7WQGmDBGR2IwkQgZlDlRSey7gwIpEeI4SijIKBSb0D2R9cxZC0ve2aaC10RKAx1OjPg4fyOYTIzJyNbQujB2QUfQjSO6KB0/x1HEg5DEXoQsGZWZU9yYU+CWGHtGHFBDNCFpouaEE3gXiEqkhpkfnx4KUSFSIlmQriBZoCR0EmbvWHOaVbIEHtAvHb055o54xuz4EAqB0CDUEBE0JXzPzGnn0h27ywgzYUFYYDQkAykYFpgpMgZ4kFJGhrIbmAoagjgkV4oErsKaHcvGRw99TAYGhAQUo6iRZKNHIvaECyQ6FSGFHV9b9Yh1eqm8rM7jN1d+dP/E6fMvWS5BOg2CQDSTUkLnjL45huXPt8JSA6mVehXWMMa0M+pARckWlBBKiZ/W8XvVJ6JXjr5y9Lvi6A/VflPX4Lc+iPHK0d9ufbLFX3InWQNJqGYEwwiagoWBVooFW88gceRAcoSAO8cdGzTjMeFhaFGyOaMOpBmhhbJktAxSbCQDcUGakPwIPw8B0SAlR6IDgZIoBnjgHqjAsMxgMMmgaidI7BZYD6aACNhOgZshDiIJ70Yw8CzATPbMYsEIoS+V4gYC+6yEKsNBxkAJNFVSmnAClRtoo9Qz3jIeGzTDsyJVSPmAOJGxpGwM7sPIGKcBU1Z2El0U86A4x4xLTkgoanY446P0oYwdGJA00+QAdCpBrkLNiVQzOimpH6VVmNJTkM6DPhcYO7E1EtNx+FHMhYiP4ejiRDq6uEr52BnOx3+iBCkLRTIpC6bG0IEIBJ1tCJGOzMowUBVyOCIrokc0lfSPuZbVIOcjyLwlxnHLAhdDM4y6oduMtsMrStIgFNyCIQFJSZIpkXm2nev1xuO373l898yl3pOXwFFGgBCU4vBm4mU9ZnwezplVLvjpidYClUSkQYsG3Zi1UD7Z0/2q70uvHH3l6HfF0T8rg5+48YXor5v5gyNu71uMPx87aq8c/e3WJ/q2wDyxD4PYSJopelgI5DHRRPF5Im4G+Q6h4cmOC790vBhoQsiIJ0iJVe5J7YbWQG8GdZDSQlI/ujkVhoGZ4OkYdtZxOK2rQKpKJoCd6IJ3EHVKAicjbmgfpI/h4lglbCICtCWeJkeiUbQgYfSxMaKD12OIOEByAw12ZlhvSJ7QaZBwtA3UO6kGFIU00VwpwxkXx/1YhxcbZDEGH+OP9DDLdM8YR7dbeqNkIbeE94QXxbPj4Vg//o1OxpBEi2NOp3vQhhJdyUMgzXR3Yt3xSKhOFJ2paWKXio2JsAER9ADuMsgZvQx0XRHrjJpwUcIC7IjjSWJ0GkJn2k60eWfbEtFuqBwJArMqFdhNMFOSOH5qvPQMw6AUJjeKgcZgDKNlYUhQLFMkIaKYBOJCdIUB4Hh1PCm3CrOAJiWPREIIdXwE0o7HK67BKI0eO7290K7vuT59YP/BG04qdK24Hq8vhZDmhfzWOX+Y+Pxu4RsXxuWKXp1kidSMoNFdSEnx/Gl2rK/6/vTK0VeOflccTZL4Y/sL/9J8j3/MSv7r8ggE+ON8wF85+p3oky3+dgoriRRGJhAKqU+knplqELXQrxP3S+GlGV0HZCWngnhCSEhSZh0gytVmYrzwkpWHosfcxeg0S7gvH4OrCy1P7JKQMO7Gsf02dKB6x0QBux3WIDqORxpJSexUd4ZlVh9YBqaFkgvenpAuuCZSctJqpN4ZpeOqqDmJFYqwl4SMF7xl1tGZ6gOpXpEB06YULUQdjLIhbpjNgDOlzH7tpCwH4IrgeuRHaiRyLvSe0N05q1MN0lzpJvQ10bvhZSA4roFPikzBSEr3BXmZ6Wun56MrrR1MgjEmPHaGV5KcEBZkZNKeoR2Pdqa04ey8+IR4Zbm7sLw31rRBNkQNlY/xUH4EpafeKaNR9xOu0BlUvaF5IVRxHDFHhoApJQWxtGNp4/1gS8qbEIoFowve9EhpnxUtjqdMl4m8NUIyNSl1gImSczB26DIRoWgGDaWOhIQzxkAtyJGwEux6RXdY7YUnf8++P2LjSotMaFCTHI+Xe0EkeJMHMb3B33zDy7VxLYIvBdYg9U4SoeBo3+lt/HQP4at+x+uVo68c/S45+qfcidsz/8Plwld/U/H3dTj/y9sTfyop+ZWj34k+2eLP3fFrYCNjdUJPFV0yTEF0hwRzQIpnrE3EyKRzxkswAB3KgZwNtRu7Fua4o+0re1WWZWbZrkx90HSGrEjAdHMW6eRlpbTClYJbwsrgRqHkwrne6CvsZZD7YLRMG8pl70ypo/nEdh/48sLyo8SVmbl3nntgaT9AykwuGeaGhyNUpqTozbi5oQ8Tk52RNo4czsgMK2yr0UZDFIROneCSZ0ZNEIX17TPTi6F+PGYpVlgEdnXWdDzutJbZdYUHo7Ow+ODedrgW9rsJbxXvHdmvzDhZ36ChbDLT75V26eR1cHkcdC54bYz0SMlPJHmE6JALMoNNVzorZb+RPgykPjHxAm/Ax4S0oIQjMthtMCyTlxMlv+cx33P5kVI+q8zphVyDXpQegg8hMzinYHDmec2MvPGWie0zZ12Fl0dFd8gV4qwsk3C2RgvHxoXRCvu88jYnLAcjB1NNtO6oF3a5UadA1dlXZWyGm1JKxlKFXllI7C8v/PDNQrm84235CW+e35Bn5T4HMgVp7tQo1JcJfU5w+Tl++MV75vZMwcmnTk5BHpmCoAPiRanS4B/46Z7DV/3O1itHXzn6XXP0/74H/xf7wH82Vz4X4Wt1/lwebLmjvrxy9DvSJ1v8nVpmnmf0bYJLRaLCqqTW2XInnsDbiV+dZoYM7m0Ffcaa4e3ofEgzts9oN0o8c5pP8PRAFNjbyn6eiGUQsiO9oHsm0ji2QPfCE5nUGtkg8WOiBENm6svEXVf2xxsjJdL9A8aFm27k03vSlHCvjBf4SVpZ6pXTWvG7CSlfYNedSYIJxX1neGeosknizfQln32YeX8yevsh5k4bO/vYSJHAE8UywsQtTazJuddvmMuM547ZPc/lCXPIPjN8YjRhtisP9shznoiz0L8Rcr1jSzOz7hSDLWdi3uCsrOuJY61so413kBPTKTNJp33ovFwz+hXI00q837g8KfftgZs+084bL1Pjtq30q6DrD2itM01fs90rb5++op0mnk430rahTeiR0RkeNigvwZATl3/wkVUWEjOMM91XxtVgS8RoDFlRLVBn2vkE5cT1J42pvMfOZ+oU1C4YJ9ycxEbzRrsFF7+hDZ5uhd/1uwv3d8LejB//aEPzzLfeONdCsPHix4fGnJW72SjeGMPok3A7OxZnvnx6Q/3lxL68Z39oZN1YSmWSM7QTK43rtLG8SZSnB+rbzxjvbkxcqb2z2YqNHbOOUWhvC/J2/hSZ9arvUa8cfeXo98HRx1vh351u5FOjkKjrBK8c/U71yRZ/L/tOLoWaTkzXHexr9trwzxYyd7T+SJu+5u5HJ66f33FV8A8KqkwV0qJQgl2D3ibSbWeaLnx5OdP9Pe9Obzg1Z9oNxBDrFIzUjZsK+2cLDw1u1xVJxsQbprUjbWcn81IX0jJRfCX7Cy039lyo2z17P3yWajmx/9wd7ZufkOs7JL7ixV9IyyOiQkwzqhnvCRtGSzvXxfD7O+TFuVy+5N32Lel+Zlah2I57Y4hhWVl0RvuJ+JDpY+KmO1M7c4lgPjfq0sEbbUu8SKXVz7hNhXy5UPcfMxi8OT0yycJ1u8dzZ06KaaEK9A/CdSTSvdDOTnWjP5+5vjhX+4C+e2F5EF7ujP2yI7+YGO8v3PyR2I20Ka1lpO+8ea5c61fk0bj9bPCsj/hLo+1XojUYDhaMlFg/v+Nyndg/PAE7efoBt7xQbqcjqWB2olYkLiTLZBHutg3ZLrS6s+qX5KcbiWD/DEbZOfcZafesuhGTcWrw+3/PG/7xP/Dz3E3Tr113f2DnX/+Lv8rjr3xDv5uY8onc7KN9wGCrTu8TslXSi6DygOrONd/4Znvgq19N3D575sP0ltQrl7fBqV5ZurOniZekvJl/ld99FfTL4P/z/gu6/Aj5Rqn9HoqwfvRZ++wxwRe/4RF51av+rnrl6CtHv2uO1iWYlsaUM2O7YMMpywvWCrnJK0e/I32yxd/9F5nzV4OSN9IAazOEkoYz+hVG5c34AY/pG2R7pIxOmWZUFxKGjEaIcSfKvAW37YtjPmV5wq1x7jsLmUww4hhkbSxIDnxydsmssnJ3FvbnitqVMMciQzHCruz9GHI+JeGcMksoxo1JlKUUzgna1rF94aUII2585Z08JbYcXPcN90TNwlQy83rmfnvhpUzsecD2gakd8yNDE2YnYEGKHEPbzdjboOtnXHiC207092QNxJ22Dqw41DNJM9IcvW3Ua2C84aE4IUKLQfYbi03orRKPO8/SaYsRsWNJsZFZSqHcCR5OfVTKXLBzZnm5MK53vG8/4knfH8PZm1HXDmbs6QZfrNB3buuJyYK5OOO50Ns9Hdiysk+GpY1E8O0P7qlfZ85jZ6mNtAniCyMbYRv1Q6AxcN3Ys3C7D5anzliF6b6DHqaxGaN5Y4vEZUuc9pmhnZ/93Q/85/7w7/t11925Vv5Lf/j3ks8rf+ZH33CLt+ALk+0U29FYSWlH5hmPxhiDsrxhKc5YX/hr+x2Xd098dv7A6XIibwVsxnKlXjLlutHePiAa5Kffw2fT1zyao7xDp9vHYfXGWjq38xm4fN9H71WfkF45+srR75Kjawrea0FKo0kHv5HbxPDPWJZBefPM87a+cvQ70Cdb/HU4NpP2Gx6VlgpDJrQPtBmFnZG+RGuhnhy77WgoSftHLytFKZSi+DDasrHPG7lklueFPe20udEJhjtNQJJTTEk7nJ6vdA/mtw/koazjhWs5ttKK3+g64QgPY5BsYgsh+0YfTnQYYrzUxErhrs5gwsPsEJlnhC4ZzUHpAzVlaMWqHqam142SK1tR9F7x1bHeUUkUVTQcZ2cD1rrRw7EPengg5Qe2mtnrEyndSDLR+oTfjrifOCkzwrfSSGGUTXExQiCpgt3wbaBnoU5CfypEc6bLyjAl+cxUFJuVNoS8rpxX4ak7PTaQG0lA0wNt2XmJG9Y7WW4sMii3xvbWyO8qTRpdGxaFRGIOwbaK+kJeg+3WGdszT+czOc3kuEF0Osa+CJETMRRfO/FyI+eJ+5pJa9AuhZEquim1C5InODujHEag//Qf/FmAjwPqvyYRISL4z/zsP8hf/Et/lXSaGDg5BooSdoePGQnFtOE6WOI961Ml5x/A9ZH93cLT9BnzNDjNUCYha2aME9N9OpILXhpfSebdPHhzecSa8tQ6t9iw1ihuyOU3ds9/1at+M3rl6CtHvyuODnYsd9TPFDXynuhZ2T+DPQ+mHrT3xmT7K0e/A32yxV/cBH1eiAyjBqaOijA5pCTgC64fWOYbpAtxX2Bk3BSso/Zx5mQUcgZdBuwro09cths2HbmWkiuIEd4oLocNSHFmoO+BzRO6CMUTqkIaBfEjqCglGKf5MJlMgs1K39MBnjDCAu+ZyDudjIXikfDS8akTox+r+Q40hyGMlGn5A1/2e55b5SaFKkGZAzSIgCGJHop7EDEI6yzMdBUWDssBzYVMJboca/vZmGSQ1NhKhRRs1yCfDCxhA1reaOFIFcpZ8OzUR6euyj4FPTmYE/34UOj9gsgTT6PT6krJK4t1Mk7Ww0g1dmG3DFkgoPYTvu1MHng0TCAlBzF8ZMa+kATuzhvfvGnY+wQvE6sac3JGg9EURdGaQRPIIPdCKgljZ5ed2A2NRFjGIsi+EU0IKj/7gy+4/5se9f7tEhEelpmvHt7yq2snWTuG3kthRKFFQVRRD3Jf8XrBs7HFM+9fOg+rc+/GG+mggzoLyynRp8M2IjRTvyy4O5friflypmwzmUTpoCHkMZH2T69bfdX3q1eOvnL0u+JoLAtdE3JLnB8zww+LnRNOQuhS6OmIrstjvHL0t1mfbPGnYVCDJgk3RwekfFzceIAn+t6xUyd7JxUgBR6KWT58h+L48kiJkoPkjQ864RenothH93JEqC7UYSQPTIVRggp0riSdMDkyJocbNWVqDWI5gq9FOVbgw8mmSBJEBTy46IYPYSKzc2GKYPLDOb+b4CSGKDlgikw5Fx7TjrtRJeMBZ08kd4YMtjCaOUiiWEKBOgqnU+LrqeHbSvT00VQ1MQjcjh+Y804iMQSSGU0LFo62BEOhCr04sgvsCY2gSGfKHQ8HHItGi8EQx7XQfcK6Qwymaec0DbZnY1inhFH0GEPxlkEL66lgLwY9cCmUBCMZA8NDyUnJaSVmR1qmnRLLNmPSGLbjIyGeUHFwh+SUybE9065GDOHWDsPmRTeKVIhEDI4PiaScf5Oun/enMz/aX4gsRCiSMlXA8k6kjEYmeRBkVDZGPNKb8rJf6fKEpiuqHcfw1JCUCC9IKJc883R3o95NLOcLy9PMfi34qHQtRFbcP9nj/arvSa8cfeXod8XRIwtdYBcsMqIdHwPCqBIMJqaqeKuo5leO/jbr03xXQKQ4AreDw4doKC7OENAULJYgGW2c8KSkkSAa4eAo5ERoHPE8I6gIJ5nYfKHfTxQfh4N77CQJKk728ZGHgjGoMiN9O+DnQeAIoFLJVWhTP6KCGAx3YhgKlF7AFdFBrcZGYonOngRkQwZkF0QTlg7ncu1OzkFOiuZKKwWN4KSZfBVyd/DDUd4AFHKAdKfYgpwUK42b2WFyaeBJ8RSEHklIiCJaWcSQ7txSwkQPWIQiLiQ1MMW2w9gzJyXOGdL46AnVce90cbwG0pWpO7UPVIxITgtjs0H3AWngyWnXyiLONm3EdbCZoqmQ0xGr1t1w6egcuG5sUWGdiTTInj662AuREiIFkYHTCBuk2ij16LpTCPREz07STnJQn4GCV8Ol8bzdflPX4C0MEbAcmEEyoUxOnjuUODJMTUETp0g4G3gw7IXR3xHjS2y0Y6apBZMOqmYIo1B5lIV8l7jcL9w+LKzPC5s4qBHZ8fk3F5/0qlf9Rnrl6CtHvyuO+gAxB8m0FFQVpAdmQbPAdIMqpKUgvHL0t1ufbPFnojCCrIZJwjzwoUTJeA10F+SSyLeJHokRAX0lDyNrQYsSGtCNNIIuwSVfOG+VbSSsrqg7KToiQVFQDSI5eTputZsI0YSBEx4UjyPjUMphOOl+rJS7k5qQhhBp4D0RXUjFjjkUBE3BVJzmxmhC6kqeFMmBuFFwXDKjCZMlrBY8NeYkjBh02wBHJVG1gCoigX2ExU0Lbs6uyhSODsfKEZ5OPTIZTSuSZnLqXDhizlbNaAkkDIZT5Ch4hjsyGlTFa8ajYJuAG+ID04/GoptQ4vDp8hb0LuwurAG7D1w7UWGjcEkBdmNkx1yokhAUsYS4AwOyIyr0prAJpR55miJAVfB0zNwgMOT4kBJDq+E2UU05q3CVwEUZJEoIoYmoiaDxS++/5XHfua/11838AUQET63xV9sziGJDcA82N8KMFIYgOMbolTQFxIRKx0MI2+nrynbbWPdO7ZD2zJyEMjthhiaBeMN8euLufmK9O/H8cuGpG2wbiYF+mnnkr/oe9crRV45+Vxy17rgKzEYXUAFFjig6z4Su7ALTovjmrxz9bdYnW/wFQh5HF2d6zEaIZ8qoHDGsO75Ulha0mDBfSZuR3KiTI5qxEGIYRGfVyoNMJAviCXijH7e5oCN0DTSnv5FDScmMAKywqqLilA6gjJxIJqQ92IYyRmYWRZISKdiz0MOZcyAijIBRYJk6dMUbJA7zVGKQPI5tOTFaL5SeiWy4bsfmvgyaNESEQqVQjpOW/XgtqXONGZqCC+qCuoMHko7uHVdMITTTc2ci0I8RPKkOUjS8OapOmhJqh1mrVUcmJbzgkbDeGX0H7JgZksCTMwx8lcOBnQlLDRc5uuCeCE+IzlS70WqgGqQBHoJ7AjdUDHWlSKUNIcUzCyd6GbSUyFMcFgUDBEUlIwEkw8wZZeI0wSUPNIRmFZiJLEdO6fGv6Cnxr/7SL/Ff+/2/n4j4WwrAiCMK6P/0i38FE0HE0V5wFXrqyHDSetzBUO/YSyGWjVUm7mQ6ZlEMxpZY1+C2D5ZunHrCLdMRsit7NSSduJSNPleul4X57kTtK6OtpObU/ske71d9T3rl6CtHv0uOjiIkGXg43eVIzOsZt0wQ7EMpJV45+h3o03xXgMQxm6L5GMDXCKolalO6KzvP4JUpNVKvEIMyCkUD0WONfgw5Hj+UxC6Z1oOWhbAbMhwpx61vQugYyTvaAvdEnAplAlkTuzrJDHUjgJ7H0b12x0cGy8RsWIHUJixBz06uCZsCuQqbgpRB3SvFBRFBzI6swigkjhkX10JcE9SnI75IC5oS6ILJEbeUCJI7TIkiC7MN3IMyCmYDFSfhSDiIYxGYB+QjrqgPIe2w10CHUXJHtdNckQJSgrIrTqKlQbaO9ExKhbUmbntD2clpoZVOL5ldMmqFmUrNE7nsjNJhF8pNyDbwbTB5ZkuZ5ImUB9YhAFI6ulI/utLiAWXlLl/4+q7T4gQ4JZzkShY9uukCWKHfhF0X2v2gaHB6UZJXRs3HgPfHOCGVRC6Fv/j8zL/yl/8j/rmf/33c119b/njeB//6X/4l/sKPH7krRztbcjmG0rXjTYk9HUPtiWMQZ32m1wslzZSs+Ei0Udlbom0D33eYjGYV78oUhV12NHXuJdPzxNNp5nwpnFdlvDixGbX9NE7eqz4lvXL0laPfJUdbdsJ3VBXiyEbPGuhNcQqyBWr2ytHvQJ9s8edesLRQps6siTzKEaw9Oj1PMM18NlZGv6D9BTro6Yynj48MtNNzMGQi5UrZhfYiSDkhbxpNDRuZKpWzCIwdbuBD6VkRC84k1qxkv9Jjo4lQ0kxKjeGBjERNE6neIL9gfpiFzsNYUkDNvJTKuTr0C2ld8S7HgQuD0UnioBVLBdFO4piXWGJwGs7zSehLPm6HhwKdEfsRwF4S0s/YmMgjuCsT7+Yr5jvDlbCE7E5yIweMqJAVboNly+ypM6ISlmkh9Jwoi+JU3DqWdkYEcgvGGuRiiGdMJ7Je0fJxpsgL6QR1mdjWik1ObEFG0VHpPRH6gfHhJ9Qlcy53+PMJl45IIyVHRGiSuQlcbePzkQm9oNLINZj6IAZ0O+4yqAuSgqAje6eOhJ0St/tG7CcWyUgyKO0IOsePbtSFujkunV/YPvBXv/5z/J7LzHlJXBv8yjeNvT6DT6RujJPgGpTuqGdGXo5c1BHMZaCnK7TOdArUOkmU4XDrjdu209adsTdMBhCk68AeMpmZbFfuSGzTzOn+xKUtnLfM8wq7DOL0iVLrVd+bXjn6ytHvmqMjKUUT2TuIHx1HMbx2aK8c/a70yRZ/uhS8zPgI1BJpVNyM0EGeEyVVHp+cNgmqjRqVECWmQItQvFB3AU+YD57imZezw/UN93bhtjnujahXIhnROW6pR6GEY954asEWV9JtJokyLYNc4CbGs8A5F5J2VLdjYHntiG0o9zSc0Vfurs5lA2FifTbyslPqYDOIlshdCYWtdCwb+tKYClg6c6o3nn/gNB7hRSh9QZgICmMEXHf6vsP5zOM1WORMjaOrdq/IXsCNpEbNxpDBnq4Md/aaOU+JjYK1DDZIU6NoMHdQLvRFScsKy4RdIdZAdWc5O2k6UZ4T23PDO5AT/kaJnmBkrASr7og6vlTOeyBfLNhaGOWZo2U+MWoiFqHIIO9GmLKVxIsq+ijcxsZbqURs7IClRMvKMKG2IO1C08yoO6fnb7hKZiTYUlBTI7shu6JNUKvkmFnF2AMmMcpd44fXRn9JtJSIixMtEB1QC4bS20aYUUI5DSf7DAlushInWNsblj1hd8rsz7zdnPH4gevbb3ixO1p+A3lD6ozEW8bVuWMlTSt1V05RecgnrvM9T+eNy50fj3K6/3QP4at+x+uVo68c/T44Wu8akqB7pSUlLvsrR79jfbLFn6w78k6J0x0+dyLtJGsk79j2gteNKJ8Tktj2ypgq55SZx0A/wgrN9Ml5kU6xB5axoekDH7YG50qZjS0aawdiYmQh98bbHpznxJ4rbTPGUsjpHu0G15VT6fyMKOW88pyE7XohmZLmzmpO1xuyG7JtPE03mp+5fPaBpezUfaLtF6QrmYaUHTSYLSA2dmtk+wFSN75tP+D5Vrjb31HroC1O366k1ZlKJc4LMZ543x9582Hiet94ZiaXypQHszZSKQydkVapbcfbt5R4y49+d+Jygfv1zBwwJLi+KPK4cZsHMl85bTPL/rsOf7D6AW0vlPXwCFvD+dE1Ma8damKyM7mvaAvOPXOJO1rp3ORr4voty7nSq5BfJvRdoZUgTTsSK9jGiMYwobdCecn0U6dbZvSZkGfG005cnClVEmcsnenLW/p6JW2PfHH+jLQ8sVyf+Ws8wzhzqolahC5HSH2rGz4dwPMXR+0MTxNr25FpI087XTpprtzVSlwz5I61jrlAcpLciAejTMds0PspmFh4ZuVnb08M3vJ0Xqh55zN7wp/esX37hqflDWXOfF6N6WHj8evOmBe+vRzeY29iZuXMU9yxpp2oG+YvP+1j+Krf4Xrl6CtHXzn6aXL0ky3+9uo8ngZTCGULpExIncENe+lc2xt+j994v1fSg8NnO9tT0MZMyZWajTw+kNoH7jmj7Y6398rXAUuqzE9PZJvpy8SuEDFzSQUpG2tsBANhxe8yl7NzvwltE15SUHtip/Kr3xoWz1wuN2Ku+Ga4GzZnpvOZs3+GpMZ1WvnwuLGcHvAXYWRhuV+Z5QZbgpYZOO/riW3+kjQ7d/GBq5x4+PbGNzfFpTAnYUHIDKQ0kgmafoaQH3O+V2QzTuUKC3SFNTJdMn0Y5/TEF5dO1gfkObG870S555u+kUvjbnIuLnhO5OuVbc28LAblStsnwncuJWOzcxtX9ucXTvqG+rbz1XLmr3zo7MvE7VxZN6NsnbdSmOobXmRG2kr+emJfEnsdLHNH12/I1tkGvOyddRhQWcqJS+2seeZp3uhrIpcv6c8feFka+bQzkdDrSuqNeRHev32kfpvYPiycv9gpe+LdZux65bTAvDzQu3MdH9DRmGthCOz9G5azcFseuEol9w/QDdkzeeycb8p8uaCnzpIGRSa4XugvM/20M38z+LzeWO+udHugxonkO97g8enCj0tnWnbuvnxkj4nnsfD+65Wf25R3dsewK5bO5Ledy1I43wvpF4P6lJnt7U/7GL7qd7heOfrK0VeOfpoc/WSLv/nlSr27kceFulVsVq5vYC9KzcYsN34UXzOtTooFtoGcduqLcroGVQJfFqyeccCmzqN25p/7OfoPf4zVM2OfkM2oacfKC1s2xDuTznj5jDYLP/du8O1L4pcsYWMwq5LuhGtOnKKTfKHZhG9XSrpR31xIPVMMJoXpOfFcwWombh0twiI3/Gnjqk5MlbifsXy4ncsKROH2zcRICqcL80mQslM0yJ5Rh4iN9uK8/OADD8sDT9OKPJ0pW5CfJs4ZzovRF9jKRh0v1LKwp8J1+THzfMfNnii+8NAztcN+S4zR2fTC89kRvTG1wMRJ5xkC7DaI9cLUL8STYSXx43Vl0cbb5RvG9C0igUumZcjJmJrT6ontbqXOnXJLNBsk/RzvgcTKMq+oOLddebne0Jq4v1t5lhPzemX+wpj3C80yQ8HnHd7sMBLjORE/UbZUST8vnL490fU9y1xJ6S22xtFx1hW5H3hZoM3kTZjsROgxV3PfBNlPdKk8lJVxyUgf9HDKloCJNQdPD4Oug/NL5bTs+DYx1QdiOJtcmcqZtZ95GomTPPPkX/Phw5kvljdQXvis3vPD+yt9FeaiWK8M7pDauZudL7TSqFynrzn9VE/hq36n65Wjrxx95einydFPtvjTueABVlZMOjKE+XlQHoD7E6llth4sX1XWvTLtJ9QSWoKsgXum50QW49J2rvlCbD9kTEYlGAP2acNCUFeyNzQEZSZZRq7OZLBFJs5PnEXpN/A+GGQwRUfHszFqMDzYxmdwPXFuK2hwE9iuneIFrxNDZvop0dIHtk3RUThbJrsQOUjFePsyuJ1e8LMT+xXzhGpG8MM0tTjJEnnMpIsxW2Z8I5zOO/2zlf1mxOaQC1QjvDN1mOJC6gnpDvHA6EaJHWfius80P4w7BzO2FeZo9FloOTNHplwN3RtL72SMNUEUY58ysjbCArEL0R7o8YSVTlVDW6ddOzpnWBZ8Utptx1Gm6Gi7Yn1AruSambTRfaCPb8izUR+M3hfq2tg9ETaYvBOsvDRn8xML0+FXZQsfxgtfNSW9fUCKwi5so9OiwVAiPqPsZ+YrMHXiFPSb0NyJMlg8qHqlz0KUK/EykewMObF3Q7bOFEGaGmiil4WpJLqv0AvjNCG3YGo3fMrsa+H69MzL9J6nh4XyVeVldKZbQn5Xo00bslW0VaZ14XS943zeOM0QL8LHHb5Xveq3pFeOvnL0laOfJkc/2eLPl2CZA02d5kbfC4SSe4N4hpcTp+kwIK0kJgNSR5OgKjgdTTsWg6sJXoxIM2N9RrKQ7YaKMuSMWCIbGAFeqOF0v7GPAmqYNiarlBB2jg0sUQFOTNpJKmyaSRkSG5MlgplWMpGOTbQlZ+aa8LLTB4gWsgj0IzJyfLRkOF6CsS+nYw3+NFhGwkxxC3QMihloYMuR85i7wtMgvU0oCT3NkJURhu1x+ByJ8hg7njrTfGEbN6pmsq+QBn0UbFMSiWKwKZAmUk8YgxKGJYEIrDtihXoeoJVmwXMfqFUuNrP2K9e+s8nKPhttTETPoBurr7g5wUcH9pIxqwyr0IQiypKFvQm5bKSc2KaKhGEjoR74cLwFgiAV+uSk7NS9001pk2EyKHZYCYwU1DqT1GkNbGyYBbmBMrBolCyoFOaoeBKarZyzoTUjUvBitN2IFQQha2CT0Xuw58pgYm+Dshg7Am0nPd54rpVLeWY/PdFuP0OPit4Z0zVoSalbxrsjllg4Y3WwXp559zCz2wysP92D+Krf0Xrl6CtHXzn6aXL0ky3+UkqcpuMw3yRo1UmH3dJxwY6NvBg2YA5HPR1+UX6ALLSj0YiAXic8O7IXIq40y4ezuB3eROqCquIVJCfCApsG6kq4kUeitkTyYCqCCYTF4YqeExZKcqdyrNtHPmGihBqt6AEzc6wGlgYxgsThsSQceYd0wQd0MrQT7jNWYdFBFWMEWARJnJKPLM3w6TAXLcY6ErpnUjQ0QSQhRiYBNQlZoUlBM8wVYiRSJGgDpDGS4LKQRdE6kMRhbhoBqgwRshy37M3TR2d3xSPgJgQF0QkpBbIgEQTCqAUribiCGrT0TDAzmtNyJ6nhWsA+mqmKI7Ug0fGamPbBKJUxgAxBokfB3ZAY1Bi4CWEdEzjvgZcNs0wSJQEpEkZFSyeiM6QTRfAo6Gg02w7/M61IqiQRyg4lZSQSlpSQfvxJSkqCFkGLMl+NqI0cheYdGQMj0RlsfSVfC8/vFp7nZ57fXsmf73x2WRiTYHuCvaIYYeOIq4qJpZy5nO+47RufIrRe9f3plaOvHH3l6KfJ0U+2+MtWSZGPi9UFxVG1w0aIgs6DRCZCj4gXD6InugihfriuO6CgOejZSE3QKcjmBJneIaIRKkRJRM0kh65KaKKG4AJpZLQJKSlpEtyd1o2gs2kwqEwSKIaRsAxExz1oClUCzHi2QamQVdGPTvaIkkKRAYPAcsbWC7kPUgl0zwwbmBkISPr4RzPhCtnoaoylImOQ1FDfCasocgxth6ECc1RwQW5CaTMjAtsVpUHqkGbcDqNOIUj7QCVIWhA/XO9xATmMOkMyPR1RSWeVw1LiVPFTgpeC2oR2hWE0AjhgJuLIMNwDjeP36kCE4+KYZuZ8ZD6ebo0mRrZBzHIYuLoQkVELxDjCzQf8/9n7l1jbtu08D/ta648xxpxzrbVf55z7oEhJMA3FkWKHEmXJikQjQgoBUg4QJDKMVFIwAtlJgACpBQhcCJxCHi4EAYIgTiFxNQYMODEEyJYjiyIZi0KkCBJJU7zkfZzH3mutOecYvffWWgpjhxJ9dZNL65xzoY35lw4ONjb2XKv3b7bee2v/v5Xg8BykpaMy4ZpQMTC4mmPmRA0iCYQw3Pf8TSbUBvggVCAyhYIBEsE6wE3QLiRJ5CSQFCEzhbPJhvRBdsG7ERFEDiw6fdu4Pl55V8/84OEz4tWJ08ufot2d0KaAQBoQg/fneFKdOJ1OnC/PrOtPbAve9AHoxtEbR79ujmo4/9w3/mnezG/4/PFz/ubT38JvHP3S9cEWf94T3QrhkA08GYKjDlWEcqxM10yrYJLRtoeXu8p+WpNMiBBipK2BQmJCIrPoYJjjLoxwRha8ZgSQFogL4plUC1EM34zugeUCBIwrw418AM+DIL/PThR6gDNIonvjcbCfgBSexpXqUDQTCKYBabdqSpsh4nQcTydq/4JshsjCJgESe9i2J9wFT+zX/ZOzrTtQh21kTyQzIgw0k4T3nycoIoxLIrbESHBZdjjl3pC+gWSsC54DGU4ZA0nvMxhNcDIoRB54GrhNIE7KQc7GKJm6TNRlN4RVq0gz3Ab94CQ1Fp+4+KCqs18RJMj7yTg8ICD1BCnY1iunc0LLDl0RQAcae3xRsBAi+62D7FFNqygl1/0LQYMQg3Bsc2ILSijyHngwCM/kuvDPvv6n+GS65936Bb/0238bm5WR97By74Y1KF7Jef/3DoLhIDVjNKJdKHJiDCd7359STGAMRtt4up759PEd0+P3uW4fcXozMz07KXfIhhOQnCgdvROWNnN6qh8ktG76+nTj6I2jXxdHtcz8wrf/y/yP//i/xCfHN7+7Br93+ZT/1S//W/x7v/E3bhz9EvXBFn+dwSpCKkrOvl99R6FLAikUHXskjUysqZArNN9wM1Ag79flMQTfjOpGVifECYEtnKwTCWjsUUQigtv7RmBVxBM5d7o4ZsLYHEuGi5MKlKWQxQl3jEx4wTBChSSFonAQx/QII5h4okQmW6FHohN0B7UghVOlU4AoJ4Yp1gexKCETRTKqGVLaT+S6UVMgWXiaKi/GxlmVlGaSJJyKRSK8E1tHehAlcJmZknEeG53CXGwHaM9kH0gxqmW67WCs0qGvDFlwzUQpkIyaO20LaCC+wTBCBnOG+5K41MRTFdpqSL5SquIdjmOm8xZyJSIxZMOj7WDU/YmlWCLKINxooyJZaFMCFRL7c0GWhNjEyMrgwtQ74oPn5UAsM2Xs0U2S9jDzgwilD3QUIiqiDdHEn/uZP8n/8Of/O3xyeP27a+9758/4N37pf89f+u5fh1Kp4Qxz0LSnJoVjwzAxnupESEEkuD8ktq0hGmguJBEknM6VC088jider29ofSNdLuQQetuYMmgSJDu1BiqZeHnk7bnAFz+Z/XfTh6EbR28c/To46qr8V//gH+df/1N/8YfW4EfLK/5nf+ZfJfhf8+//vb924+iXpA+2+BMM80bUGdVEHoE5rLnSVDn0C00yqQl9CVwTXTfcHKQgZIoEZUCowtr30+xkXDqcScx5f2aY3PAt4TUgGxFCQsi9E7mTIzFUca4M3xi5cEgCFjjKNgL3YI6JnARLFXGntI0k8ByV7p2UC9kdXHCdIRyxjZDBmAZSAtQ4JucHNqFz4KosHWpRtAo+BZGMDIgVcheOi5C+CMrhniyC54yPAt2xcBp7gLeHsh6c/LBSnq4UPTCHguz9JLMaRTrT2nkicZUgjbEHwKdAtOO5EDkRIViF2BTMkLH3gSQdTMqe11iMmDrZG6TMGgmPIObCts7UMEYY7hs5jETGKJSAtg20JM4q1Fo4H4QICElE3qOd0sh4F6I5de00D9JdQ3KQro3Ug0iK5sScMhq2T7oBUwR/5qf/NP/6n/tXfmjtfXR4yf/8z/6P+J/8h/8L/sqnf4OcnRQBsgPLrRNjkDQxBPo8cbSZuQolZ/ox8AoOuBjDGuu40HjiugrpB4Evn8PhyNp2YIUI2RNV9mxOL0I6frDb+6avSTeO3jj6VXO0UwkV/rV/9i8AoCK/Zw2qKB7OX/y5/zb/0W/9tZ2LN47+Y+vD/FRAuGIWYPE+pFqpbntwdofcEsOFpA2NjRhCnsEjMbzilvDopBiQneaNlBOUjOZBp9JKIGEsDsWhdcFLouTYnziSYQraK62Cl0EKJ7eCFRhXpUvQbZBd0ToTUrAEeEe9E8DJL7QJxnQgtSvhgqgy4yTZIF+JpESduZpzEmFKmeNcaM+Zqa8kGe+d2wVSoDmxUqltcJrecU1HJjkh2xVPBuoowZaE9XDAPZF60JPzLIXjUVlGUEdBPWFZ0Emp60BLo6jhTdCh6AxaAyfobGzD2LpyCeEhzbTlBdrfMV1Xrp5okWh9t3MQG4QXNlHaPN6/PU1MEcx9QIdNMqZKuGB0XAZxgRFCeGZOsFBQj/0JyhUELDpb7/i5wVZ41sLkHe1O9MrWY+/HSYrMTpJCiLFw4XCFf+3n/1vAj4bVv/pz/zL/wb/7r2Ajk7xSipBjEGMw2J9vZGvMeuDOrmxXIZ2O5AmaBoSQA9ygt6C3lev5HfZ5Y/yBAyKdLAtjgLgyiVCzMDQom3HqH+z2vulr0o2jN45+VRw9yJWfevgEOR745PgtPjq8QpF/5DpUUT45vOaPvf6j/M0v/s6No1+CPsxPBVyysBwqUzXoHQzmGlQ1ns+FLR/YRmN5aGh7RvWIlnvCK30bbHbBuNLD0F7wunC3DKSDToUhCQnFbXCNgUQwopLyjEyOc+FZYAbgfSMtE5UFicxFOok7qggyvaMaBMFF3rFJkA4VOxTy+UzVvDcKS4cQ9LxR0t5D4bZBdWTKpFXJsaLHxsPrifK0IdNATiubKN0nYtW9aVeDqBstdS6j4cfXTFfBXNF2IRXFppmUCumoqA/isbOkmfL4QNIr5cUjyTrDhTOKhXC+mziwMFpjTHt/SB/PjBxwrfQhjOLkPDjFmUPL5Eh8LjPn0di2jW0oQ8BEWX3iMgrj6pCM8jAxtivT0WENimWGKFsCCyPiitUrVe4Y76fsQjulzXiDpIVcFUmN4Ve8dMbLxGiFbJnEjPoG+TVdG50rYokYjfALpzAWEf6Zn/6jfHJ8/SPXn4ryyfENf+TFf5H/53d+DbPEiEGVtv/Oa+F6CPSq3KVCJOM6DyYDfZqRDFTHJqXXgmhmfYL+4sy701s+8decpVP0njQbjmOi9JJwh2qZ01S/lr1204erG0dvHP0qOPpf+Omf4ef/1J/lcDr97lr7v8Rf4U/1f5o/5B//yPX4UF5xbX7j6JegD7b4O4yV4xhMMoPFPr+TwGpQtPHQnV9/Wlhq55HKyY0YG5fR2MyxPFCVfVS9zzxZQq9vkRyUnBhpQy+751WkIHRFwtgewb4IllPC5hdYueJTcBpXmjqbKct24YhjMuN9Is132JToTagXpUyDPhqjOdKCL2KllBNTbFz7+6tyH7RwsgRFZsRmwpXid3x2DabzhrUr+Sjo4mSHdG54g5yDqQZlDN7ZwPQTpntD3BGr2BG6COVaeH0V1IyzNs6+sjWnqLBII+5mzucNOa9kE1SCiEQPh8eCxUQ/bWgue2h3esQsU9aF2Svp5co5nhDP9O2eZk73J9ydjO45lZGxwxlM0Gsg7zrXXNnedmpTxqhYdCR3ckpoPpFSMMYZexfcf/vAUpUmMN4J1oVNHK2NPIR7u2PMmacYLHXjnIVxPYE8o8tKBnTdTUqnyBw0cFHuDm/+/65BgJ+6u+P/dRhsG5AHuWYWCbolZBWW6cJWn2m/3rl++0gKoV4eSTmTfEYkIZLJMZPsjvqHZqY14z3YtkrJCacw1InsWIIssCzB9CHa0t/0terG0RtHv2yO/uzP/FP8uT//539orZ3Z+PfLr/Ln+x/7kQXg9/vn+I2jX4o+2OLPVKhXZ340mmTOU6KHkTfjtGUWSyxj4wefDXzKvNPEoQmJlZS2vb8ljiQpeA7ydiTdOa1dUWZyVsYEOlaKDBDBDQobJUOdZrwEfj7Ttr1/ImkDFWKZSJMhUhjJWWVFIlHlhPhEXoOcwaeM3jvpB41yfqL7a6ooMj2RUiZHxazTe0eGcTgWpmPm8Rq07cqLuyClTNgLenNEzuSlQ65sMiOzI6o8Irz+QacdBTDaJbDijLoSqpRrYqQJmRdejo362nnsM/2LwZKMY2RyT/S88sX5ibdDOUqmlk4yyOnEIXLA6gAA/tJJREFUc7mgXrE80XqQHp+RJ0cSRBs83D3jdC6XzrNd+J1x4a1vDB9MPmjF0ID44sSrN0+s20DLbugaveHbmWFg+cCWj4yRQAvhR74v3+UuQ3pxouhAc8MNhi1sPbN1J5IwX7c9M5LO53xObI74Hc0Et5Ucynpfiep8+vT4Y63Ds585lZkkhaEXmgwagmmGmGj5BYMLdSrcW2MhMeYJdUeaQc74wclLZ56de3HGYvQqPDSh13f7M1Y6cOjCcu0IiU0ngvuvdI/d9OHrxtEbR79MjoZv/Pyf/lMAyH+mXYb3QRp/tfwdfmb76Pc8AXs4n16/4P/97jdJNd04+iXogy3+mhy4jhPZAi3OjJJHJSVlOhktgtMXgdYr162y6sqwhOdKJpG9kdoj0RLW7tDS6H5E02DJZ4YU+lRoUuhALJ2yGSWE8QJaudKeIJdCkhfIQ0eSkC+O1sp5XvHWWc5BbBNNhDJfmJZCpeyn4DkoOvHpnBgvg/txpWwJS41pfgafubaJLSsyOXJycg0efvuJZXnBFs5zGaRjRzvIqrjtTcLhBaIg8yPRCiOeWeoDVZVl3fYJrwrPJbFqIl8ax/MgHxdiHWjdsM8aLRK1BKMMrjlT2pHKoBwcyxPrllnaYIkF2TI+Z7YHGEdBtx9wfh64Zla/YNsV1iuyNcqAqQZCR9egHCY+43O6Nu4STJ7oNu/+YTWQVCi9cBwzOQaPvMPnM+Jv0HTgfFFO2mFWBgVaUC6NRMMPBbs03tbCXArxZBwkcV2cNTWSw6yQrxl9dlbJ/OIXv8n3nz/jzfElKvpD68/D+fT8Gb/8d36VWDKTdbIvRH4gVbDUuSYjFuPjR+HyErwU0Hd0nRiupOjMAmmdkZzIry/cHTcaleVx4/NZkBf3lMlZGMhWYc204ZxF6XH82vfdTR+Wbhy9cfTL5Ogf/MY3ON6dfvSCk/0G8Lv6lm/5S2BnqSD8m7/yf6T0M3rj6JeiD7b4q1sgc2I9QKoNZaDueCS6ATmoOngkgTkriotQNDE51JbxIWxFkbkzuzHbREzKpRWSOJN2LAmhgYZQpOA6o5tzEGddM5fzgBCKZVwLnf67f//QwdAgZViy7LYIcsWiABWs0kelzBvQkM1Zl72XJtaMLgdICemDuBikQntb0TdwtsZrTTz3Sjwak4JEwUgMBpYvJK2IJo51YC8rUz2TvjjRBkRRJinUrLTs+DxYmgGJVhKH1JAHxR8ThqJl7we/lsRswtTA0kbIRrsYrIlUHY1GbY76YDvP2LoQ/Rl3CL+CX9AY+4TgPnSHYzwOR64H7raJp4Bpzmg3Wr/SrGEjSGZ4WmmnQNbGwSqWz7gldHWGBOGOZNAIWBJZYNaB5Y56YrMjevdIWqF6QnSQwniwSqVwSULLux3A/+aX/w/8T//s/wAP/z0F4P8XVv/bv/FvEakwMKwEmFKTouYwjIPutx9EQun4EK5z4xqKlMSsygHnkIJDLczlQOLAN2dhLBMv7xKVicmDaEK9KnMrJBu8Kyvr9AGaU930terG0RtHv0yOvpn+fxR+/5CubL/7359eP+ff/MX/E3/pe38NK+XG0S9JH27xp0o9NOIIQyC1vJtz4nSC6HnPK/TMUYJjVARHWkdlz4y0eZ8uqyUoHWwziil9g6EKVFAlF6PiGIWREzkysWVq3Q0t17nTPdCrMIbSDaY1UxRcDc+CTJCzIi3eX48byRtJgzwcSuI5OyUn8pZpURhSSTnABEzoNnhrMy/mzFg7KQWHDWwIDkgoqkpJ7OgSw0R4406ThEhj4JhPMJyyBlmNVI2rGl2gXgY9DfJxEPUOzQuuIHKlrBvrUDwSrQRRYLKBamcdE80DdcgdwpTVg2RK5MyBRldHw0lDYCjWArdBTvY+FH1lac5bEm0xijR8G8g1yCPIbqCdSNC17BFPJhy0YEtjG0JpQW2DEBiS0bQ38yZfSTFYh1KSgOfdwsAFy53uSlEhJpAywJ3/26//dVz/Df7iz/13+egf8vn79PoF/8u/+W/z1x//Fuluf7YyEaI7xEZpscdMJUX64JIzUQd1AyMhJLJUkmSg0EPYEHpZGNMrlmNBTndUzpTRKGtGPEgpYFbUlUMezOv2j9oaN930Y+vG0RtHv0yOPp9/vELqf/er/2fuz5XfaRf+7vPfYWyOng43jn6J+mCLvzwJ06lDgtZmfBTEnCTGipK2wvAMntA6ODLjvuGjA84oCUuBBtRuoMqWg0QhyTPDCj0yInuDrr13Nbf83ndoVGpeWabCtjTWzSmjA4MNJ42CipAqhAYGVE0omS0GFhulBJSCXIFcuBR4MKMk8CR4cpIEUhULMGm00vC2IGPjmp3FN7aUGUPA9j+fw4lIXLUQXVnaRtZg5AmZA00JRDDrxBaMSPR5/38FQ/pGbEJLlZwLkgdukC7GXQQkxavgmqgJ0qGhq9El7dGRHogFIoM5DywnuimhhdAJct1NVMUwaag4tcEsKz03JivYcLorI3bL+SSyu893x58EO2VcDWVmAix3hkEDHCEBMoISgkYi2e4tNrcNaQFRkVERUawqqzZSNCISSQcWg6Hwl3/7l/l/fO8/4Z/56I/xennB0/YZf/s7f4fzcWGpgZSKJEhhDAy1QUIpuSApY3qhJyGXPd9SmElxwkZl1QSamVImixI6o/Md63LgFMK4BnFyxubUCcph4K7YCIoqS/rh5+ibbvr96MbRG0e/TI7+/c9/h8v5zHI4/HDPHxARnC8Xfunv/Qr5AtfTgaU66cbRL10fbvGXgqLASNhIuARDG24dawvDIY1A9EhMV9zLnkE4MjoaYQYeTAGTBy0Pyiw0FkIz4+pYrFQNEGONTLO0O6RnY/KBbk5/kahcuYpgqVN0ZU4Z10yflOIJxzBxrKfdjVwGSTtSCmvOzNNG2ISgdDGE3RFdeD99JhUToQ/nYTsTvMatcQ6nDkMZJMlIclIMkgsuMzmgthPOFcXZ2sw9is1Gk2BsmRjvYaWJSI7VR1JO1CsUAa9nXAxpgslMLR3NHeuJzYT2/jkm5kEVwdwYrSNDyZuSp4blxNvzxIUDo8zEIaNXp1z3E791Y2tBbg9c05XSlbjs4esjbdjU0ZLQ97/nNQTcWJhoB2VrQb0G83DONXOeClWCgznJjNRtj2kqyn1baRlWCmJCiUARajKEQcQBRdEy8AMkq0jP/M3v/9r+3DWuLN6YTfYvglEoFyeJI8ORaLRSMSmkLbCjUCST1gObX6kqxLhnjaBnR2ZlnjPLUjmliUMOfJqp2zMeFZOFMQKrjqdBcSE80bVg/sFu75u+Jt04euPol8pRHfzKr/wV/oU/818jIn5PARgRAPz1X/pPiCvkaMymv4ejWZxvLROHpbI6fHpW9MbR/1z6MD8VMFAunkAUmxwTAYfUQNvK82wsB8iiZMuU3ljrhOtCSoksKxqNhnBNC+5KjME6XxF/xZbeUXwj44hnbC2kTZizo0mxMGSsxPOF0ykxUzGrYErPibPMLC4kVc65oeo7CAxqnkgo/iicIxNH0D5zmJ5hKJomyilBamgbiCU01X02ajtDuWNR3zdwVjQcx9HskGL/c62jq+PLzHWDORq2bkj5hNSE5I0uzlYGUpVJFI1B6wNPE1lWJjvTPfZ4JMmUY6aLkGUl9UEOp0fC/A5PQcWAwUiCoZy7c0xXxCpVpt1iICCvg3J28jUz2kQfHdLAczDageygspHDMTN0NKooNc+78/wwlIwYnLeB5yuRDJVMlopGpsjAZOM5d6QoYz1yryuUwOaJ6AINEk4VJVNIeQADaWCbELOgp4xvgmKk7vjmbKfj+3xOpcfGnGCWCZVgKLgovgW0xP1x4uoL7zDyWIispAiOYyDZSVVhScQyow+JpTg1JeoCZ8m0LOQt2K3LHKKjONmC1H+SO/CmD0E3jt44+mVz9Pvf+03+o7/6l/jn/kv/PMfjPximuFwv/PKv/jLf/e5v4vbDHP3ZVyf+5Lc/5lD/QdnyvA1+8dce+TzajaO/T32wxZ/aO7y/AD0RmkEN0ffxOl2RIpSU6PrM9k4Z+YCNBjJoMpC0EaNBL0gUDto4X69MqZLHAU2ZnoUnMbQbddtQSYxJsQpjemZGOZ8zLzxT5j1KaESFMEQNWx23QqmZZRmkbOD7hguHNmAwc2TDOSNtUHNGauAK6gIhuBhujbQ15tS4aCPVlVkfaL4RAqSEp0xPeyh7lkSk/STecuFkysknRg7W6yAGaBZyHZgbZuDSoSkynGsYaepYCN0UzUEunXwxrCtMgmvQVt+zHau8D/nOezzQ2GORVoOWA6srWTa0BbEmdMzU5HhVTATTM2kJ+CLQZ2E6CmcuDLH9piFAZSBJmc1QU/oU5PWCRWdEJ3JCUqe8DxlvI/DVIYJWFw4ysDiCdTx1JCvijmmn5IK7IrKRRBnjiDaQJRhs4Bl6xcYJuGOKDesw5UbEYKPhJthQlD0wvc3GNc2IbkznibF1sjww6ZX3D1jMeuC+nHg53XE33WH1NUsAc0EkQxukgNT2n0PPBVEltgbXD/O54qavTzeO3jj6VXD0t7/z9/it3/wNHh7+AIe7haEXfvu7v0V4+kdy9GdfFn7hp775Q+vzWBP/4h95yV/+7eC3Lo83jv4+9MEWf6RBqQMRx2V3fVcz1AejVE6+X29LOrDNnc0ylSuqG6ENs73/YkQhKQxXJO548dy45GdUIUfCk6GTUz0YHTZLlBGobKwc6HakXQJPQS/7xFXYoOiFNCVkVQ5r5iiCz8HQPVZGsxAlEaE0E85zIJYpWQjArzt80tibmolGYVBfTHwhRo2JuReyDrrDGDBISGQGRtaOCPB8QQ5B10wcE61/QWSgVEKUNAZuiV4CoeG5o8kZzw0pC5omDuHMwORKb4PmEF4hgWSH5lTZUAVXQZKzhCH5gHJgYyDDKJFRBMFI2dDsjOacCVpS7uiQDlzizOyNNXWoFUnKug3WPpjCOMSV0RtxvCdvG77N+KEiKVMBbZ0xBjhkTyRLyFFwmVjNKc8Nfbmhp4quGYtCmDI3oaXBJRVkmZhLw1tCSqOcjbjGHs/UN6QUvANpRtIzNmBExdTZQzqNph3LiVOD45awdGFK7+cItUA+UaYTx8OBF/ORF9NLlvnApDOdldN5RmrjMAdZO5YFL4GkA8kLSf/RUUk33fRj68bRG0e/Qo5u794yX76Pi0IJ6j+Co9Hh57+x2778Z/sERYSI4Oc+vue3/u7bG0d/H/pgi7/wO2QkJF0RBZVMIiNpEKUwx0Sfr9TLJ7A8Em1FcFJSkhcYhdWFaxEu0xXOyoMc6XGl50EzZ2qyT5DlgCS4O9E7OETpNNlguiPG2CfEasDcGNeNCCOnjM5CGLQQ9ApWDMMQMq4VuhCacM3cqVCTsiXDNkNa7CaWvA+vrpWVgkannU+c7zskIVre+xky5EmJ5AyMeQTanjiUyuN9IUVjakLSwGXgXfGesVoYVShWSBZIDewwEa70SMwCsztu++kyTyuGEH5gSQ4ywBKSC1MoyTshg8gZl0o+J8SNNQbbAfqLBK0Sl41hO9DmgEsPuihxcrpD9xnxhJoRIxjNsBFsmjnPg5w7kYQuhWmsSBo7pDpoD6J2bNqb1QsbQUemhm1KViUdC1ISMTrumY7TeiKNSk0wquOyP4nlcDJGpBWLMyZvMILtEkxTRpmAAqlhAjKUFILY4PqcmWPjFAq6saaK5ANzXTjWhZeHOz558ZKPDi/4eEpUv6PTuBNhoORslKK7+z+OilNmQQ/2E96FN/2TrhtHbxz9SXP0o1PmWH50qSIi3JXCg8x8cePoj60PtvhL18x4cqwakjMFIQgiK16dZk4O47F1PDk1XSD200vqE2yJzEopT3uHRZkwf2JLjZwzya4QBV8roQrVGIc9/Fyb05Mgp46HYXmjIGRd8FpgHWxM+CjYECiBCzAMlUYMYdOKlUzRINeFl0/BnAWfC+QzEQP3hCclJUUk4Rr41pmrkBI4QbghkkhTJdfdyV3DkV45GngVKAuyDGwN8uFAXA3ZOnmwPwUUJxVhyQvHnrnKIE33uA/euXEZsU95mUEGJRgxsNjd5FUFk4LETO2gA4YKh7HyTgfNKyWUngObMxwesDkxsqF65U4GsTW+EwXJQa4TvgnRFbeGREeiU9VQTWx6IuYzmyvJFM9O39b91CiV8AxJkBJEavgQUjOsXihF6dxRnwOZBE+NFBdCJt4m8AgetkB68BiQZ+fbLz/h9LDQ3nXWz75gzVBioGVFnhruYNPAfT+Nak7InFDNDA26bjwBIhUpylYmDiVznIWPTpVvPDzwyScvefn6SK4nbEskf8m4M+xpposTJXBJBJUgwdjQET/hXXjTP+m6cfTG0a+Do2k2igVjt3NESLT3HD3NPx7HpgpPqd84+mPqgy3+3GGE0oDsnWJ7dJAlEL0wYjB/OtF1o7nwEIKq4SQiEhICYqgaSRYiFzwGdQghjqKEZFQKKo6pE1RKOjDlwXkE1CDpYKv7WH65GOmciLFQayGnoPWN2oNpgbUa4bqDrwQcGyKJbVu42474MC4OPiYkYKQ9Zza5kFKgBSJvpDwzS8BzZRSjzZm0FMqUwINtVWIEWyQijlzHzP3TmUdWNukUSQzNjMmJ1ECNshaqJGQyNpR8CWRRpqLgRjeoACoMm3GvGI6h1FLI0zPWGptBUuckBrLSwxiz4hvonHh4mrAtsWKkEqSa6MDowrSAbpl2aWzBHg7vSojiCGjCciZEKWelzTB1J98lHo93zBcld2WEgXSig7SKrJk0D7yBeGHpM2m7EOcVK0ZPDcoV8wktE24Qm/GHv/1t/uTP//Oc/qGm5fPlwi/+6q/y27/2GVWBGlgkenfAyNL3dagKSSgUyMJzTKxLoCcovbHU4PTRxN03K6dPDtQXFX3jNAomg1dWkXhLp/5uDJKrIKLg+1NJXD/MQPKbvj7dOHrj6FfN0WQDu1TqUnEdWAZZMqnMjDWz9fFjrdXmRis3jv64+mCLvyfJzHIkjU4Wg6Q0LawjIeughtPWii4Hsl9w36/8ySDVEAkkIKUFyZV5OKb7dFmJhlvBcyarUEzQodSAokFS3zfsc+WYJ4YekDzADRsOU2EuQe77c4JKIo/gJAuejRFGRMcaWKpM8RZZJnQN0mikUhipIjQmN7Q7zYPLUpnTibyurBelMFG17KdFC3QzXATJiVDlSmeKM2UI8RS8zv13PZm6JxqKaJBd0ZbZ1DijXJtRaZgLiSMRiksAAdGhFMIrbgPxjngwcqK1YNDJdFj363W1mSPBdmjU6hyegl6ca3HaAQylrxNjUx6mR57PT9z5lZmgTzPdoFngEYwUKI6OIF0SefqMXI5UAj8cyF6RrWNxBSD5PonWx/tTvGW0w4yx5Uq3Th+DnoOEkaJRY0FS4ls/+zF/5hf+3A+tu8Oy8At/8k/yH2y/yme/+RtYCoafCAtSvEMYbAHnVlGrvLaFkYNJCy/SBe+ZYsFJC8tcme7vmF68oB6OFIMlO2kuWMDpqpg0uhfcHLGBEsSWaF5o0/S17rmbPjzdOHrj6FfJUc8b1ZVzUp5UmSZhzvG+V7FiMfHp28G5DQ4l/UhvwEszfuuxMendjaM/pj7Y4i/NDVFHIxM509Ju4OmxkcZgtEqbnwBDbMPrQOO0X12HkQVCM1eCaIG2/VRy1Zfo9YmUrvRonN1onnZbg5zJOdG1o6ORGpgkAkVTR+gMSVjM6ADcSKF4993UNAu6VWYKUw7GGKT2BXEq6JoJCWLaKEkoKWPMiIOr7/FhksjRsScj9cFyCkz2J4M0Au0CCEUNUSfJzJRhS4PRG3MRpFasVYaBs5FsIFEYkaB0cinM1hnlRK5nhqxoJMTgeh1YEhKBRkO1IdlAdkjuXv4DL8JzZOpFSTnh8gxlBUnotFDvFiYOaGzI9crSGzEVNAauwfUA0xrU2rmKMlomdSPrQCeQ7UJelW6Vp2wsPtBrwmLDc4BDsYnqCsWQqeM606czy+PgujSMiX5RRgNpisT+M68dtG/83L/wJ4Af3YD8x//Ez/J//+1fQ0KwvDIkE3ZAvaJiFElITEiGaRpwNRoLWUHrzHw8clruOS33LMcjx+PMi3pgPhwYMdHGgfPq5INjCD6ELE7NG0zBJoXu/nVvu5s+MN04euPoV8bRthLF8JrJwPNIlCIIG9u6kq8FXfY4vl/8zqf8wh/85Ed7A37nkXrj6O9LH2zxRxpYeqYIpLkySoXhTG1FHdq4421+JtFYBKaUEFeaGYyGedBzYkuJbJlJhZEMdMXXDXKQcUL2/MYo+7TSwBgGGaVcYc0DFaHNDrJPl4l0NARTMB3gyoYiCqUJaCZpYpKO942xJdwUTo4eJpIoxQI3YVPB1SkZFtn7ZMwyVhcsB2tRCEEsiCGE787soRlJhTE31tmJz2cKQUwJa3WHXRKyFrRnTJzkwbw6mygdJXtmE8OSorNg4ogmcihVNpBthx3O1J0Ue2/NqkqkxOwzbhcsCWITaROKzpT5SD6vJH9ETdE+o9GJVtF04UohmyMtEWV321cFyAyFoQOZMjEfuG5B8ky9bFgKJCUUQJxeAyZHcaIrlmEzIeRCqDFKIigUgtyUDCQ78/E3v8Xh9KPDvkWE42HhxZuXfPrbnzJNDmXjirIRlGwsMhCDNWWkb8yT02xiUqO8eqC8eMXD3cd8PL/mTb7nyIljXnDNtOeVvASRAyYhVFDPlBCKOr0oPcru2n/TTf84unH0xtGviKOKYEUYYiSZOWKcOsxd0etAOGN3QV+D3/jUQL7Pn/jWa07/kM/fuQ/+6nc+5zfODbEbR38/+mCLvy6ZMRsRTmjem3YJ1AVlX7grlZwz99VwU5IbMWBE0HWwiRI+81IVTU5PiRJnND8zckVdyZpIWQkBSw0fhmyQNFOpDN07CSQr6vs4PQTmgyGKS2aqBSkwGEiGRkcwqgoWM/FF5S6BvUhEYvejkoFHJkiEBiV3jgHXSBxq5rOaMb1iVZkTsCnhirnQXQmCUmK3O0jK5gfuSPQGjAALtCSkJgKndKdEJfoAyUjvaJkpNIY6Q4NclWkI6mBiOBCiZHWKJ0gZqw6pkzYnkxmx4kOBicWCVCZaXqi9MG1BcmeVhMfK1BYW3ei50IviPfC0kXIQqWCeMIJQ8JNQXhTkO0oUEBqKIg6EY2r0ut9KSHdyGySETScO0bj6huv72CWf8RhYfyZ6R48/3jNAqYXtEjwg5MlwsfdfBoMpQC1xLoDBdByEB7XM3N8def3yIz56+Qkf37/i5XJiTu+TCbb9OavOF06bYKmwSEFLYkqyN4mPilDJ6cOE1k1fn24cvXH0q+JoTBlLyoiEZyfHoPRMaXuvaORGHzN9DOJS+O4Prvw7j7/BcZmZi9Ci8cX5jGwTPucbR3+f+mCLv5CMpgNjZNplxoeA783KeRpkeeToR87S2WTjeYM5nLCMl8KYBSRxbMqL5DzaxJSEvDp9edzNfbe9SVhFMRMMR8pA10SKjD9kasqYNu5dmHyhHSpnNi6PjoWicccyBaU+Et2JKaBvrAPWSExm2LqQXxiNTN8aoh2rsjdUt0yKjAAxOmmu3Hfl03jmzEa1uodUSwLLuCWyCinDESFdFmJLjGlFSyWvRrAiYngEkQUkyJFgnnlMjeVs5K54zRyvjeIbowTVIZ+dpyF0CTRl6imT5sSqE80VdOABTYwiA62JWCENI9eCy0SqM9O0MNeJOQsxbZh0lktixJHrwThTWK6NnASrE6YZ8ULpRhXQtNJyJa1GWVbsmDAc3xo4aEpkF8YajLabe5bViYeZKic2f0LDyeN9w/s8yFPAOfPZ9fJjrcH18sjoF8YlM6fEQfaILPFC8kxuhfvDhe3uSHEjFtDDHa/qxB94OPGN1y85vTpRXiR0DvpwIgbp7sT2kCl/36lbItdMzOCp4GuiGhzTM3P68Rqlb7rpR+nG0RtHvyqODhEUIWxiK4H3fUjH8thvhKdKbZkRZ3rvv8vRd8+dzy3e91He3Tj6n1MfbPE3n1fq096grDbII5N8b9L1UETPpOdE9pXxtpLvhTqvJFUYINZBz0TZ+Px65Atx0vEd8v3EU+m8eKukvBBpd5FPkqi9EAnycUJk4qxnHvKgtc52nRku+DRgDubDTPSEaODpHRfe7vYFrUCfKF5I6kwZ2oOwvZqIS+M0TtQJ2rqCB8Uc3QAT8n0hx8q2VfoCqq+o56BGRwqEDnJ2EoVRJ971TrSgAsu3rjyvj5jeUw4rU2xUUUIXlAoFnsJ5ZKKfnqhX4XlZyevGFE5Rx1Jjm5W+LswYc3ZGHLj2mTaEYkHxICIzaaa+3vD1FS+mjvlgM2eNQOYD+eUDd/aSqGcuTxf4dOLd8S2XL6BNRlsd6UIVhSEkCtkLPoTRC9465+3Kcobl9YWRFqIvUA2JThqOjo6wIgaW7iDgdVx5fq4spwyq++9MVkSuVIFyKLz7wQ94fnrmeDr+6Abky4Xf+c73iEPlWoyeNoYIYYG0hAnEtDe256eZYYAsHOrC/cvXTK9eUh5OTMsBLRPrlMnHzGJX1pEoW+L733zDA4MSjfNFuMYMaXf0H+Mtz2P7urfdTR+Ybhy9cfSr4mjrcH2rtBiUO0EsM9RZUWYOjNdwfrdBhziUG0e/ZH2wxd9xKZyOd3QJzAayDAK4jn1TTXeQppUZIXFC68qWFGlKaVBdkYBrNM5ilDK488H1ZCxbxcaEXfPeGHvcQ7dtTJgliEYdnzLpBQ8nDkq/zphtqBn1OqPZ6JsSMlATKA/IXSIz0Cntk1aj7f0vc6ZbY/iBa2wczdC60dUZrhyrMI+NrTnv0gvipXB3/8Tm4GfhsC3US+cixnMK2vv+mJEesLtOm7/HT7/rfEZlAvB7NhpIY7oqbBORNsrxibS9ZvMDbSRO71aOqxAlYw5pKHFceBgJxGgvBTEolyu5TGhODN2faqopvmSu75zpBOSgvFMe1ooOR2RmrQ9wONN8cH3sPLvyYj3STwLlLeJgllBTil1he8vanW0+8UIPPMqFePkF16cX5INxkI2umU4lMJolLlLwlEi9sswXHi2o8yObC1uA5szkhT4mUkpEXsj5mV/5lb/Ef+XP/jd+ZAPyL/7Sf4xlZbjRuuz9O7qRhpJzoYqQ6Vx1Ja2Z5+PgVBqHF695/fCClw9H0ssj68sFKYnZjHwFtcqb6cAWG89vV6yubJPw7InuK1kaUwnmIkzlR/cl3nTTj6MbR28c/ao4WvIzeX7i4gtzBOqPEEeQO0ZS1itkvxIl3Tj6FeiDLf7WZxjPAcWI3Bli2ADZMkfL6NZ4rnDaGr4U4vHIWiFHx5uzhmI1IRn0kvDzM5dI3NsLbKxwgKodSGBKio0iffdJaoG0TH1xT/jGcn7m4s/0aUKqcrHGMu8nlksUuB6YEXJ1yrxRrxXxjh8aVibWVLnyxEE/5ahCRGZDcQXcaD6R7AUSjkcjXToD5RSds068q4lTFxRjVqdEoBaQr3Td8Md7rmuG8xUJI52eSXeCZ4VuqF/IkciXQsSZ3mEsxjoaVgyRhpujY+HwLlOysdaKp0zS9waok+Jjh5ikQpteEOtbKI6mwlUzmgcxN/KkvCiV0JmhlTUSi2fChcPLhvcrm1YWKRxkn94bDJoIRsf7ykWDLMFSKv604WwML5hULAkhhgQctMCiuFxJXog58/hk3I9BCmWtmX7YoTWbES2z2Qt++9e+z1/xf4+f+/k/w+F4+t11dz5f+I//xi/x9379NxHNPF8ayY1j6YwRbHEkFlimlQcTOJ+oU8Ze3nP38gWvPnlJ/eZHHOZvcbKPmXtlqZ1D2SBdWR4rbQ7IL0j5e3tvTj9xKI4kI6UgzSCizD+pzXfTB6MbR28c/So5umThlDrJlCiJTqP1FbcF3LgOEJUbR78CfbDFX4mBJMN8I9pGqolaKkkLNRTbEu/q4E5PrKf9mnwpUN2xZKwheKnUqqSpwqf3bIcz7/qMHZzumVAoElQfdHGIzMNamU343tGwXml+ImuQ06DQ0JF2q4AyQ66cFqWoMQVwyFx72h3GfaKNStQraXyBm1AXZWkVi30yqcQeoeSmPAXklHnxPPj+9swpfcTBBz43Go1zUmoEmCOmJBGm2PDLlW6wtXu8DK4ezNKYPaG9YCZA0GPwvDVizdylxMgXQgXJC25O8wvOPokWXfGkaOscwsgy87wNzPZbMpEF3SrVF1J5RobQYsBkaFHSVmHrkApFJ+7lQP84YfLE4zth3ipxqTTvrG6kvPcIJYJMMHunbplNg5hhzdBbJnkiArrt0U+TOMswwjojr3h7gSfDZiWeCtpBfGX4E+QDXify1Ign5d154frr/ym//pu/yZs3nzAvJ563xnc++8HvRrfFGhSFIjP4xDlvjByUSam1sCbIUoh84vDwMR/ff4uXH7/k/uGeWStzh0MLyqJEKWQqzyejvlDCGq8EJBIpVXwOemm4gGohb4q0D9Oi4KavTzeO3jj6VXO0HgxVsF7wyFgSLG/oJd04+hXqgy3+xp3jKdC17JmIxQlxhipGYGXifgnsfGST4DpdONJIIkjk3522qghRBJ8TtIV2ALHK3IMuA82OiDKi0l3IOggCH4Ilxzcl5plJrkQ4qwipBN4vaFw4TolUJtwycumMKwhGSYXiyhhGIfHQjeOcIRxn99rqYpQCJSm5D+i7F5SpU+SCypHa9tOpiGBZiLR/nkhAAqikeeWZC6jTk1Gl4F5wE8wGIYOmwmpCnQa5Ki6Cj4T4TGgiYj9RnmkcPTFF0MIZKKlmBKWtg/CZGkqNR4Zs9DKz6ELmmd4db5nchAqkWSnzwj0ntqxc3sz84HolWUEAaxdUHc1BlM6QICwRlug2KGXl07Rx1IyScBXM9n51DIYIa+yTi2cO5FbQtxs1DZ6mIE2Vapm5d1Z3tmr0GLiNvfHbK5s0/tPf+S4lKlNSDunKWQytC1NXilZQ8ICsypQHRRIlJgqZMgdlXni4Dz7+1oFvnWYeloWUlVQ6ZRIyE+EzJKdNGyETOS5Mk9J9onvsHlpURN83mPdGkh9vMOWmm36Ubhy9cfTr4GiTxgilROYQgvuNo1+1PtjiL0ToGKqKhiDDCTrDlb4pqpUpOhdTzmunS+MwhEEhXFFzcnfUIJJBeSZJQkVIdiVLooTt4/yh7w1I4VpWIozUD8TYw7ezG1YyLe3B5dUNaU4zYzNAFPdEvQZpc+Lg2OSYGU0EicqhGpiy2sD3sEhkJIYC2UnF0QFWM1NPbLH7MgmZZINhhnnC54yJgDcO2YklE33C5oamjKYNdMJjInpgGIjhKZNFmVNnMLCtAM7Ie/6me0FlP90OICdBo9JcsRr0mhmeSd3J0glW0EzXGZEFXzusBuZEMvSozPnAYe14arid6eMOG2d0fIGHIMX2IHiFeO/FFCp0ElYCS4ORlfTW0OI0DPFMMQGDLokeieqBB3gGXyfm3Lm4IXOilEoyQYcQ10BiN1+N0lHPTC5QBlo20kiUDdoSGJ1U9t6nCxuRYPbMZAktCVUlIhGycTzNfPL6wEdvKp+c7ribF+YpUyrIBCkpKRIpGsOgr5kXaWXRRI+F4UJyJQ8FcUI6wUD1wwwkv+nr042jN47eOPphcvSDLf5yF0SMUWNvDo1KDgGcYZ0JGNdBmx7R4SyaSJEwTcQ+oIQ1pw9HCYoakWesdyQ2Wj5SIwgPzAOJYMIhgZd988ZZyMmpsbHlmUhKoZFQxBec4LwNNN5bHSAgu59Smwwbhtn+717myvWiXBPk1Jhid1g3U0YIromaEpIytSk9V3ABgQBsgA/H+2CYggWiDZkLl7Z7SGWvhBhRwcTRgLB9gKG47KBO7AA0YanG5dDRMHIfuBSSJToZz+yh6ANsOJKhCJQEGYeeKHlBAR+NZAIp09J+pR8yURyWvHGeMk2DTiGHUDw4hKJzpoVgXUEKop1cOmHO2me2njjpvCcADNv7ZMwRlNCEeiZ1JUkwWce04eTdBHVsDDrnsvtBiQulgWbHS6IH+BjMI/AqDAm6GXkk8siYN0Yyhgib7a78EbpbI5SK1oRGpqSJFy8e+OQb3+Yb9294eHjNXZ2pKZO0IJIJQHyABykqY9lIY6X2e5IXpqyoChID9/3naCjtw93eN31NunH0xtEbRz9Mjn6YnwoYHuBKloKkvPdI8D66ZbkyRUcicT684zAOlL6QcuzB4rpv9pGMMToJpZggW0bSFTzRSyFhCIa5kjrMtm+MTct7k8zAD0qOIJnQEUaGawLVvbNitE72RCZwbH9GCEVNqJYJm7CkVBlcPIg5EMuAQV4Rz2RfSF7IEZAHusjufj/53psiY99Y6oQaORRFSJFQgZoKKpX5ndJrxbLgdMzAhP1kZgmVSkwDV4OpQw2iduqA0oOzd8KFSAMviaSJ5IFERkZQ3ABHgRKJFkERx31AciwHw40ejngmPCAN5GBILYReOM4O/bSfBsVgQPOEa+wn99Rhc2xL6MjMbUamgThoBJ4GngJQUheSG56DIOOjoaUxVqeUTsigWwCJWSYgGMkISYhnRDdwIzgQm+LNQXyPSCowimF5pkRGYrD/sCFPMBXlkBeOLz/iGx99zEff/Dav7r7JcveCKQvZEzIqKgVEGO6QM+IHtF7YngSL3SFadY9NChdCEhaJTrAl/cltwJs+CN04euPojaMfJkc/2OJvC8H6xCIVzQ4pAEEZ1JKIBnNSmCZUKj6MOYLk0ELoApFAFGiZZgXVxGyFRNA046kSalhkigjaAumBurLmRJ6cbU7UWFjW3TU8CjQ2wpXsifAgJWMOAXeaQGyJtCmTJ4YsaBo0OeMa+/NL3z2xRjZQIdv7ZxVpBIMUmVhXxnwgFUGlMxEoMFyQSJSoJI54b5QxEffAoyOq5PcB3xaKpwmNBLFPdoUNKEKugmVHPciRSQTJG6pOZEG8AhVJ7F8W3ZHhDN1BnyLoqZNRwgU38OSIBNGNtjWuW+PCwEpQ7xeOT89MhyPPfUUtyNtETYakwDSQnCgy4SrU2fCRyLo/I422e4GlHIgZgePJsdkAIba6RyolY0wbmoKiwXj/c1PARelRMREiO0sKBEcsgRXIjupAtg3NiY1OycZUMuFp/3MF0gSHY+HlwwteffMN3/zGR7x58xFT/QZTnsnJkRA0IJvuUBJwVYYmUlW8VkZPewwWCuEoHVFnsEOL8pPbfzd9GLpx9MbRG0d/cvvvq9QHW/whCdhPnpKFKEHEQLyhEvRIZM9kE0yVUTuH7X00EYGJIkkpKZMMmhtZleqVyOyNspKJpIAgvjuH050g4anuzyUqXDkgZWPKg0kcbCMXkDEh8v5U5koaicYgzgNc8KpIUaarcWVgWfeF70FvQrNMZCGHkWlk6cwNysj01bmEcTgJow5UBmkIyRNIkEpiMNH6gL6fSsdhpRdljiCbIFFIkUmSiOoMb0gfUPZnjSa7FQM9MXBkEVQcs4CxbzbLju9OEUgEiGE4Q3ejWBPQtdIiMAqiQviFbW2cV+e6ZdwqpxcZ3Vb6nTP4lDW2HbxqtDQYEYgpxSpDEvdHYxuVOZxGpyP0BMWD4nvj8LUYpoPaghwDm6a9gbk6kxU0Mjk7VYQEWAjiExZO12dqZDwVpBu4QcmIFtyFOmCUQMUg77cTxQp1UpZj5uHVHa8//ohvfusjvvHqYx6OJyZZmCIjkpBiiAok0ARZBUVZBcxP5IOwPRo9Eh0hkpHESQE9nJ76vgVuuukfRzeO3jh64+gHqQ+2+DtosMxnXAsjVyQHOlbCNzwJROb5ciDFF2iZkDSxhiBxIXSQyETkPcKGFdWVxoKqsOlMxpHoxHAQwSQY+PuMyEoaCaGRR0dDGWUwaaOuxuhCKXuuZEIZAqsqWTKuDXxlC+jFONaMbmdwoB9hFNxXrLH7LCXHteO2B56bF6bcId8zrLM1OANFMocBtQXoniPZUtAxbDJObwU9gJdCap1kgfaAcKJ0tmL0LqQ4IGXhGmcsnDpANqBk/KhIccZzkNeCamAxsKF782waDGBoIg7KQZ1zcVJVogRdD0RXRCuaC0U7QaaqUyd48zJ4dOMyD868Y6SMRbCGc+2NsI0Ug9DK/ew8W2F6escZMHTvmzF535OkmO3+W9kGbiskZdgGWhFmIvaIppwMMSUwNIwpAmtC98O+vnjE/EpqE65HrpGQZox5ASpxLcw1cZwLd/cHXr2+45OPPuLjl9/m47s/yMv7V0ylcmQwp0rXGVUnlf3UOUogCNIKZQSsC9PsXOUZ/I6a097fExk3RTBC/YMNJL/p69ONozeO3jj6YXL0gy3+hj0TNpPjAY+Mr4PejC4TOh2YI/FFXDnGHbUHbVTeWTClwmRKMWUkZTs422nmOAQvF+IHM2V6y7JkQqBbMHrssTIUJBe0B5mNetyYdMMaHG2jqrGS6CqYB2V5RraZWR3qRhRlOTrbg/B8yahnjsVoJ0e6knyDXjjCHj1zAF8cGsSaMS3IsuJeOUqgp5Xx2ZHlMZGXTMzCWg1GJ1+Nok9Ms/P8cOTw6ZFxvXIYK+l8pY/ENglyUAhh64NIwVxfI7LQp4aNxjXeMZiZ+0vmx4yWR9z3ZxQddX8K0UYkJ0VBsb15WiZ62+hp4wnDZCFtzvxOOPjE8S4z3gTWjmzvKtfnwf2a+cXZeFVe80bf8q488rR2qjmHvNG4sPaNsMxWzkyHzNvocD7xYmn42mlS6Wp4dCZTSAfO6YJHZUHgIBxtgVJI44nwy+7RtQmjy55mUJwYQg+n5Y1VnXKoHETwy4VNgjJNu7GqHniD8dHBOL644/7NG77xBz7iWx9/k5fHnyJ/8xV2/0A5zFhacO0kNTKFqXeSG6bCdc48swfe343Kfdvwj+6Jc2HSK906kZX5lCmWeLxmvvuBTqnd9PXpxtEbR28c/TA5+sEWf2/1JSf7iCOVJFBpzDFwT1x74fGbn/NqPXH+LLF+Ual3gRWHSGzauU6NlqGKUEg815e8evqMDeX8bqOcFJjxllEz5nLdbRDEWJdgG5Uahadt4XHAm2J8VJRFFvo4sI4r5fuJ40j46cqlPnM5XCh95k2rvGyD5+3K1o18p9Ta+V40Hj6dsVpYJ0cA8QWdJigBdiVfM18gnL73PS5/+EQVY757ZlRji4UxTvg4ETGotVGl8JhnTquwLs9cr3eUyJTJkTrIlknnA6lX/OU7puUt+sUgH1dKL3yaCtdZybKx1Ofd4iEFKV/o6x2hr8lTw/0tMDN357Bd6VPQliNv80tepE69Xkkm2CI0WcmpUUal+xFbEmV6YpqCn13+EO3pe3ynPDPXhbvnI8/dWVkZsbCdE9vbTNuM9Hjh1SdH1m++4/lJOb3NXPrKIxthg7ot9KhUBVpizReWfocfvgfXjG2BSGXSBa1GnwZrXFgJxrKQY+UwJl7acXeoz4HVwfFyQdKGlMRUMi0fuS4TH9294qc/esMf+Og1Lz56w/xwz+EV5FNiduEjXdmulaep4XcrD6KcWsFbYQAHgyEz+u53kJz4Ygr82YmrMtVCLMK1GdexEfJE9Xc/yS140wegG0dvHL1x9MPk6Adb/J36QHzhOg1EGtkSeZrQ6cpcNo6PB+x5gvzE9lMrx6QsY7+OjlCmmFnNEHfmy0TWM5/5A5dN+fbS8fMdMmeSCH0UuhUkd0I2YlvJ/S2jCod4xTemQczB58Px9cqL4pymwTZOXGTgOTHWB8QGrQvvgFkLRY8QR95251V+5tsJrg8XtpZwT1jPyEjkvFLLRmnQPlMOnww83VM+N1DDh2Ms9HwiSmbWQRrKmXs4NV59dmYDvL9kOhzRwxPGM3LdKBfIYUg1xurYcyYL2DSzna5Yy6TY2GKw9YW7AgdV7CLIdkbDeG4zz/qa+3hmLhfs2DAPti+O5I/eoi3BaWHUR1LAiXvydoI+GKeNQ+2c/74y/cGZ+p0XHH9qRX7znu3VM0/mzNugXc+M6xl7CPxbmafW0bf3DDbk+cTltLLmRBnGYZxp2zsutcEGx5Y4L8KLS+J77Zm7u2/ybt54joEgnEtwHVfqtbO0SvSCakbuKpqFNl/ZHHoYh3Th4zeVd3YkdeWez3hV73l1d8ebNy+5e/1NDtPHHO2eo07cv3zJ0t5wPD6SIvF66twP4dkObHni87J7q4kbl4ORSqK0mV//PuRvO3MWogqPx93L6s6DPAnnekJ/8OYnvQ1v+idcN47eOHrj6IfJ0Q+2+FvYyNOVkTOpVYoLFKXnwRZKnI/4y8795yv5+hJ9U3hOj9gQNBnCRlgQTKT7wdKM8/ULHu7e4KePKe8yYo5NgiyQhlGnoBtcVyGfXpG1ktYJz+9YozB8ZojzeTZOB6EXI7WgieAFDlLY2oR70KvCDGZfoPUJGS+YmnLdCs89EbMyHYKldKoG7s6FFV4dmNvGrELSI4/uXOx+NynVRCTFayZPwZ1sxJNwKUd+p77jD22O5y9IZyeXhMyZwBg0igQHTVzY2ByWp8ppecGL5w1bV+xkpBNUFR6flLZWlqJYViwaJ90oW2WYEldQP3KZzpzOxjUaL1XRccJao/vKxSE0o67wzjlEQbTwjRfCF+2bPNwrljYO25nRHomRGeuJyxP0vnL/ZMw/dceFQj4bX2yD5zxY1yfGRbDNWa8r56eJix3I5R2jP/Hq7Uvs2jjFgcpG9GeiG1on/FDpd8JoQozOPHfqqHRxkhjJZ/J4zbkbIRf0BPXuRD3+FC8O3+Jb80s+uT9y/807Xrz8mBeHjzE/ML144kW8Zhufs7WCyiDHIMbetJyrkmtBKFwsM168YNh3GHHgZao81sCAgylzCKkN7K2xfP5hPlfc9PXpxtEbR28c/TA5+sEWf1s98LImTmKEdqzuxqEpKi0KSziHxw1q5t3DxixXNu+EODNC9UTtAWZkFTg4L/zI29qo4lAyFx2s0UEz0+lAyleyD6acSZsRSVnrhcuA2QvTMGYbTF7ZrndctBO2oVKYyCRz5vVKmoNR9lD0vBbuz4V+rFzdSaLMh3d4bmSpeE20DGlzlpYZQ8lU1o9eEnllbVCaMdVGTMamlVUqXWDpwWKVqBe+NQvVnbkeKXnG1mC9dFZz3BNahfVUedcyy5qo6gye4b5jh8qWZlxW5tZo5mxeKGNiTgNP227MGZnnUbgWZzl0jlSWODCXldQK7gmJDsmI2hALZBzo6YHpxZne3qKlsswzkRsyEie7w1LF4ko7N46fO60N4oWjU+c03rDQKeeN+9h496yc32bGeSLVZ8bS4Lwy/yC4tJk1X7EXEKOTrh1Xx8WZRLAQrDsP7oDw/aeJs00sxwN/9I8YD/fOp48bf+vvLhzawul4YL6756OHF/zMt97wyU9/gxff+piXrz/i1eElS0p84WdmC76rma5H5hgsUwWdUcloCWRymgTjujFtiTKtHG3m7nwkYkVrYdZEorB5wQ16NLzc/aS34U3/hOvG0RtHvy6OzocjVZ0I55o6F5k59rhx9CvSB1v8Rd4npJoLRsKTQhYEpXaodaOQeVo7NsF2GQRCnjOpKDF2o0zzfdFqX+jzxHQ+75mWGgz6bk4pyqCzDUPGhPcdeKUm0mh4Uu7Svol6DJQKvpJywvSAhpB9kHuQbKaLIWlQEPqcaL6gujKmmQwUFsZYEDM0VsoS1JQoR3j3heEHAX0ixopWJ9Vpd5lvnaR7hBGasaG0oTAlZodNhdIhlwGqCBnRwZYaXuAowuRBXmBrSlwGs8LIziqDvinNBeaJPBdGb1zdCTmSceLYyauwtIliDouzkbhbJ7KAy4YOYcSeh6kmpBiobkiAjYWUr9yT2aSy1kJSJ2fFLKE+0MPeY/PN4rxtzv284ddn+qGQdUIfZ6ouXHU/jS/RSbNwyTOVlQPK9+YJeeoEsntRWRAjESkhaaMno0uBJvzpPzr4l/8l49XrfzAR9vkXz/w7/9eFX//bL1nmE28++ZhX3/pp7r75EYePHij3d/hUQI1D7oykVL8wPShiGbGBizMcMEVbYBmsJ+pauPaNTEbOgU0FKRWLgnuAXuHQuebOs5x/Yvvvpg9DN47eOPp1cDRfndQ3Ug08F4TMkgcvX8wc0o2jX4U+3OLPO5fR0KikqAgC6qRw6hBGDrYcxKMwueNFKKmQUkIDMMNFIUGWhMXGhcQydxigdGYEV8U0cO/02I04BeiRqSIImYMFtgRrTYQ5eXRibGhMEDPqQTAwU0IdE0FGokZCyVRR6CsNxxLknsAUdA/6ljC8OJsHWQphThkbz2HUbJRTJkLxNUHL5KZ7SLYLvV6Jfia2PdpnvQrDdld5Jid6MGyfyJIxSAqLF0aHHkJ+71VFDKxlshiSM6Ig3jHAAsITuQbLHHQX4rpHHF100BpIDWpWBKU7hO/O/pQOuuJjpmyDqhO1ZqIpQ4PIfc+jDEXVyAen2uBw6NQvglIeeNrgeEjgV+btSj1kUg+sdawHURpp3kixkFJlqSDnwcYzFw3cIHtC3+dtDhKimX/x553//n8vfmjtvXwBf+EvXPl3/+3B988v+cabb/Hx6094dXfHsU6oBoOVFjBZIUfGoqE90aXuDvaquAvhDtFJ7uQ+URlcL4GnwA7Qo2BNcQuKBLlCZMdLZ6v96910N31wunH0xtGvmqN32cm5EQGIoimoZhz0yH2aeKgHTi9uHP2y9cEWf2yOddAk5N26idBAJBjsET3UBEmpw2m7AyTJQXsQPRCP3SjSBM+Ox4BZMEvkNZBI8N4VfDeQCjQF4PShbDibBpEck4T5hETCAbdG6k7xdfeYEqGTSbmjIkRTwmDJQXHHRZHYsKKkEQiBS8Yi4z0RPlAJphzQnOSFXoTFoOKEZjoFsUw2JaXApeFppbSNbkpdwD0zNkUnIWWj4iSBLuB9kF1AOhGVQmJEQQZUD7ormhv4fvpXV9QzJDCcPECjQzhmhWULIjtmTtf3Xxi22ze4G5LfgysJuDGnDU8n2lxIXZlHYLFHb6YiGAOSIZE5lEQZwdpOoBtzzjCmPetx3qFvLlhKtOunyLpSr5WowSF1fKngEMMYJNyDoQPSRHZIIvyF/yZAIPJ7faBEIAJ+4b/+Xf7Dv/wn+OTNG148nDhOhTkSYULonmeaU2LSzJM747KvKREhI4QEloyUBolA6DAbvgWaM70Yfc3YkN1HLO3B6jISyfcczJtu+sfSjaM3jn6FHFV2TnkaIIkIJbozCUipxHzg/n7hk4/f3Dj6JeuDLf7SpkyRyarkMIjA3HBxHBgBwzM+d+YNUCXUUIfUAjNHcQIYA6QUkgI60977LJkFwwWSgjqqQUYwdXoyVIxrCrwaaoIMhVRw2U9YNEfVkKQMFFMjERSXPWh8NzXHfCBFEDpDFS2gYoQMhhScDFGY1bGykruQxnHPryx7rE64kkIQcVQdrUKPjcAosSdiLhW2kaDvUUclOeSgJpAQ5uaodcaUsCTcFaWFIKbMnrAQVGUPUtdERCZbhSSYNmQMwjYinJEUG+8hL46loHuQeiAGCRCX3eE+dov1qspzgavCaRKqCcMTZJCsmCiJHTKVip6M7fMgn+5I4kBCpj1cXXIh5oLMMN4+89wv6JZADTGjz7KHh7cDTQtNBqSNkjPixh/+w52Xr2D/OvxhicDxvvOH/vDg5ct7pvuC1rz3n2hBJZitk0iIGuYTfXSO7KHpSpB1v1mARLjjabAV6HliycpoDUagCqaCadAJ8tgP+nV8mI3KN319unH0xtGvkqPuA+2JkSDXYLPAWiKXTDol7OWB5eUDH735iJevbxz9MvXBFn8lModUiSzAwN0Y1nEGUyjYjF8T1xocNkFsD/MWBwaAIEkRFUZSRDOJjfAJ87w/T/RBH3k/aeX3EUjvswf3TEfbnerNUBsIAapoKiTJtNHpKVNiIZkTPCNN974SEZIAKJvAlBX3PXImlYSmhJgTPoBE0kxV2JKTJkW2IEkwpoxaECZoOFUdKdAnpdneuO1FwQY1ZaLPuMoe9m17GqOKkCXIsgemew6QTsoHaAEWJITZBYnMtkJPsoMoBmqZWiC6ERFoTkBibE4JY5DJBH1rSIcpJ7IKQ+X/w96//Ni2bGme0G+MYWZzzrXc9+Occ+NGVhZJ1ktQBUpeCRRKUoIOEr1qVQMkBB34J2jTpAsNGjRpgQQ9JFp0aJQQLaQCKimRRbzu45y93ddac5rZGIPG3BEgMrMUihtxrsLln7S1daSz3X35mvZbw8zG+L7zyH4auhitXMkCfYCLoJIoCWnYqNQJOieHcL72OpDyyva5Ue7jzMnMxpSJrAWJSrWOPz5xq51hjkUQR8HKeYU120YtK2Lz3KVKxzn48P1f7jn8+Nm4ft6o14ppo1SjLkbV89ossnLIjSgXQgP1IOI4MzOlEtGIbEg6XieaRtQNbJKPck7HLUbK+cxlJExBu1LH24TWu34+vXP0naN/kxw9PJFQFgq6Hvg0Mld0W7l8XLn8YuPy6Rd8/O6Hd47+NevNFn9awcxwCyKTCAgXIGimhK9nA+qzwsaZRalCepy9FBhaDG1KbwWXSdkH3RYsdkL6yTYCMQEMD1BPwgsxA6nBOhfiDtM6WXdKJpIXTISJM6iss7KFY0WYERxDEBOywpDCMGgUxCsaidRCLQUyGZ6o6Jmb6IrHRq5K3weFxAVuvSKeZ5Pxt9/PwOnSePLCuHTysBOYHXpRXAULZx4QqdCMR4EUZY1C68F9XegxEdkpmmfGZy/MkdShyNY5yo7RWL8duFNXSllovcJ06r7zOhfWKkgfpEJZlPLnm/opjMNYlw1pQfGddZ7XH+cVFEQXfDTqLBgT9QO/BoTSnhO7Cm0ULJ2Zwp52gtAVqc887A/4LoPftC/IUEo0TDpZnbEItoJsyXyF/oARk9efBPhn+/3+/1WWX7JeGmurLFJZF6HUQHBmqcyhHFKZNqkGRIAMeiYjCimJRiAJsxZaKK0E3RPTCstBFaeGkSkUDEslk7Mh/l3v+h30ztF3jv5NclS/XatqrYRAaZXSrrTrhR8+fc/f+fjEf/JfOfjul/+EdXuw1P8iq75z9K9Db7b4y+bETMyEkqd/j2klSyFVaGnU1UFW+nKgFDSEPg/QQfGg9EqakU/gXdAjOcS5yuBRklkrVkHWfu7CRhI5cXEo59TQZT94NSO60fJsZj3c6LXDBGGy84USlVou+DbpU9BDkVnILciSZBEWBB6gbti2kVJA/MykNCfSaLEAxm8+fuFJDWxnyIXiAEEvSliiMfgQwtZhfgz2dZBh9PITN1bShSI7KgONhRSh28Jjb8y88918Yr83RhFiC4oN1vvCOAQtybMLPo3bBVyVEYOQibKgc6G6Iteg3yo5NsYAsfMaRRWKTzQmGcaBUW+F23ed/PJgKxeG70ytMPVspmZnLt92+8fkkAQxrp9X7j+C7lek3slSoFXUA+tKkWeWj4PLfef10TnUkR50MYotlE2QNtlC2Wvhx26UIfzR/0P48cc7nz4l8s+5+c0E9w+s67/JJkYNh+qkNLInDzVel4UlJ718gHnjWWBDSann1BkFdFKY2GHMvWJPN2r8yHj5gf7hQS5GuysS4O08TSglMBZqfvx5F9273pzeOfrO0b9JjrZU9kx2U67DaKbYtfD5+Zn/wn968F/9L/+fWJbHt4cR9sd36PrfZ5F//M7R31FvtviLbUFNYQi9Nrh0TAd1JrIvVAzmnxD7L2h2RdogRIhyIItjLtgtyK/Jshj3z8qXAd9vBr9SQlaKBsaDzAfhipYnuj2Rx42rDR7rMy92x+oTy8skptEfSegDVmEURS8rHsbL1+Pcja5Kj1dacS5xpT8uSCSur8zlK45RuZBWOICOIzhw9lZ8n0L+6uC3f1AZm1JnsKJok/OaowiWkMNIlFcb+NwY5crxvDF7UF6+QL8TmuxLQ58aT6WyvsDwYPTBcfmRjeDT0529dB5zoZcNGULNna435jop20bMDXKn+UHxgVvnWBK/fMVXpdwnenXavWFjY24QVeDolHDWtZIM/vgYXB4Ll6ak79j1K7UuKJXIIIujZth6ZY7k1X+Ex2f6445poLGcA2CtUXVS22RdClfg0nfEO//R8YTMP0H3GzFX0CvL08b3udJrp3Ll8fLEo/wx/5v//eB/8O+eU2r/vwVgfjsQ7PHv8OnDQvWG+0JaYd43nEKsDzZ9oPmHfN3/Q67tM/6icLszritzuyANKMnMQTLYbqfz/WHKUyvsh7AOYYk790UYpdBT8TwInYzoP//Ce9eb0jtH3zn6N83Rvd+oE5bZQBvXy8p/5R88+K//o//bP/M8Jr/ltv9P0biw2n/jnaO/g95s8ff1y8bnRbkUpZgiFkQRejZeyoYvg7/z9TOl3lhSiJmoCasZcX9iduHRkviU6J5cfqW0XbBL0L4rNGvc9sRnZWVjc4dRGMVgbeDQvDH04CjOx1XgLtwSslTyXvgwCocna3th3QbdGnYzmm8IQZpz0QfZL7z82a/4IB15+sDcjEefyBFsAnIJsEAeB18+bmi58nH5I/jyRGu/4FEmng9SJnrmixMkHklfO/HrDyw1+VFeqDWgLpitrI+B/NSR2yA+we7wdDGevHE8HnwtHe6dS1GesxIM5vOA2zkhKEdwnYOUO6/FGRhRJpo7l5+uPF4+YW1ydGNpgZQn8jDq64GtTtqZESqlMLIg+Vvqy4Xb54FcriQ/YClo3tB4kFlw+8ALheu8Iynks+PtAzs/su3O56ORsfK1Tr60g30+eC0PXj5/4uMj+fHLr/nQPnC/fGDUg2I7yxBKvbJ99y+zrQcvf/wbfvW68E/+g5/4X/2v7/y3/1t/wqfn/29fSO8Xfnr8O1yf//M8PReyCEczalXK44XYV2q/sBwrVn/keR186V/o2yd2uyIHtNuPSN4Y5jiNlAvHeuE+N0r8KWu7wa7cbfKyTooFLYOwQlxARImnN7u83/Uz6Z2j7xz9m+Zorz8h1ejrpGpnLcJ/6R/9hwD/3FsVgNv8n7Gu/5Dan985+lfU23xVwIenH7HvPtKjk8cNfizQv2fOT1Bu6PVP+X/GM0tUrEx+qDd8JOwNk4JWznigF6FsTnTl4+0PmDKZHsinA6QjxRmszOMzOpUnvbHKxpd55ebB05OQx5/xq2asbeVpGB3Ip8D3g83vtMdG6RtNfstRYWNDZcVLZcep8adcLp+ZQ8jvC+1YkcOYlx0tnWsUMq/s3yfruOH6I7/648Kn73f4AOU3lXIDl8pDG131vGJ5vjP/9E4x5ZJXXl4Gchl8UmfYwtfFmNKoxdhG4eNxoHGjrcF0o+jAekPHzoxXcl5Yl+S1bsg1WPqDGEkujvWfkL6T68Z9XNj/TLAfHjSZfL4u7Ovk8MGlL5grhxvHNDQn69NPqAl/0B7M3whRDCN4et0hG/ta8O1KmbDeJnXtvP7BpBSj30BvO1mDF9noW0FjMOZByA2WG4vu/KA7R12QH5TjV19Zb8HFB/1DpX/8TL1+4NmUD5+ufLcOnv/sK+v8Bb/5p5/5X/wvv/Cv/d2f+MNPhcv6mQ/f/af49C//faxM+h85S73TtsGrrvy6Jc8rfFgHxzGwkZRjcENY5wvWP4IWZlHKSMpNiKWc4PpJeX0q/Ov297g7566cr3zUoM4JJLkqOYx5DEp//T2vwnf9bdc7R985+nNwdL585qFf2ST5z/z9weX6f/+PfS4jfsNX+b/w+ekfvHP0r6g3W/zNR+UxBjw79VKRHebxE54PLvVKj4XnR+dDe6ZKpUryU6n0moh0JCeEsaLUEL544Vdtsv0S1l8Fr4+d0hs11zP2SP4EsaRrZS5CfzqQrwvjNakV/uAhHCQ/LoUsV2a88BSDxScpP7E/VWItLLrQQpne8RCMJ/bl4HlRmIN5r2f4+MUYWUiMu0HBubwMZu7cLy98+jsry/afQH7j6HEwcjLi9Hl6UmfZB4Jyf/6XaL8yXi/OKkJl4VEmzJ11Ol2TcS3s7UrhA3MKP46d0b5y3S885oOva6FoY81CFqf5j3gkY37g3Lp/QU0Yy2fm8QmZV57/budSV17WneKD5T6IsWL9IKqDF+Sb59P48bxWWY/vqR8qt7Xz8bjBpoxlgu6UDNKMhxXqT0nvH3j6gwcf7RO/2e4MEZovtDhf23x1yg22/gyXP6B/+MpN/ph/SeDXv/qB37ZEbXKRxrN95rpc+PgHTxjCrRr2ZKyPrzzuD763hYf/K/zJlyd+Kd/x95a/z3f+C8Qm9ktl1AU/CqUnT5q05sxnGFppf6TsvnKpk3zeYfstuRuHKUOekbATbLVz+aVyrUa/H1St3D5dWP1glMKISRJoztOeYLTT/PZd7/od9M7Rd47+XBz9wRqLX3j65a//Us+m6W+Yn/2do39Fvdnib8SOPnZWURZNdAxmQG+GL8YcG+2Hwe1L5/PReOgPyJaIvpATbFa0GnIB7cbnI/DPnfwySX/iOgujOS6DSKcXYFasB6V1YlaeHe71idTJbJOWwveezNH5UgIpA1kKcrmQpTCnUaPxqsGUgY6AY3K/PnPZbsgfNaIO9mlc1FgLjGnsEcxMNC8ce2OsK8xBaXdGUWoRyAplILUTBI9RcFckCv6Lg/2PX6mH8/hlZZFJ1QWWRo1J6Yk/gq/DKYuhXmkC+vHOZcBoC4cpL8dpNvB9bmh5cFenPgr29cK+Kc5Cj4JenbVB7EpM5yd1ile2KoyaPFBUk6qBWJLi/HAEN1soj53nXbhIxS3pj0aIIcuObgORSugzIYJ82ZiXSfTk6k7ozsggpeDrR2b5wDEGfuxcb53bsfCj/gJp/wG6dmJRqEazg6U1VqlcrbFcOuMoDF1ZMeSWLKvy8Tvlu8+F1hLWgCW4Kaw+znDzKsCC9EK8KqUJl++VfDH2FnjtPPyKtQLlnDazCTUSHYl7Z4Th8058fEbiATnIYdT5REuDnEzr3IbzerzZ5f2un0nvHH3n6M/N0VKvf6lnU+P7d47+DnqbrwqoNFQWPIzujroSoritmMJzXyht4a4vvK6Gx05xIVFELywmVHfGayelElsBd4jO6J2qhiGECpmJ+cT6JEfyqCvxpDRLJAezPgM3ZDo5krQHIg+sCls2whemwjWdJTtdvo2ZqzKehJVOYtgqpxllAS9JyMQFhEKK0mfH943nL4EGzP0gF6HVRiwASsyVnPX8HfkDXx6Uy+T5U6d+dSwLfRYkOSfbpDIy8P6gx0DKhU0qk5U+B5QLpsKG0/UMFY89kVKpl8mKY72gGD1OD6Xhk75Pwjvl4bA8U+qNrE6OldoFmQ4eREI0I1RYFyNmp6h886kyxgikJIWK5EbkxmiF6p26J2N/ZR7JKIbOs6U7yhkuVGWCBzag+8pW4Lr9SD4pmWeqQJVJpbOJ8VQKdV2IeeXTfpAJX/zOrgPdnGVL2roRT09gFyqDURMZDQPcBinzL5z5c2/0cOaWXGby8rigWThEiVSMAgKRE5GBXZI9Oo+y8WEPhBf2EcRcsBmsWihi7Aj3OZl+/L6W37veiN45+s7Rn5ujj/4Dczxj5eVf2PMn8h2l/Ku4z3eO/hX1Zos/STiOMxKnNEGt/kW4c8uBpXF0QcsFrwG+oxlIJBGFzun3M+N0nc96I0PYzHioI2bIDAJDzFgzqRJMNbobeXT2TJY8cLmeVgcBbsYw0Dx9kDIqAyUykQwUWCmkFrwJKUYOQR9ONiVFIAtQmDKYMREc1UpoIO3O1k9T0n4/Paq0nFmXEqd5aoZBndCSqJ18CGsJ5mpIXekFek+WaZjV8/qy7rR0ijmmygzIXkg7d5cllUWMYZMhsFBRnRz1oOnZ/9NmEjgNRUIYS2KhLOboUGY6RKDeiEjCzzierRRYlHp0XlYDgV6S6QqSnNGhhrnhBJGdbXck4PVuCDsSBsiZlSkgGmeilBVCIUl8TbYIfmwbfnRKh6pKrcI6G5uvNGmwbvTtwtof9EWwGlhJVBKzybIGXM4rlxiFEcoig6Jn/1POJBXShHQl+gARtttG04JHgoA1MNUzNSEV1YU2EmjMEdTiGIoySOE0XRUh0sl00t9mJuW7fj69c/Sdoz8/R4Xf/ua/yS9++b/9Fz6X1/W/e8YF+jtH/6p6s8UfJnjI+ebVb/mre9LGIBG8CDfP87hZJ4hTZRJHsEewS2NUAREyJsUfRFmodYWqTEk0HUhMoSmYFKKeRqBy62cYt1YyEsNQzviY4JxES4dbJiFBknQN0EKJSimKaiCHo6HYcEJXgiB6JbLgFrg7SlDUEVG0dbwLY12YfdKGcaZkfvs+MsCclGQuyiSZu1B84dBKamUqpPgZzJ6n9UGrBUSIOmFMPDo12pnTWECnoSk4STRgUbIU9hF4VRpKHIFJngtRgmEQa0XmgCNOYC2TLEr0SeJoEVpJXAs+H0SsLCVxnYQBTSimFORbosCkAUskR8I+hOslaHKmhkIls5IC2CQaeJvEMXA7kPvksMYRC204ZgWikLOQvWJRWFqlLBVBKCIUE9wbOSsiOykPZNnIvZJHwxWGzm/xWEK4kkPPjOBISofj2qguEPX0BJR5+nVZoppknC71Le/MGOyzorVQmyIcOM6uhYcYrkJawexfsG1+17v+snrn6DtHfw8c3e9/jzn/e9T2v4P88S8eR5HvuG7/HZr9Q/ydo7+T3m7x14x6DbwmpJIBmmce5WGQJfFQWn/FDUaeDz+ZDAUvEE0QEu+BdUEszh2wVELGt4ghQ1WBSYogobQxKXvSWjI5XcKXce6i7yQykuZ2Gmo6WF0wC0KDYwEPxaPgPunR2SxRDMmGjMEcSmKEGx6Vb+lFqAFr4ZHnIo8lyGm4O5FBVCfLRKYgUck0ZApIIHFhipwu6G4IZ37l2bAtqBREhc7EHoMIp2phEhBn6HsMRbLAMvAtQArqhYEiaoAzPYGBRafGgpeKz4HoQaqgRUjpyBxIChTDNchM9jWRnwpLC7pCV0dUUDUExSOYETQJbB3ETSlx0KqcsaEjifzWt5PfopdsJ+qOtGDdldvh1AK7lW87W8Wtcig8EGoWVIVSFbF6Zn16IXyFspJh3LqzeCDxoOVC8YIjhCmqSkhBU2lHYDIoubLHyrROLWDEabKqQWagCKGNIcFig907Or8HO5u5i3RSk12CIUJqQdZC2d5qJPm7fja9c/Sdo78njr4c/xbfffhH1OPfJ2xifE+p/yq0ZOY7R39XvdniLwss68AR5lExP6ElpiCVQHmKRvMXfDzjKDLjnApSoxRBipBRzggabcju7MukuME6TivwXJAMPHbwRENoE4pVFgsSw8tAjom5UFKQ7tSWeEtkVyQMDWgniqhlMh7J/ciz0Vgnnu1ceHJG8oSPM4MwC5MkmeeRuRlI4/4YLG2SBiMSdUF9EjJJUcQLTFiGIGVSc2OZExQshaQgdu4w8STm+c1d5bwGKoq0xD3hcfbQyEhKVXKdDAnaNBYV9jA0lZQkijMtsQdsuzKWgpdBfhR8VqyfHwCzJJRCWsHnJPNgPq3IoszDEYOiRphAKBHKzPO6YtAxG5hsLPLAZyFNKC6onLt7TSXSGJmoOEsz7OuFr4fRBFpN6gjMElkEvyijQZczI1wqtKUBjQhBxRAqoz9zfG0cV+G5BlUH2Rsujayn3xYokoIBqoFLwbrQJYkFFkCzkCQk58mLKmGDcGOWO59KEqXQDyFiIRIQTv8xcwbOsPg9rsB3vQW9c/Sdo79fjhrP9d+g1meyX/Gc7xz9a9KbLf6YN+z+TIpRclDjDKkOhNyFrIGJI7UyFscjeTwg3UDPh1swRFYkO4iRPuj9TpkTFZB+wVKQy87UG+6QPc4riq1hOEKhaOemwRLKJskwRZrDspKZhHMGWmtlvTc2U26joxGs9Yk+DRk7a3nFnhR7JBIJWZhemDmZMkEac3RKVsaxs10ciuM9z59DlMhCBmQGcyaXcLqcR//FVvomp8v7MFIKEo5OZydBJtsRPOKKlE7aoE6FUelu5853GUR14lD8mIgE5orFeW1SNz9NYufz6alUB94OxsWYXwu2K6Yr0TakCEQSPXjNHbld4Sq8iFMyz69bwBTEwaacVxgy2e9n5qW1YH7bUYtWmiRw9hodeYEBlYnVwWHCas/ImEjezvc45LxGkbOQMznf1aBiVZDFEE2KCVKDfsD3XxL9mCzfKTXBI2lmTCscFDIhJfACKsYLic0vXHLD63bmZcZAhTOiSASJP28cX7nKg+Vp56aNwxzJFXPHMlgkyJxM74y32aryrp9T7xx95+g7R9+k3mzxl+nMVwM19DLJmoyp+A6enVkn++UM+flp3ZFp9L5So6HDsT6Rqqzb6V21z+RYJ7kPwhqJUiQofiPmF/pyEFIgB6PBUg1zRdUofaHPYOpk+eb87p6Me+KtgwzwwrDGHo0xjIOOlc5l7Oxe2O2M6Imq1HJQsiJZWMQJm3h1mM5xD45254d6cFg9G3Kz400RGuVI5PDzSiQVFmPXSYTCemVyJ+Hs/wglPAhP5jbR7cE1QR5PHEPQgMtScFN6Br0ExZxlCKGFbgGPgXRhR7FtInagVMDYLzttHXgY8upoBGURajTS23lVIgc3Fb6WlcvLYI1ALsqYQc6grqeVhO6CPBKRxETxx8ZrfKF9JzQtaAZminiSkXgRhggRRtkrzmT/uBO/hfpYKDSyBT2EfAR23Fijc6mNoco+G5OEzb8NbxheJ50vGFeuDkUESdD1wEoyRz2b1Kty9sYnkka2IMsd45mixm4w4sAyQCtZGurCMgpjDS55YZjTjoVHu4MOTAdCENPAGxWj5Ztd3u/6mfTO0XeOvnP0bXL0bb4qTl+ouRZamWRTIhodY1hQc7A59JHsOMttJXuSVhEK4kLGeYxdx2AplUXgS3S+hvDx42dMbsz2wtfp9CF431hyY7NKjQPvE5WNTKWnU7uwlAtejS4TGwdbdqIqmUZ7CJsrfdv5QtK7U4DUQfWd/r1y9AX/euA7lNIoa2FpTtWJ2M6wO0sWam58XL/yx48/ZHluZL1BT7JPvAvZCxqVixrRnrlvST/urPuBzcpaFoo0MgXXyTAnhyB24dEGyxqnXUIIRySRD0SdqtvZhN3P64QXjIiJ2uRQO4PGY0GHsRwH8VG53q/M2fEJeXV03fGH4+Nc6FISscqnxbA5WW3A/j2YM3GyKx1BSmLPQQ3g9YkLB3JpzD0pm9L9wd47xaAuhpVKmZOarwx95TYezNyZ8ROf5sBbcntyjgwsGktztBRK2VA7SAOThWUItzlgQknheBZePgTtCZ6L8SzKqziPOSlTeKqGZlAiKVnx+Ijtd570I/flwO8Ho3QK365FshPpaKyQDe87hz1hoxG5sNwPxB7ocprN5oTIMwSeNX+/i/Bdf+v1ztF3jr5z9G1y9M0Wf9fbg48fO/KsDHH8OMhsxGLEdSKviXNnju/Z/CuRDX3dYBquibSVUoLDgh9X4VP5kejBRTsyleW1MLaVWJy2JWsxmqzIo9DnehqS9gdHNGq5MaLQaLSEub8i4yCfvh2Hc/AoC/323Tm1JHHaGTThdt3YXneWrw+Kf0LmhfX6FW07Y8K9cz7dm6EL3L8oxwYqT+SvJvlY2Uol5SAjSFHY7NxhzSf0saGffgM++MoTP2iw2kRlMtLpZTId0BWujYkzEy6XydfckUditWBlw3zhNh/MeaV2Jef93HUvDwqB9xXYiFLp9UDmg/4luHy48ePzRuqgeJBN0eJIKiqFa4McTi3BbT7x6K+sl8A+VGwX+Oq4HYzLwbRALwvXzwuv/SB+I3yQpHmitmFSkcegs5M6wWEcyrTzquPp+SP35xeavHLsOzGVY73y2wMu28G6DsZYKVfY9h95Kcbj6Fy/7FS9QnsmFPYUvrgwRfGxMrRSmrGEw5wci3C7GrIn8XhFPxqX28FP/QMjKlmcWJKisIWz5Y1pd3gYLzP4wyv8EV/YHp01DJYnIhW9TTbfuT51fmVv9L7iXT+b3jn6ztF3jr5Njr7Z4o/FsLhSdmEunbDAQng+DOkLnVeqfyLGK6tW7ntnyp3WGlUEMpgz6KKYCdIrc65MgcvllWM7YK48R2OxBFN6SYid9RBUg7suCF+QDEj4Wjt2rZDKj/Psk/hweWGqsJuytgchFxJlyERL5zsFLleOFyi1Ex+EIwuuyiyGTWcJUArTBRUhm3L3Cn9v5XE/QB1zACOKQOssebAdgsvO9cvkKpM/1heKLeQj2SUZktgIriL0a4eurFl5LIObKOkFdWM5GujCvSi6whIHMQa2TNrHiviKvhZGCfr4wvwyETE+bMHxZPR8Yuwr9QJSAnH/5uY/UHHWYZTFeXy5snIg9UfWeiVlITUJ7agP/G6naWoohyj9t5UP10EV46VfWKOeLd3VGc15SPJ6NJYw/qAW/qNS8N984ccPK1/+bMBUntaF1YRPFJ6+QpMd1kKVjS7Kuj643qDphWor2pN4ST79nUGOTzyssa2dJ71RZMXLhcOMR+6Mo7PmzmKFoitDBV1+S6lKmSvIxl4aXQrRg60fuA9m2Xm4Mb4OxCahp4FpRFJsImZoXJC3GUn5rp9T7xx95+g7R9+k3mzx19LY7c/HyEHmQfjAJ9RuPK1KacZv9MGvRdG6kCNY/KD24JHwZav4WtlG4VUWyvKn1LHwtQ2WvVD1BgZ3Gj4MYRICP0kDS9q2oz7oIzk+yNkw/WNipfJ9mzQO5FH5sTXiWpEU5HaQGTRZWPMj6xG8OsQi1A8PSiZ9GP44J+LEKkkldydWGAZ5fCWjEe3Gx+cV2Y2Uhs+zx+PIoK/K0YTr/Qub3nhp3/HUhTSjS5Dzmy+dOqMJwxSdDx6vL0Q78H2lRaFlQaqS5hRLbnrhp9h5WoO6JPs0Uq7kxfF0WlZKE6bcufzplcM2+HiwtkK6ojkptqAY7J0xOo8svAzYrpMnezDjA7sLe7/j6tTqrALbspC2MX6q6Dj4YTv4snzl3n5Af3vgT3nCMJ1MpTwWnnalLQfH44A/m8TTwZcf70BBKcgMCsFc4LY8s+YT2s9JubOH5YIsH+h0Wn1w+dTIHypyaSw/GUGn/wH02lj6ZOkv7Fl5KSu2nBOFJV4pj84+b4xs+LWiz0Lxg3Wf5MM4OgwOaoXFK/44+KBGb8Y4lGpCvQBPwtyBWzD8bWZSvuvn0ztH3zn6ztG3ydE3W/wJwbKBSCHugh95HrUX8HZQMjheOjmeKR9hG1cmhVIepHQ8DE1jPQarDI4U1FdIaMBradSjYHmHckO1ILmgolwWP8fWe0PrC3GpLNrJW5K3lbYYVq+nYSqTGoYOZ63GWCs8OnrvCAk1CFuQEnytDe8rilOsU1MoUYCgl527TFQ3Pm4ru1eWr8rcGu0+UDljjDICdVBR8MHtWonR2CY4G4WdmeeEnhY5jUJFT+gVZaqBPGhRcIXdnGVz4iLci6EqXKpRdwWBtAU6pCt9TOo90KLsHzecJ57rg5Dg9eGwgJiQI4kZTBemFKRWntW46gPorHH6c22zk2qkNkJgDKeOr7RNOMyYpRPtB2R+YLPf4mNlNyN1YDLJekau3V4O9lvHy86xKlOM3/pK8eBJg4yGZGHnt7yoI/U7Qgesg+NroI+ESHYbDN+5JLzeO08tkaKMMNLlNIX1gDG5jqCNAneIvtG/W1FbKdsrIpUyEj0GuQc5OBMJJPmshZAP/OrLK3/43Jg1cZ/EEsimKGekU+qEnL/fRfiuv/V65+g7R985+jY5+maLvyjBnPsZpeWCVECNiEQcShRepcP4dBpy4jRVhgTDkkNAI1liUHG6FjyVaLB1cO/IUija0DiH1hNHU1CFocrhypNsuKzg4DLIpUMujAiiCqmJ45QjaSNJa9ylIjmp8+AojsZ5deH+AQ2o01jGgkaQ1qE6pQWrCsvqlL4yKiy5M2ajZ5Dm+HL6HJWp2FCUYIiTa2WNg8f1Qs5vRqQSuAaDIN2oPk4bhFrQ5cyslL5jepqz+mmpSZMAtzP3sgEGmS9EVgSHcrqollTu20FffoJpPO4HlYpcjCiGuBHipHVUOlIre+6sutKjU5cnqi9nzmg1TiMnJ7wjOuh7o1NY1xWbhtlKlEo1JyXOrEcVKIF7IFqx51falyee2TmWQRYBqXgIMQrqwHKD1iiH8iiVoCLp3z4UFmZf4QVkdfLznfz4CZVCuhMJR0KGUIALg7LCrVWyQaRCWWnTEI/T5X8BasWGsszOHEKpk8xALLhKYy9nrFzxiY5AuxMyUX2bvSrv+vn0ztF3jr5z9G1y9M0Wf+nO43EQwdlIu3yLpekgXZC5kU9+xv9EZ3IgEoQmXWCKIwg9zofJFTIPWC+UR3IdD+YSqJ0eVhkTSFIHQytOA4LCFZ3L2UeiD7Q5x1GZcQDzHCuPE6whAT4JlGKGFHisjr0MyrWQR7DEDekVHYZIMvW8UrBMtrvQqjC7caFzWSe3nHhzsgQsBVWlopRxLsjSD/zJOA6DtjMkqK0iJkSAd6H6oOS37MtSCQoHE2NHvcKxAIXaCkLQd9CpTGDPgVhHLc4oJFVqJO2uHNvOvQZkQdcJIjgVE0UtED2zFX0OxIRZdtZcAMNMKVHoEkSe01gCuJ15j3NCsCFzcvnm81TbZOadMQceCxIFRxlLo9SFxW74F3iypC/OnEIJoylnZmgsLApSdu77xiEXkI7ZjVRDy0qVC3IE1RtDnLIoVerZkDycPgXndPlP7dS1sxbFpePHHTns25NjZDNop5Fs8cCs8NCC8mBZgl6dpSTm3/zKDs5pxAOc8yrjXe/6XfTO0XeOvnP0bXL0zRZ/Ms8H17QgWpgRRE4knazCMVZSoHzorD/uPPLMZ6yiFBHCA09n6MJhCmUg7qcTZnY8k7knTkIzlEIZjsu3SSk/H74ZMAlcYUEpo7J7w7lTQjAv2EjEoC/GDKUMaCbo1c7v/aNifSPFkQhidBxB1JgYGYLFgBv40pDiXN3ZLiujT9IS6ulHlUMokVhJhhvVV8B43TcW/YkwB85om5Tt7K8ZD0SC0hbEkmOH6IEQeAYckPkNGlNP13wNZg/GTGw1SgX3RCIoQB1G3ZzBhV0by0cHN+JYMU+UB1GCyIUciUShFVAfbHJlUSE1zlzQAeGCEFh1ZgW00kZhjo6k0DKxObk59EfDRqVgdBpsgsVk6SuH3GAz/Khnz5ApSxFG6bjn2ZuyKlEDt4LYwiIHQyYhen4fAtGVmEKdjlmgnBFDIUKWSaoyojLGKxYXvpTJNiYtd6xsKA35llUgERADt4W8NHzfkYvTi4F2ksF0oRfFVDGFg+TR2u9zCb7rDeido+8cfefo2+Tomy3+dius0VjlzFPdRfBIVIJZgpGFxoP5pVO9sKudZ8kSmEEzSAJbnKjCHEKa0o+d29bxYuCFroKL0KSgouQYaHasfuUYSl87e3OiKcvhLH1ASzpG1Uo9IMdEa5KlchwVDcGWQdhBvfvpfn7stNLIWLGYVHEmSShQDIoxooALs+5cxOgpSO2IKNkqhYq4kiYckoz7JLThy8rzBD82tDzgUGYTfEtEwaMgXtBZqCUIHbRLYx0LOhuxK3NOhgkRhdUHQwdhZ0TT3I2Zykxni4LFhVFgm1/R2ei1oyrITZFjB3W8DkKNhlGLcj/uPO6Fp00I/Qj6YG+D8MCmYlmwLEiek22lKEsmXZXjfuBFMK+EL2gmEk7mwBC2FAqd6c4mwTBjLgIkIx3TZNWgpZD93EFuFT6VyU+mUAo1J8c4mENoBpaBjYL5oOjEM5ilYEUwHmiA5TNQeXzZef0kXMSgnJmodgSSB1iQ2sGDOZ5ZVLiVj4j+mnovaE4sDVuCLIrYeZJh+WDV4/e8Ct/1t13vHH3n6DtH3yZH32zx54+DrAMWRaZRpIII2CBIok3Ed4jO/XrF5jj7GJYK1rBMQp1s58XDmoa3Qd46x/NHZJ1cImnpDIIiQQxjHIX1SVAr0BzRzvUoHA69GzWVdTXIBdqk5yAJ1paYztNo9FIIHH2ZbDiPbRLTKPJgz8qqiVsyi1CqYGa4GrkWLvnKfgn2XXnkhUvp59SZOVUEpeJueJ7N3HoYfj9YRyVr4eaBd9AZtBggMJZCIjxJ5eLJ6yzE00ZE4XUoMxLzSb6OMxi9Oo4ClUtJjhSk618Eo9/VedXkBxyfgpdOzh3yA5mV8IRoyFRCklt0JhOx4FEcj/P3xuhYP6OWQgsoFDaOKWQ1muw8XhvGg7FVDm3EnuQ4EN3ROSkYfpFzN/3qPIqSrXNZIGag01ErLDppBaxUdDbKdMoAwtldMYTQcb4PdprKxlrpQwh1woV4lDM70gTTpBXlKBe636izQIFDrojDYk5dDV+UI5SpzuaOvDj754WqK6tNGAEGoknGPDumlgKjoPe3mUn5rp9P7xx95+g7R98mR99s8Rc9IJOZjhw3WhFaTQ6FPlakFca18Dl2RnmFqWfweDgxhVQhUoh74JKU1chb8vlY+alVNA+wglHROK9BdhtMBvuolEvBJJmZ6J40NfYovJI0CVSMkDtcAqsgciBDufRKbzBnYCPQD4FIMlL40CZh5/XEyDxBVE//rBwJx+DeBvVr4ZZO+o1SJq56Nr5aknlOVLUIyJ3AqffgbnlO4+F47SwptOP0OcrqjPWBS2d6sEQ543VEzmuJTZFUik8khS4D10aRBTQoqoSDdGXkRMqgpPLan1hMKfc7PCWxKRHlvMqYSbiwC/SYhE3Wy5XklcIreYcxkwwDKQTJ1MkM8NnOBIJqzOmUpmQGhBAxcTmQOjCTs/kYZY/K2Cuvs1Lnwpgv9AgEpbpQd4HnituCjiSPwTFOB/h9QnqSC0gLHtyIOJC6QBEGiUcle8KciJzB8bpCaHL9cGddF4pvDCDbxMug1LMRHi5Ym1jCfUyucWNcFqR/+dYjcwabC5CiTE1cFMv6e12D7/rbr3eOvnP0naNvk6NvtvjT1pBmZBFSHHDSQWjUVCSdTVfWOlnovGhyHwEyEDlHu2WCdHBrzDXIfWG7FJZlR6Zw7IpkxdRIOpmdihMjmRO2URnLhYc4n1QpJjyic3Sw8G8h2Yapnf0vQ0mpzGmI76g6eylEK+AFyqQZHH7Fp5OxwzHQGdiA/nAe03jek6dViNzxZqALQSVR1ISigfZgHM5hndU3jg832p4UjDPoJ/BYz96SAJmOS/AFQRbOuKfsrARFhbQADcSVOZxhzqyBkhQ9J8NiMfIQlpgszRjjiRKTdisca2GiiO1EOjOV0IXURpvCoTd6XbjegrmCvAThimdQtLM0/2bnAIsXYgi3BfSDU+YFOZwxnZDBrMKUlelQh1O6wz0YDjoMfirs92BqUmpjZ+U6NkCJmAiCiKJe2AR2d16H4wpXTagTd7gkMOGIwKUj24H5RIbh3thnIAFrgbt03J8Q2U8rAwHPRPNbj1MuPKaTa/BLhT/JO8cVyuP8WZLEFNTknAaMyZO8TWi96+fTO0ffOfrO0bfJ0Tdb/NVMajrUhWEroycynJrCVoMZX7l8XYmnBeqGxp2QA6phKBKBiGNA3SujVrp94OvzC9daidcL+QjcBt6EDAGvFCtQEsJpYbisDL2zxKDZZFpwjIWLBDor3TtjmVSrRAq9OFMEy6Q7jKOil6Dowk8zEZ9ELIQJSaB9fPOxEmRTLt3Rj9DGBZYLj80Ra4iC4dR0zI19Cvde8aWeAeGLI32ycmFMiBiETqK9okVoEUxPXpZCU0H3SrcDkaT4IGXgWoiZrPuCUzg0qBm0OajiDDVEAIdXEZ42zqbguSH3A7UTKrsOXAqoolVxIMYFHcLLgLRBK8EGjAOKJ1uFZoVRF6YodRp/9vpAtoPRNy6lMmNgKjRtHAI+JzPmaS0xAXNaGcCddMPknPZ6scZTc9T2bxN8jRSj1KT4TmUgJaEkRRMVYy+JuiCPhbSBlU6zsxE+ULIkUSdulX585LbvrHWeTdqvlaaF2hpaCwn4dBjJpklKpXz9Cf3+CTmEIRUNPftvUBzBRnLxt3ld8a6fT+8cfefoO0ffJkffbPFnc2AziCmEniPvhmAYxkKur9xed+rXxuOzMXvF3FlCMSphp5+UaEBWcl6QAp6C98AwyjKIOplVkaHIorgE5o2mwchB+kKVwh4DElSVXJypd3wk3Q5SB+4F6QXEoExmJjMbfhjbY0d0kr1AgMaBUKEWTB0y0WY8VdDfPngsT/xZKs9lod+/cl2EKmAxURdwRWdSNiHqE6bO2AvddjKMsJWwSqgSKlQ7IAYjkmTAsSIvSXku+PXc45YQ+qHceyAs1DwnA00m0h0Pg7piGCp5Zm4u0C7Ol5psPymrB/uidFlAFJMk2ZkRqAvPXyv/tCTb/YWsK9ti+KVAPy0E6JXQimvQ1LHHQu2/5cfjglxXpAhlrlhPqAedg5zOGMIYlcwNli8sH4TmhT4K4QW0M+zA5ZksH/DqhOzMDzv330yGTQqOVYVWmPHgOG68HCvXp0YtivrKMo3qeZ4ILA/iojy4kpnovrDEoLSVGAu4oDuUA9yUUoJaOvtN+VIPYqx890XZa+Gu4F6YrqddQSpWkrj03/cyfNffcr1z9J2j7xx9mxx9s8WfpjCjwYS1OLUKXoUjkomwLoX6m86LnD0XU6GM4PAFyQWZE6uDaMYAeHylieGtQnmgW3CPg344ZSjWFCQomWwaFIPbJ1juC1UUX3YynU0gy8ExAp3B6kbQOHIyYlLWSqnQjoJM42YVDuWqyaPckCpoCjETH8qgIDh1QpVGrRtf+sJH/S3Vf4nJxBzCjClGpsIURJzrAus9oQ6WcWfuQWzBUiCLs5dkz8pIBTHSG9djckjifuM4GrIlG5Uixr5NogW3Y1DjznN1bFH2XRh747DC2oxVB0v6Gf7+64Ci+BD2kUxJtBgiFUnIcWDjoLHQ8ieelwIRcLvR1w/odT3tHe4d3zsRk6HCEiC+E/qZbQ1GnWfWaBVclcgKvaABpTZcD0w3Uq7E/hXdB9OTaMZqQrYkNGjTESoxNrY+2PPGNCMzGLOSsZLaKb9J5qfkobCasAAylWGAnEauMo3vZOOlvLI9rYyjwRJIC8Y0ZnL2tYggKcQSPLjyw9HRqGRN5iLIIxhTGCbUOlhtQJ908ve6Bt/1t1/vHH3n6DtH3yZH32zx11BaFcaSWD2Ps9POxUc3xu3KlE77sMMU4jCqNLDGPAoxHc9vO9zZsCXQ8YKxMI8DvTkzAmdQPFEa86J0hbor2QNZDobeqcVoQ+kjzomtUQkg9I7nQP2CykYtwZqAB5lCVigt0C+FR0n02fhaK5sW2iF/4fVUipHl3C2XsvLcnvjAr+jjlbs3XDaWMjFxRgGXicXBcDhWx5aDa/+e2+UL+lKgDEIe4EGZF6qAVGeWwZjC/iGRR7LPwTUBqRxW6BXWx+TbTQNWlJwVHwbWzlucozPqRBvE1x1I8iq41LPpeeY5PSf7+bc7DYGaUOBadubrwtWDRxzInuQ4+3vSC4zBiw/0utI3QdrC5/7ELJMcA76dWhBK5oYXqKXxVODL/pVcBjMcEuYjmcdgq4rOC+JXfCgLcJFCFKFX5d4aJJRI8MFxMbI5pRVqFnL+eU8NpBWGFUQ2Li4wDN0n2991Rg58VnROjCS0MOXsn7LqxKLY+qC/gJnysE5i+G54h9lg2BkdVWOyz7cJrXf9fHrn6DtH3zn6Njn6Zou/uUCu8zwqjonvjqiwVDv7KqLy66vyi+UCP02cQmtOapLqhEFqAS0YwlRlMSjeOb4aOZxlg2qJzMBncnQIc3KfbNPQSB5+QK/Uby7y6RPtlbmuqCkXjAhjCLQCK4KnMVYn6qSIIUeSMViBftTzOLtD04EtiRqEJKjTVXn2zk92ZdNEHmB1UMqgZFC60XfBA3IDbx1PoRhgimRHvk10ZULGJNMQEaQEWSpLDqYI6kmdKyUnqY5ZoZXLaRgqgSnMQ5l9kk0Ys6F0CpMlVhDDmtLWzhTDOtRwsvsZSaQTMdC6oGVhPiAeSik3bBlc5EH0yfSzoTkQcgpGA6+gHbTgLKgIvUyCOM1DRfFl5dCk5YG0r0S9M3ryGDvRHZ+TYxF22/gcC+KQuaByBZmoFwYLcz6Q6Odz5sJX/8QnNW5pPEUiNgkVJBVNkAhMkkJhj06EMrZJzh07BKKQNSklaBTCKzkbrEnfOo954UkLewrpkBKUNOiCSyGrUEae6QPvetfvoHeOvnP0naNvk6Nvtvi7t2CpsKbQB4wBVSatDrLu9PqBY1Re9o3n4wuynM7iFpxNxRkEgqgiy6T7xFQoM5jRyLXQaoeEIUnPhnhSMvAR+MNwltPbyYVWdrJMNJUSARnM4GwstXO3mQS7KooCyRl0ZKxPie4dpfI0jMerIOHYGiDJkEQJSgp7JDp3Xhen1IYx0DnwARLn0bbchaBh1ahyMAJ2O7C9w5Jkq4gYGo7rxDPJNKCyquC3wXQnIsgROI7KYJOKlY2sEyWQVDIE4caIOz4nDWFLqDnxJrxKYPTTnd4WNozMwQRc5ZvXUyGWZDyCeCwsH4VhRvXlLybvMnfcnSmVa31CskC/8HQxhnVMnVmTHoEOqFEQVbQFOu8kd4Y54+F4OD4dLZOyGAWlIGe/U2lkawzvzFnpaswYzGOnRFCodC8kSibnh5HlCXxRtAd6BJPkYYHX830fxxkufzHof24xoIIB5sncA5FJbEIpsOjkMSu+JLYFhaCO09pClfO6pyy/1zX4rr/9eufoO0ffOfo2Ofpmi7+ZRp8VtKFuqIPrgLwjc/LTatRH8tPyI9ulE5Ic9UI7gnUeVHdSCzOM2QIeD0qpTDXkMkGfiaHkVIJEq9DsnCaby+mLNEojdYUysXHHD6PLMylCmcbuBz8uia2VFkL6ndccLFlpu6ERHE+BlYKbcPhGmKPLibMonE70mmhNFi+UgB3jml+ZeoEK0WG6cSDoTCDPAPH7RqsKdUejEOqwLgwrlDxYdDDFkEx0NGKs1NwZvbBhPErikhwqlFiorwU0GR8VteU8RhelrEGMV2oeXGOh2tm7M/zBT0O4foEphkpQVRAFFRAp5Cwwkl5ez8m0PFBPHlSmrkgFlYH0ic+Bl6BdFm5DmcdHlvZgn51MRyTJFHwktjurTdbSCXeOsTL6juRO1Uovk6LBJvBpv9OuDbOGqTHl4GAnW7BeJ3kTjmloKhdbsKKsFT5JMFUo2WgyydrJKXg2Hq7sMVnbhEj0a0W4Yquj6YQbPRpewEqg40COwSc2hB3TicwFWqUqlOakno74adCLcTxtv+dV+K6/7Xrn6DtH3zn6Njn6Zou/Gk8s93r2mFjFvXDLZBd4XoJefs1SV67f74x9o9yAuZ+TWpdJYEgRjjJ4DOUexrVe+PEzXP/fzsIDdeiA1MlSgkjDdUGekqPs5FapX4PFHmhRTCposmtycWGLym7PLOvKR3tFemL3RvHCxYMSwYiDGZPBSkSl11/B6gjP5FixR9ACtFVaq6T/hod0ij9TDyeHUupk1oNpFV0WdNfTjyk71YXUKxXhuFyxXchx2hqcP/OkhBCzcfhkCIisoIWyKNdiaJ/4kbgXxrwjR4A0GJ1YByFGGxe0JI/FeV2ciy1c5sFn+w79DVxzMK8PDiYyoUyQlDNFPhfq1xXtd9bnyZemjLujq1NLwiGMfiX8emZSahCzg73wMpVjh60brVaWkfgRhE9kOeC487oHv8mKmPI8V37SFcs7SwZiylwMEaPEhnVOjypdKLVTl3MXW1vBbND9IMedYckYzucx2GQlZ2PyirPj2/nvl6FshyLyHfcPjfn64LgXlnZHls5hCVFZorK0jdkmpk88Xh789qnRHz8y5mDT8xkf2nCDFkGZ4xxxe9e7fge9c/Sdo+8cfZscfbPFXz8G5XJnmQvTV9yFKoXKFdeNJZWPJpR95Yso1ToiO2Fx9g5MQ4fQJxzTaXdhzsnlYpSHctcfmRhzK9gaXDKoZXI0J/eVVRuyD6o8uM+NPFZED7LtXOqFy2VFbpOLT+R+Z44gtaK1cG9niPnzLqebeql89crz/MLWNnY3XCbUF0QU0vCevF4CGrCfmZWXx1deYuOuefpZpUE0zATWB9EfHHahirBHod4SuyRlEdKNYxSOEGiT5SlYxDk6+NcVngPXF7pUliioKuPjIMvt7LGZCzOEGE6RirXAbLCloEcjtWDHSu0H7h1ZKkepTIWlJ9aNJoksybE4edngV53dJsaG58IYOzxAh5x78HYgxTmyMXRj/fjCUzTK5YHxPeHGgdNVwAqlFpYGT/7gEQf79uCn1wncoPXTtd4rzgbtA6019EkA43IXjrmR9ytFHJnOYzdqCT7MSe8vzKNy+27l8XT6eJkrNTZ0yBmn5PByKE/fTy53p7cbK08srrAnwQSDaEqvRo4gbiv29MoHHfyZXCmXAy8bipLpRBzsefYX5Vh/v4vwXX/r9c7Rd46+c/RtcvTNFn++JP1ZUAN0UDnzBSWcOQajPrHLYPTBM4aPG/tzxcoKQxmhjAljTLQn2xDGhHIP4vsX2lclvaK5YC5MDyCp9q1/wQbRg9zuXGfnXp95pCL2yrbt0D6wHxu1H9Qc3NjYZcPaQfAga2BU6gKZB4tVehbqOKguZ6B1NbIWDhF2B7kl17zRnr9HbvAn3PHqNDUWTSKd8EC4UGol5IbbHTkqm72S3w/m2smvwjxWPFeaGGU0PCcHD5ZReFpgxGS+Cr460/WMMbJJt+SmK3e50OrOkzrmleEPJJLpZ0SQ6M49hf3DxvV+YbPB0YQ4GulBNyHEKJl47rw+Hthauc6ELuS8n2HpxXAFl2CWJM1o+8KnevAVqPuN+9LAHzwimcukLIlO41DllUL1xlIPJBby5Vd8eozTIqAKbWmUXJkP5ctxoHfjaU3yedBfk02SX4xBzFe+SpJWuCP8GB/4Y/vIL8P4N0JYc2XJgbYbtiRQkFHYp/HTcD7KxgeMaY4WIzXOfEkBChQrlLJyqz/yivGHc/K0wTGCPmHIJMtpktqsYA9YHuP3ugbf9bdf7xx95+jvi6M7wnevhnxeSBP2VJq3d47+NenNFn8mC8MXMjpVHzQ1LBsmjWKV+z7oulMezkd3hgh5Lww7I14kzt6GugTLZbI8GrcRvNyC+l3laTPWcWYR+qH0JuxyZhdqHoQeLLXSWjBM0K8PPmDIcmXqwd6DyAPxiVllqYbEQQwniiJFEQvq0rn/NrhWIWM5x/DFSSmMnN+yMYXUxpEDyZXZP/DMjt6FyY2QxpBy2iLIRMsOmZgFGWCbwOzI3riI8TqTRyTIg0WUohWbgYbS2jOqgzHupBVueqXWSeiOujB0oR9GvOppBrA8GPMrhzmaC8UMCZgusB7o0lF/JjSp0XFx4gJSNggjPBgYPhufV2EXxx8dicTy9HJySTzO/p1KYRGnzge/qE9c2uBPymfSdsS/Td/F+f6qD8Ye9A5rdZ4fwksTfmuT19XBCuKCTvCrM8TpliBG8+Qok7I6mZOjJLcW/JPvfuDf+9f/s7wu3/pEXuGHe/A/vNz4x1Jwq6f1RBFac+p3lWN/Zapy042tPqA1+jCiOwWwGEy/I0uBeLDUK/7bJ/jwleua9MeN7EKmgCipBSkQ9jan1N718+mdo+8c/X1w1GOweaP0MyJv4QMLTs4b3t85+teht1v8+aCMRAWyghtodYiDMRWfirTJ45jMxTjYAMG7McTBnFICq8rYjMRYLoPf3J3v708MfSCrYB3MA7ITw/FjBQNbBmZJ6xX3AxOBaqQtrL0xEChCt5UpRmVSpYPDzNMaIXTymMB6WhTYFPZqtGL4hD5PG4CixiIFzUZ/+Y5lcR7tlc2eWZgMV4552gyoAzKJFawJx61i0jhMeHKYw5jkmeMZMEKRHCgH2gq1TWYWbk0Z5siYlBEIwUzl8IZzUOxGmuJFiRj0mlj5Fq9zF8pYzp4bN469cItJXR5czIlswETb+TOUh/ChdXRcydkYZce0YKZMGolQYtDmYEnBCHoI6cH4cGX1iYug2vBRiHHg8yDcianf/hR27+TR+Mku3NhR7yQHckmeLoWlFqol8yiMfuFev+JFkNYos/NHH77n//hv/cN/5ln8dcD/5FX4H1+D/1q5MDEGk2CntjstK1MnBZBIvJ8fEEsDLImAdFiBbVvZdniIcZyuFNQYkHbmlu5BYTIleZS36Uz/rp9P7xx95+jPzdEyDqQ7c1uIK+DJcMfmGddHSYJ3jv6uerPF3xpfWOKJtBXJKxqViIn7g+lK1Y/I/IozuJXG3k87ANeCB6hPFBCB26Mgu/Jx63y0wtKDviYmC1kroY7m6T5uZaCpBIVeQG+TNYxYhEcZJM7ChRQlijIlieHonIR+2wGPQQXUlKMY9aLwEI4Y+LcQa82z/ySyMt0owBpCPirX7QtfdeJXx6ZCKlb1DAefYPN0aNeYbCSWDdWkyORrSzyS4kZKxVGOmWgqKsm9H8wo9A8F9TtP/solC5rGPSC/WSdkSx4BRSqLGmVMBHC/ERkkPxBjYXpQ+sE8JiUHpSmR8IhBcloJFFdy6cyXFRmnMecYglglYyNT0Cmox2nuIJU4Fm61I0fyxK9JWQkWJMBxnI57kLNhIxkDbo8D7tBcKQGZiVRoF+O6LVxLZXHQAWMqKXb6hanQGPyf/7X/3PnwyT9vpyj8zx/wb6+FqsKIYE+nLg7yGbdOHZ1uAI4CSBDihAaahdCKLsZ4+YlYFhYL9D4Q6jdDWMcikB6kBzXm3/xCe9eb1jtH3zn6c3J0ZfChD8TrOShkxoNBmV/Q+hmXDZTzKl955+jvoDdb/BmO5CCkoRgyKjm/+T2p0Rx2E5pUpgs2JjMNWydaE/PC4kbgjGOiuzK0c1UhdWK6nNmVBYYFDMVkodRAfXCEnX/Y+WgLxYKKkxmknNcEcZwxRpYTAzyUCCdGInNQKvQrZBhVg6PktwZUx2pSijE5J7kkBxmDtlVinZS4IDV5zAnaaGI0/Az95syE1P00LMVAOCfuzvDs09KBHEASYpAL4sq9A7zQfMFfz62UWCJaERR8UFVJu7Lv8Q0kirqiw1E78BIkg1mNGA+0JC2SiEIfQmiwF8UD6gxqGPZotOyEdC4UHsPxWkhNmM6cnUMdLw2dCzIK5QrpP+Lip2nnAOKEdSTgiY2OzGTPDm5MmdTYWUaAGhctXMV4MmUp0FpBSxL9xjInjxR2Bv/0+onbev2PfSZ/HfB/PYJ/GEHJwVSo60LXcloOWMHtnIzrceacaj9D3KclewZ6KO43dF5ZZCI4vZxAVD2AIDUpR7K6/o2usXe9fb1z9J2jPydHY05arHi5UgPShb4apkaJZGJkVqoI1fs7R38Hvdnib2dlYz13SXrgmaBBIWkl6Lc7XBqTRL2c/98cNCZSlJQVdyN1Z4mgIBjOXR9gK1soYuMbAM4mZbyQVuk1eOxJiNK1UGzwbEoVIxJidpwgdihzwxrE0iEKdUwsF9QCl06OBmHIEth2MMUYXRENvBiRdu5CIxmq6DrxEsTrFVmCKSAS2BAqijOYJc7coIA9klompHJUwWZShqKZkBMJGFLPkPEeCI6USQ+lp3BMR8Wp7cy71EwsQZpTjjPs+16UUjibxVkpKbg4pQbkeW3iGgwK4YJ86ztxYKrjanz+kmgLvO/o5cpWO/es+Jxo78gczJKEGmUUzAS1lbEWIhbGLmQHzYmQMM6dcGTn8M5koLZyeDB8ol3RIlSFZoFlEJrYZqDCuB3U22AtgafzxZ7/Us/lj+6EnLFDVhYiwdZAANOCC8QwUgJTRaOdH2YMcgx6n5RRyXEgdiYFIAYoztmfIjVRyz/f977rXX9lvXP0naM/J0fHNFIa2ZRk0hbDtgvYlRmcvyOpSAQR452jv4PebPF3pBJSTldvOmGBmdAUoig5wcKIejaDehXSHeaEUDwmIbAYfKKSUvBsUCaHKuVw1DpiSg0jPdEINOFRgr3ARRW1RtGdnA4CKoFnoFVY6mkWOkRwixMSqbCWE2rj/O/RlayGtQ6WeCpDCtRKpuLpTFWkKeEP3JM5BS9O4DQJhDzH85kMhWIVVWEGlHrGFaFJ3wvLfjqxSylnjicLMe2EukyyCnPslGaIKIcpQwxczviiOK8+agmiNzQEr0lYYZlXyhRCO4sLeOIqeDh8ex0pSYagqYgorSk+OoUVyeRrBk2TOoXFAnCSJKNg01AcvwiBMm1j1UDjzBAdxNmkjdHMgM4++zm9t08Mx+W0UzA73esRcDmd8v3bh5PRWOjcRpKzsoy/3NXAZxt0m2cskRmjCpt09hHkKGQ5r2tqJk1AVRjCedIxJplC5DO6dMZaQYScgYd96/FRpJy9Q/lGofWun0/vHH3n6M/J0REFdNKaI8iZDiKOJ6gZTSeWO1MTl/HO0d9Bb7b4QyZZzjfZPUmfVDW0FVwrVKfuldgcSFzzDMMOQQfgZx5iEWMZjcOUQxaeZGOKMo87KZO2KIslIGgGGQPxTtVCVbA0Ng0yzwffxM/dSSlcRYhwjgJOQdwQNahKRmBdMAlClGMU1qJsmXQxUhe0KEiczcAoTYP6sjP2lVp3anRKEUomoYOhnSNPs1AVARSzhSxBO2DuhVdRZpxfS9SQUmEWRjiRSpkV1SQkWBbBt41ORVxZM884nJlkdiQ6zYImxuEwpFBjRdCztyKMjB1SqKEgk1Hr6R0VhoRhZlhJxjbZRkeLcuuCp/Axk4VgVmFYAa/UgOBBUNhDsaOieadOJ0U4ODtBVlOsBhKBRpCvk7E/0OcgX53IQVFDveBuzKrnahkOs1JyQZeDw864oV98+TOu+53bsv0Lev7gFwr/5hKkCikClogptt8JFuYBkU7RPC0fUsh6elTlTMaRaCjTjLYJk4YtkxQhvaBuEPXsBdKJ6duE1rt+Rr1z9J2jPyNHpzrEg0sUSmloJDWUnJNSNooJngeenNx75+hfWW+2+FslwZywcjZh7AkpCJWoBpeO78YojV6dIQ/cFQvDTKmHntcEszJc6dWJYlxjxVOYEnSUtERrx9IQCj6T1YW1cH49HyiDISsuAimUWXFXBEfXAAz6ivmZNzimIz4RddKgmfLwxLtScmIpHAqeA2mJWIUs5OFs0Xh048OHO6ULs62MA/COMqnmhCQWO7iidaHrYJmQoyIXyBoMC0qNc5R/HBBJaCFmYZvB0SrZk3l5wlGWGCya+NyIBxxamAKmE+TMgdQehE6kNqo1ugk5nqgelLrSmVQrrMVYOHtqDiaenaqK2QFmXIbRRUH7eZIgyrTz2D7nJMaD8MZ4Fp5+XE5rhqrntCJJRp62AjJ4EKRX4rZzb36ayx7J2Au167kjDIMmaA1MYIazK/SrcNsn/uuByuTf/vf/Pf4P/+AfQ+Y/twD8H31QRAs1hZVEU+h70l8f+LISS6HkTkvBtBFFiJq4CZkVy6RkY3CQWqhfhXwSplVaGs2NDGOEIHKgGj/zqnvXW9M7R985+nNyFJlEBn4YpVSWsVB4okqwSmDF6KGEG+2do7+T3mzxZ7my9oar4R6ECF0qilD6zgzl9fprtv6ZRygxBqkXsEp8CwxHElugtZ2nHnzRSq4Tezwh1wuLBqsmyORRnaXCcjRGGLIsLI+NXXfysXHXxvDkaShVGrU35tKZJeHWaH3H1gd9WXnMc+K8bisFWB4XmiQ8nrjFg8oNz5dvWZCNTY1mSmI84hOvJnx6Pfhpq8yj4HJDRmXpjVqEtP1b03BlfgysLbyEsT4r8mUgdI5rsGfS+sSOytYVzVdyhVtt1GXixxPtKCzlweTgx6PRxk6E09cJrnhfOLZx5izOgpfClKCOnTwuML4nbOdQGK879ZbokzOfHzxq8pigfeeX+h1yLdTjTmfi+8o+Tyd+NxiZzBwUzdN4dSn8oUwevykc12RcG70qNn/iab5S3HidFx7zgsTA1On1K+P/1Wn3BZvne3OvwnMTruqICIcqhwmvI/4/7P1Lr3VZl98J/caYl7XW3vuc8zxPxHvJVGaVTZUzXVaVKNuARFGCFl+AFlANJEu0aNCr7wB8Ai6iARI0kKBoQBsJCcRFQrgMFiqQyrd833wj4rmcs/dea805xxg01mOnUdqudKYdQR6dfzsiTpx91vztMdYc4/8nb53+bLRbUK3yV374RP1b/1f+97/3b3Gd/sgV/ucp+G9/cP56DvY9kyxxSuCqfH8VigbrHiy/WDhdoZlyraDTjnqD29F5zwpMzrt2Yh/BZJ3v10caKxEv8NVLrLhgY6VhP9n5e9Pr0BtH3zj6Y3NUNWMoAyVkAmmcsjHPRpSE7Amz9MbRP6NebfG3XSbWOh/GlXNDwgk3OoHJMyOUX0yDsXa+7wl/PvHtUtFS2FscsxHzRp82fIenajzExG+sEvmZxSfS6og0alGyVMwL9+ysaVC2K42d3zBzbiuXUZinhJ43QhojCW1k7qaEvXCRK+FPDHuiTHeKQmknZHdazvhVuJ0eeYhnxD8Rdme+CqzKLi/0h2fmpdJ1Rp5+l7H9Az60hSE7PjqqQEAfsFO418IeEx/ShH2ZsPl7PlRjyoXbUMYW5E04eyYz8VIK91rJ3zZsEy7W6Y+fqbdKeIM5KCljNphlZlKj235ctYyOTcryQblbZt2VX5aV2XZe0mfS5zNlKkgV2Jz1nmi+YPPKkp2n8sSQKy/2iH5srCJMOTOHEqMz0kCzsvjEQuIydT6K8umLY988U26ZJe4kHhjyyDOFIKhJ+eCNu7Xjyuic2eKFIRcGK5EWpnomVSVFJu8LjIKWQT4N+DRYRuMxnOdWuQ3jt9s/4G/cP+P/6r+O/u7v8jvlxF/2QDbDi7PQKGmnpUEX51s70/Qbym8P+vUjn+UBfeg4zyQztC/othA3wScnv+9IX1n/IHP/1yrl5ZkqG20ex1B3d7Y9sVEYy+u8rnjTj6c3jr5x9KfgaJRGOulh4F0XIp74/NHwPEhvHP0Xoldb/H3+9Y6+f+ahOo+nRDpNNFH6BnWvVLnz6/Zzfpa/Y/r8S6iG+wrs5CWRFwhVzIPSnTEtXP9C5uH//Uh7mNj34NaFJJVTXjnVhk87Hon5OTHdE0iQ2xV/7yTdmcwZttLc0LFhTxUuhWkI0p6gFx7iM3arZMmUuNPuQf3ZwrqsPOWV/DKTrJDGEyIVPQVJDCLYbGNMn/lXNuNTL2zTnak/UL0iZWOUjX1ktn1GvwiP2zOjveMhdX51TXx5HFT5hIyERoKTc8s3+j3R+sIUMH83uOfEp9R494dPuBp9ytgNbLuTL5mhK2RlUxj5xgmlLEbaO9oSu134NM6I7PzwsvAokMvOEkrJgujO7EZsiS0SP+A8tAtPaWcDlod3TEkpa4JNkB1GzcR5os+ZW1eqPzN969yaw6KkdCFbA3FKElI3ZO1sV6etgasz/f07v9LKpfwGjYzmzCKJrJV6ScwXIXdnEagj2L/sfPlB+LRtfHZBZeKSK2U68a/mjb9UPzJ9e6fsldgLPYQkgqSEF0GjQx4U4Bv/GdfcsecNZUUGhJ2hTshFyVNj7I37J+fyshG/eGBJhf4wo+uEj2AnyNZ5NOM9zhivE1pv+vH0xtE3jv5UHE3TCbkkHtJnyvuN0xtH/4Xq1RZ/jyLMdcJPmX52onRi3bFVuBWYnxaWaFzrz7H4yLgKxYMaDzDNjMkx7eg45is68O5XiYi/z+iBXguZCS+ZTU5In2EMei/IqLTLZ6rfyaMwbcLVPrPmE1XeMbYNeb/CxVm6wC2zdkEl8KLYSRgexA76MOjx93m6fcNV71x7RyRRqjCnTo1BG40ejmki5Bu++7XjP89cljvZT2xpp9MOC4EsXMRIITgnwLisJ979bGau36Naic8brI7eJ2LK+MXwDOtaaHdjHwPRne/rjqfKSaFkweaJ8EBTIlG494pa5RSN630n5UBLR/UZ3+7gM+8d8qnw8HnDu3CdZkaZqbqR3Wgj07wyT8Y9dpIp2SvWnvk8OnE2ck4UF3JveDTuVRmaYd+wGFzqEy83sLiRNA5fMRL7pHyOnXXd4dcb67XhLz9j1ETOjSUK7zXzs1PloVY0G/k6E3un5xfW+Yx8u1Mjc0nBwLCp8e4yc34/kZ8WvBTue5D8hpDocmJNh+loYeaDLWjtfPrhCz1fWXKhSoF5wVwYo7OvnchBWpy6zbyknfljQT1oL1BDmfPOosEkSi3BLXWu/XVC600/nt44+sbRN46+To6+2uJvonHxQbSBJdg9o56Z1VAcGw5q3AV++ZCw88K49sMTanG0ZMqA2gOVQeQV8UE/QxswnRtdApNCkoKR0VCqHtbl4Yl2ulDWjK9OGoOYG/sMae44Gd0zviZkgyowVSGlcWwzqdAXASu8kwd8euBkVwobIUGKmUzBtdATtO74DXIRyjk4vSQYC7dzQXogBtaD0QTrjiSwx+DRO5+8sUTlZbvgj0afJrpBJGepA7Gg7emwAMBI+8Q6HpElodPh7ZRCSDIjklh3o7hxSfXw+orBuStdFB8N2RW1iYsFXy4rTWe2omCOykC0cvdKZqWeOksucDNSy7gGS/qerS3EBZCCR2LXwMsGDnWfSevE6fGMP/wG1kL1wW2AyaCGkyyRCM7RKZPy+amSnhPZPqJt4yRweuqUbxr+GKwjkT5OVC+UakxNuLmR8pUyGdWVhIDM+O2JoheiC6fN2BsHROd6uOOniuSgubDX91R94ane+Li8Y3jjFkrqN1IK/DJj8kDZYWbDqqHlzPLJ+CGMU33h3Dq7TKyy0FRI6gwy0tNPfQzf9Odcbxx94+gbR18nR19t8ZdIJM34pIyccUtob2h0hhfkxeh1MLYb9pB4kEqfM3cx9pQgEjEcBmRdELmzW2YbF6xs+FJII0hjA98xT7QQxJXUOxYDhhJxo4ZjJ9irMNiptpLWEyoTW4MljIcTpFJIQ0nDaHkQ6TADvSss4vQotNmp0RCCYYnwrxYMOKoTp3VC3wXp18GuL2yzUTchhRJxuNxLJHJxxkNjU2X9IfF+dGJt4I00MjYqYoWkGZk7GWO0nf18BIEvkcmzM2VjGIwRLNFIOXO3RB9OxEpXx7VwFkdcKNbpATetMBpa9PCPWjJpEYpuVBojCt0rLoMSKy9VeZohWuJ+S2QBGYYpHDlNRxi3kVh9MA2FF+cuQV1fCJmYYsFGATfMIfyIeMKdNgaSN3I2Qib20wPnx8KcobQd5sBK8LIPdAXZgX6nXXfYE/ke5G5ILfi5YwxuK8zN0RZYPsOYSeIkMcwXhhXSdCflF+b1HfNSiIcJ366YdSwdVg4ZJSXFBXZA1/f0xahVMCoRh4mq6eF/5UnAlSOC/k1v+tPrjaNvHH3j6Ovk6Kst/to0GLXR88wWgvZBsoFopnKm5yupOZqMthf2kkATRCA9cMAiERnQQelCG5WGM592YijJlaSOD+i7s9sRCVMC1IR9VGQCx7F8RCRlClNuKImUjmsUAIowysA4AqVRw4EahpsiDGg3UpLDpFMyFmButAhaVlICtHOPiWJO9xnt4EMJrYfpZ4bIIItQcqFpIqfM1X/AfJB7JhRECtqPWCbLiianaMMikU4wC3gZ5OYkB0uCoiSURTNdlT11mgS5C02N2oRiSpLEpgZpkOKwN1DJSIZBQ4dSSEgGYlBH0CMxTp0pF6aXRKo7PTsugbh/zd6sdBVMhJiCvXbMHtj9hpaZyROGM9ToaeDWMW+s1vFtY7eBZKdrQtMEdUFrJWRmaCKmwX3o4Tk2Zbo5uyk2DtsqwllKZzkb2gYjYJWK1iDJ1/kfcWQ4eTg1QcwdXYI+ErkNbGosoxy2CwBDUT/c9Fs2YgCTkBZDIpOKHG9O4oiPUjJpHFuXNfpPcvbe9Hr0xtE3jr5x9HVy9NUWf6PEkVE4GhID8cAFTCaSFygJ7U7JiSjBmqG6oq7kcJyASKgqKTtTOxFNsOqIFNrNSCLknLAI+tcnV0pH6qBIpYVSS0K8ILGhMUiaKbIgOqEiLNVAEyMEzBnVmHAMwTzjISSB7obaTgqFNAGKYgw1RhKGJjycrXSiC16EkZQ8OodPuRzO+ElAD5d1TPEORQcmg5gCr0dnFAY2gtYHYxvYBDmM1BJWDCSQCHwEKl/D0zlMUhWQFHiGpEoNhUiYCcMypExVJWWwgC5OwogRmCfEMyqQOby98sgUoHchpUCqMzJ4PeJ7aApdSAbHy4YJOw88CTlBB9Q6qCI1APBwXAbuG6OtbHpjtXHkhWoicZiz+rRg88IwQQySBqU6Is5YD5juwD2DlsR5zpxKpYzEjJCmCUSOLw4giZIi0GhHMlR1dplIRYie0OtOtoKmgoogJl+fXYMu2HBiGgxuDDtTp8PZv3tgIahnpAvSx9e/xJve9KfXG0ffOPrG0dfJ0Vdb/JETnjLZDaUfT7NkhsnRmbngIZRckWlgGAxIlkHjiJ+J+NpZVkQDjQ3xGbOF0RthglVh6NGxJRNUg1Q7KsoMTAKWE9LzES0TjltFaiYGTMnxFOwDJALThCuIgkRGTbAkDHNEhURgOlA4YpA4rmZqpOPfF+NiKzZXIgfFO8MD16OfTKQjPtMD618jbXBynjj+oyB+BJ0PcdyN6B00IMARRjfEEotVXIwhgbseXROdLIaLoHo48Ndx5F+aKtvXqCFRwVCsGfdZyDKgCWqVEgnJAQrhyhjHa3u7Ke0Co3x18gfI+TDkbIepcg0hh9IvsN8zKT/Tp4GtK6EznjnCyJse8U7mWNvZpLHuhjYlz4maxpEskCu5JsoI6MosTkmNzRo2nD2UHWdPTmUmcUbziZTPLGRqzlgcXaWHUeDoVAU8NUyVtlcekkJL5E1ADSlK1uNLFDneoKSuDDEQYd0GlpwRCZAjs1KMQQdJaCjDy09y9N70ivTG0TeOvnH0Jzl6/7L1aos/SYmSK8UMpBP5uL9nBERj2oPugUvBXRAzvAfiEwSIdDwMUyEBV0BTZ5ETPqCIoh5Yc3wCKYqgB/DE/lHmYR4KOYFPmA/MBOlHJI2LoCG4BpsGhCKr0ouSFmdJQR3KNU3YvpFLQSWwfOQ3qgnJErMLmgSZIUZmlsZejSRQjH9ktnocaBgELRzr5cjSbEq/LNTVSaMTcYBt6BG0XsJIzbBSuM/OWJ2KslhlV9gwsEHpHdOAegAnUymayXlQemPPiVWMiE4WZVcjrU5L+bBzcCENPcLhEwSZ+JrXGaLIrWPnhGlmGEgPcslQKlYViaASZB2kZbDfCro1yA4JXAKTwM3xULpl9i1ht8DvQXtRZivkuTBXZ84wS+KEkL9+kZRoSN65D8da0IczxrHNWFhIPjHqhMwPqBxvImwEhB9ffDhoJnI6vhR6YWyZeTbuKtArpkaIoyEEEHr8/RSFKuQQ+r2SzjB2QclI2DFAIx1SxaSwj39yzNyb3vQn1RtH3zj6xtHXydFXW/yFBEsSIiba17zIEnI40zOoY6A4mxf61ShqdA8UOzpPgiGGp0C68WV0HtV5qsFGR6uDGHvAkETkhBMEBRmZZCBLptsx+4FMKAWJ45W76uF8r13wDH0Ct0xeA3WBCULsGDZtFfVOqGBlRpMj3o8x1A55DHI9LiXcC2YTPDhTCMVPdG2gRiJIMTA1wJGhSAh5z/xA8M4augNFEAlSdlIE84DShTUnLDtRMkUUCUciQRLAmcY4OuPlmLkJSSQG0CixYaIICY9AgEJBO8ReKdmOgPJkdA2cgpCJGrTciH2h7o0shynpJolucXT1CbSADsADTR0NUIR0eyBON2qWI6txON6PDntEorfEuIN/Mcam6AI6VUqGkhNVEsWUyIlUjPDODnRTuhlhHemdyYQ5GaSOzUo6FbastKFo35kJsioW4C5HzJMMYn2kWjDZxi0rYxxvAfLoiBoDx9RI4miZkJIo2yD1iu+CYKQEI45rGCmOZGco9PE6B5Xf9OPpjaNvHH3j6Ovk6Kst/mQX2AIPCBSVQAIiFEtG7tASyC2QvME5IUnI2kmm+A7ZEmFBApI72Zz8ZGQLLAZRhBHCLoOQRk5O9plip2NTDcMMalfGLEQ9Zk8awpSFaoE0I1yodUJTQqcbWY3RhK5Cn50cX4gkRNUjiNzAHUw6kR0pg14g2WCkTL5Vxkmp+QVhotRKSzeGNb4OkpA9CNsQAZkD3SZqakSCUYXG0fzUUPBK74KQmdgIYJzheXwirxnVQtdgr4nzPGHTDXVFTRi9M7pCP9GSY+EgTgIerXCbBzlNhN/xGIyT0EogWyJbILmjyVjDqFHBg9MOrhVJnegcMM5Gmo+/jWbBnmfmXZE0kYCIHdzRlpBWIYIwP654xs7L/oKfG/b+QjpN2FxpdaJlIavQ3ZksiJy5IdxjY9gLpXfEEolMmUBOjYnENG20NBMjE12JSLiVw0U/ghwbqjuTPpHCaBvcnjJJZur2TB4DVfm6fQeSMt4V2Qp23ZCaSZuixZnVsYA1Es2UyIrloKfXOavyph9Pbxx94+gbR18nR19t8UdNbLVAKMmCkhQpmRYJrg66cS9Kev7ClCeiJlJpUJwWQrcgehxXDqJIWVnngj7cWJvw7vsC2xMtjmDsXhuxNNKAcR20YuieKOVGmxOSEyefka7ceudqM3XOvPM7726NuVT8nZBqJ0vmvjXsbtT6S26l4waTfib2FYsHtnwhys5Ux+HmngvuifvIfEjPfPnDmXhveNxpMUMo5pnhIAKTBJnOCMeK86/cCj0XdINmjqdBMmUbE7c+U/fEaQRzumLNeDk9sUwd68cQ8qyJ+STkKtytsrcJ8gTcyb3xRQYiwUkTUxR8c1YR9lxRbTRZYLmRxp34mJARxBykJMz7hbg69ZfOroUYP6OXTJIbvjrWG1wMfYrD4PSzcd8bl/eD7UWYcT7NEy4TEgNvK1u/cx8rLzFY04mzf+JK52MMHtaNelnQ+oDmB+Yc4I6nmTR2dP3Mtl3ZKwxtWBdGgkue+VB+ydJP1PsTeg72BCc5M0mlzw1NDY1E0jOnqTCXO+tVeJ4+wBD6CBAjK6ScmBKk7DTJdH8g+c7tsbFk5/pFkTzxEI1JlJImSIAb2hrq2099Ct/0511vHH3j6BtHf+pT+C9Fr7b4C+1oUhYR6gw5H4HdsgtbSjxTuNQnfvXe2W6JuD4zX4RThTR1SI61emxMhXGWX3D7IOT1BW2VZwvK3jDLpAqTDLIYJCEugQawfM/z/YSn4PE+mMZApspyUXb5Q/pp5rv9dMwepCvbx8SkO8suSM9UlLFv8BRoTjxvBc8feG+Dd9sdGxwh1JMh0xeCnZ/7gjwJp7nx5XIhjxltwRzGBIROrKPwZd9Io5Eegq1tfIpf8a7/Ni95Jef3sJ/YzNF5Z75saASrTOz3QqNysRc++uBh7lzyBdrMft3YLifK9C2aEi++s5lTp2fmMcAfaWXCUyP6xnMxTtvCS71S0gPVOu4bviQ8Ba4ZklCzUVxoXhi//gW/+W1F4oaXjqpCB1kht2BK0CJ4LBU00/sN7Jc8XHd0WtnjziaGeoa+MO6DeDY8doY535QbLCf0qZMud/I0UyOT2kqNlS7K1YFngz9M6Ity9p2xTOznmZdk/OzWSA+/RtsT5d2JUw0i3ZCpIT64A1oEpsT3E+TfDM7Trzlv31C0cq6J+zRzzQuhkH1HtkF+ucH7iV/uG/3je9gTTJ/IVRkijAG2y/HsJWF6qD/lEXzTK9AbR984+sbR18nRV1v8pWL4tLGvQtx3hmSGKh2h5mf0XIiXZ6ZduT18ZroE/f4N12c7BnMjY3LCJiXPV0ISv7xuXL8X/C8+cPvymWlJZM2cI1j0giv0cFRW/HIn8kQ//4J5bZAGbT/sBvbHQcQZ3xrz804qAZdOSQWTxJ53mAcxJdQqL1sjjTOP+g3t1hj+EUeQ80J6UEKCvgm9B60trE3415Jy9c+83CZSy4zFuVw609QonLg15WU3cj/D+Ejaf8FHfWGaFiqKzncGO7sbew+sJESCHsLIiXVTqj+Spw+HJcL2TPdMrE79cmU7Kb1cedd2anlHyGA1PfapzGnWOBncVHl/X1AtmL3jxb+hTzceirNkY2uJH24L37TOb36A97/9kbHD51p4GFfO66BsGR8TL1L5Mikfngq9F243wb75hFRg39GekP1ExAa2sWwr49MnfvPpB3qZeJoG88sD7zP8Yiz8fLwjjXfcizIeK/tnZV8n1v17+pcfsNWx1ZATvH9SHvLEKR5Z33/LdyFc3ncu9oVrX7iWRFihRqVKozSI7cz0UJEPlbm/sC2gemNrC3lbuHiw2sbVgl0XzpfKlK90nvAvwS4PPOrAaqfbYBTBHzMlwfTcWcZPfQrf9Oddbxx94+gbR3/qU/gvR6+2+PPQw0/K5XgND8zszApeZrY98d1fDJ7+gSPn32a2Z4p2wk/AifAdb1/oa+I2T0zJeckLl/rM9fY9H5aCjwItkN7YymAYYJBjYstnPu5XzvZ3mUrisxXWNvEgFV0TuBH3BZkDqYk8HpAwpliJSUnlhLaZ8QyXyxU/f8LWzLSC1MTIkGKgW7DX4Itmhj1wPiee6t/jV48P9I8zl8sTqob6lSYV8xPJK6ex088PXKYNuS3cp05dhNSCL/mOZKF2wzdjHQrZqNXJ0xO/LTvKA2v9Q+779zRmSj6jubKr0/ZGbZknQMZORxn+xJANqTt0I9bK/M0HuhyGp56B/RNzvVMWwE+MdWHW4HTZud+NX9SZ79y5jMrvZEUF7gLXnPGcKWlQy2AfGWkb53eJLy9nvr1/5vNauZrRw9iasa2d/frMPj6y5hsf18p5f6S/fyGdfoc9/xbXXjinz0wEp1UhD/aHoOmKnQ09v5Dajfu2kD5X3mXl2/fCL9MLIy2wBz0qWjLnrPhQkiolJxZRZp+5+07ER2a/gAjy9Zqtc8xayT1zQliSgd+p4USHVYzHy0didvaqlPvE1IRhxt3vrM+DbXudsypv+vH0xtE3jr5x9HVy9NUWf93fEzlT8o7sMNaMu8JsdDbGLeM/OPayk9IO18oWJ6TOuB5u8lJXUurscYIWlLmhnhi3xqbHNUYehylmTkLVoNfGngSRd1z8AXnesLKQC6QpU1OlY2zayPOG+EKLw2U+O0wnUDkGmqk70/vGddy43TJ137j0ifPTmXraabcbYx1kK7ybT+x5wp+N26KUPpNyYN4o6mhNQEJwVDZK7ZxxaInbGe7+jm/vnxACHxDZqNqZObbmRhJq3eFLYzWFZSDFKA8nbDvjqxJ0cjqRJ6OEk60i5YFG0PuNvg9EjlmceX1PscolGy6dbonT6cLCRB8DJ2Hm7M3JZEQcubyjxEemfYWpcr0rIybmeabmhQCMDYkrqs5+U9rI7Ccl70bZdzqC2XJYC4QwEEDQkzPFO+rThXfnCx9yJaeZVQ3z9nWwGVYJxlD6F+hfGpon5mXh3TeZx18q+r4y6jtGVvKqbFXIkzGVG1hHPVNiIlOJ3JhfhLU43pXb7bABm8SwDpsbVjpMnaSDnITn5wK7crIbRYTmQu6DrImcEtKNpRmWdvL55Sc+hW/68643jr5x9I2jr5Ojr7b40xeFJ0FU0U3xXdjoeOpIAk2N8rkTOiGcsLKxlxWvQo7C1AtVFiJ1rDk5CdOsmDl9U6wmknR0MRQYGuRmeDP6kkj5RpkCX864JZabk8+dfHGSZTQSRRTL4HTEHZFG6AsRGfEzKTJhA82OlELx++FllRISjqWMqyBRUITp0vBTIkalejBwpL1AzHgUQhyXnZECywmzjD0Hep45fW6UBLYEkwzEErNPVA6aRgS2C1dvUIz3U2X0C6UFshnuAeeKS2WXRh0QO9xEER3MFhQGWxtYDuI8uJpyUmf9asIp3Qg9DFytD4ggJSUVISVn6EDtmdPlzG6Kjgfm1EkpCO/EOJIEopzYNVN6ME8rPTaiVNJmpJFRM2Bn88aXEdxHZqqZ9H7w+PSI6LH1dkqDINgFmgkbGZWN1Ff2zRk+ITnIs5KmTNIThQujTswVwoOpGDmDJ8WTHia1W6GpEJcdsUTIic9xIcudSQNxCAbifsydxISmiSnd6XT2COShso+JnJ3WnNUV8ThsI76mH+RX6kz/ph9Pbxx94+gbR18nR19t8ZfiDv2MJUHiq2O8G8M6vSUmgb6fkFSZ70pUgwmk7OThFHNyGN2c3Ad1HsSo7D0hqVAdgiAUPAESRBHcMxEwbCc2QE+EG4UgRrANp4iTBRgTbo2sO8HMUBCvqCREjgDxplCnwmVASR2foPkRwxNZcZ2+mok6WTvnktlzJX1ptJLQXRiRsBEERkrHyr8nxaOgEUTvSDamWblNxpBB2Su0BVNFtFHM6CMzkkLZcZ/QrhANAfKUYEqMbugIRBXPnWE7Yo6aQjiYEClBdbxveICYktPOSAZZERPyCFwdqUKoffXk2iiSaaLszYCKCmAD7x3GkSnZEFKZCWvkJLQumDmujskObEg847bTPaBAnZUywXmeSBeB5Vj1j68+XhkjJ2PfGuP5Tms7qzlqzjmMkhN5EZg3uhhTZGptpNghCpEEj4yngqlyhBQN7BScZGJEpeyf4VagCOoDHcdAtSYlmVB2IU8d88DrTLNgAObjq+dWgq/h5RJGtFc6rPKmH01vHH3j6BtHXydHX23xh2wMGiICRchANseA5mCeMF3QJOjmjOmIABIxkh8O4u4DDyfFgDxom4IrWk5k6xBff5b68dJbK6JKojFGYENIoXScXL8emp5IWfBkR1bkgJwd+epbpHYiRyFMaAQtTSysTJsjHoQYNowwIWphqBI2DtNNgnNqlDBkV+wklJwhBt2/XsGoozFgCOGC1oDR4Qm8XujeCQxPgzYGKb4+IsMRM5ZzxXVHWiaZ0xNIVnI+Podkg2k4qQR72onouAk9OLrJVAipRCiVdlwhSVDU2VIiJKgykOQgSkjCXAmvZDrBiW1PhCpFhSSKuJAOIzLMj22tMgd3vZPp9LV+jUwyhu44d8I3hg0aDgWyBktVTvPE+UNCVWhD8ZHwSMS04+70tbGtK5vd2NgpmiApeS6kRyUeGjoOiETtNHew4wvLA3AhqRzXRhaY+jEHlBpCQqygUyZhiBx/b1JH9PAkIytVAQLRxkgFN0PEScfXKJocrENvP/qxe9Mr0xtH3zj6xtEf/dj9GHq9xV8YIw8oAi0IOaJlkmZEEh4VzsISTupOj0pqgoahOnAB5MiH1MmJmPEBmh2VTMigihESWAAhiGWIRJaBfPVeTyRGgpYbpMMtXZuwT9AZ1JKIBIoxhaFdiV2ODkSEKBO7PzM3wR0iC+KCWjDcsXRAJrkTY6IV59SMbhMFYdLAbaVHJr5mFWpPRFcIJRIkM6wMbijDnRIKSehTw32QQrAIJAYPmrj9w1xLC3Y5opLwA6QpoEgg1rBoRAihCc+KSEJyOT7XgJISPXN0weFIF4KBSztiiKiIZUaAj4Tq9o+yRRedKP/wtX464tlDFEMIUTwGm9x5DIieEILhhlnHbbAPZx2DnYEDjw2Wp8RcCuelIOH4CDwKXmDMwX5fGX3Qbaf5iuuxSZiWSppPyHxCl4m6Bbpu9BqMnBHLyDYgBtmdLImcjysn6Yqxk+KGp5lS69G1D4fkDBmoOKIKFUIT0yyM+BringqhCa1GiYF6HBb9Zn/0pfqmN/1p9cbRN46+cfRV6tUWf4qRSqAI3p0xQCR/BVFCtRIPkO8dXYQ0jqgdzUcOoxOEKFEEEqT9QinO8M9IaggDUQfPhGdMguEBLUii5AnSNIPBECNHPfIFPSFDCDKOkaYZTTPSrhTZsd3w1bChuFTKBtYGsUCLQXhCyeSA6HrMXXztUt0TvVSmUXCdeOg7QTDUkACNQOwI/VbLkBRLQhGlP99pagBkyXhRRjWcgQxlBKRhpK74pHT1A0g7SHbIR4fugGfBRxC7oiVDDSIpmXR06aGUrEiCSEZXsN2R/Rgb9iJ4VnIkUghdVtwKVxIPOpBJmGvG935caSSI5HScZgV0ptkLmenwodJnZFeGH270vg/a1ml7J9qOhLDkwsREiSC3QkwJcqDujJwwPaJ/vA9k76g5KQlpFpbTRKoXiCeKF8QFicHoGfLXKCLSkW7ggtRKTEdc1dyEzTdaN8qojCURXbBWjjcmMgiPIyorFAi4CKzOMCfcj45Zg6qCuGHdMQuGvE5/qjf9eHrj6BtH3zj6Ojn6eou/HBRzCAiOgWVBiAgQR+eOlAdGbJTk6OakohQRkCN2aLiAFSQXMpCysK+BnHe4NXoXYmSMiTY5vRhlDM6MP8qdDIHdgUxExQHTnSKJCKGkRKREJx05lu7k1IhcEHGy3Y98xjSOQVcOl/QgIyhFA/ToIJsbbgXJT+zywpML30umyUxEO7pIUzQKWQMtQu8ZbBD3CsWYKJASZkewuBQnxCEnhgqtN8SEYYOqSu6DVCHVBMlY1egc1zpIIYthORDXI5/SHSVxriAoPQXhjWYDN0hSUZsI5Gu3a2jq7OIMOVFyJ+pGROGuR1dfEYjOLp1NnMSZMNBxYYjQ6yeEDdknog9au7JvL9BW5tZIqsxPF2qaqe7oCyRN9KyH7cRwoEN32mbY1al7gUk56cSlZk41MUUmr4XeDw+vpUPyYGMgopSoJDIhcYS9q1I8wybsa0Fwxm2ApWNWJXUozlBFh5IbwKCdN8bzkYHK2AntEBn3yrDAd4dbx/bXGUj+ph9Pbxx94+gbR18nR19t8dco2NWRPNByXFFgoGPwYIKfGg/PE5Of2W1nr4nmwYKzCFTJR9j2mEhUrstGud8hKnlV7i0heyKFQ10R8hEkXgaWNyRABsiYWZth0knqzJoRdeocFFlIyVll/7r6PjOpkE9GTY7rTpPOdnsgexz+UMOxvtMtQDIqEFppKrQkrKsx5saQnTUn7kAMAQd1I2xgGF4cnYR1N/S2sZwmtqlTn6GFYA6Mw+RVNSM90wjk5Jzbjt8S9aR4ObhMDJL2Y/NuCERlFHDdib0gmrEESKYoaFnxVllxJCt7OYEpiQ5h2JppEpRJyXICDRbg+6R88Ia9bFASkQpukMMpdHpKGIPUC707JneGVcrjRv3UWK93nvcv3NszHSMWocgE9cRynjmdK9UGcm9EKtjQ44qjB+NmXNfObQ+kF5bpxEN+4nw58XARztVJdoSEb5Px1IRJJ8SOWZqpZiKDpxc8Oq6ZPb+D24zLoE2d2jdEFM2DWjbGFOx5wgxySXSBzCcGjzyWjLhxM2OEYhZYU3TL1FWp7ZXeV7zpR9MbR984+sbR18nRV1v8ecqYVEpraO9QYNcJbKJeO0Ur6XZlmS48X4RNBN82XAbiM5kLfa7Y1GHs/CCJxzWRXTitN9rDBS8zyQK0o9Go1hjSaZbJLRHLxl0Ef2iIV6JvbNKIklgsMdGh5aObk8LIM2YrkjpJDNudPgZpf8CXShLFxsB6QAxQwYciNpjSIJWJa9r5shjvbsJ9bEy14nTiH248WcUpMJRsneIruu5wNt5NjftDou0TJpUoCdJATZgDUmo0GXgIpWXu9dj+EzP8GSQKrkJLiYzQZ8VjYnoWohS6no5ZnanxpTv+3OgCy8MHPCe0fmbERh9B146XiagLEkqqG9GEkgfJz/SxUz1oVMIETcE0GZ4HtI7ExvtZ+G49MW9f0HHh+rxy/dy4fWls+04vCR4f6NOJxgLnB6JW+jSwPjG5c0lX3I3bzbk/P/PycqXfE6ek1BNMH86cPnzLPL8npUrKG/W6IWnAZaZ7oY8JTY5Wo9HZbSAdsoDL4J1ciHdXxh1GNkoXwgQnyMlZhsA2Ix4UWZHvn5jKjfCF0hIPASCYNnoxDKOXzNiXn/oYvunPud44+sbRN46+To6+2uIvF4HaYYOkC1IKUoSswiVOaHrm43jiN+fjIRwZpumMurK5H9tg0fFbHF3udmHmTuRnXp4mWukk78frfwsqK1KN1ib8XinZcM/4aWceij6fCTr7aeXZd7wpO4nTWFjSAufEECevzjBljQXZKye7cXtnRze8wbUZUjPnXMkYPhqyG7Il7lWR6QOXXz0T0WGuzFKP3Eqd8JToetgZLN4oW2c9LaybcP00ofZIXe4YhoyOkpDImAyiCDKfGOps9413p+BejIfmpAotOeNuTH6mSyHqHaETQ+kEondKqaxeWXfhtAfX0nkYG0UeSNIwbUifKZFZ1BATbFf2Wuixco4NLQOzX/D8/ntcFUyYRyb7mRQzZU/UEPy8QPuB6Qv098a6LrQWpPuZZX/HSILKCjdnqieW385Mp8a4TkynR7bs0I7tv8/sfNxWPt6eebE7fKPoE+iS+FAXzuXC7DDdbrg6bZpJ2bifghgF+TLYe4dcKJdK0YnuwdY66+dOmxfOnunVWNKd3i84gdaZSSdqq3jf2dYddWUn863vXD84NX1ExoXuCZMAgSLKaMdc1Jve9GfRG0ffOPrG0dfJ0ddb/N0a07wwh3I2gV24bc66dbABMrOrs3DGY+dJE8sw1Hcc2EUZo8NwJt/I7z5Rh8L9Z/zw+e/i0wOpVtRARrAWpfnC2GeS7oRutE/OXRJPsfKhKOGZ+74QYVhbiZq4vtvxvIFNx3i0dvqaYTcmaUgJpu+E5yfl/Xxs2vXifL41ZN2Ya2daMoPKLUGVnXdL5vspGNqwTzBpopadXjprht0zfQtYnZAgPU787nnw/dlJ+5nCMyPdcU7YeGQopLkzaUX2wuXizBoMFVYCL0dn9TAHkwRbTXg/0+0L27LiUknblQ9bZVZYa4HTmcty59we2fcr4pVzOpGnhEentyOuKMnO4/2OdWF9iGNGZfuONYyHi1MxpCnWBELxZHyZdnZ74Cxn8uR8WR+R2JG4QmyEGdaCLkq+CLM0Hu4P5IdHTr4Qt4l3p4/suvElBT0d9g/cK6qJxhf2W+bn8zd8mx/RmOlaSSfw6kwhyJiI22CxhOdBLEE6BzYbDGHpgmZn+S3YvhO264mQndusnGc7vLwiUFMcpS9OKgb3FevB89OZ8mljpJ/Rk5HTlTqMPs5suRIPK2m+/dTH8E1/zvXG0TeOvnH0dXL01RZ/EkqNjM5CS4MSwYMp81L4IsrDQ+X8MvNl3Tl983w4hI/E3g6Dx5ozVYQGlCQs+4nbckdDOfkjX04n9hW0Z3I58hCTbVh0bMx4fGB5cJZl53kftGo8jYllz3TbqDzgE4xIyDhjEfTxcpiO7pWpFx59oMMZT531sbGuF87XzirOSxHGpHjPRMyU08LpBNLg03nl3W8y+ZsL8Xgl7TNNhc2VtibcMlsa9IfgMiVO9y/8+jSRfZBk5ZoaLSmlZIo7bDvclTJlflZWNGfGb544l2fSZFhS1JzYBnuAxCDVY9vvoVU0V/rFWV+CSys8ROLujXRf2dMZDWV66LgP1jtIqjDNDAn22SkXpf5BICht2/ktP/PdCrIL41zgcmwl9r1jdzkikh7uuH3is75D45m2Gs89eK7C5wfly16JFnwriZ/pA7/QnzElw59mtBb+0C80m8htcPr0QvsHH5mvv+J6/QS5cPmd9zy9e4IpQ9t56Qu3fOa8bMAG/Zl1uXDvjVR3NAfZlWSVvGe4GZE6aCeWK9+24HN5IV8f8RvEtKI4Zp0dYcuFmh/J+TNrf2T70vjFUljLMyl1wmewM8mDsm70TaC9zi21N/14euPoG0ffOPo6Ofpqiz89VzgXxtgZY2OgZMkMBTeQLfFy+4S9e6DYxH5Sri1Q78ymBAViwWPwWV+4j4LGIy/txiXOlL6QFFINNMURft4zzYOUHNGVrXWW6+ApJtpQWin4ubLfjUIn33dCC+mkDCvcYgG7kvOGF+XFvw6ohvNNH1xvd57l8JM6R8fUQAC70+87thceTdnLHVlPbL4ylkCpFElYGsiAsgmzZ2CijZ0f8sxmwoeU+OyC5GDJweyD3DuWG52FrQ+e651vVtDLM6rB6AXdgBzsNWP3ibw6moOo79AQ1O6kUXFNbDlxDzA33k0PrDHh3PmhOWqFEhkNoQxjwbAIVhOGNGTJlFvAsnN+NGzAfm34PUiTkFJBUkHd2H5YqSMx2o1E5nnbuK6fWG8v2H2nDkj5TJoesHqhzQXaRMnB2m/0HESv9PvKdf0N33HluhSUR84VHh4K57IwpkydE5dyB1kxgtUO3/nHZPiUIFeIxtY33Aa1z1QqIo/k28Y5rXwaG1oKvu+odrIovky0mugWpNWYbUa8sPzsmfLDR24ZzlRie8dNMp4EmfXrFuHGGvbTHsI3/bnXG0ffOPrG0dfJ0Vdb/A3vpDSQiGOYdxw+UT05UiG8Ip8Hl8dO5oHdHDHHorGGkOyrHQCGhDIiMKv0uhOrkLagFgh19tGhNTAjFKwaGaO3DDflrMpUGind8UgMnUgEbTixByo77EIagicnhRE6oCqTd9qq5AW0tmMDrGY0VVQH+CD2gbTDO+qOkJaVPU3st0SflNw3kCCHMJGIJChOto6aQB5MxSEy1QEFtYSbHm72iyLa2a932DJJGlsylgJ5JDY/OvtejJQGHjs2HPTI3kytkK0yBNY6MG9MIZgriJGSs4XgqscG22iENGz24/91U7QHfkm4D26906tBTkw5fXV+dxAnpWBgBIKvQF+5MVjbTru/0K8vyOacpXDKcEnCXAJNhobQZSfKShoKO/SXO/3LTltX1rVRxXk4Vx5PM5fLwjIvVJRJDE1Gb+A+E5OT6k5OCRsT0gbIoNUgckenIOeEfwQ9P0BtjCkoLXBz3IX4miqUHVQgpx0N4UFAa+ZlP+Fz4m4TiUxODno8O4mBvFJovenH0xtH/wVxNDpP+98mtS988Z+x618hqb1x9I2jP5lebfEn98G0NaQkRs5HGHcEkQIVcDN8FkSdzRN6S5xRVg12cZoHJTaSNFRBmx6GpwIljPD+9ScZHgMYhDoUR7KhljmlCbaCJ4MLRHHY/ehwE5gXdDRshWjpyK2smTSOn1Glka1xbYnnLgw3kjlSEyMXXBQJIREkD0SUe1JOqTBmJfoAKyAbSELsGFo+ZnGOzyDFiZR2JDXSZkx90FOmM9EiY8lRVYYOiI28P9Af4shFzGCl0w2aGK5QL6B9YIAPYyC4FsQVkY4UI6lTdqG7IZPCiKNDq0pEIOa4OKM4roKuQpWZm2d67vThmEzEVMgV8jiCwy0cl42ugUyKWaIP454H+95g62gzhCAluGTlQ0rMNbOoEtnZsrHEEW00bKWNO9tmtN6xbUdK4TyduCwPyGki5YyoHakBKKowi8OsdFXEjehGMqHWgmoFDVJpiMDmC6ITuUy8eKMoDAougrqTupC9QCiW+mES+1IYPDExg8lhn4VRwlHteHIkBzW9zkDyN/14euPon52j7374P/P7f+d/wNx++Eef61a+5e/8/t/g9s2/88bRN47+JHq1xV+1RNoE169O6UXxCHBI3bEhvPw8YZNgbeWyZ+qkjGR0hxF2JPzJRkRHRubkHV8HKkpPTg8lCdQijCjsBF76EZFjEw9SwTJtVtaqhBSSCFk7njuigpjTEbw6FIVUKObUFGg46w67bnifWW4DUSFtg+jKkIR4oN1wCUyVlIXcT9iUkb5SPI58QzLOEQfkMYiwr/FAlRfNaFN+3o2IgXomJSVkQAS+J1SUyQ6/rC+RWGzgI7Emx2InuzDSjJ6E1DI0RQxa7tgpkTcl7YPKEbWUZdA4IoZ030lnIULp5iAJlYnRguFCGRnTwwirlELukJjpnulxxx0khHA//LykIN0wEr0JPYTtvuLdEI72z6dMvpxZLg/UcqKUTMtHOLm+zBQzrtG5+8rNGluA5ExazszLe07TAyMXhjQ0OT0MQ0izMtudnBdaFGw4avsRnZQyJQoAYX5EI1GofaU4sBdcAtcjQiqIw6DVFLywJ2PXYNl3mk/M73foJ0oPXBu7NtQhkw9PtvQ6/ane9OPpjaN/No7+8vP/iX/zP/rv/LHPderf83t/67/L+Mv/Ptff+S+8cfSNoz+6Xm3xN6bKVTPej1BxRIGEhCFJ2CzR08SwOyWMkY9wbDEh2eHM7nG89k3hRDLGpqh17CHD2NhsIaeJOX3tGjlcxFePI0fQGh5BXhSTB1p35r5SbYAd1wMulRY7Vr4gtZJGJpmS/ThsTYVSG6VOnNpglxNhmTSESQTliE3qFYYlzjkTOOIJk0JON+iBieIlIwpp7Ih1PM2kHGzJeXrJFEms0wANal4JHGuJsmVyOHsKdrlzv55ITJSWsKWhclxzNA+07wSgZLQ1uq54mdAxk7cBveBpgrzjKWgflcWFXBNxd8Z10GuQcsb2hI+MkbiPlcv5RBHHp4nSndJhl0QniGFgB7gjKWW/YudCfEno5yvr7QvNGniQVKhzJX04IY8nUqp09eNtBIXuyuiD9da4P6/EtaGrErVSniam88KUJ1KCQkP34w1pFMd1YpeE9CDnwzG+NCEXOxIATAkthFdYDVHQ5zspOsv8RMwNMVBTxAOXI3s0CJBy5FeeVtK6YPnOGBPFEu7HddHQQYQTPRht/NTH8E1/zvXG0T8DR8X4vb/zPwSOkcJ/XMIRGfuf+v/8j/m/PfyXsJO9cfSNoz+qXm3xt0awdaNGP2Z50SM0XJRwxemUPpO4UrczMrfD42ckxB31nRGNwCmpgAq5NXQObqJMGhQMt0Efx3q8hlKb0LSTY0ctsZUgQmE9od2JvmF+rJuvodR7IXlmO69MBVKu9KKM0dDhFMCsUA10rqgtbO1gcC6BqBPqSATCoJVE4cT4vCGnAZ5pIiSO/EoJEAlEwN2YpsGUG9UqzRP7EuQSRN7oDHoEzb92WlOC2Ki9cdeCambi6OTMoeGYrCypEyK0MCINMGHrDYcjeLwFYsHlMXiWjlXD1ZnCSEmOEO+ACQMJZAyibZgKdr8wSxxB8RpM+bAN6N6xUMDJY3BG+Bh3mpy4j8+MuLNGobBw1sq78si5XMjzBEuipcrUG12Ml7Vx2z/y6fkHbl/u+NaZOKKi6imjJyXqMaRehtO70Qh0JLLNjAS7bdhpIevDcfUSGyKAOpoaxSA3IdTxUO5ZSAh9BJqcrMcXz7DjfcNIRgho6vRTZo7EKkpeOmXA6IHA4Wpvig9BU/npDuCbXoXeOPqn5+g3t7/JtH/8p362Aiz9e6br32Y6/ZtvHH3j6I+qV1v85X2gp4amI9vQezAYgKC70FNQZUdLpRtMTTHJdFVKcuZw3KBHpqSChZLGQPcTWWbQdyzDsbHSZECamWxi2jfmubGI0iOzlYF1QeJG7R18gwpJ8xHg7UcEUotCNEMuO2PKKA5mdClIXIjd2XKi546Fk0MREubC8ArqSHH6vOMjI/eP1JyxtJDTiSqQYhz/PAkXJcLZ2LgMx2th942hguZghNB3xTv0oew6caJRAiYZfKcbLTvLODpvSwlJHZ8CNJA20JzJsmD7EQTeVElm1LaStZConMpGT0BzzpNTSzoilMbh3B50wgfV4CUqZsYJZ31Uig6KOWGBidBLOq6kVtj8xP0Km33kue64dhKZcym8Py88nWamOKKqUskUL7Ti3OcX9r7yvH9Hay946+zuyDlYLpXTfEKnCUsFpSASeAGNQR1Cmp1IJ0YTuhTUEgqIF2gZMrhumA00JmYZ2JOyr07kgeeZPCUiGqMb7cgVILKSS8J3p7fESXfaXTmfr/g0EwI5lCSJQeIuA0uvM5PyTT+e3jj6p+doGZ/+RJ9x8l+j4y+9cfSNoz+qXm3xd5ZOnjotTaSWETtCIkdysgixF9I7I8aFiEYzYfiAeoSZZybCF7JBNhg5HdtPcUZF8H6i9AZsSDLGMDyB5GNQ2VJlBXwP0paoy52QRs8DzmC3r/MHSyAX54wim+Nrp4+OdMU0s9fEnDIxNsYu9NrJ2Zj88LHynglVZBqk7HgBWzfqaZDWglbIF6VOdmzsWWB+dEG7C2PvLMUpYozJ8FqwPJAGZWS0QemOyTHnkV2JdmGe7mgErQEJqE6NTtk6Uo/rirMceZHXGEipmGZS6+QwkIn+nI7B7qeOIOwEpCCFUFWJkCNrMSbaKUGfIV/Zi4I3GCt9GD4KIQVTp2MkEreX4OXa2cZv2FfFQ8gTLItzfgxOF6jT8XMmFyQa18XovuHjTrvD37tVvkgiLZXfOjkPjw88XR4o9ZGiJ+qYMFFqjiM5IAeclKQC9zNp3JG4oswkKuGJMIhuDIfQQKdBLYo2YxQl6kSqYHehjUajgXZmyTyWE1/IzLLh9ys6HBcY4UgSEpDMAUNobLf2Ux7BN70CvXH0T8/R7XT6E33GrVxorb9Kjo4VrHf+QDOf5guXmvi3HvSNo/9/oFdb/Jk0nAGRkJBjmytDzJ0sQVkH9nBivQ7O+4w/OOJOiWPo90UhVJmGMCwoFXwejFNDgNw6fQ4sG9Zh751R78yTka0wUFYdTF2oI6G5MbIgpSKy4d2wkagl0HwMP8/rxL4XetvoIeScWRh0ydADjUCoWNnpCpIExNDU0dwQBzbIXwr96QP5+weSQSZw1+PaJED0WGXHFN0MmwfvO3z/ucKHhpVETolUj8kUlY5H0KVCmhl1RuQZcUNEkVkYZcG2Eyfb6BF4zlQV8MGmR8akFqHUQKqyt0TOhpuSayM3obdEz05SJwc4wXCFMnEtwbfpinuwSef8qeMV9pqgKnF4oWJxWDyk9YXm33FtHX5l6Dwj7wr5YSKfJvI8My8X5tMMpXOLoO9X/Afnb/6q8L/69S+4+h8dj8vz4L/yuPMXHk7EPJGyUofTqWRxen2glYU6JqayMqJRWxDLYHejkLHZ8DRYCKYBq+8gE9qE6ZyZgXW70mOHqCTJVAEV4xzGRTLb44TYzvqlI1PhelvQaSDFMA+yOWnAvCt1f7XH+00/kt44+qfn6Pcf/g226Rum/Yc/NvMHx8zfVj/w+fKvIz5eHUfjCn/ru5n/3Zff5xbT8Uu/wP9hc/6rD8K//cbRn1Sv87cCvvRCWQv1HHgZRCTEYTalZsfDefkcrOcbU6owKloGoQULQ7STpx3tQncnMTMmiH2n5BlM6TloMmFpRlyhD9IIFmZeVOg1IeUw6hz78Vq9eqK0jHgmWeeUhLILzxqseZAUSskEGe+CXoM4J7oISykoxrp3XDM1C2OBUTNKouzBtA/2UKQXYv4ZtnyktoHdA0PAFfdgpCAvjnpnjYnbVLh/b5SbYenYaBMHwrAyCO1oBs8J7AtfWufDOqgqSDqh+YSniS1luhrpajwPR2oGFcSOa4cuCR2C+8p2UU7D8HXGbUZTJqvDMHp3LBK9gC8vnF4K9VKY/cY6MiPmr4HlA4+N6A2xoOwwbo2X2Nj3Rv+8wm1DpTBH5dTP1PaEnM6MOTPSQNqKeyFtxv/9V87/9A/+eMd+tcT/5O+ceHwv/PXHQZeCeadOOz0nvJyYU6HGmdgLrXxmmiopBSl2tAc9CS01VAxNxxdhapl0afRNqPNONqVtgcxOnhwx6EO4M0itIXLhug3yxZm00NY7rh0hEVbZ/fgMtQ7q/DqvK9704+mNo38Wjk78P3//b/BX/+Z/j+D/d+njH+6P/l/+4n8d27dXx1HfGv/hc/C/+fzhn/BMCf/9/1D4by0bf/0vvHH0p9KrLf72pWJRsC74WZEk6GpIS/RLpb5bcDrne1DylZtPtAgqQmqGNoMpiKeK3xa8n6gELS10NnKkw8TTBXrGEbRkkiRawN5unDcnpQp9J4pC6Zh0VinYkzHWKzdN1FrJKqgbozt9gaQJbRlDeH8avKhTqfhtZWkJLYM9r2xWSPfgoXcmU14eEh+XO7/bZnL9gdTuNM+MbMeavGW0JbIFYv0Y6L1/oNbv8HNGkuOfwUVJOcgySDEgwK3TXdBSyecTzSqBIt3xPkh3J7ESJ0dKZrVj0PaSlcWguRLDSclJ58rmx4Zc3Gd6ntCyUFRA7sCGWkPk2NBa5gvb9zubOjkX7nMl0ZlkR3F8CP3usHfWLux34flzJX9xfni/cTkvFL2g9R1+esTrgsiAfUNeOmkC353/9d/92qH+U/bz/md/2/nP/XLD0gOfCB5YmUrhwky1AmVn9zuTd3IpyAQtBdwH054wy6w5MTQ4h5NyJx4Tt0/OnsAppD5IJQggRiGP4+3EPpxzAzHYT+/wm8BlhxDy0GMzMTKI44tjD6/TouBNP57eOPpn4+gfTP8O/N6/z1/5j/9HzO2Plj/W6Rv+H3/pv8mvfuuvMT+/Lo7SnBbOf/Dd49ff9p/M0v/532z8td9NfII3jv4EerXFX41GjmdSmxC/YCp0XzFuzPfKcw7yqZJvmS2cmHbKckbTETAeo2KSaEMZd6fUxvlS8BDYlFwGtgdJClUHke94cpQTrBO+zaA7vThuTudEkYmqOyYTHjBkI8uEohzmppUocRhyAiUHcqq8rIPt5PT8mfPZkTyxuxCxc3JHKSD5yLX87FzqndEXSv2CJGe0znUHD2XR4JwcPTXuRYnnR/LnxvrtYSz6uAtrOHsS1BJ5nw4fq+XGGF8I/TkpBbonzBLnJeEEYca0rJwcbhQGiYiEGTSBoSdS5ANEo0ESWk/MW+b5g3NCOJn9kSdTJKoGKWXuqSJNKZb5OCdO+YRWGC3w3ck+0A5qjo5OrHds7SRf+cMhDHniQU+ccuE8TSzLwnSaSNWxa8bvwhD4Wx8bX/o/a05H+LgL/69PE793OobTFWOsmSYbQkf2hM5GToPcZrpuSNnwudLgsIpQQZXjS9CNtlUep05sC/ec2dONNEBawobi4pSSoMKt34g2sT8/krZgqzfSyKhkJB2eZU6CPhjNf5zD9qZXqzeO/tk5+t27f5f/41/+z/Du9rco+mtu+cyv3v3nqdOE3ser4+itr/zHL87LSP+MJ0v4uMF/9JuNf+NnD28c/Qn0aou/JxvMSYniqG7gia7KnhdElLTesKnReyOkcloWIgsxDGvOGI43JzxhOzS/MWKmxg3LE6ROWY3hGRfFuoHttFBkCKfSWNRYi0KZqS2jfcWSEXIYaZYiFCvILQMDDYd8dJMWmSaVSY37deNcOnYLej4zNBO6UaSTCXQoYwhfwrB75qSPXO8JOa08uFBLpgzFhiNlEAWQQvTgXgI9rcx7YzkXUsvEBCpOaYkklVEq4s75Nkip02wcbvIYhKBeKapwDu63iXVXRAdTHxTPmAqD47Mte0NiY+hAOJF8I9kjpGDzjiYlixMx8AGqlTJltH1C9ES1RL4Eo57Aldg6vQU0PQaAR+DtxvXWab2zv39mLr/FqUxcLsLy2MnnHS2KoQxN6KPAbLz8wZ/Mz+ljdyScXIxUEhYVIiBWeijBBc2w09Fwctcj5sgVl8NaAQvWoUQVbE1fHfhPnMpAS0ICYgx8HDFGIxuWhNTvTNOZ977Tc+d0C1YrtFMwTY2pB94LHYX+ao/3m34kvXH0XxBHdeHTh/8s6JV8e+F0d5pvr5Kj8nHj08ufjKVfVqPkN47+FHqdvxUgqoy0ENlJuoMnnIKVyhCHj4FcOrJXckkUV7wNvAW+Cww/jEkVWsm4J66t43onxYT0TMEo0WmmR9xM98PcNAulGimMSYJdFzzD6APMqDqQNJhSog5H+wbSCQ3MC2ywJWUUwRlE6pgNUi80zdjXfEkNRQ2kOxKQyuA0KXK6sLbBVQvFOpqFaVK6GpGMVQvGxIgBtZNmmPZg2M5aEiGJYk7Sjk2wObBmznai0BlRKMVQGs2UHE75uo6/SWIPA+tkYPajqxTd6RpECZInkhomhs7Bw8hAp0ngVEp2ZIAPwc2hrYzUMFm4nAcjOuqC5ozphLnQrdNto48b1jecHdzI88JDXijnE+n8QJ7L4etljmyBhtFP0LedU/T/hKfq0MNZSFGoopRQUEjpyI1UB1PBhxLScBNIM4lM4WvskgdGcI9Au6Jr8AKk1FDb8Xy8sRDrlDi2rIc5TWDalaQziZVuKzXPbCJQdpIakziBQXJ0/md13m9603+y3jj6xtF/Xo6ObTD7n6z4+zC9cfSn0qst/p5TwnNiBgiliyDhTF9XuDepzCVT5RGXwX41knZUC1nKAQSBVA+ndh0Lbo3PdB43B5s53iB3UjSGCmGZTCGVTLfCTYJJOzGMLQUjErVVMMenQbYJjQblhchC14ndnL4mohp6MiwZU1KeR+HxPCHJ0C5YVAZKxhGBrHCuQq3G82lnEeMqM7dsLO7EdGRimjtDjL0aasq5d4KCLxNXu5PLwrxm1IKRBxaGrc6whC+VnDp1qSTuWBxh4ckayRriJ/JcmKrSTGlZyUOYvVOKs87HgDEGkzTGgK2eOYlTYmeF479JQsuMRTBGR28Nz4+QlfSh0X4TZL/TkhBVkRHQ7zT7wmpXPDq1rNTmLNtvc3l3ojw+kOvPmFSYzIiucA9QuMvgh9/c+UDjkoyrKX98TuXQh5Pwe791QkclSyK5I33AKYiSKV2RtHLfhepBk0rMlbQYKob0jA7w5FhyRkvkCIYZnD5iNoCZ0pXJnaKBEvjqRBX6XmgI96rUj4X4MFHShjagK13A88AL5Po6Z1Xe9OPpjaNvHP3n5ejW4BenykMavFjin85S5a/8/EyW/MbRn0CvtvgTgyU5eVKMirTE1A9w9QynR7hGMB4azAn7PLG0Tj43cimYF1pUWnLUOpMeHkSXHpzM2U8BvoAnIm70baU1SKfOlDt+TfSlILkR6wtCkGNG/MymjTYSaQRmSosTqFBSObIJNZgJzk0JKlIL4nAyxfdM15U9g5VKT4PUOtLARuZzhnG9kguUW+E0C2oFy34MXnM408860DWo/YVWHmiWKI8ZrNHXoElCNHHyykOFno/5lbtOPFWjPA8e0gXZOt6CUYWtNLrCQ5rJmug5iCE07/QkeKuMMrgtg7EvmHeuS6JYR3QgTJRR0ICO0COjVJZcKHbiPj3T74WTF4YOduuo7GRtMJy+Fe4j0XLnvm5ct8q7slGWJ77N8PNL55QrYcpGcBeh3wq9N7bW2SXzX/yw8r/97gx/bD/v0H/tr86UlPDotNwISZxMyO0wmm1itGJcS6J2oc4zVUDsxsCRMVMcFD8ySs+ZdobynSAxuFwaQ2bSEHQGxzHzA8yqJIemz4zf3Uj9RI6dwqC7sqoSKQEJ2QZ6fZ2xRG/68fTG0TeO/vNydCXjkfkvf9j5X3534p/G0n/vr10gD1rubxz9CfRqi7+LzFTJDA4jSIYTfri5a4MeDZWE9i9sOdC8IJXjwfJxRMhIYnjAFKz2ka5wKu8ZZlg88yUNjAAztHZiAhNHe0fqiV2+PpiXO5e9EkPok9PJTKvTHUwKkuAkxmUoN1VGbUQYsiYYwfPi+KcTdrqRS6aUGfFO3xpWdkZ2RqmETHhX5hvMU0NrkL4oe26syTAOh/Q0KgxFLXB7d1i4eMfGifm5U6QRdSc0IVFos3KPhDZF5o24K3PMnE24VedWJlwziU7pg9Av4JnJKlaO4G3uQuVO5EEDvGbSSVhu/bASmGeKztQIig3CnUDZS+JahHeTs9zP7O2FPd3YX6B4w6Oz74N93xj9hf32zMtLQwTGXHiaOtPYubhQeOGeFm5pooVA7Qy5sX3/A2pfyP6ev7o4P//lnf/ghxOf/7Fb4G9m5b/x+xf+6i+OuCULAXNqzmwR0DeQAXOmyUymMClEWqECWeg4nl/QkUlUUjZ0CsazMLSwp8R5F1SDJjsjGWhCPJGlcbFO1kxtK+nzzHjMsCrREzoNcu2IN8pdsR22/joHld/04+mNo28c/dNwdNLEv/vU+fZx8L/4e4VP/5hP8jeT8u/92zP/6d/J7CPeOPoT6dUWf6Ov9B4MKpjBsOPVewqyJKxlkhTwO+En+hbcbCd7J+dMnk+kqszujG5oMtb0yG6DsmxwG3QqbsHUOktSSGf2UaF3ztG4V/i4Ce9SZWoVdRBtXxfdg94yVoIyOzUHORqpQd0E7TCiIzlYmhNzZZ9XbmPhYZupZZCrIykzVBgpCBrfjHd4SbQOqxlZ7+i5MmkiBsRXYLsY81SJMRP7jTZv7PcLw5xFJ6QJQuClgzUkCrvOyAqtK2kufOfObom7OpYGc85cxNiTstdG8g1vlbRlqjpkO+J1tDJZgWbUOLb0hk9ENDpCK4nIBbcF7YXSNmLqnM8L35UzFxHS/fr/be9temRbljStx8zcfa0VEZl77/Nxb11AYlBCQg3qalAPWiAhGDFC/AbgL/ELADFhwG9gQIsBPUNC0IXoUnXTXeeeuz8yI2Kt5R9mDNamJGgQJUp1DidPPJM92SllZri/aeZu/r6AEi1BGziDlhurVbYYWIJczpy/hW/enyhPzwwp+DYhw1Aa7pVYN7jtyNrZU2WZgr/9m8Tf/ZPBn3+8sN8Sf3SCv/WcaOPM/bZheicvC7acqMnIU0XWTIqZEoa+BF2CdH5H1x2JStoTMWAXwb8OX+fTTl8HJ01sMTFNhXKr3MZxGpFCyBiJYATc6kbMN6Z+Quozw3YsBJkAseMqxAOzRi+Nmt5mJuWDn46Hjj509P+rjnJK/J1/Qfh7f3zmz39v3PrgQxb++PLMvuyM/tDRn5M3W/xJU3w1uoNIw8SBwvATLkpbrozSsfXM/ElpNihhiBb8ZLRzYKz4KngIGsaTDT5+hGcXpggsDkf0PJQUiufpmIvhxg/Zib7QemGrCV2CeXayKqqDLhWf/Ajy3pWXAWvZcQSXhZEKITuLdiLBSJWrTAxWNITiyhjKCD8GYwMykHfhS5/g4uS6McIJKq6J4YKPQVgjcrBWZ553EkbbF9Z8xaNjFCYUi0Hgh7P/19mf6e7sqZHFkAjMExOHDcHoIFtg8wKXRtc71QaXSchRiD4xXoSEcrLEqoWUHbOM1UYtDZ+V0EyPAhZMeSMxjiQB32lurL7TpjvaE70q9xbc78F2D7grHoYP45vvjO8vJ24haBTSruQxmPbBvq9c+5VtbOymeFLsdKc8PVF+MzFdBn+77MyrIlIYp8DZyVtivr1HcqbLETE0WiK2whCBpdIkwDr4lVF3OkJLiuoRyaQjSNVJJLa1cFkq2RruQcyJNBQZjg1n9oGEckO5Tsplcfy6Ey6cmtNnZ8SAqqSeSCK4wv41BeHBg78ODx196OhfR0fzeTDVO//6twsiBTkFnfrQ0f8f8GaLP1xAKmIDUUeZUF/IPeGjUpcbQSbdnDRnZN4PF3DLkBOpD6Q2ogU1JcQVrTvTMpNjYsjX+YEm9FEYYQwRXAM0EydHr3AuE0iwlyNUOrSR04bVwRKO7bCuC+s0sSp4OCkN0gDpmRGJFoNtHuQbLASRK92O9jNqHBtSCoRSh+NRkQnOVbhbYgwhshPZkS7QCnjQaEw3h9NEPZ2QcWVEoifFdOAROEa40IegDjl3RBtzd3o3Ik+UrIgF4QP1RonMtkP3wzepYkgS5iTIq9L3gS8VGe8YRekEaRLsbMdT/64ox8+XpENSemReRuLZO23ADbB7Y7wG631nu90Ztyu6vZBEsdMH8rtMOl3IdWEZBctA+tq2jxt1+8LremeNhiw776eFJWfO44m0CYWOaqWRcEm4KfmUmOT4THXcGV1JciF0BtmIdEc67H2ijAp5p48CCGlu4AN6sPfE1gruRo8jzF32z6z5gmVHaMQYNFNkGNKcD9Gw5sT7gvaG6BHY7l1RNzwdRqiBYXWQ2vYzb8IHv3geOvrQ0YeO/syb8G+GN1v8reWOTp+wVNA4o30mD8MY1FRRDzqNJBDLmTLvtOGItSPfcIVogpoj0RmrMha4fIBRC/u6EatCJDQNksEYzmiF4s+ce2P3jZIrnIQRiT2+hkVv6Xjl1ja8BSZKFkFGOTyNvGIjYTHRA9owYlnJA0wuNG0gFe0B9Ri+Nh1EgPad6XJjXZVtFLazoJEprYI7LplgIkkQJ6W/DmIVxjdCkowkY0SmeUfj2AA4SA1Sr8S5M3VB9k7LQS8ZLKN6+Ci5Op2VbUtsnkmWcFVUO1aUmIQxgk07ERvumRGKpESyDNeAWslLPQK2t4xIsGrgkpmi4660nlj3xrg2tvXO2q9scqVbo+QT5XwiM6Mx8XR6R5mglWCNTksbe6xs1zv32wtDB/NT5p0/c5ETPs7EEDzdiSJkMp1MJ2GTHVcv6pQQbllRUyJ26Bu5KnltDFcsZWIJ4pZIFcISmyaSKuLCPgbZOqNOlN6pJbHujkgnxUBE2AWETu6dp3pEIH1ZJqZr0N51Rm0QBWzgNhgKOZTZBkXfZiD5g5+Oh44+dPSho29TR99s8edzBelIzLgIPToaA0/Omh0fzzBeYTpDS9g84+ag4NEJcYYFol9fvLXE+BDkYtzvQuwNHwlJGZsVyTvaKxaAT9SXjPSKe8UmIbqBO+Sg9xM2FSo7TcDyIMuGmZFM8ZERy8QstLHTUbJ0es4MUyJ1PAIZGYYDFbdK0Yw2QxNce0dMjusG53hGHxCiMEMxQdPEbd4Y98x878gMJCd2JdxAnaSCacJLMGxFvFPXBXD20xGJlnYjsh4v5iwY+8AdVBMhAVqRLoc4uZBVD6+wacdwQi9QB7ii1WAMLPzohCOx7kLoTvGVsVfuPaAFzQe7N9bYucnOngO1iafThckW3tnMOYwtDfapcifY6kq93bi97Ly+dPbayQUWLjzHB87zwvYu41IPg0/JFCmoJhKKjSPcHp8wnShLI6h07nh3tJ5IVchLglmQIzeAJEHzjKphKbCAvTuv7jx351QLt7nQ7YqvO+EZTQlXIVonuuNZCE7cPsHkK82DiE7ggKEhR0yWH272UvTn3IIP3gAPHX3o6ENH36aOvtnib5LMMp6QnGnZGbrTeiDi1DDwC2UvpDCkrfQNhsFgoD4wEdSMOgzvUBYl2Em+c7s71IGmwZg55koYFOtoXumjMrYLkyQGHWLDw8hrxmqmL4HkymBGUycXBxHUg0yiywVPiZorQ52UnDISPdLxEio4lql2TINwGB40c9LJ6JJ4TcaHrRNXwaMS6qhlRIVOsLuybIq1QWPimZ0hygilx0CF49g/nOydPinDwLZORYlZWD0odUDf8DYROtMXw2gUE0qCYDCiM0ZGhgOCZaVY4NmxEDBBN0ECJBnkCRUh0el01p45UWj6QjTn7hO5DtreqP1OxIrIYFKjFGG6JN5F5vz+hGdD/cbQwLdMexlsn3b2Lzu++lcfskKxM8lOSJnI2ZAWSE8gmWGACyUUrNFOkGQidmHJgy6daEFrSnXFmZA006VB7xhHvJJKIoehHoQImHGrTk9OTBe0XdFpx1ockURecBe0K0NgTAqeWZohl9/T2geSA9EIFFTRNHCCrUHb3qY/1YOfjoeOPnT0oaNvU0ffbPEXnrEoqNvR1A1Bu6OiJCk0KowMllC/4+O4mlB3kvvxki0lQjMqK00b5ePhiSR9w9KELoaXw/og7ekwkfROhKPTwNVI55WeOWYL9kKPmZEqwzuWpmMRa0NkIL0RW8J6QWboAqML57xDy7ShZHbMhBwJ7YOIwNVAoI5OqGEmTLcTvt3wlGmygTcmAtVBiLJ3gSG4JzTBaZ75Ekd+pknD7BCR1ji6HwWa4lWPl2t9OiwPckPywDpot6NTFZhkULWzE0gLXALHaZJJKlx0YJIgC3nfcAO3Y9bHIjBvjDZoNZDWCV+opdN9xgVCK1Hv9P3K7huuwiQLzxiTZS5ZSMvEOmXS7RUlk1pB1yDWO15vwMCmienyjvfnC7POjCUTNSP7MY8iRcAaagIpEN0wUQYbHo357pgbUy8oSsvCIHMxEIIejkvQXMATgdCiH+tEdlLaufcz+TwxxaD7oKdjAH4EDBF0SqgoUsbxe5FEL4O+HaccA8XdjmByFBXD6yCub/O64sFPx0NHHzr60NG3qaNvtvgbIyEhpHG8MuvV0e6YKXlRvN8ZUqiTct6PY30NQ1WRGHgIww/L91BotZJWoY6EBaTpCcmOWEfcSQ7icsTQ+MBLBxJqMDSRsqDhuCiUzNgas3VUBCcRAtEbMSolVvAg2oA6jqxMMqEJkWBSRbrRWmNEIyYhEtQ98KE8a+NDr7Q0GHnCPdGjYQyS6zGn4k4jCDNmnByJsQV4YCgaQg/YhwJB1gGiVJkY3shk0sj0AkkLGcO90euAEkSBtgzacIorakb0IGo9ro9OR9eNBKk3wqBroHSyOMHGHkILowBdnD5fSN6RWNn3nRora1/Z2o5oQdNEskKWgp4yIUYAgwSjEK0z2o1tXLmx0czIy4nTt++4nCfmCpsEvRk5MqEd1yAlyNmJJchasV2pNROs6K54MwyjzILPAgM0OuLt8MkycALEGRpEq0htYIOkjbrPrJedczTKDZpASEdUEU24GZWj05UWjHmlaIFyZcjpL9e6CYCgQygd0vi/d9Z/8OCvykNHHzr60NG3qaNvtvjLZESOrkEdQoJqHbXBiEppyk5QpTOrQjj4DCnoulNbx72iEpCO0PGYjLGfjpduktF+R6JDN4YGwjjmUSyAHWWgWzqO40dBQ4i5ktWJOsi6ETqzaz6c2wXKsqOt0rvSCEQGa5vJVrBskNPRCXlQJYGAmoPGcR1hSqrKMn/ingqjbSwBkvgahC7kEYTsNFUkC7YH6/UGLdBSSGNmIHQGLsGIgadgmmDsGR/AMsjD2DdDSCSBe1RS3Y6on+eJ++RQGynPmKfj1Vm/o5Jpdj7sGGqj+/E6LdyR7JAHXZwmQqSEcMxseFo4l7/g47oxts4WsOvAY5BGECZ0M7AZ5hlUOI8jN3Orndf7J75sH3lpd64dzGae5zPvnmdyMkIGsjekTEhxgn78kfGMDYUOUgQxQdxoI7OpgCpzBJZA50A257V1VAZqiTBHwkEbngZ4w9SPz2OfwI1872xtI2+CaCKyY3k/rtdcWcOR7vRXQ79fUZ1IeYVWiRFHhqccBqgRfswpzcvPvQ0f/MJ56OhDRx86+jZ19M0Wf2ZGs6AmJ1jolmla8fGCfrlziRNaoO0rWwlOdcJ0kDh0J8KpUlEaswmfi/L+pvxwKby7ZrpfWV7viAvNFZKiuUJxUsrMQNCZXSgo+xBGCDJ2fL8yHFYtiBl1DBqdlI5cy5Wgyc7AkOnECLD7nV5OhCi33bFeSWUgllBfsD3AVvS0c3uZiaeOtycYMEvFPBMuRIcRlSaDKIlZM/dz51oDT4kklSmeMAWzHU+d3jshwaKKW+V2/p5WGnK7Mt2dIg2ZJoZCEiGlO70b/XNB+wqs5E0RdWyasXyiSyK8kPYfqPnEdA0kBzEFbgnLC6ek9O7cQ5EG9uVHXsbEuAcxNvYGURIlBxoGoqRU+IaFZVZqLpgqTz984cu18ofXL7yuFa/CJYxpeuKb8zt+48K8LdT3hm0Nky+EZZiMEMfbYFTjTsNk5jcUkg5+H4fvVFHHVLE5yNao6qzeKePE8qTkqPj18BGzotRJGRGkSEzJEB/Y1mijErZw0sIY/VgDshE40cEkIVnpHV5sIn18Ptztu9C9UiVgCDIl4km4n99mIPmDn46Hjj509KGjb1NH32zx96qZuZ/Q1iA2zF8Rq2jqnIpS043Td8qn68ry6Tt6NXJvyMkICqNObLWyTYG1wfkLfIjMermxLkqNgtaJySt52kmLEKKM0nF2Pn9+z/Yc/G50sjbKWYgWXFvjY5qY0+E4Po07Ume8ZzztDMkkOp6dLRXmceG5/BPSnpDozKkRs9PqgBokDybvTG2gK9zzhTzdWONCej1hpXN9+kLqguyZSIKWxDQthMBze+H+HEhXtn/4gbwImhSZK2KBhiKqR0zQnrksGcF59p3VOl/mM9cmlH0jacZZ2KTiffB8zaT+DE87fd6pY+JGJo2N7647dpqZvXPF2J4Tl6GUeyauwcg7IzlDwE2x9yf0x4nrX/xjPvsOL1e2/RNtCGcKT7mwLCfmpw/IN9Nh9Pn7ifpHX/izvrFuV9ZNYZwp2UiL8fx+5t3l8Luyaef7UonbxJ0ZvdxwyXidGQRr6ZTSmMZEeGDSKONENSfWKyNXhgjtPrHkD8zz4HWr7GmgzWFawRz6zOxG0YQb3MZGfnfmvipcJ+xSmK3TXkH7xGSFnoVbZNyVyzeNP7/f+G6rrGvhtHTmGWxxvARbN66bMNqdkj/+3NvwwS+ch44+dPSho29TR99s8bdumRcbFIKMkBFSz8S2cDPDm1J+WGk/fs/6L37iXTRGZG4ILSCmxFwmTqowN8jB/7pt7HWnLe+ZJHE9By8DplAuZOyW4XNneapcPjhf5mAfzl4HPRrDMiYnfrMffkZTc5iVaq+MW8XawninyLxz6sF8TfT9lVbewfkz79T5cWRYC8mesJxhbOzjxjpVsnW0CfNvMvufBsv8j9nvjslOTCeqZVobyN45+cY5wbUs+G1m3nbm8xfkrLSm9LsgJUinr/MUG9yLMWRQbp/43L9jrE9c00JMlfN8PewJBLIXok5oMUbprGPB28QygjNKMqXO8Lkb0yKEdtJ9gQE1bTTdGL0RecLmM1MT+Hjj/vqMSwOEVwucwTkNzkU5LcLTRTldOiOM+JTwp9/zT374wqfPQfUvNPnCbM7ZFqb8xLN+x9P+Dl0GlwVe7sHdb8gzTPbM/MnIdeDPAUug65ldjHY2Up2IvfLsd3oBHcrdd35YBt9vmaAjMmOfn1mr4GSmvJPMEDP2MPamnGzQ/szJ7185Ld+g5RV2wU4zW3P2LbB94uky4ekzvWS+/yHzw1NnmoVZOtY6mye4PnNuC4sMPif4cb383NvwwS+ch44+dPSho29TR99s8XcqP3CZL9iYcJ1olujeabXiDsI3xKeN8ruOy8LLdoEmWNop5ehsJYTQRFOneoV3mdOtMPedPnY6xpAJ1KhpwHRlNiAlbqKMljmH0/uJHqA2kNTo2nnHwOKZz/edLRv67szkd5KvbNvEzoJaQH5hfppINdh2kDmTUzCnK87C6Aqt4BJ8ToJ45flPN9TP+LORPg9MFB+dtN+ZGniBnoxrg3F3vvs+U/eEjY/UTTCHNAVyErJlPGaGBq0FNgmUhfu7L4yPDZ87cwpyD6IrpzA0DB9BzIOYN6bewN6TascmYZ4Ky5aQqqRSkFdnbTd2yXAq+CRH1I85wo0+hNuoeP0LFmbqj3/OLrCPJyzP2DQxnybKPB/2BpaIZKwvDSp0faW9vsJNIZ3I7594/8073p0msk7HUPr6B/z8Pa7vKbZCv9G0UNLEuRkyMqMXdgXaK1oHyd5RR9CHEPdB3E6czmDceS2JeG2oXpkujo6KNGHyBJFxEzQNpBucF2rs7FqxL4lMwdTxcaxBiY6vgzY5ftuRbzbOU2baBdox1zKs0nKllRujz9yqUfvbDCR/8NPx0NGHjj509G3q6Jst/pI7jcyeZ9QSQscDSIppY1xf6DIIHzRpLHJHtRNmdC8MT+wS9OikF8Geg9hmFt+o4lzdsR4oiShGlISdjGiBu6MbtHvl93TmMbEvGzLuTAziZOwo1e6MEpz9GIRONmOWmEam9SCkoXOmtMZ1zOS6k3PFCEQDkY6PTOxCEuG8NF418/JtZR7Bi8H7S8bXxN6VVqH1oAGosNycKQs/fPmCWPB+LnhOSO9YONQj5marndHhpIZWoUcgtVBaIrPhpjQSWTs9vWJyzNzsw+E+MYnBvDPOxghD78F0F/Tp+LqgcUtKOsE8FVQKoYO9N/oWeBVEXrhvG5sp1xFMzZgtKMXRU6KeT1iaWUZQwln2xtP/9j9wu/8pvT3xg/yOb+XEwsTSF3Kf8bywz8bogy1/w/y88I07dW80DC8QZTBCiPuO9xWmhBRjL4KNleEKFDwNtFYu92A+Ldz2RJSd+XwMG8c4HPWvHRgNyYomIFYKF9RnrjFYliPbs9bjtZuJEzrYdCL8wrf7xn05890+2LdXrvlMUaX7YWHRTdHoTD1Y/G0Gkj/46Xjo6K9cR+ug/aHx8foDrxtMsfCtzA8dfQO82eKvBixiKAPpIMOwrphWLBmrGWxC3jvb1LFcSKnQAMeQZBRTJBRvgq4w6XZkB/YnzA9jUINjLqLNhx3BsrJX4AplfEJwWv9ArjtaKmKZ3C4oBvlKjkFpzqrGZko2Q1D0mLtlDsdbUJ/PJDW0NFav1JZw79TeMFFmUaQNsil+BntZqVtnTU+oH97lkoI8gSUnxjhE9+nMiI3f9ht7MkapVClYS6QOY8DWwLsyZSHJzopS3BBzGHpYO6Sg56DliYYi7qhuaIPYAk2BqEETYhe2BCNveGx4zljJRBGkO1NX3JXRAt8qowlfbHDfO7PsPElBU1CkcE4LdprxM3Rp7FX47Q//gH/tz/4L5v75L9fDC0/89/N/wKdv/m2m5zNpmXAL1CrZ4vj8GaRIWM6kClsMujqJoEx2hLuPAZ6pqkhqaOxoTDiZbk6ThopQ2PE0mNcTosKWVtrsjOGYOpMJqRXqdqbPO1OFJ0mUfIeqVFGiKOYBPZB94Ch/iIGFULdECSM3wS2OK7YRqHVgwNdM9AcP/jo8dPTXq6PRhLhvbAl63LhLkMKYJuHyvDx09BfOmy3+ohamLuTcqUAXheRkcVI3eoF6V04M1IGc0cho7UQ0NA8Smakbw53hji0Ow0g9k5fAm0NV0gB00IYyLKMCTme2gWXnS+2EChEZ9wIjIRRkbER1wjmGgcOOKKSQQzjd2CuU6FjK7FRKCyz8ML10RYE0jWOx1oF4InbYtTPdDYnANBgeaFbyWbAi9Hsn1gAR5liYxmBLN6ZNWTXTNfABgaABKOwxaKPT4sJpBH526pbQUMwGhGA9g+rRbVvHYiA2mIDRZ0Y4bo2G0NNOSFDGGVVhb84WggCTC1MITYONQWuK787mG7bs5J5I6UReLiw2gwtV4Dcf/wH/5v/yn/5z6+GJV/697b/kv+OJT8//DuSEayDFyWehrJX+xenpTFwgNFNawnynaEcn+2r62en7hosgk2B0hMaQma5HHBCaENmJ1Nn6CYvDLsMsEDte6zlBBOwUJHUkKnlMeA8kHa5ao4K3hLoQUmkcrvNPq/C6CXmZme/Bfj9ijtQGOlfCKiqOsP6EO+7BW+Sho79OHW0oq1fG9pkeiU/3OykcXZ6IWeDJ0Of80NFfMG+2+FMpoEpSJwiGHc/sj3xGxwyYIWUoLkQ40R1pjso4utFQYq+M3unieCS0J7BGnho1BO9HFmDQGW7IrkeXJkJiZojjxuGAjyLDqHEIU/SM+2A3wYdhnkgp6BgRwfBB7YVuGb/v9K1hCpGEKgNBKaZoFrp1pHeyHDmano25GBBoHogHYXFE1wgYSs9K9MY0gqpgKSjVcBt0BoxgoKgkVB3oUKEIxxG8JhjGUHBVRIWCEwGOYMPQUFJWUurAEW3kyjGLQqcDRSdyG1+vDY4Qbo1DKUMEHwN2YO/crZJSoyRDTwkuCVIhuUEM/s4/+s8A+L/acgoQwJ/8/r/iv/njf5eUDVFDp4wVIW+N0YV7b6QciIFpkIagcYSqkxzxBNUxDzQyNpYjWF0dRUlDCMu4TpgaLTktOmUIWRJdjMGgWyViUM1JEqCOxEbrgRWBMRg1oCkmCilwqVhL1HsQNWjfC8l3dM2koXRzXDriFa2Dsb9NZ/oHPx0PHf0V6ijQfXCvnVd/gS8X1uvg3ZJI5zN6OiHngsyJlB46+kvlzRZ/kqEmJWuQIsgEXRVRCO+0lpjK13mQIQz/aqTZByQQlBiwu7MFxC6MmqEZTDuZQSRjLBDB0UuEcti5d5INmmVGD8QdUSGnhIZQe9B0HK70SRk5oA1wI8QQP4ZTRRquiZqN0a5HJ+zGEGGof7UPNTyM3g21wbMkJAe9Kmlu1B26DSQFKkJ0YVRBdkPNkRgYN/qcICkDxfSIcHIfhDgiBiiFoIxMk8GeGrZNaBPIgYgQBhGD5oGEkjh+XivByJVoIKJ4ssObaggSgxaC9IH0geph0rr1wHvQu0N1+s0ZdWOfnCkWpBTkVJCT4AY9hA8f/0dO9f/5Wb4Ap/aR7+7/E7ff/hvkpGhKlF0wd3SaaLfB1INgP4Q7FOkz0QX1Dm6gGbOOaEKGEh5giiZFq9PCiDiTamOIM7TjQ5GuBI5PHdGOuWAy6O74EEIrQ45TBnHH5EglEAMsjixLFz65c4lOk0CXjjU7fq8GHUGaEveg3d/moPKDn46Hjv76dNS7E7VS7yu1bYzXJ8p04vJ0Rk7PLOcTZZmwyUhZHjr6C+XNFn8qjT4CDygiFDk+WFEQLdQt864EbW+490N5AooqakoTZUOObjISqe6MJphBEye5YEmOQdbhjCYkN2wczvdJg7WAXhtFgkAgFYSE9Y7kYEwwQsDG16Fjw82OQPToSHJCDtPRXXdmzXAXEkIWpWvQfRDNGGSkCEMygh15k+aEHB5PZgIo0RNRldyDNAEMYNDnE6Nv1KRHSDsJl0EcbqaICMkyhnL1zq7Ou+5YDEIgoYgHG05rTtFCyHF871kYmpAe6DCGKS6D7EbuStOGacW1gxx/LNru1F5p3mnd2fZBj52oiYl3iBghBRFQ6bgHtv7+r7Q2Tu0zt+JQguJQVuhdoShTDFIE+14ZDIgJHYrYQBlEDZCAcnTeoocvmSQjVKkA4iQv9NrRs+NZvw5cd0YPnI6pYyMza7DGwIczTI7IptHI4SQDT4PIQciALdgFbgnexZ1YhVog2dcYLY5Q9KgZr1D38Te2vx78Onjo6K9PR2vr7OuNdl+xe+Aol+8+UN69YyonLjkxaTpyeh86+ovlzRZ/tjfyNoikRCqYpSMaJgKPCevHcOo+Bvr1SmEsCYaABK7O4OjyJlNyVvoQyhPcdyWZfG1Vd8QDcyP1wxl82ECK0TKk1JgFNlOaBlNAoWPSuedEd5gAzcJm4DYog2NCeIBJIPWKzn6Ef2tjGoZJZpXACeT/OFJPia1AabDvSljG2IieURxE8NDjWF2PI3nXTvSZKieCj4ymDFcEY1jCmyHD0QwuhRpwH4GHM/JGIoisiBRKU7aA3BolJUIyro0WwWhK2kH349jf5y+4nI5cS21EHmD9EKMe+Kj42Kg0buLsWmlTJ+3PJFsQUeiKNEHCid54jfJXWhu9fAMIrQepd7ILG0KvGycFj6BtQlKhZI6B7BgMwPvO8CByOa7BUpCyIHG8ThwpCBo5hEYlK2iB3pzuwRgDaYFqIWLmVI7R+CEDl3KIfKyHNYFnhgV+pL0zwqniLDoQ2ZHbEX7euyPDCe94VGrrtNHYY/+b22APfhU8dPTXp6P7tnJdb7S9krbCtMDy4Zl8fuI8Zhac7EfEnT909BfLmy3+IpRkTrOCS2YOIW/C8ERX43nesZIoUyLtRlPYTvnwYNo7RlDMWfIK4ijPLB+DSYUXjuNqfwWik0rD/Kv/FaAyIUOZeqGK8zI5TQoWioWTScQLyOQIDVFBLCGhx8CxOO6d2I+OdauB3h05dbBgxAn3gkhgDIoK0mC/g+iVnHderu+wkhEFYzrCzrUi1lEzRkBvM3WuPN8yVRvpg2J/gHGvSA7QQng+grFL0IFM4smDJk7znVkETYe1wZCJZdyQ3Cgo3hKdRHiDAIkBMg4RjIr3zIhgicKwGbVOap3q0HPFtTG2yrpX9rLTinOZEyGJZQHRhI5Co7O2lR/Kb3jVZy7+8s/N/MEx81en79g//AnLPbET3Gk0MYYaunWqBDFOEIlijXlOeDLGEFobDBMQjtkfGTQTNE+wDXStzOZ8Lo2n2TEBWxPeA4+NPYOTSCSkFfCZMjcknM+WuXRlZ9D7oMc4ZnkiEy2RxEgIJsJT3fHtjOqdvQ/c9RD6uFPjC/u2c10bn8frT7rnHrw9Hjr669PRW3+h9u0ogs4TF3XeiZGngbaZmAc2CYlE3OWho79Q3mzx99km/OKkUsldGTdFbkqPgryD/FSp7YbnlTpnTq3g10HzjnfDYsZyw5cru294m3h32hli/Ob8Ad+PbmEviTitxxH/lo5bj5EZV8Gyk8dMjcZ2FhaFgmLfZtZNGfdXShOCCY9AY+PmgpZAywDvpAbpfGZJldOrUPTM52UivJH68dprDOUcG9ME9TozdIf3Tq47qd256YYTTKJMlnAbrAF7m5DhSLnSz79lmyfOOOiMxqDQsJPj08ToQV2DcVY+ZOFL7Pg8MdpEapksCc1ObcGtn9kZLL0xJUHTlQ7Ik9H6jmzC4gYbZD2zWOfHVHkZHYZgnhnAzTufd+e6N5azs3jwjT4x0oWpDCpK7UJdg/UetK3w357+Q/79639O8H9+9BFf//1H/+p/gm6Z3Rt7vrObA4V3QxAWbDIwIXSQtTC8cN0GaX9F444qkOYjg1Q6fUBaE6d9gs1oduP7U0X8RNwH8x1WLkSC3F+hKVPOlNSI+Mh1A7tM1LHx+aacmIkZJN3R2AlWPBJZLsyXJ3x1bi+NqoV5vjDGzr5V2uuddbvzqe58vn5hvf8zev/9293gD34SHjr669PRviVyO1FzsH0D398Xnp+VWJyt3hlnUJnRVR86+gvmLf5MAJz5keXHb1EzVBtDJ7azUNMrqcM0rfzT/Qt/JL+jfvlEhENL9DwRWRk2yDJgm4l7Zoqd12w8XwctnHVRZHk9gqB3RQacdmeKwYZztxNndsb6mcuY+I6dmgt3nqmrkRbBTyd+VOe87Dw1ZdwWLrqzSyEiYXlDpol33fjUG7dJkf2IT2LuZK+UoYhnNhJJE7YYly/P6Fax0wv3/ZlkM9MilATuja3ttCQs3wjf1so/m0+8/+EHYgv2fSVbRvTCHkGvTrobC045OcNmav0Dp1fndoZdBugJ9UKMK/O0o62Q2m+YcxD6Qu9PbHPj2TuzBNuT0BLcvzjv2w/Y/j2evufSM0M/8YWV/cVp6x3TL5zOg7G/Yyl/YNNgWQr+vHKKnWnfuY3Gfay87hv/s/0tlm//I/7uy3/NuX3+y/WwT9/xD/+V/5gvH/4e5p0IRdrleLkIaDU0B0OdcX+F/kR/XvBZaKMxamZ++ZY6BXFqvMcIK/gKixT81KjPK/Sg2jP1ZYcfb4x8YSxGzXfatBIjsenEZIHKxmsI/OEv2Obf8e5fuvLDzThdn8lDqBT2HrTe+GIrZTrxvsHvFZ5eK+Y/8vHmfIw79eMr/csLr+MLP+yf+fhPX5hX53e/XX62Pfjgl89DR3+dOjqF8UfnBZkS3y8XyodKK9/yZAntjR5ByENHf8lIRMT/+3978OAXhg/4s78P17+Ay2/hX/63QO3n/q4ePHjw4MGDn51H8ffgwYMHDx48ePArQn/ub+DBgwcPHjx48ODBT8ej+Hvw4MGDBw8ePPgV8Sj+Hjx48ODBgwcPfkU8ir8HDx48ePDgwYNfEY/i78GDBw8ePHjw4FfEo/h78ODBgwcPHjz4FfEo/h48ePDgwYMHD35FPIq/Bw8ePHjw4MGDXxH/O/WHoH4YbP7RAAAAAElFTkSuQmCC",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAn8AAAFECAYAAABWG1gIAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAADXYUlEQVR4nOz9ebxsR3Udjq/T852nN0igEUkINIAMCDCTBMboZzA2GIwBD2BEwuTIOMSO43wTg+0EY2NDGI1IDHIMJsEDHkLAYbIBDyDJjEISAqGBQdKb77tTd98+vz/u26fXWb3rdD/pivfO7Vrvc193n1Pjrl3r1D61qypJ0zRFRERERERERETEWKByogsQERERERERERHx/UMc/EVEREREREREjBHi4C8iIiIiIiIiYowQB38REREREREREWOEOPiLiIiIiIiIiBgjxMFfRERERERERMQYIQ7+IiIiIiIiIiLGCHHwFxERERERERExRoiDv4iIiIiIiIiIMUIc/EUMIEkSvPa1rz3RxSjEi1/8YkxPT5/oYkRERESc9Hjta1+LJEly18466yy8+MUvHin+5Zdfjssvv3z7CxZxwhAHf/cSt956K37hF34BD37wgzE5OYnJyUlccMEFeNWrXoUvfelLJ7p49ysuv/xyJEky9O++DiBXV1fx2te+Fp/61Ke2pdwMrcPi4iIuvfRS/OEf/iF6vd625xcREXH8eO9735vrp61WCw9+8IPxC7/wC7jrrrtOdPGC+MIXvoCf+Zmfwemnn45ms4nFxUU89alPxXve8x5sbm6e6OK5uOGGG/Da174W3/rWt050USK+D6id6AKUEX/zN3+Dn/qpn0KtVsNP//RP4+EPfzgqlQpuvPFG/Pmf/zne+c534tZbb8WZZ555oot6v+A//sf/iJe+9KXZ789//vN4y1vegl/7tV/DQx/60Oz6wx72sPuUz+rqKl73utcBwP1idZ522ml4/etfDwC455578Ed/9Ee48sorcfPNN+O3f/u3tz2/iIiIe4ff+I3fwNlnn4319XV85jOfwTvf+U58+MMfxle+8hVMTk6e6OLl8N//+3/Hy1/+cuzduxc/+7M/i/POOw/Ly8v4+Mc/jiuvvBLf/e538Wu/9msnupi46aabUKn03//ccMMNeN3rXofLL78cZ511Vi7s3/7t336fSxdxfyMO/o4T3/jGN/D85z8fZ555Jj7+8Y/j1FNPzd1/wxvegHe84x25TuVhZWUFU1NT92dR7zf88A//cO53q9XCW97yFvzwD/9w4SDtZKvz3NwcfuZnfib7/bKXvQznn38+3va2t+E3f/M3Ua/XT2DpIiIiDD/yIz+CRz3qUQCAl770pVhaWsLv//7v4y//8i/xghe84ASXro9/+qd/wstf/nL84A/+ID784Q9jZmYmu/fqV78a1157Lb7yla+cwBL20Ww2Rw7baDTux5JEnAjEad/jxO/8zu9gZWUF73nPewYGfgBQq9Vw1VVX4fTTT8+umX/aN77xDTz96U/HzMwMfvqnfxrA1oDoNa95TTY9cP755+ONb3wj0jTN4n/rW99CkiR473vfO5CfTq+ab8ctt9yCF7/4xZifn8fc3Bx+/ud/Hqurq7m4Gxsb+KVf+iXs3r0bMzMz+LEf+zHceeed91FC+XLccMMNeOELX4iFhQU84QlPABD2H3nxi1+cWZzf+ta3sHv3bgDA6173uuBU8re//W0861nPwvT0NHbv3o1/9+/+3b2eVpmcnMRjH/tYrKys4J577gEAfPOb38RP/uRPYnFxMbv/f/7P/xmI+9a3vhUXXnghJicnsbCwgEc96lF4//vfP1DWl7zkJdi7dy+azSYuvPBC/OEf/uG9KmtExDjjKU95CoAt9xsA6Ha7+M3f/E2cc845aDabOOuss/Brv/Zr2NjYyMW79tprccUVV2DXrl2YmJjA2WefjZe85CW5ML1eD29+85tx4YUXotVqYe/evXjZy16GgwcPDi2XcdX73ve+3MDP8KhHPSrnZzcK/wNbPP8Lv/AL+NCHPoSLLroo44+PfOQjA3l85jOfwaWXXopWq4VzzjkH73rXu9yyss/fe9/7XvzkT/4kAODJT35yxrfmcuNx9t13340rr7wSe/fuRavVwsMf/nBcc801uTD27HrjG9+Iq6++OmufSy+9FJ///OdzYb/3ve/h53/+53Haaaeh2Wzi1FNPxY//+I/Haej7CfHN33Hib/7mb3DuuefiMY95zHHF63a7uOKKK/CEJzwBb3zjGzE5OYk0TfFjP/Zj+OQnP4krr7wSl1xyCT760Y/il3/5l/Htb38bb3rTm+51OZ/3vOfh7LPPxutf/3pcf/31+O///b9jz549eMMb3pCFeelLX4o//uM/xgtf+EI87nGPwyc+8Qk84xnPuNd5evjJn/xJnHfeefiv//W/DhBaEXbv3o13vvOdeMUrXoFnP/vZ+Imf+AkA+ankzc1NXHHFFXjMYx6DN77xjfjYxz6G3/u938M555yDV7ziFfeqvN/85jdRrVYxPz+Pu+66C4973OOwurqKq666CktLS7jmmmvwYz/2Y/jTP/1TPPvZzwYAvPvd78ZVV12F5z73ufjFX/xFrK+v40tf+hL++Z//GS984QsBAHfddRce+9jHZiS+e/du/N//+39x5ZVX4siRI3j1q199r8obETGO+MY3vgEAWFpaArDFZddccw2e+9zn4jWveQ3++Z//Ga9//evxta99DX/xF38BYGuw8rSnPQ27d+/Gr/7qr2J+fh7f+ta38Od//ue5tF/2spfhve99L37+538eV111FW699Va87W1vw7/8y7/gs5/9bHBGYHV1FR//+MfxpCc9CWecccbQOhwv/3/mM5/Bn//5n+OVr3wlZmZm8Ja3vAXPec5zcPvtt2dy+PKXv5zV8bWvfS263S5+/dd/HXv37i0sy5Oe9CRcddVVA+477MbDWFtbw+WXX45bbrkFv/ALv4Czzz4bH/zgB/HiF78Yhw4dwi/+4i/mwr///e/H8vIyXvaylyFJEvzO7/wOfuInfgLf/OY3M3k+5znPwVe/+lX8m3/zb3DWWWfh7rvvxv/7f/8Pt99++8A0dMQ2II0YGYcPH04BpM961rMG7h08eDC95557sr/V1dXs3ote9KIUQPqrv/qruTgf+tCHUgDpb/3Wb+WuP/e5z02TJElvueWWNE3T9NZbb00BpO95z3sG8gWQ/vqv/3r2+9d//ddTAOlLXvKSXLhnP/vZ6dLSUvb7C1/4QgogfeUrX5kL98IXvnAgzWH44Ac/mAJIP/nJTw6U4wUveMFA+Msuuyy97LLLBq6/6EUvSs8888zs9z333BMsi8n0N37jN3LXf+AHfiB95CMfObTMl112WfqQhzwka6+vfe1r6VVXXZUCSJ/5zGemaZqmr371q1MA6ac//eks3vLycnr22WenZ511Vrq5uZmmaZr++I//eHrhhRcW5nfllVemp556arpv377c9ec///np3NxcTl8iIiK28J73vCcFkH7sYx9L77nnnvSOO+5IP/CBD6RLS0vpxMREeuedd2Zc9tKXvjQX99/9u3+XAkg/8YlPpGma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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "def plot_generative_sampling(dataset: dlc_torch.PoseDataset) -> None:\n",
+ " # Sample the same image 3 times and plot the results\n",
+ " for _i in range(3):\n",
+ " item = dataset[0]\n",
+ "\n",
+ " # Remove ImageNet normalization from the image so it displays well\n",
+ " mean = np.array([0.485, 0.456, 0.406])\n",
+ " std = np.array([0.229, 0.224, 0.225])\n",
+ " img = item[\"image\"].transpose((1, 2, 0))\n",
+ " img = np.clip(img * std + mean, 0, 1)\n",
+ "\n",
+ " # Get the ground trouth and \"conditional pose\"\n",
+ " gt_pose = item[\"annotations\"][\"keypoints\"][0]\n",
+ " gen_samples = item[\"context\"][\"cond_keypoints\"][0]\n",
+ "\n",
+ " fig, axs = plt.subplots(1, 2, figsize=(8, 4))\n",
+ " for ax in axs:\n",
+ " ax.imshow(img)\n",
+ " ax.axis(\"off\")\n",
+ "\n",
+ " # plot the ground truth on the left and conditions on the right\n",
+ " for ax, title, keypoints in zip(\n",
+ " axs,\n",
+ " [\"Ground Truth Pose\", \"Pose Conditions\"],\n",
+ " [gt_pose, gen_samples],\n",
+ " strict=False,\n",
+ " ):\n",
+ " ax.set_title(title)\n",
+ " for x, y, vis in keypoints:\n",
+ " if vis > 0:\n",
+ " ax.scatter([x], [y])\n",
+ "\n",
+ "\n",
+ "ctd_loader = dlc_torch.DLCLoader(config, shuffle=CTD_SHUFFLE)\n",
+ "\n",
+ "transform = dlc_torch.build_transforms(ctd_loader.model_cfg[\"data\"][\"train\"])\n",
+ "dataset = ctd_loader.create_dataset(transform, mode=\"train\", task=ctd_loader.pose_task)\n",
+ "\n",
+ "# Fix the seeds for reproducibility; you can change the seed from `0` to another value\n",
+ "# to change the results\n",
+ "dlc_torch.fix_seeds(0)\n",
+ "plot_generative_sampling(dataset)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "50d4a150",
+ "metadata": {
+ "id": "50d4a150"
+ },
+ "source": [
+ "The generative sampling can be parameterized through the `pytorch_config.yaml` as well. Let's play around with these parameters a bit and see how that changes the conditions that will be given to the model.\n",
+ "\n",
+ "First, we'll just lower the `keypoint_sigmas`, which impacts how much pose conditions can move during jittering."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "d39ee88c",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 989
+ },
+ "executionInfo": {
+ "elapsed": 1669,
+ "status": "ok",
+ "timestamp": 1744358509345,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "d39ee88c",
+ "outputId": "58637ccc-53b2-42e9-9eff-4235da1e939b"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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LXVM8nv729+eHHvmM58ZNxqN+I477kiOXX/99XG5XI717f6EE06IX/SiFyW/LR4XaM5+61vfGgOI/+7v/i451ul04kc96lHx+Ph4vG/fvjiOV+5dmzdvju+///4k7D/+4z/GAOJPfvKTcRwv3z8BxH/5l3+ZV2WONYQv+64C+/btAwBzi5GLLroIW7duTT7vfOc7M2H0bNSnP/1plMtlXH755anjr3nNaxDHMf7lX/7loG19+ctfnvr9uMc9Drt27UrK8OlPfxoAMnmv9QyUtmOtYZXzxz/+8VBxb7zxxuR6nX766Xj729+Opz/96clS7Kc//WlccMEFyXI1sHztf+u3fgu33347rr/+egDA9PQ07r777swyhiCOY3z0ox/FM57xDMRxjJ07dyafiy++GHv37sW3vvWtgym+wzESeNKTnoStW7fiuOOOwwte8AKMj4/j4x//OH7u534u4bL/9t/+WyrOa17zGgBI3DSmp6cBLK/eLC0tmfl8+MMfxtTUFJ785Cen+um5556L8fFxfPGLXwzaKNxqLfdaWC3/P+lJT8LJJ5+c/H7Ywx6GycnJhO96vR6uueYaPPOZz0zNPJ5++um4+OKLh7JpWHz605/GMccck/K3rFaruPzyyzE7O4t/+7d/S4V//vOfj02bNiW/H/e4xwFAYnuz2UStVsOXvvSloZbXHYcOX/ZdBaRTz87OZs695z3vwf79+3HvvfemHiIQVCoVHHvssaljd9xxBx74wAdmyEKm2u+4446DtlUvO0jH2717NyYnJ3HHHXegVCqlyAQATj311IPO08JJJ520pukxGo1G4hco2LRp09DkceKJJ+K9731vsoXEKaecgm3btiXn77jjDnN5n6/PWWedhf/+3/87Pve5z+GCCy7Agx/8YDzlKU/BJZdcgsc85jEAlp8k3rNnD6666ipcddVVpi333XffUDY7HKOId77znXjIQx6CSqWC7du349RTT00eqhMue/CDH5yKc8wxx2B6ejrh0QsvvBDPfvazceWVV+Itb3kLLrroIjzzmc/EJZdckjz8cPPNN2Pv3r0pHmDk9dPJyUkAwP79+4cq02r531pKZr7bsWMHFhYWcMopp2TCnXrqqckgeS1wxx134JRTTsk82Dis7Xw/ApYfPnnjG9+I17zmNdi+fTse+chH4pd+6Zfwm7/5mzjmmGPWzG7HCnzwtwpMTU3hAQ94gPm0lgwSQs6p9Xp94BPAIejNOQV5DzaUy2XzeLwKv7u1QLPZzByLosi0Y7UPaoTKOCzGxsbwpCc96ZDSAJYJ70c/+hE+9alP4TOf+Qw++tGP4l3vehf+1//6X7jyyiuTfQN//dd/HS960YvMNA51WxyHo8i44IILkqd9QwjxJJ//yEc+gq9//ev45Cc/iWuuuQYveclL8Fd/9Vf4+te/jvHxcfT7fWzbtg0f+MAHzDS02GQ8+MEPRqVSwfe///3BBToIHC2cfjAYxvYrrrgCz3jGM/CJT3wC11xzDf7n//yfeMMb3oAvfOELePjDH36kTB0Z+LLvKvH0pz8dt9xyC77xjW8cclonnHACfvrTn2aU4o033picB1ZU0p49e1LhDmVm8IQTTkC/308cpwU/+tGPDjrNYbFp06ZMWYBseQaR+eHGCSecYNaHvj7A8kDy+c9/Pt73vvfhzjvvxNOf/nT86Z/+KdrtdvI0da/Xw5Oe9CTzE5ppcDgc+RAuu/nmm1PH7733XuzZsyez3+ojH/lI/Omf/imuu+46fOADH8APf/hDfOhDHwIAnHzyydi1axce85jHmP307LPPDtrRarXwxCc+EV/+8pdx1113DWX3MPw/LLZu3Ypms5mpB2A4Xl8N355wwgm4+eabMxviH6ztgpNPPhmvec1r8NnPfhY/+MEP0Ol08Fd/9VcHlZYjHz74WyV+//d/H61WCy95yUvMHeZXo8Ke9rSnodfr4R3veEfq+Fve8hZEUYSnPvWpAJaXE7Zs2YIvf/nLqXDvete7DqIEy5C03/a2t6WOv/Wtbz3oNIfFySefjBtvvDHZTgUAvvvd7+JrX/taKpxs3moNFI8Enva0p+Eb3/gG/uM//iM5Njc3h6uuugonnngizjjjDADArl27UvFqtRrOOOMMxHGMpaUllMtlPPvZz8ZHP/pRc9aY68HhcKwOT3va0wBkuevNb34zACQ7GOzevTvDz+eccw4AJFvCPO95z0Ov18PrX//6TD7dbncgF732ta9FHMf4jd/4DdM96Jvf/GayHcqw/D8syuUyLr74YnziE5/AnXfemRy/4YYbcM011wyML3uwDsO3T3va0/Czn/0M//AP/5Ac63a7ePvb347x8XFceOGFq7J9fn4e7XY7dezkk0/GxMREZrsex9rAl31XiVNOOQUf/OAH8cIXvhCnnnpq8oaPOI5x22234YMf/CBKpVLGv8/CM57xDDzhCU/AH/3RH+H222/H2Wefjc9+9rP4x3/8R1xxxRUpf7yXvvSl+PM//3O89KUvxXnnnYcvf/nLuOmmmw66HOeccw5e+MIX4l3vehf27t2LRz/60fj85z+PW2655aDTHBYveclL8OY3vxkXX3wxLrvsMtx3333467/+a5x55pmJ0zSwvGR8xhln4B/+4R/wkIc8BDMzMzjrrLNw1llnHXYbAeAP/uAP8Pd///d46lOfissvvxwzMzO4+uqrcdttt+GjH/1osoz/lKc8Bccccwwe85jHYPv27bjhhhvwjne8A09/+tMTf54///M/xxe/+EU84hGPwH/5L/8FZ5xxBu6//35861vfwuc+9zncf//9R6RMDkfRcPbZZ+NFL3oRrrrqKuzZswcXXnghvvGNb+Dqq6/GM5/5TDzhCU8AAFx99dV417vehWc961k4+eSTsX//frz3ve/F5ORkMoC88MIL8bKXvQxveMMb8J3vfAdPecpTUK1WcfPNN+PDH/4w/vf//t94znOeE7Tl0Y9+NN75znfila98JU477bTUGz6+9KUv4Z/+6Z/wJ3/yJwBWx//D4sorr8RnPvMZPO5xj8MrX/nKZEB25plnDnzt6DnnnINyuYw3vvGN2Lt3L+r1Op74xCeaqxK/9Vu/hfe85z249NJL8c1vfhMnnngiPvKRj+BrX/sa3vrWtw790Ivgpptuwi/8wi/gec97Hs444wxUKhV8/OMfx7333osXvOAFq0rLMSTW5RnjAuCWW26JX/GKV8QPfvCD40ajETebzfi0006LX/7yl8ff+c53UmFf9KIXxWNjY2Y6+/fvj3/nd34nfuADHxhXq9X4lFNOif/yL/8y7vf7qXDz8/PxZZddFk9NTcUTExPx8573vPi+++4LbvWyY8eOVHzZMuG2225Lji0sLMSXX355vHnz5nhsbCx+xjOeEd91111rutWLtkPwd3/3d/GDHvSguFarxeecc058zTXXZLZ6ieM4/vd///f43HPPjWu1WsquUJ1KvoNw4YUXDtyeJY7j+NZbb42f85znxNPT03Gj0YgvuOCC+FOf+lQqzHve85748Y9/fLx58+a4Xq/HJ598cvx7v/d78d69e1Ph7r333vhVr3pVfNxxx8XVajU+5phj4l/4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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "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+1w84hGPwOc+9zk86UlPOuo6PXrmM5+J008/Hf/9v//3IUAbRdu3b8c73vEOvPSlL8XTnvY0/OIv/iKA/Fby8vIyLrvsMjz0oQ/FG97wBnzmM5/BX/zFX+C0007DS1/60qPi9wc/+AHK5TKmp6dx55134hGPeAQWFxdxxRVXYG5uDldddRWe8pSn4EMf+hCe9rSnAQDe9a534YorrsAznvEM/OZv/iba7Ta+9a1v4ctf/jKe+9znAgDuvPNOPOxhD8tAfPv27fiHf/gHXH755Th48CBe+cpXHhW/kSJtRbrpppsAAHNzcwBWsOyqq67CM57xDLzqVa/Cl7/8Zbzuda/Dddddh49+9KMAViYrT3jCE7B9+3b83u/9Hqanp3HLLbfgIx/5SK7sF7/4xXjPe96DF7zgBbjiiitw8803461vfSu+8Y1v4Itf/GJwR2BxcRGf/exn8TM/8zO4//3vv2YbjhT///Vf/xUf+chH8LKXvQwTExN485vfjKc//em47bbbMjl8+9vfztr4mte8Bv1+H69+9auxc+fOkbz8zM/8DK644oqh8B0O42FaWlrCox/9aNx44414xStegVNPPRUf/OAH8fznPx/79+/Hb/7mb+bSv//978ehQ4fw4he/GEmS4M/+7M/wi7/4i/jBD36QyfPpT386vvvd7+I//sf/iFNOOQV33XUX/vEf/xG33Xbb0DZ0pGNAaaR104EDB1IA6VOf+tShe/v27Ut37dqVfRYXF7N7z3ve81IA6e/93u/l8nzsYx9LAaR//Md/nLv+jGc8I02SJL3xxhvTNE3Tm2++OQWQvvvd7x6qF0D66le/Ovv96le/OgWQvvCFL8yle9rTnpbOzc1lv6+55poUQPqyl70sl+65z33uUJlr0Qc/+MEUQPr5z39+iI/nPOc5Q+kvvfTS9NJLLx26/rznPS89+eSTs9+7du0K8mIy/cM//MPc9Yc85CHphRdeuCbPl156aXrmmWdm/XXdddelV1xxRQogffKTn5ymaZq+8pWvTAGk//Iv/5LlO3ToUHrqqaemp5xySrq8vJymaZr+wi/8QnrOOeeMrO/yyy9Pjz/++HT37t25689+9rPTqampnL5EihRphd797nenANLPfOYz6a5du9Lbb789/cAHPpDOzc2ljUYjveOOOzIse9GLXpTL+9u//dspgPRzn/tcmqZp+tGPfjQFkH71q18N1vcv//IvKYD0fe97X+76Jz/5Sfc60ze/+c0UQPqbv/mb62rbevE/TVdwvlar5a5ZfW95y1uya0996lPTsbGx9NZbb82uXXvttWm5XE51uD/55JPT5z3vedlvD8eNFLPf9KY3pQDS9773vdm1brebPvzhD09brVZ68ODBNE1Xx665ubl07969Wdq/+7u/SwGkf//3f5+m6cr4CSD98z//81Eii3QMKW77HgEdPHgQANwjRh796Edj+/bt2edtb3vbUBpdjfrEJz6BcrmMK664Inf9Va96FdI0xT/8wz8cNa8veclLcr8f9ahHYc+ePVkbPvGJTwDAUN3HegVK+TjW5LXzBz/4wbryXn/99Vl/nXXWWXjLW96CJz3pSdlW7Cc+8Qlccskl2XY1sNL3v/Ebv4FbbrkF1157LQBgenoad9xxx9A2hlGapvjwhz+MJz/5yUjTFLt3784+l112GQ4cOICvf/3rR9P8SJG2BD3+8Y/H9u3bcdJJJ+HZz342Wq0WPvrRj+J+97tfhmX/6T/9p1yeV73qVQCQhWlMT08DWNm96fV6bj0f/OAHMTU1hZ/92Z/N2emFF16IVquFz3/+80EeDVu97V6PjhT/H//4x+O0007Lfp933nmYnJzM8G55eRmf+tSn8NSnPjW38njWWWfhsssuWxdP66VPfOITOO6443LxltVqFVdccQXm5+fxT//0T7n0v/RLv4SZmZns96Me9SgAyHhvNBqo1Wr4whe+sK7t9Ugbp7jtewRkRj0/Pz90753vfCcOHTqEO++8M/cQgVGlUsGJJ56Yu3brrbfihBNOGAILW2q/9dZbj5pX3XYww9u3bx8mJydx6623olQq5cAEAM4444yjrtOjU0899ZiWxzQ2NpbFBRrNzMysGzxOOeUUvOtd78qOkDj99NOxY8eO7P6tt97qbu9z/5x77rn4z//5P+Mzn/kMLrnkEjzwgQ/EE57wBDz3uc/FIx/5SAArTxLv378fV155Ja688kqXl7vuumtdPEeKtBXpbW97Gx70oAehUqlg586dOOOMM7KH6gzLHvjAB+byHHfccZiens5w9NJLL8XTn/50vPa1r8Ub3/hGPPrRj8ZTn/pUPPe5z80efrjhhhtw4MCBHA4wjbLTyclJAMChQ4fW1aYjxX9vK5nxbteuXVhaWsLpp58+lO6MM87IJsnHgm699VacfvrpQw82rpd3Ho+AlYdPXv/61+NVr3oVdu7ciYc97GH4+Z//efzar/0ajjvuuGPGd6RVipO/I6CpqSkcf/zx7tNaNkkIBafW6/U1nwAOkR7OaTTqwYZyuexeT48g7u5YUKPRGLqWJInLx5E+qBFq43ppfHwcj3/84zdUBrACeN/73vfw8Y9/HJ/85Cfx4Q9/GG9/+9vx3/7bf8NrX/va7NzAX/mVX8Hznvc8t4yNHosTKdJmpksuuSR72jdEIZzk+x/60IfwpS99CX//93+PT33qU3jhC1+Iv/iLv8CXvvQltFotDAYD7NixA+973/vcMtTZZHrgAx+ISqWCb3/722s36CjovoLpR0Pr4f2Vr3wlnvzkJ+NjH/sYPvWpT+G//tf/ite97nX43Oc+h4c85CH3FKtbhuK27xHSk570JNx44434yle+suGyTj75ZPzoRz8a8hSvv/767D6w6iXt378/l24jK4Mnn3wyBoNBFjht9L3vfe+oy1wvzczMDLUFGG7PWmB+d9PJJ5/sykP7B1iZSP7SL/0S3v3ud+O2227Dk570JPzJn/wJ2u129jT18vIyHv/4x7uf0EpDpEiRRpNh2Q033JC7fuedd2L//v1D560+7GEPw5/8yZ/g6quvxvve9z5897vfxQc+8AEAwGmnnYY9e/bgkY98pGun559/fpCPZrOJxz72sfjnf/5n3H777eviez34v17avn07Go3GkByA9eH6keDtySefjBtuuGHoQPyj5d3otNNOw6te9Sp8+tOfxne+8x10u138xV/8xVGVFWk0xcnfEdLv/u7votls4oUvfKF7wvyReGFPfOITsby8jLe+9a2562984xuRJAl+7ud+DsDKdsK2bdvwz//8z7l0b3/724+iBStkZb/5zW/OXX/Tm9501GWul0477TRcf/312XEqAPDNb34TX/ziF3Pp7PBWb6J4T9ATn/hEfOUrX8G///u/Z9cWFhZw5ZVX4pRTTsHZZ58NANizZ08uX61Ww9lnn400TdHr9VAul/H0pz8dH/7wh91VY5ZDpEiRjoye+MQnAhjGrr/8y78EgOwEg3379g3h8wUXXAAA2ZEwz3rWs7C8vIw/+qM/Gqqn3++viUWvfvWrkaYpfvVXf9UND/ra176WHYeyXvxfL5XLZVx22WX42Mc+httuuy27ft11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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "ctd_loader = dlc_torch.DLCLoader(config, shuffle=CTD_SHUFFLE)\n",
+ "\n",
+ "# We'll edit the model config here directly; In practice, edit the pytorch_config file instead.\n",
+ "# The parameters that can be set here are the parameters of the `dlc_torch.GenSamplingConfig`\n",
+ "ctd_loader.model_cfg[\"data\"][\"gen_sampling\"] = {\n",
+ " # lower the keypoint sigma by a factor of 2 (default: 0.1)\n",
+ " # -> this changes by how much keypoints are jittered; the smaller\n",
+ " # the value, the smaller the jitter\n",
+ " \"keypoint_sigmas\": 0.05,\n",
+ "}\n",
+ "\n",
+ "transform = dlc_torch.build_transforms(ctd_loader.model_cfg[\"data\"][\"train\"])\n",
+ "dataset = ctd_loader.create_dataset(transform, mode=\"train\", task=ctd_loader.pose_task)\n",
+ "\n",
+ "# Fix the seeds for reproducibility; you can change the seed from `0` to another value\n",
+ "# to change the results\n",
+ "dlc_torch.fix_seeds(0)\n",
+ "plot_generative_sampling(dataset)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "055dea55",
+ "metadata": {
+ "id": "055dea55"
+ },
+ "source": [
+ "Next, we'll update the probabilities of make errors. You can edit these values yourself to see how it impacts the generative sampling. Note that these probabilities are **not absolute** - as a single type of error is applied to each keypoint, changing the probability of one type of error happening will change the probability that other types of errors occur."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "fa509d65",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 989
+ },
+ "executionInfo": {
+ "elapsed": 1638,
+ "status": "ok",
+ "timestamp": 1744358513089,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "fa509d65",
+ "outputId": "d20a3ab8-6a7c-4f5f-8212-a47bfea0c3d6"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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+/D6178+C/PiF78Yf/7nf47nPe95ePjDH45//Md/xFOf+tSTztOTZz/72Tj//PPxX/7LfxkBWpHs3bsXb3/72/Gyl70Mz3jGM/ATP/ETAPJTyYPBAE9+8pPxkIc8BH/4h3+Ij33sY/ijP/ojnHfeeXjZy152Uvp+73vfQ7VaxeLiIm6++WY8/OEPx9raGi677DLs3r0bl19+OX7sx34M73vf+/CMZzwDAPCnf/qnuOyyy/CsZz0Lv/Irv4L19XV89atfxec//3k873nPAwDcfPPNeOhDH5pBfO/evfi///f/4kUvehGOHTuGV77ylSelb5QoO1GuvvpqAMDu3bsBbLDs8ssvx7Oe9Sy86lWvwuc//3m87nWvw7e+9S28//3vB7AxWHnSk56EvXv34jd+4zewuLiI73//+/ibv/mbXNoveclL8O53vxs///M/j8suuwzXXHMN3vKWt+Bf//Vf8dnPfjY4I7C2toaPf/zjePSjH4273e1uY8twovz/zGc+g7/5m7/By1/+cszNzeFNb3oTnvnMZ+K6667L7PC1r30tK+NrXvMa9Pt9vPrVr8b+/fsLdXn0ox+Nyy67bGT5Di/jYWm323jMYx6Dq666Cr/0S7+Ec889F+9973vxghe8AEeOHMGv/Mqv5MK/5z3vwfLyMl7ykpcgSRL81//6X/ETP/ET+N73vpfZ85nPfCa+8Y1v4Jd/+Zdxzjnn4JZbbsE//MM/4LrrrhuZho6yBZJGmViOHj2aAkif/vSnj5w7fPhwevDgwexvbW0tO/f85z8/BZD+xm/8Ri7OBz7wgRRA+ru/+7u548961rPSJEnSq666Kk3TNL3mmmtSAOm73vWukXwBpK9+9auz369+9atTAOkLX/jCXLhnPOMZ6e7du7PfX/nKV1IA6ctf/vJcuOc973kjaY6T9773vSmA9BOf+MSIHs997nNHwl966aXppZdeOnL8+c9/fnr22Wdnvw8ePBjUxWz627/927nj97///dMHPOABY3W+9NJL0wsuuCCrr29961vpZZddlgJIn/a0p6VpmqavfOUrUwDppz/96Sze8vJyeu6556bnnHNOOhgM0jRN0x//8R9P73Of+xTm96IXvSg944wz0kOHDuWO/9RP/VS6sLCQay9RokTZkHe9610pgPRjH/tYevDgwfT6669P/+qv/irdvXt3Oj09nf7g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P+suD/rSl76UbYcyKf8nlWq1iic/+cn4wAc+gOuuuy47/q1vfQsf+chHxsa3PVgn4e1TnvIU3HTTTfhf/+t/Zcf6/T7e/OY3Y3Z2FpdeeukJ6b62tob19fXcsfPOOw9zc3Mj2/VE2RqJ074nKOeffz7e85734LnPfS7uda97ZW/4SNMU11xzDd7znvegUqmMrO/z5GlPexoe+9jH4j/9p/+E73//+7jf/e6Hj370o/jbv/1bvPKVr8ytx3vxi1+M3//938eLX/xiPPCBD8SnPvUpfOc73znpclxyySV47nOfi7e97W04evQoHv7wh+PjH/84rrrqqpNOc1J54QtfiDe84Q148pOfjBe96EW45ZZb8Cd/8ie4z33uky2aBjamjC+88EL8r//1v3DPe94Tu3btwkUXXYSLLrrolOsIAL/xG7+Bv/zLv8SP/MiP4LLLLsOuXbtw+eWX45prrsFf//VfZ9P4T3rSk3DgwAE84hGPwP79+/Gtb30Lb3nLW/DUpz41W8/z+7//+/jEJz6BhzzkIfiFX/gFXHjhhbjtttvw5S9/GR/72Mdw22233SFlihJlu8n97nc/PP/5z8c73/lOHDlyBJdeeim+8IUv4PLLL8fTn/50PPaxjwUAXH755Xjb296GZzzjGTjvvPOwvLyMP/3TP8X8/Hw2gLz00kvxkpe8BK973evwla98BU960pNQr9fx3e9+F+9973vx3/7bf8OznvWsoC4Pf/jD8da3vhUvf/nLccEFF+Te8PHJT34Sf/d3f4ff/d3fBXBi/J9UXvva1+LDH/4wHvWoR+HlL395NiC7z33uM/a1o5dccgmq1Spe//rX4+jRo5iamsLjHvc4d1biF3/xF/GOd7wDL3jBC/ClL30J55xzDt73vvfhs5/9LN74xjdO/NCLyXe+8x08/vGPx3Oe8xxceOGFqNVqeP/734+bb74ZP/VTP3VCaUWZUO6UZ4y3gVx11VXpy172svQe97hH2mw20+np6fSCCy5IX/rSl6Zf+cpXcmGf//znpzMzM246y8vL6a/+6q+mZ555Zlqv19Pzzz8//YM/+IN0OBzmwq2traUvetGL0oWFhXRubi59znOek95yyy3BrV4OHjyYi29bJlxzzTXZsXa7nV522WXp7t2705mZmfRpT3taev3112/pVi+qh8mf//mfp3e/+93TRqORXnLJ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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "image/png": "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38sFF1zAf/kv/+W4QPt8Tj/9dH7913+dH/7hH+bhD3843/M93wMcO5RcSuHiiy/mPve5Dy94wQt461vfyi//8i9z/vnn88M//MMn1d6PfvSjOOfYt28fV111Ffe73/1YLpdceumlHDx4kFe96lV813d9F7/3e7/Hwx/+cABe/vKXc+mll/LIRz6SH/mRH6HrOv7xH/+Rv/mbv+Gxj30sAFdddRX3ve99j4b46aefzh//8R9zySWXsL29zTOf+cyTau9odCr6yEc+AsDBgweBdZa96lWv4pGPfCTPetaz+Ju/+Rue97zn8b73vY/Xv/71wLqz8qAHPYjTTz+dn/zJn2Tfvn1cccUV/P7v//4xj/2UpzyFV77ylTzxiU/k0ksv5fLLL+clL3kJ//f//l/++q//+mZHBJbLJW9729v45m/+Zm5729t+wddwS/P/r/7qr/j93/99nva0p7GxscGLXvQiHvGIR/Cxj33s6Hn4p3/6p6Ov8TnPeQ45Z5797GdzxhlnfN62fPM3fzOXXnrpcdN3Pnsaz2dbrVY84AEP4MMf/jDPeMYzOO+883jta1/LE57wBA4fPsyP/MiPHHP717zmNezs7PCUpzwFEeEXf/EX+Z7v+R4++tGPHj2fj3jEI/jnf/5n/t2/+3ece+65XH311fyv//W/+NjHPnbcMPToi8BGJ+zIkSMG2MMe9rDjfnbo0CG75pprjn4tl8ujP3v84x9vgP3kT/7kMfd5wxveYID93M/93DHHH/nIR5qI2Ic//GEzM7v88ssNsFe84hXHPS9gz372s49+/+xnP9sAe9KTnnTM7R7+8IfbwYMHj37/7ne/2wB72tOedsztHvvYxx73mF/Ia1/7WgPs7W9/+3HteMxjHnPc7S+66CK76KKLjjv++Mc/3s4555yj319zzTU325Ybz+nP/MzPHHP867/+6+0e97jHF2zzRRddZHe4wx2O/r7e97732aWXXmqAPfShDzUzs2c+85kG2F/+5V8evd/Ozo6dd955du6551opxczMvvu7v9vufOc7f97nu+SSS+yss86ya6+99pjjj370o21ra+uYv5fRaLT2ile8wgB761vfatdcc419/OMft9/5nd+xgwcP2mQysU984hNHs+zJT37yMff9sR/7MQPsT//0T83M7PWvf70B9nd/93c3+3x/+Zd/aYC9+tWvPub4m9/85ps8/tn+4R/+wQD7kR/5kRN6bSea/2brnI8xHnPsxud78YtffPTYwx72MGvb1q688sqjx9773veac84+99/9OeecY49//OOPfn9TOX6jz83sF77whQbYb//2bx89NgyDfcM3fIPN53Pb3t42s3/533Xw4EG7/vrrj972D/7gDwywP/zDPzSz9f9PwH7pl37p852y0RfROOx7C2xvbwPcZImRBzzgAZx++ulHv37t137tuNt87qdRb3rTm3DOcemllx5z/FnPehZmxh//8R+fdFuf+tSnHvP9N33TN3HdddcdfQ1vetObAI577i/2J1Cf244vtpt6nR/96EdP6L7vf//7j/6+7njHO/LiF7+YhzzkIUeHYt/0pjdx73vf++hwNax/9z/0Qz/EFVdcwXvf+14A9u3bxyc+8YnjhjFuZGa87nWv46EPfShmxrXXXnv06+KLL+bIkSO8613vOpmXPxqdEh74wAdy+umnc/bZZ/PoRz+a+XzO61//em5961sfzbJ//+///TH3edazngVwdJrGvn37gPXoTUrpJp/nta99LVtbW3z7t3/7Me/Te9zjHsznc97+9rffbBtvzNabGu69Kbc0/x/4wAdy/vnnH/3+rne9K5ubm0fzrpTCW97yFh72sIcd88njHe94Ry6++OITatOJetOb3sSZZ555zHzLEAKXXnopu7u7/Pmf//kxt/++7/s+9u/ff/T7b/qmbwI42vbJZEKMkT/7sz87oeH10b/eOOx7C9z4pt7d3T3uZy972cvY2dnhqquuOmYRwY2899zmNrc55tiVV17J13zN1xwXFjd+1H7llVeedFs/d9jhxjfeoUOH2Nzc5Morr0RVjwkTgAsvvPCkn/OmnHfeeV/Ux/tsbdsenRd4o/37959weJx77rm8/OUvP1pC4oILLuBWt7rV0Z9feeWVNzm8/9m/n7vc5S78h//wH3jrW9/Kve99b253u9vxoAc9iMc+9rHc//73B9YriQ8fPsxll13GZZdddpNtufrqq0+ozaPRqejXfu3XuP3tb4/3njPOOIMLL7zw6KK6G7Psdre73TH3OfPMM9m3b9/RHL3ooot4xCMewXOf+1x+9Vd/lQc84AE87GEP47GPfezRxQ8f+tCHOHLkyDE58Nk+3/t0c3MTgJ2dnRN6Tbc0/29qKPmz8+6aa65htVpxwQUXHHe7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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "ctd_loader = dlc_torch.DLCLoader(config, shuffle=CTD_SHUFFLE)\n",
+ "\n",
+ "# We'll edit the model config here directly; In practice, edit the pytorch_config file instead.\n",
+ "# The parameters that can be set here are the parameters of the `dlc_torch.GenSamplingConfig`\n",
+ "ctd_loader.model_cfg[\"data\"][\"gen_sampling\"] = {\n",
+ " \"jitter_prob\": 0.5,\n",
+ " \"swap_prob\": 0.1,\n",
+ " \"inv_prob\": 0.1,\n",
+ " \"miss_prob\": 0.25,\n",
+ "}\n",
+ "\n",
+ "transform = dlc_torch.build_transforms(ctd_loader.model_cfg[\"data\"][\"train\"])\n",
+ "dataset = ctd_loader.create_dataset(transform, mode=\"train\", task=ctd_loader.pose_task)\n",
+ "\n",
+ "# Fix the seeds for reproducibility; you can change the seed from `0` to another value\n",
+ "# to change the results\n",
+ "dlc_torch.fix_seeds(0)\n",
+ "plot_generative_sampling(dataset)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d46ecdc8",
+ "metadata": {
+ "id": "d46ecdc8"
+ },
+ "source": [
+ "#### Training and Evaluating the CTD Model\n",
+ "\n",
+ "Next, we can simply train the CTD model. It should take **20 to 60 minutes** to train the model to 150 epochs on a GPU, depending on the performance of the machine you're on.\n",
+ "\n",
+ "If you think your model has converged before the end of training, you can always interrupt the execution of the cell using the \"Stop\" button, as I did here after 150 epochs. The best-performing model up to that point should be saved.\n",
+ "\n",
+ "You'll notice that in the logs for the bottom-up model above, it's printed `using 78 images and 34 for testing` while now it's showing `using 234 images and 102 for testing`. This is because CTD models (and top-down models) perform pose estimation on each mouse indenpendently! As their are 3 mice per image, each ground-truth image creates 3 examples the model can use for training. Checkout the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/architectures.html#information-on-multi-animal-models) for more information on different approaches to pose estimation!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "7427576f",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000,
+ "referenced_widgets": [
+ "c95c172c3f4c468c8d3c4ed859405670",
+ "9ce048962ea4456ab5201da2ec611028",
+ "50677fd71424491f9f963efb76526763",
+ "e394660e6bf2425d81dde09c75367caa",
+ "d9204c9f72ce4e729ba5ff2168cc787e",
+ "d8c442f4d8ab4484b2a91accd2fdcd9d",
+ "60c578d6f09e48b3939ac559a5305fde",
+ "f6f2745f152341fbb8f909ff9c159d61",
+ "cd18393881074e36850cb8cb4ee96e56",
+ "ef61bcc8f6124b6c9681bd3c29b1bcac",
+ "ac9e6c7e5a55470b87443a9d1f290e20"
+ ]
+ },
+ "executionInfo": {
+ "elapsed": 3371917,
+ "status": "error",
+ "timestamp": 1744361892651,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "7427576f",
+ "outputId": "2b50e01f-1bbd-48e3-f110-06f1b4f5fd70"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Training with configuration:\n",
+ "data:\n",
+ " bbox_margin: 25\n",
+ " colormode: RGB\n",
+ " inference:\n",
+ " normalize_images: True\n",
+ " top_down_crop:\n",
+ " width: 256\n",
+ " height: 256\n",
+ " crop_with_context: False\n",
+ " train:\n",
+ " affine:\n",
+ " p: 0.5\n",
+ " rotation: 30\n",
+ " scaling: [1.0, 1.0]\n",
+ " translation: 0\n",
+ " gaussian_noise: 12.75\n",
+ " motion_blur: True\n",
+ " normalize_images: True\n",
+ " top_down_crop:\n",
+ " width: 256\n",
+ " height: 256\n",
+ " crop_with_context: False\n",
+ " conditions:\n",
+ " shuffle: 1\n",
+ " snapshot_index: -1\n",
+ " gen_sampling:\n",
+ " keypoint_sigmas: 0.1\n",
+ "device: auto\n",
+ "metadata:\n",
+ " project_path: /content/trimice-dlc-2021-06-22\n",
+ " pose_config_path: /content/trimice-dlc-2021-06-22/dlc-models-pytorch/iteration-0/trimiceJun22-trainset70shuffle2/train/pytorch_config.yaml\n",
+ " bodyparts: ['snout', 'leftear', 'rightear', 'shoulder', 'spine1', 'spine2', 'spine3', 'spine4', 'tailbase', 'tail1', 'tail2', 'tailend']\n",
+ " unique_bodyparts: []\n",
+ " individuals: ['mus1', 'mus2', 'mus3']\n",
+ " with_identity: None\n",
+ "method: ctd\n",
+ "model:\n",
+ " backbone:\n",
+ " type: CondPreNet\n",
+ " backbone:\n",
+ " type: CSPNeXt\n",
+ " model_name: cspnext_m\n",
+ " freeze_bn_stats: False\n",
+ " freeze_bn_weights: False\n",
+ " deepen_factor: 0.67\n",
+ " widen_factor: 0.75\n",
+ " kpt_encoder:\n",
+ " type: ColoredKeypointEncoder\n",
+ " num_joints: 12\n",
+ " kernel_size: [15, 15]\n",
+ " img_size: [256, 256]\n",
+ " backbone_output_channels: 768\n",
+ " heads:\n",
+ " bodypart:\n",
+ " type: HeatmapHead\n",
+ " weight_init: normal\n",
+ " predictor:\n",
+ " type: HeatmapPredictor\n",
+ " apply_sigmoid: False\n",
+ " clip_scores: True\n",
+ " location_refinement: True\n",
+ " locref_std: 7.2801\n",
+ " target_generator:\n",
+ " type: HeatmapGaussianGenerator\n",
+ " num_heatmaps: 12\n",
+ " pos_dist_thresh: 17\n",
+ " heatmap_mode: KEYPOINT\n",
+ " generate_locref: True\n",
+ " locref_std: 7.2801\n",
+ " criterion:\n",
+ " heatmap:\n",
+ " type: WeightedMSECriterion\n",
+ " weight: 1.0\n",
+ " locref:\n",
+ " type: WeightedHuberCriterion\n",
+ " weight: 0.05\n",
+ " heatmap_config:\n",
+ " channels: [768, 12]\n",
+ " kernel_size: [3]\n",
+ " strides: [2]\n",
+ " locref_config:\n",
+ " channels: [768, 24]\n",
+ " kernel_size: [3]\n",
+ " strides: [2]\n",
+ "net_type: ctd_prenet_cspnext_m\n",
+ "runner:\n",
+ " type: PoseTrainingRunner\n",
+ " gpus: None\n",
+ " key_metric: test.mAP\n",
+ " key_metric_asc: True\n",
+ " eval_interval: 10\n",
+ " optimizer:\n",
+ " type: AdamW\n",
+ " params:\n",
+ " lr: 1e-05\n",
+ " scheduler:\n",
+ " type: LRListScheduler\n",
+ " params:\n",
+ " lr_list: [[0.0005], [0.0001], [1e-05]]\n",
+ " milestones: [5, 90, 120]\n",
+ " snapshots:\n",
+ " max_snapshots: 5\n",
+ " save_epochs: 25\n",
+ " save_optimizer_state: False\n",
+ "train_settings:\n",
+ " batch_size: 8\n",
+ " dataloader_workers: 0\n",
+ " dataloader_pin_memory: False\n",
+ " display_iters: 500\n",
+ " epochs: 200\n",
+ " seed: 42\n",
+ "Downloading the pre-trained backbone to /usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/models/backbones/pretrained_weights/cspnext_m.pt\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c95c172c3f4c468c8d3c4ed859405670",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "cspnext_m.pt: 0%| | 0.00/49.3M [00:00, ?B/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Data Transforms:\n",
+ " Training: Compose([\n",
+ " Affine(always_apply=False, p=0.5, interpolation=1, mask_interpolation=0, cval=0, mode=0, scale={'x': (1.0, 1.0), 'y': (1.0, 1.0)}, translate_percent=None, translate_px={'x': (0, 0), 'y': (0, 0)}, rotate=(-30, 30), fit_output=False, shear={'x': (0.0, 0.0), 'y': (0.0, 0.0)}, cval_mask=0, keep_ratio=True, rotate_method='largest_box'),\n",
+ " MotionBlur(always_apply=False, p=0.5, blur_limit=(3, 7), allow_shifted=True),\n",
+ " GaussNoise(always_apply=False, p=0.5, var_limit=(0, 162.5625), per_channel=True, mean=0),\n",
+ " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n",
+ "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n",
+ " Validation: Compose([\n",
+ " Normalize(always_apply=False, p=1.0, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225], max_pixel_value=255.0),\n",
+ "], p=1.0, bbox_params={'format': 'coco', 'label_fields': ['bbox_labels'], 'min_area': 0.0, 'min_visibility': 0.0, 'min_width': 0.0, 'min_height': 0.0, 'check_each_transform': True}, keypoint_params={'format': 'xy', 'label_fields': ['class_labels'], 'remove_invisible': False, 'angle_in_degrees': True, 'check_each_transform': True}, additional_targets={}, is_check_shapes=True)\n",
+ "Using 234 images and 102 for testing\n",
+ "\n",
+ "Starting pose model training...\n",
+ "--------------------------------------------------\n",
+ "Epoch 1/200 (lr=1e-05), train loss 0.01711\n",
+ "Epoch 2/200 (lr=1e-05), train loss 0.01699\n",
+ "Epoch 3/200 (lr=1e-05), train loss 0.01687\n",
+ "Epoch 4/200 (lr=1e-05), train loss 0.01690\n",
+ "Epoch 5/200 (lr=0.0005), train loss 0.01683\n",
+ "Epoch 6/200 (lr=0.0005), train loss 0.01477\n",
+ "Epoch 7/200 (lr=0.0005), train loss 0.01076\n",
+ "Epoch 8/200 (lr=0.0005), train loss 0.00796\n",
+ "Epoch 9/200 (lr=0.0005), train loss 0.00670\n",
+ "Training for epoch 10 done, starting evaluation\n",
+ "Epoch 10/200 (lr=0.0005), train loss 0.00602, valid loss 0.00515\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 7.56\n",
+ " metrics/test.rmse_pcutoff: 5.56\n",
+ " metrics/test.mAP: 90.48\n",
+ " metrics/test.mAR: 92.35\n",
+ "Epoch 11/200 (lr=0.0005), train loss 0.00472\n",
+ "Epoch 12/200 (lr=0.0005), train loss 0.00448\n",
+ "Epoch 13/200 (lr=0.0005), train loss 0.00435\n",
+ "Epoch 14/200 (lr=0.0005), train loss 0.00387\n",
+ "Epoch 15/200 (lr=0.0005), train loss 0.00341\n",
+ "Epoch 16/200 (lr=0.0005), train loss 0.00344\n",
+ "Epoch 17/200 (lr=0.0005), train loss 0.00309\n",
+ "Epoch 18/200 (lr=0.0005), train loss 0.00310\n",
+ "Epoch 19/200 (lr=0.0005), train loss 0.00308\n",
+ "Training for epoch 20 done, starting evaluation\n",
+ "Epoch 20/200 (lr=0.0005), train loss 0.00305, valid loss 0.00318\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 6.61\n",
+ " metrics/test.rmse_pcutoff: 5.61\n",
+ " metrics/test.mAP: 94.12\n",
+ " metrics/test.mAR: 95.00\n",
+ "Epoch 21/200 (lr=0.0005), train loss 0.00273\n",
+ "Epoch 22/200 (lr=0.0005), train loss 0.00267\n",
+ "Epoch 23/200 (lr=0.0005), train loss 0.00256\n",
+ "Epoch 24/200 (lr=0.0005), train loss 0.00254\n",
+ "Epoch 25/200 (lr=0.0005), train loss 0.00241\n",
+ "Epoch 26/200 (lr=0.0005), train loss 0.00247\n",
+ "Epoch 27/200 (lr=0.0005), train loss 0.00246\n",
+ "Epoch 28/200 (lr=0.0005), train loss 0.00233\n",
+ "Epoch 29/200 (lr=0.0005), train loss 0.00234\n",
+ "Training for epoch 30 done, starting evaluation\n",
+ "Epoch 30/200 (lr=0.0005), train loss 0.00222, valid loss 0.00280\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 5.40\n",
+ " metrics/test.rmse_pcutoff: 4.04\n",
+ " metrics/test.mAP: 95.15\n",
+ " metrics/test.mAR: 96.18\n",
+ "Epoch 31/200 (lr=0.0005), train loss 0.00223\n",
+ "Epoch 32/200 (lr=0.0005), train loss 0.00239\n",
+ "Epoch 33/200 (lr=0.0005), train loss 0.00211\n",
+ "Epoch 34/200 (lr=0.0005), train loss 0.00193\n",
+ "Epoch 35/200 (lr=0.0005), train loss 0.00210\n",
+ "Epoch 36/200 (lr=0.0005), train loss 0.00204\n",
+ "Epoch 37/200 (lr=0.0005), train loss 0.00201\n",
+ "Epoch 38/200 (lr=0.0005), train loss 0.00186\n",
+ "Epoch 39/200 (lr=0.0005), train loss 0.00197\n",
+ "Training for epoch 40 done, starting evaluation\n",
+ "Epoch 40/200 (lr=0.0005), train loss 0.00195, valid loss 0.00255\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 4.46\n",
+ " metrics/test.rmse_pcutoff: 3.74\n",
+ " metrics/test.mAP: 97.18\n",
+ " metrics/test.mAR: 97.84\n",
+ "Epoch 41/200 (lr=0.0005), train loss 0.00188\n",
+ "Epoch 42/200 (lr=0.0005), train loss 0.00198\n",
+ "Epoch 43/200 (lr=0.0005), train loss 0.00192\n",
+ "Epoch 44/200 (lr=0.0005), train loss 0.00186\n",
+ "Epoch 45/200 (lr=0.0005), train loss 0.00188\n",
+ "Epoch 46/200 (lr=0.0005), train loss 0.00178\n",
+ "Epoch 47/200 (lr=0.0005), train loss 0.00180\n",
+ "Epoch 48/200 (lr=0.0005), train loss 0.00186\n",
+ "Epoch 49/200 (lr=0.0005), train loss 0.00171\n",
+ "Training for epoch 50 done, starting evaluation\n",
+ "Epoch 50/200 (lr=0.0005), train loss 0.00183, valid loss 0.00262\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 5.46\n",
+ " metrics/test.rmse_pcutoff: 3.90\n",
+ " metrics/test.mAP: 95.49\n",
+ " metrics/test.mAR: 95.78\n",
+ "Epoch 51/200 (lr=0.0005), train loss 0.00191\n",
+ "Epoch 52/200 (lr=0.0005), train loss 0.00198\n",
+ "Epoch 53/200 (lr=0.0005), train loss 0.00173\n",
+ "Epoch 54/200 (lr=0.0005), train loss 0.00179\n",
+ "Epoch 55/200 (lr=0.0005), train loss 0.00181\n",
+ "Epoch 56/200 (lr=0.0005), train loss 0.00187\n",
+ "Epoch 57/200 (lr=0.0005), train loss 0.00162\n",
+ "Epoch 58/200 (lr=0.0005), train loss 0.00156\n",
+ "Epoch 59/200 (lr=0.0005), train loss 0.00154\n",
+ "Training for epoch 60 done, starting evaluation\n",
+ "Epoch 60/200 (lr=0.0005), train loss 0.00152, valid loss 0.00216\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 4.11\n",
+ " metrics/test.rmse_pcutoff: 3.40\n",
+ " metrics/test.mAP: 97.45\n",
+ " metrics/test.mAR: 97.65\n",
+ "Epoch 61/200 (lr=0.0005), train loss 0.00151\n",
+ "Epoch 62/200 (lr=0.0005), train loss 0.00156\n",
+ "Epoch 63/200 (lr=0.0005), train loss 0.00143\n",
+ "Epoch 64/200 (lr=0.0005), train loss 0.00155\n",
+ "Epoch 65/200 (lr=0.0005), train loss 0.00149\n",
+ "Epoch 66/200 (lr=0.0005), train loss 0.00149\n",
+ "Epoch 67/200 (lr=0.0005), train loss 0.00148\n",
+ "Epoch 68/200 (lr=0.0005), train loss 0.00147\n",
+ "Epoch 69/200 (lr=0.0005), train loss 0.00160\n",
+ "Training for epoch 70 done, starting evaluation\n",
+ "Epoch 70/200 (lr=0.0005), train loss 0.00159, valid loss 0.00240\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 4.21\n",
+ " metrics/test.rmse_pcutoff: 3.46\n",
+ " metrics/test.mAP: 97.49\n",
+ " metrics/test.mAR: 97.65\n",
+ "Epoch 71/200 (lr=0.0005), train loss 0.00186\n",
+ "Epoch 72/200 (lr=0.0005), train loss 0.00187\n",
+ "Epoch 73/200 (lr=0.0005), train loss 0.00158\n",
+ "Epoch 74/200 (lr=0.0005), train loss 0.00161\n",
+ "Epoch 75/200 (lr=0.0005), train loss 0.00146\n",
+ "Epoch 76/200 (lr=0.0005), train loss 0.00145\n",
+ "Epoch 77/200 (lr=0.0005), train loss 0.00145\n",
+ "Epoch 78/200 (lr=0.0005), train loss 0.00144\n",
+ "Epoch 79/200 (lr=0.0005), train loss 0.00154\n",
+ "Training for epoch 80 done, starting evaluation\n",
+ "Epoch 80/200 (lr=0.0005), train loss 0.00154, valid loss 0.00225\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 4.01\n",
+ " metrics/test.rmse_pcutoff: 3.53\n",
+ " metrics/test.mAP: 96.84\n",
+ " metrics/test.mAR: 97.35\n",
+ "Epoch 81/200 (lr=0.0005), train loss 0.00154\n",
+ "Epoch 82/200 (lr=0.0005), train loss 0.00144\n",
+ "Epoch 83/200 (lr=0.0005), train loss 0.00138\n",
+ "Epoch 84/200 (lr=0.0005), train loss 0.00131\n",
+ "Epoch 85/200 (lr=0.0005), train loss 0.00143\n",
+ "Epoch 86/200 (lr=0.0005), train loss 0.00140\n",
+ "Epoch 87/200 (lr=0.0005), train loss 0.00142\n",
+ "Epoch 88/200 (lr=0.0005), train loss 0.00148\n",
+ "Epoch 89/200 (lr=0.0005), train loss 0.00139\n",
+ "Training for epoch 90 done, starting evaluation\n",
+ "Epoch 90/200 (lr=0.0001), train loss 0.00137, valid loss 0.00210\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.86\n",
+ " metrics/test.rmse_pcutoff: 3.39\n",
+ " metrics/test.mAP: 98.17\n",
+ " metrics/test.mAR: 98.33\n",
+ "Epoch 91/200 (lr=0.0001), train loss 0.00132\n",
+ "Epoch 92/200 (lr=0.0001), train loss 0.00114\n",
+ "Epoch 93/200 (lr=0.0001), train loss 0.00105\n",
+ "Epoch 94/200 (lr=0.0001), train loss 0.00102\n",
+ "Epoch 95/200 (lr=0.0001), train loss 0.00107\n",
+ "Epoch 96/200 (lr=0.0001), train loss 0.00102\n",
+ "Epoch 97/200 (lr=0.0001), train loss 0.00103\n",
+ "Epoch 98/200 (lr=0.0001), train loss 0.00104\n",
+ "Epoch 99/200 (lr=0.0001), train loss 0.00109\n",
+ "Training for epoch 100 done, starting evaluation\n",
+ "Epoch 100/200 (lr=0.0001), train loss 0.00101, valid loss 0.00182\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.91\n",
+ " metrics/test.rmse_pcutoff: 3.06\n",
+ " metrics/test.mAP: 97.78\n",
+ " metrics/test.mAR: 97.94\n",
+ "Epoch 101/200 (lr=0.0001), train loss 0.00105\n",
+ "Epoch 102/200 (lr=0.0001), train loss 0.00098\n",
+ "Epoch 103/200 (lr=0.0001), train loss 0.00101\n",
+ "Epoch 104/200 (lr=0.0001), train loss 0.00093\n",
+ "Epoch 105/200 (lr=0.0001), train loss 0.00102\n",
+ "Epoch 106/200 (lr=0.0001), train loss 0.00093\n",
+ "Epoch 107/200 (lr=0.0001), train loss 0.00104\n",
+ "Epoch 108/200 (lr=0.0001), train loss 0.00094\n",
+ "Epoch 109/200 (lr=0.0001), train loss 0.00094\n",
+ "Training for epoch 110 done, starting evaluation\n",
+ "Epoch 110/200 (lr=0.0001), train loss 0.00096, valid loss 0.00184\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.62\n",
+ " metrics/test.rmse_pcutoff: 3.03\n",
+ " metrics/test.mAP: 98.36\n",
+ " metrics/test.mAR: 98.43\n",
+ "Epoch 111/200 (lr=0.0001), train loss 0.00096\n",
+ "Epoch 112/200 (lr=0.0001), train loss 0.00105\n",
+ "Epoch 113/200 (lr=0.0001), train loss 0.00092\n",
+ "Epoch 114/200 (lr=0.0001), train loss 0.00098\n",
+ "Epoch 115/200 (lr=0.0001), train loss 0.00098\n",
+ "Epoch 116/200 (lr=0.0001), train loss 0.00092\n",
+ "Epoch 117/200 (lr=0.0001), train loss 0.00088\n",
+ "Epoch 118/200 (lr=0.0001), train loss 0.00092\n",
+ "Epoch 119/200 (lr=0.0001), train loss 0.00085\n",
+ "Training for epoch 120 done, starting evaluation\n",
+ "Epoch 120/200 (lr=1e-05), train loss 0.00086, valid loss 0.00177\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.85\n",
+ " metrics/test.rmse_pcutoff: 3.37\n",
+ " metrics/test.mAP: 97.23\n",
+ " metrics/test.mAR: 97.94\n",
+ "Epoch 121/200 (lr=1e-05), train loss 0.00087\n",
+ "Epoch 122/200 (lr=1e-05), train loss 0.00092\n",
+ "Epoch 123/200 (lr=1e-05), train loss 0.00084\n",
+ "Epoch 124/200 (lr=1e-05), train loss 0.00082\n",
+ "Epoch 125/200 (lr=1e-05), train loss 0.00087\n",
+ "Epoch 126/200 (lr=1e-05), train loss 0.00081\n",
+ "Epoch 127/200 (lr=1e-05), train loss 0.00077\n",
+ "Epoch 128/200 (lr=1e-05), train loss 0.00083\n",
+ "Epoch 129/200 (lr=1e-05), train loss 0.00087\n",
+ "Training for epoch 130 done, starting evaluation\n",
+ "Epoch 130/200 (lr=1e-05), train loss 0.00081, valid loss 0.00165\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.36\n",
+ " metrics/test.rmse_pcutoff: 3.01\n",
+ " metrics/test.mAP: 98.75\n",
+ " metrics/test.mAR: 98.82\n",
+ "Epoch 131/200 (lr=1e-05), train loss 0.00078\n",
+ "Epoch 132/200 (lr=1e-05), train loss 0.00083\n",
+ "Epoch 133/200 (lr=1e-05), train loss 0.00079\n",
+ "Epoch 134/200 (lr=1e-05), train loss 0.00088\n",
+ "Epoch 135/200 (lr=1e-05), train loss 0.00087\n",
+ "Epoch 136/200 (lr=1e-05), train loss 0.00084\n",
+ "Epoch 137/200 (lr=1e-05), train loss 0.00085\n",
+ "Epoch 138/200 (lr=1e-05), train loss 0.00083\n",
+ "Epoch 139/200 (lr=1e-05), train loss 0.00088\n",
+ "Training for epoch 140 done, starting evaluation\n",
+ "Epoch 140/200 (lr=1e-05), train loss 0.00081, valid loss 0.00170\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.40\n",
+ " metrics/test.rmse_pcutoff: 3.04\n",
+ " metrics/test.mAP: 98.34\n",
+ " metrics/test.mAR: 98.43\n",
+ "Epoch 141/200 (lr=1e-05), train loss 0.00084\n",
+ "Epoch 142/200 (lr=1e-05), train loss 0.00081\n",
+ "Epoch 143/200 (lr=1e-05), train loss 0.00085\n",
+ "Epoch 144/200 (lr=1e-05), train loss 0.00085\n",
+ "Epoch 145/200 (lr=1e-05), train loss 0.00083\n",
+ "Epoch 146/200 (lr=1e-05), train loss 0.00089\n",
+ "Epoch 147/200 (lr=1e-05), train loss 0.00075\n",
+ "Epoch 148/200 (lr=1e-05), train loss 0.00079\n",
+ "Epoch 149/200 (lr=1e-05), train loss 0.00079\n",
+ "Training for epoch 150 done, starting evaluation\n",
+ "Epoch 150/200 (lr=1e-05), train loss 0.00084, valid loss 0.00167\n",
+ "Model performance:\n",
+ " metrics/test.rmse: 3.56\n",
+ " metrics/test.rmse_pcutoff: 2.89\n",
+ " metrics/test.mAP: 98.04\n",
+ " metrics/test.mAR: 98.24\n"
+ ]
+ },
+ {
+ "ename": "KeyboardInterrupt",
+ "evalue": "",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mdeeplabcut\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtrain_network\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mconfig\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mshuffle\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mCTD_SHUFFLE\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/compat.py\u001b[0m in \u001b[0;36mtrain_network\u001b[0;34m(config, shuffle, trainingsetindex, max_snapshots_to_keep, displayiters, saveiters, maxiters, epochs, save_epochs, allow_growth, gputouse, autotune, keepdeconvweights, modelprefix, superanimal_name, superanimal_transfer_learning, engine, device, snapshot_path, detector_path, batch_size, detector_batch_size, detector_epochs, detector_save_epochs, pose_threshold, pytorch_cfg_updates)\u001b[0m\n\u001b[1;32m 285\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0mdeeplabcut\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpose_estimation_pytorch\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mapis\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mtrain_network\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 286\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 287\u001b[0;31m return train_network(\n\u001b[0m\u001b[1;32m 288\u001b[0m \u001b[0mconfig\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 289\u001b[0m \u001b[0mshuffle\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mshuffle\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/apis/training.py\u001b[0m in \u001b[0;36mtrain_network\u001b[0;34m(config, shuffle, trainingsetindex, modelprefix, device, snapshot_path, detector_path, load_head_weights, batch_size, epochs, save_epochs, detector_batch_size, detector_epochs, detector_save_epochs, display_iters, max_snapshots_to_keep, pose_threshold, pytorch_cfg_updates)\u001b[0m\n\u001b[1;32m 358\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 359\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mloader\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmodel_cfg\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"train_settings\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"epochs\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m>\u001b[0m \u001b[0;36m0\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 360\u001b[0;31m train(\n\u001b[0m\u001b[1;32m 361\u001b[0m \u001b[0mloader\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mloader\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 362\u001b[0m \u001b[0mrun_config\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mloader\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmodel_cfg\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/apis/training.py\u001b[0m in \u001b[0;36mtrain\u001b[0;34m(loader, run_config, task, device, gpus, logger_config, snapshot_path, transform, inference_transform, max_snapshots_to_keep, load_head_weights)\u001b[0m\n\u001b[1;32m 190\u001b[0m \u001b[0mlogging\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minfo\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"\\nStarting pose model training...\\n\"\u001b[0m \u001b[0;34m+\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0;36m50\u001b[0m \u001b[0;34m*\u001b[0m \u001b[0;34m\"-\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 191\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 192\u001b[0;31m runner.fit(\n\u001b[0m\u001b[1;32m 193\u001b[0m \u001b[0mtrain_dataloader\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 194\u001b[0m \u001b[0mvalid_dataloader\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/runners/train.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, train_loader, valid_loader, epochs, display_iters)\u001b[0m\n\u001b[1;32m 212\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcurrent_epoch\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 213\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_metadata\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"epoch\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 214\u001b[0;31m train_loss = self._epoch(\n\u001b[0m\u001b[1;32m 215\u001b[0m \u001b[0mtrain_loader\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmode\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"train\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdisplay_iters\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdisplay_iters\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m )\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/runners/train.py\u001b[0m in \u001b[0;36m_epoch\u001b[0;34m(self, loader, mode, display_iters)\u001b[0m\n\u001b[1;32m 274\u001b[0m \u001b[0mloss_metrics\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mdefaultdict\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlist\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 275\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mi\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbatch\u001b[0m \u001b[0;32min\u001b[0m \u001b[0menumerate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mloader\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 276\u001b[0;31m \u001b[0mlosses_dict\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mstep\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mbatch\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmode\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 277\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;34m\"total_loss\"\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mlosses_dict\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 278\u001b[0m \u001b[0mepoch_loss\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mappend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mlosses_dict\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"total_loss\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/runners/train.py\u001b[0m in \u001b[0;36mstep\u001b[0;34m(self, batch, mode)\u001b[0m\n\u001b[1;32m 438\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;34m'cond_keypoints'\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mbatch\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m'context'\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 439\u001b[0m \u001b[0mcond_kpts\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mbatch\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m'context'\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m'cond_keypoints'\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 440\u001b[0;31m \u001b[0moutputs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmodel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minputs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcond_kpts\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcond_kpts\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 441\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 442\u001b[0m \u001b[0moutputs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmodel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0minputs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/torch/nn/modules/module.py\u001b[0m in \u001b[0;36m_wrapped_call_impl\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 1737\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_compiled_call_impl\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# type: ignore[misc]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1738\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1739\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_call_impl\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1740\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1741\u001b[0m \u001b[0;31m# torchrec tests the code consistency with the following code\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/torch/nn/modules/module.py\u001b[0m in \u001b[0;36m_call_impl\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 1748\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0m_global_backward_pre_hooks\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0m_global_backward_hooks\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1749\u001b[0m or _global_forward_hooks or _global_forward_pre_hooks):\n\u001b[0;32m-> 1750\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mforward_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1751\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1752\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/models/model.py\u001b[0m in \u001b[0;36mforward\u001b[0;34m(self, x, **backbone_kwargs)\u001b[0m\n\u001b[1;32m 76\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdim\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;36m3\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 77\u001b[0m \u001b[0mx\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mx\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m:\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 78\u001b[0;31m \u001b[0mfeatures\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mbackbone\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mx\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mbackbone_kwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 79\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mneck\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 80\u001b[0m \u001b[0mfeatures\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mneck\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mfeatures\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/torch/nn/modules/module.py\u001b[0m in \u001b[0;36m_wrapped_call_impl\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 1737\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_compiled_call_impl\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# type: ignore[misc]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1738\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1739\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_call_impl\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1740\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1741\u001b[0m \u001b[0;31m# torchrec tests the code consistency with the following code\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/torch/nn/modules/module.py\u001b[0m in \u001b[0;36m_call_impl\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 1748\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0m_global_backward_pre_hooks\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0m_global_backward_hooks\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1749\u001b[0m or _global_forward_hooks or _global_forward_pre_hooks):\n\u001b[0;32m-> 1750\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mforward_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1751\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1752\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/models/backbones/cond_prenet.py\u001b[0m in \u001b[0;36mforward\u001b[0;34m(self, x, cond_kpts)\u001b[0m\n\u001b[1;32m 98\u001b[0m \u001b[0mcond_kpts\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcond_kpts\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdetach\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnumpy\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 99\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 100\u001b[0;31m \u001b[0mcond_hm\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcond_enc\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcond_kpts\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msqueeze\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msize\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 101\u001b[0m \u001b[0mcond_hm\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtorch\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfrom_numpy\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcond_hm\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfloat\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mto\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdevice\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 102\u001b[0m \u001b[0mcond_hm\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcond_hm\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpermute\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m3\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# (B, C, H, W)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, keypoints, size)\u001b[0m\n\u001b[1;32m 243\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 244\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mi\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mrange\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mbatch_size\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 245\u001b[0;31m \u001b[0mcondition_heatmap\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mblur_heatmap\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcondition\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mi\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 246\u001b[0m \u001b[0mcondition\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mi\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcondition_heatmap\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 247\u001b[0m \u001b[0;31m# condition = self.blur_heatmap_batch(torch.from_numpy(condition))\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/local/lib/python3.11/dist-packages/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py\u001b[0m in \u001b[0;36mblur_heatmap\u001b[0;34m(self, heatmap)\u001b[0m\n\u001b[1;32m 76\u001b[0m \u001b[0mThe\u001b[0m \u001b[0mheatmap\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0ma\u001b[0m \u001b[0mGaussian\u001b[0m \u001b[0mblur\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msuch\u001b[0m \u001b[0mthat\u001b[0m \u001b[0mmax\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mheatmap\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m255\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 77\u001b[0m \"\"\"\n\u001b[0;32m---> 78\u001b[0;31m \u001b[0mheatmap\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcv2\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mGaussianBlur\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mheatmap\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkernel_size\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msigmaX\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 79\u001b[0m \u001b[0mam\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mamax\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mheatmap\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 80\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mam\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0;36m0\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;31mKeyboardInterrupt\u001b[0m: "
+ ]
+ }
+ ],
+ "source": [
+ "deeplabcut.train_network(config, shuffle=CTD_SHUFFLE)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5d4b810f",
+ "metadata": {
+ "id": "5d4b810f"
+ },
+ "source": [
+ "If your CTD model is well trained, it should now outperform the performance of the BU model who's predictions it uses as conditions!\n",
+ "\n",
+ "Note that during training, the model is evaluated using pose conditions that were created with generative sampling. When you evaluate the network with the `evaluate_network` method, the performance will be different as you're using the actual conditions from the bottom-up model we trained first."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "0cb3c2da",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 9116,
+ "status": "ok",
+ "timestamp": 1744361908966,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "0cb3c2da",
+ "outputId": "6b446a05-afd1-452f-c3f9-d866caed6bc7"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 78/78 [00:05<00:00, 14.65it/s]\n",
+ "100%|██████████| 34/34 [00:02<00:00, 14.91it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Evaluation results for DLC_CtdPrenetCspnextM_trimiceJun22shuffle2_snapshot_130-results.csv (pcutoff: 0.01):\n",
+ "train rmse 2.46\n",
+ "train rmse_pcutoff 2.46\n",
+ "train mAP 98.51\n",
+ "train mAR 98.93\n",
+ "test rmse 4.41\n",
+ "test rmse_pcutoff 4.41\n",
+ "test mAP 96.88\n",
+ "test mAR 97.06\n",
+ "Name: (0.7, 2, 130, -1, 0.01), dtype: float64\n"
+ ]
+ }
+ ],
+ "source": [
+ "deeplabcut.evaluate_network(config, Shuffles=[CTD_SHUFFLE])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5ea24b55",
+ "metadata": {
+ "id": "5ea24b55"
+ },
+ "source": [
+ "## Tracking with CTD\n",
+ "\n",
+ "One of the big advantages of having a CTD model is that it can be used to track individuals directly! Let's say you have the pose for your animals at `frame T`. Then you can use those poses as conditions for `frame T+1`, and let your CTD model simply \"update\" the poses depending on how much your mice moved.\n",
+ "\n",
+ "In the simplest scenario, you only need to run the BU model on the first frame, and then the CTD model takes over for inference and tracking:\n",
+ "\n",
+ "1. Run the BU model to generate conditions for the 1st frame of the video\n",
+ "2. For every frame after that, use the predictions from the previous frame as conditions\n",
+ "\n",
+ "However, this may not fit your scenario perfectly. Maybe all the mice aren't present in the first frame, and if they aren't detected by the BU model they'll never be tracked. Maybe at some point the CTD model makes an error and you lose track of a mouse. There are some options to deal with this:\n",
+ "\n",
+ "- Run the BU model every time at least one mouse is not detected (if you expect N mice to be in the video and you only detect N-1 mice, run the BU model):\n",
+ " - In this case, the predictions from the BU model need to be \"merged in\" to the existing N-1 tracks\n",
+ " - We can merge them in by using a similarity score between poses (OKS) which ranges from 0 to 1\n",
+ " - You likely don't want to run the BU model every frame, as this would slow down inference.\n",
+ "- Run the BU model every K frames in case new mice appear\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bbc777c8",
+ "metadata": {
+ "id": "bbc777c8"
+ },
+ "source": [
+ "### Downloading a Tri-Mouse video\n",
+ "\n",
+ "First, let's download a video from the Tri-Mouse dataset. Note that this may take some time to run (1 minute or 2). If you have any issues downloading the files through the code, you can simply download the zipfile through [zenodo.org/records/7883589/files/demo-me-2021-07-14.zip](https://zenodo.org/records/7883589/files/demo-me-2021-07-14.zip?download=1), and then drag-and-drop the video in `demo-me-2021-07-14/videos/videocompressed1.mp4` file into COLAB in the right panel to upload it. Make sure the video is fully uploaded before you run analysis."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "d678a5e4",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 43,
+ "status": "ok",
+ "timestamp": 1744361934728,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "d678a5e4",
+ "outputId": "4d9dd907-183a-4f34-94a1-2550b24cafc0"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Video will be saved in /content/videocompressed1.mp4\n"
+ ]
+ }
+ ],
+ "source": [
+ "download_path = Path.cwd()\n",
+ "video_name = \"videocompressed1.mp4\"\n",
+ "video_path = str(download_path / video_name)\n",
+ "print(f\"Video will be saved in {video_path}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2247556f",
+ "metadata": {
+ "id": "2247556f"
+ },
+ "outputs": [],
+ "source": [
+ "print(f\"Downloading the tri-mouse video into {download_path}\")\n",
+ "\n",
+ "url_video_record = \"https://zenodo.org/api/records/7883589\"\n",
+ "response = requests.get(url_video_record)\n",
+ "if response.status_code == 200:\n",
+ " file = response.json()[\"files\"][0]\n",
+ " title = file[\"key\"]\n",
+ " print(f\"Downloading {title}...\")\n",
+ " with requests.get(file[\"links\"][\"self\"], stream=True) as r:\n",
+ " with ZipFile(BytesIO(r.content)) as zf:\n",
+ " zf.extractall(path=download_path)\n",
+ "else:\n",
+ " raise ValueError(f\"The URL {url_video_record} could not be reached.\")\n",
+ "\n",
+ "# Check that the video was downloaded\n",
+ "src_video_path = download_path / \"demo-me-2021-07-14\" / \"videos\" / video_name\n",
+ "if not src_video_path.exists():\n",
+ " raise ValueError(\"Failed to download the video\")\n",
+ "\n",
+ "# Move the video to the final path\n",
+ "shutil.move(src_video_path, video_path)\n",
+ "if not Path(video_path).exists():\n",
+ " raise ValueError(\"Failed to move the video\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "721ce122",
+ "metadata": {
+ "id": "721ce122"
+ },
+ "source": [
+ "### Running Video Analysis\n",
+ "\n",
+ "You can track using your CTD model by setting `ctd_tracking=True` when calling `analyze_videos`. Of course, you then won't need to convert detections to tracklets or link tracklets, as the CTD model will directly be tracking the animals. This should run at 15 to 40 FPS depending on your hardware.\n",
+ "\n",
+ "You can create a labeled video containing the predictions made with the CTD tracker by setting `track_method=\"ctd\"` when calling `create_labeled_video`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "50b3787c",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "executionInfo": {
+ "elapsed": 193256,
+ "status": "ok",
+ "timestamp": 1744363303936,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "50b3787c",
+ "outputId": "c9e0c4fb-fab0-4dec-a587-a6c3e003b4ef"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Analyzing videos with /content/trimice-dlc-2021-06-22/dlc-models-pytorch/iteration-0/trimiceJun22-trainset70shuffle2/train/snapshot-best-130.pt\n",
+ "CTD tracking can only be used with batch size 1. Updating it.\n",
+ "Starting to analyze /content/videocompressed1.mp4\n",
+ "Video metadata: \n",
+ " Overall # of frames: 2330\n",
+ " Duration of video [s]: 77.67\n",
+ " fps: 30.0\n",
+ " resolution: w=640, h=480\n",
+ "\n",
+ "Running pose prediction with batch size 1\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 2330/2330 [02:10<00:00, 17.80it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Saving results in /content/videocompressed1DLC_CtdPrenetCspnextM_trimiceJun22shuffle2_snapshot_130_ctd.h5 and /content/videocompressed1DLC_CtdPrenetCspnextM_trimiceJun22shuffle2_snapshot_130_ctd_full.pickle\n",
+ "The videos are analyzed. Now your research can truly start!\n",
+ "You can create labeled videos with 'create_labeled_video'.\n",
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames.\n",
+ "\n",
+ "Starting to process video: /content/videocompressed1.mp4\n",
+ "Loading /content/videocompressed1.mp4 and data.\n",
+ "Duration of video [s]: 77.67, recorded with 30.0 fps!\n",
+ "Overall # of frames: 2330 with cropped frame dimensions: 640 480\n",
+ "Generating frames and creating video.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/utils/make_labeled_video.py:146: FutureWarning: DataFrame.groupby with axis=1 is deprecated. Do `frame.T.groupby(...)` without axis instead.\n",
+ " Dataframe.groupby(level=\"individuals\", axis=1).size().values // 3\n",
+ "100%|██████████| 2330/2330 [00:58<00:00, 39.95it/s]\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[True]"
+ ]
+ },
+ "execution_count": 19,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "deeplabcut.analyze_videos(\n",
+ " config,\n",
+ " [video_path],\n",
+ " shuffle=CTD_SHUFFLE,\n",
+ " ctd_tracking=True,\n",
+ ")\n",
+ "deeplabcut.create_labeled_video(\n",
+ " config,\n",
+ " [video_path],\n",
+ " shuffle=CTD_SHUFFLE,\n",
+ " track_method=\"ctd\",\n",
+ " color_by=\"individual\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2jDlgrJnEw_y",
+ "metadata": {
+ "id": "2jDlgrJnEw_y"
+ },
+ "source": [
+ "We can then visualize the results of tracking with CTD."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "JU8d1zvBEwWq",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 501,
+ "output_embedded_package_id": "15Gie9uW38e2cxxI0IyBAAYBUpaRBia4K"
+ },
+ "executionInfo": {
+ "elapsed": 7557,
+ "status": "ok",
+ "timestamp": 1744363338435,
+ "user": {
+ "displayName": "Niels Poulsen",
+ "userId": "07147513190166716525"
+ },
+ "user_tz": -120
+ },
+ "id": "JU8d1zvBEwWq",
+ "outputId": "ec8e3860-5e21-47b3-a457-7576593f2379"
+ },
+ "outputs": [],
+ "source": [
+ "from base64 import b64encode\n",
+ "\n",
+ "from IPython.display import HTML\n",
+ "\n",
+ "\n",
+ "def show_video(video_path, width=640):\n",
+ " video_file = open(video_path, \"rb\").read()\n",
+ " video_url = f\"data:video/mp4;base64,{b64encode(video_file).decode()}\"\n",
+ " return HTML(f\"\"\"\n",
+ " \n",
+ " \n",
+ " \n",
+ " \"\"\")\n",
+ "\n",
+ "\n",
+ "show_video(download_path / \"videocompressed1DLC_CtdCoamW32_trimiceJun22shuffle2_snapshot_best-80_ctd_id_p1_labeled.mp4\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "56bd9ab7",
+ "metadata": {
+ "id": "56bd9ab7"
+ },
+ "source": [
+ "It can be beneficial to customize the tracking parameters a bit. The tracking parameters you can set are:\n",
+ "\n",
+ "Note: [OKS (object-keypoint similarity)](https://cocodataset.org/#keypoints-eval) is a similarity metric for pose estimation, ranging from 0 to 1 (where 1 means the pose is identical)\n",
+ "\n",
+ "- **`bu_on_lost_idv`**: When True, the BU model is run when there are fewer conditions found than the expected number of individuals in the video.\n",
+ "- **`bu_min_frequency`**: The minimum frequency at which the BU model is run to generate conditions. If None, the BU model is only run to initialize the pose in the first frame, and then is not run again. If a positive number N, the BU model is run every N frames. The BU predictions are then combined with the CTD predictions to continue the tracklets.\n",
+ "- **`bu_max_frequency`**: The maximum frequency at which the BU model can be run. Must be greater than `bu_min_frequency`. When there are fewer conditions than individuals expected in the video and `bu_on_lost_idv` is True, the BU model may be run on every frame. This can happen if individuals can disappear from the video, and each frame may have a variable number of individuals. If `bu_max_frequency` is set to N, then the BU model will be run at most every N-th frame, which improves the inference speed of the model.\n",
+ "- **`threshold_bu_add`**: The OKS threshold below which a BU pose must be (wrt. any existing CTD pose) to be added to the poses.\n",
+ "- **`threshold_ctd`**: The score threshold below which detected keypoints are NOT given to the CTD model to predict pose for the next frame.\n",
+ "- **`threshold_nms`**: The OKS threshold to use for non-maximum suppression. This is used to remove duplicates poses when two CTD model predictions converge to a single animal. If two poses have an OKS above this threshold, one of the poses is removed.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5e667af4",
+ "metadata": {
+ "id": "5e667af4"
+ },
+ "outputs": [],
+ "source": [
+ "dest_folder = str(Path(video_path).parent / \"custom-ctd-tracking\")\n",
+ "\n",
+ "deeplabcut.analyze_videos(\n",
+ " config,\n",
+ " [video_path],\n",
+ " shuffle=CTD_SHUFFLE,\n",
+ " destfolder=dest_folder,\n",
+ " ctd_tracking=dict(\n",
+ " bu_on_lost_idv=True,\n",
+ " bu_max_frequency=10,\n",
+ " threshold_bu_add=0.5,\n",
+ " threshold_ctd=0.01,\n",
+ " threshold_nms=0.8,\n",
+ " ),\n",
+ ")\n",
+ "deeplabcut.create_labeled_video(\n",
+ " config,\n",
+ " [video_path],\n",
+ " shuffle=CTD_SHUFFLE,\n",
+ " destfolder=dest_folder,\n",
+ " track_method=\"ctd\",\n",
+ " color_by=\"individual\",\n",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [
+ "b2829415",
+ "d46ecdc8"
+ ],
+ "gpuType": "T4",
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-10-02",
+ "last_metadata_updated": "2026-03-06"
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.13"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb b/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb
index fe51d1de88..935d7bad59 100644
--- a/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb
+++ b/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb
@@ -1,229 +1,251 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "3G1Nx3YLOVaZ"
- },
- "source": [
- "\n",
- " \n",
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "23v-XAUNQIPY"
- },
- "source": [
- "# DeepLabCut Model Zoo: SuperAnimal models\n",
- "\n",
- "\n",
- "\n",
- "http://modelzoo.deeplabcut.org\n",
- "\n",
- "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n",
- "\n",
- "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n",
- "\n",
- "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n",
- "\n",
- "## **Please consider giving back and labeling a little data to help make each network even better!**\n",
- "\n",
- "We have a WebApp, so no need to install anything, just a few clicks! We'd really appreciate your help! 🙏\n",
- " \n",
- "https://contrib.deeplabcut.org/\n",
- "\n",
- "\n",
- "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" that will set the video plotting) - see the end of this notebook.\n",
- "- You can also use the model in your own projects locally. Please be sure to cite the papers for the model, i.e., [Ye et al. 2023](https://arxiv.org/abs/2203.07436) 🎉\n",
- "\n",
- "\n",
- "\n",
- "## **Let's get going: install DeepLabCut into COLAB:**\n",
- "\n",
- "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "03ylSyQ4O9Ee"
- },
- "outputs": [],
- "source": [
- "!apt update && apt install cuda-11-8\n",
- "!pip install deeplabcut[tf,modelzoo]"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "TguLMTJpQx1_"
- },
- "source": [
- "## PLEASE, click \"restart runtime\" from the output above before proceeding!"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "4BejjXKFO2Zg"
- },
- "outputs": [],
- "source": [
- "import deeplabcut\n",
- "import os"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "GXf8N4v28Xqo"
- },
- "source": [
- "## Please select a video you want to run SuperAnimal-X on:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "xXNMNLe6xEBC"
- },
- "outputs": [],
- "source": [
- "from google.colab import files\n",
- "\n",
- "uploaded = files.upload()\n",
- "for filepath, content in uploaded.items():\n",
- " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n",
- "video_path = os.path.abspath(filepath)\n",
- "video_name = os.path.splitext(video_path)[0]\n",
- "\n",
- "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
- "# manually upload your video via the Files menu to the left\n",
- "# and define `video_path` yourself with right click > copy path on the video."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "A8sDYMa08f62"
- },
- "source": [
- "## Next select the model you want to use, Quadruped or TopViewMouse\n",
- "- See http://modelzoo.deeplabcut.org/ for more details on these models\n",
- "- The pcutoff is for visualization only, namely only keypoints with a value over what you set are shown. 0 is low confidience, 1 is perfect confidience of the model."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "ge589yC4v9yX"
- },
- "outputs": [],
- "source": [
- "supermodel_name = \"superanimal_quadruped\" #@param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n",
- "pcutoff = 0.3 #@param {type:\"slider\", min:0, max:1, step:0.05}"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "zsB0pGtj9Luq"
- },
- "source": [
- "## Okay, let's go! 🐭🦓🐻"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "yqcnEVVSQDC0"
- },
- "outputs": [],
- "source": [
- "videotype = os.path.splitext(video_path)[1]\n",
- "scale_list = []\n",
- "\n",
- "deeplabcut.video_inference_superanimal(\n",
- " [video_path],\n",
- " supermodel_name,\n",
- " videotype=videotype,\n",
- " video_adapt=True,\n",
- " scale_list=scale_list,\n",
- " pcutoff=pcutoff,\n",
- ")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "gPLZSBpD34Mj"
- },
- "source": [
- "## Let's view the video in Colab:\n",
- "- otherwise, you can download and look at the video from the left side of your screen! It will end with _labeled.mp4\n",
- "- If your data doesn't work as well as you'd like, consider fine-tuning our model on your data, changing the pcutoff, changing the scale-range\n",
- "(pick values smaller and larger than your video image input size). See our repo for more details."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "ejFJ1Pbg33i6"
- },
- "outputs": [],
- "source": [
- "from base64 import b64encode\n",
- "from IPython.display import HTML\n",
- "view_video = open(video_name+'DLC_snapshot-1000_labeled.mp4','rb').read()\n",
- "\n",
- "data_url = \"data:video/mp4;base64,\" + b64encode(view_video).decode()\n",
- "HTML(\"\"\"\n",
- "\n",
- " \n",
- " \n",
- "\"\"\" % data_url)"
- ]
- }
- ],
- "metadata": {
- "accelerator": "GPU",
- "colab": {
- "provenance": []
- },
- "gpuClass": "standard",
- "kernelspec": {
- "display_name": "dlc",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.9.13 | packaged by conda-forge | (main, May 27 2022, 17:01:00) \n[Clang 13.0.1 ]"
- },
- "vscode": {
- "interpreter": {
- "hash": "ef00193d8f29a47f592f520086c931b5dd2a83e8a593fa0efe5afff3c413a788"
- }
- }
- },
- "nbformat": 4,
- "nbformat_minor": 0
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "3G1Nx3YLOVaZ"
+ },
+ "source": [
+ "\n",
+ " \n",
+ " "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "23v-XAUNQIPY"
+ },
+ "source": [
+ "# DeepLabCut SuperAnimal models\n",
+ "\n",
+ "\n",
+ "\n",
+ "http://modelzoo.deeplabcut.org\n",
+ "\n",
+ "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n",
+ "\n",
+ "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n",
+ "\n",
+ "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n",
+ "\n",
+ "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" that will set the video plotting) - see the end of this notebook.\n",
+ "- You can also use the model in your own projects locally. Please be sure to cite the papers for the model, i.e., [Ye et al. 2024](https://arxiv.org/abs/2203.07436) 🎉\n",
+ "\n",
+ "\n",
+ "\n",
+ "## **Let's get going: install DeepLabCut into COLAB:**\n",
+ "\n",
+ "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "03ylSyQ4O9Ee"
+ },
+ "outputs": [],
+ "source": [
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "TguLMTJpQx1_"
+ },
+ "source": [
+ "## PLEASE, click \"restart runtime\" from the output above before proceeding!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "4BejjXKFO2Zg"
+ },
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "\n",
+ "import deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "GXf8N4v28Xqo"
+ },
+ "source": [
+ "## Please select a video you want to run SuperAnimal-X on:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "xXNMNLe6xEBC"
+ },
+ "outputs": [],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n",
+ "\n",
+ "video_path = Path(filepath).resolve()\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your video via the Files menu to the left\n",
+ "# and define `video_path` yourself with right click > copy path on the video."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "A8sDYMa08f62"
+ },
+ "source": [
+ "## Next select the model you want to use, Quadruped or TopViewMouse\n",
+ "- See http://modelzoo.deeplabcut.org/ for more details on these models\n",
+ "- The pcutoff is for visualization only, namely only keypoints with a value over what you set are shown. 0 is low confidience, 1 is perfect confidience of the model."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "ge589yC4v9yX"
+ },
+ "outputs": [],
+ "source": [
+ "superanimal_name = \"superanimal_quadruped\" # @param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n",
+ "model_name = \"hrnet_w32\" # @param [\"hrnet_w32\", \"resnet_50\"]\n",
+ "detector_name = (\n",
+ " \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"]\n",
+ ")\n",
+ "pcutoff = 0.15 # @param {type:\"slider\", min:0, max:1, step:0.05}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "zsB0pGtj9Luq"
+ },
+ "source": [
+ "## Okay, let's go! 🐭🦓🐻"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "yqcnEVVSQDC0"
+ },
+ "outputs": [],
+ "source": [
+ "videotype = video_path.suffix\n",
+ "scale_list = []\n",
+ "\n",
+ "deeplabcut.video_inference_superanimal(\n",
+ " [video_path],\n",
+ " superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " videotype=videotype,\n",
+ " video_adapt=True,\n",
+ " scale_list=scale_list,\n",
+ " pcutoff=pcutoff,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "gPLZSBpD34Mj"
+ },
+ "source": [
+ "## Let's view the video in Colab:\n",
+ "- otherwise, you can download and look at the video from the left side of your screen! It will end with _labeled.mp4\n",
+ "- If your data doesn't work as well as you'd like, consider fine-tuning our model on your data, changing the pcutoff, changing the scale-range\n",
+ "(pick values smaller and larger than your video image input size). See our repo for more details."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "ejFJ1Pbg33i6"
+ },
+ "outputs": [],
+ "source": [
+ "from base64 import b64encode\n",
+ "\n",
+ "from IPython.display import HTML\n",
+ "\n",
+ "# Get the parent directory and stem (filename without extension)\n",
+ "directory = video_path.parent\n",
+ "basename = video_path.stem\n",
+ "\n",
+ "# Build the pattern\n",
+ "# This uses '*' to allow for any characters between the fixed parts\n",
+ "pattern = f\"{basename}*{superanimal_name}*{detector_name}*{model_name}*_labeled_after_adapt.mp4\"\n",
+ "\n",
+ "# Search for matching files\n",
+ "matches = list(directory.glob(pattern))\n",
+ "\n",
+ "# Choose the first match if it exists\n",
+ "labeled_video_path = matches[0] if matches else None\n",
+ "\n",
+ "view_video = open(labeled_video_path, \"rb\").read()\n",
+ "\n",
+ "data_url = \"data:video/mp4;base64,\" + b64encode(view_video).decode()\n",
+ "HTML(\n",
+ " f\"\"\"\n",
+ "\n",
+ " \n",
+ " \n",
+ "\"\"\"\n",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-06-30",
+ "last_metadata_updated": "2026-03-06"
+ },
+ "gpuClass": "standard",
+ "kernelspec": {
+ "display_name": "dlc",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.9.13 | packaged by conda-forge | (main, May 27 2022, 17:01:00) \n[Clang 13.0.1 ]"
+ },
+ "vscode": {
+ "interpreter": {
+ "hash": "ef00193d8f29a47f592f520086c931b5dd2a83e8a593fa0efe5afff3c413a788"
+ }
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
diff --git a/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb b/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb
index 306ee6b079..1cb74fcb46 100644
--- a/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb
+++ b/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb
@@ -16,8 +16,12 @@
"id": "TGChzLdc-lUJ"
},
"source": [
- "# DeepLabCut Toolbox - Colab Demo on Topview Mouse Data\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "# DeepLabCut on Single Mouse Data Demo\n",
+ "\n",
+ "Some useful links:\n",
+ "\n",
+ "- [DeepLabCut's GitHub: github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Single Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html)\n",
"\n",
"\n",
"\n",
@@ -42,17 +46,9 @@
"id": "txoddlM8hLKm"
},
"source": [
- "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\""
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# Installs a CUDA version compatible with tensorflow on COLAB\n",
- "!apt update && apt install cuda-11-8"
+ "## Installation\n",
+ "\n",
+ "### First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\""
]
},
{
@@ -91,7 +87,7 @@
"source": [
"# Install the latest DeepLabCut version (this will take a few minutes to install all the dependencies!)\n",
"%cd /content/cloned-DLC-repo/\n",
- "!pip install \".[tf]\""
+ "%pip install \".\""
]
},
{
@@ -100,7 +96,7 @@
"id": "XymV_Hnlp1OJ"
},
"source": [
- "## PLEASE, click \"restart runtime\" from the output above before proceeding! "
+ "### PLEASE, click \"restart runtime\" from the output above before proceeding!"
]
},
{
@@ -122,11 +118,32 @@
},
"outputs": [],
"source": [
- "#create a path variable that links to the config file:\n",
- "path_config_file = '/content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30/config.yaml'\n",
+ "# Create a path variable that links to the config file:\n",
+ "path_config_file = \"/content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30/config.yaml\"\n",
"\n",
"# Loading example data set:\n",
- "deeplabcut.load_demo_data(path_config_file)"
+ "deeplabcut.load_demo_data(path_config_file)\n",
+ "\n",
+ "# Automatically update some hyperparameters for training,\n",
+ "# here rotations to +/- 180 degrees. This can be helpful for optimizing performance.\n",
+ "# see Primer -- Mathis et al. Neuron 2020\n",
+ "import deeplabcut.pose_estimation_pytorch as dlc_torch\n",
+ "from deeplabcut.core.config import read_config_as_dict\n",
+ "\n",
+ "loader = dlc_torch.DLCLoader(\n",
+ " config=path_config_file,\n",
+ " trainset_index=0,\n",
+ " shuffle=1,\n",
+ ")\n",
+ "\n",
+ "# Get the pytorch config path\n",
+ "pytorch_config_path = loader.model_folder / \"pytorch_config.yaml\"\n",
+ "\n",
+ "model_cfg = read_config_as_dict(pytorch_config_path)\n",
+ "model_cfg[\"data\"][\"train\"][\"affine\"][\"rotation\"] = 180\n",
+ "\n",
+ "# Save the modified config\n",
+ "dlc_torch.config.write_config(pytorch_config_path, model_cfg)"
]
},
{
@@ -147,14 +164,26 @@
},
"outputs": [],
"source": [
- "#let's also change the display and save_iters just in case Colab takes away the GPU... \n",
- "#if that happens, you can reload from a saved point. Typically, you want to train to 200,000 + iterations.\n",
- "#more info and there are more things you can set: https://github.com/DeepLabCut/DeepLabCut/wiki/DOCSTRINGS#train_network\n",
+ "# Let's also change the display and save_epochs just in case Colab takes away\n",
+ "# the GPU... If that happens, you can reload from a saved point using the\n",
+ "# `snapshot_path` argument to `deeplabcut.train_network`:\n",
+ "# deeplabcut.train_network(..., snapshot_path=\"/content/.../snapshot-050.pt\")\n",
"\n",
- "deeplabcut.train_network(path_config_file, shuffle=1, displayiters=100,saveiters=500, maxiters=10000)\n",
+ "# Typically, you want to train to ~200 epochs. We set the batch size to 8 to\n",
+ "# utilize the GPU's capabilities.\n",
"\n",
- "#this will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end (default, 1.03M iterations). \n",
- "#Whichever you chose, you will see what looks like an error message, but it's not an error - don't worry...."
+ "# More info and there are more things you can set:\n",
+ "# https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#g-train-the-network\n",
+ "\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=5,\n",
+ " epochs=200,\n",
+ " batch_size=8,\n",
+ ")\n",
+ "\n",
+ "# This will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end."
]
},
{
@@ -163,7 +192,9 @@
"id": "RiDwIVf5-3H_"
},
"source": [
- "We recommend you run this for ~1,000 iterations, just as a demo. This should take around 20 min. Note, that **when you hit \"STOP\" you will get a KeyInterrupt \"error\"! No worries! :)**"
+ "We recommend you run this for ~100 epochs, just as a demo. This should take around 15 minutes. Note, that **when you hit \"STOP\" you will get a `KeyboardInterrupt` \"error\"! No worries! :)**\n",
+ "\n",
+ "A new snapshot is saved every `save_epochs` epochs. So once you hit 80 epochs, your latest snapshot in `/content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30/dlc-models-pytorch/iteration-0/openfieldOct30-trainset95shuffle1/train` should be `snapshot-80.pt`. The best snapshot evaluated during training is saved, and is named `snapshot-best-XX.pt`, where `XX` is the number of epochs the model was trained with."
]
},
{
@@ -185,10 +216,10 @@
},
"outputs": [],
"source": [
- "%matplotlib notebook\n",
- "deeplabcut.evaluate_network(path_config_file,plotting=True)\n",
+ "deeplabcut.evaluate_network(path_config_file, plotting=True)\n",
"\n",
- "# Here you want to see a low pixel error! Of course, it can only be as good as the labeler, so be sure your labels are good!"
+ "# Here you want to see a low pixel error! Of course, it can only be as\n",
+ "# good as the labeler, so be sure your labels are good!"
]
},
{
@@ -198,7 +229,8 @@
},
"source": [
"**Check the images**:\n",
- "You can go look in the newly created \"evalutaion-results\" folder at the images. At around 3500 iterations, the error is ~3 pixels (but this can vary on how your demo data was split for training)"
+ "\n",
+ "You can go look in the newly created `\"evaluation-results-pytorch\"` folder at the images. At around 100 epochs, the error is ~3 pixels (but this can vary on how your demo data was split for training)."
]
},
{
@@ -212,7 +244,7 @@
"\n",
"The results are stored in hd5 file in the same directory where the video resides. \n",
"\n",
- "**On the demo data, this should take around ~ 3 min! (The demo frames are 640x480, which should run around 35 FPS on the google-provided GPU)**"
+ "**On the demo data, this should take around ~ 90 seconds! (The demo frames are 640x480, which should run around 25 FPS on the google-provided T4 GPU)**"
]
},
{
@@ -223,8 +255,9 @@
},
"outputs": [],
"source": [
- "videofile_path = ['/content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30/videos/m3v1mp4.mp4'] #Enter the list of videos to analyze.\n",
- "deeplabcut.analyze_videos(path_config_file,videofile_path, videotype='.mp4')"
+ "# Enter the list of videos to analyze.\n",
+ "videofile_path = [\"/content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30/videos/m3v1mp4.mp4\"]\n",
+ "deeplabcut.analyze_videos(path_config_file, videofile_path, videotype=\".mp4\")"
]
},
{
@@ -234,7 +267,7 @@
},
"source": [
"## Create labeled video:\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. This should run around 215 FPS on the demo video!"
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. This should run around 215 FPS on the demo video!"
]
},
{
@@ -245,7 +278,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
]
},
{
@@ -266,7 +299,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.plot_trajectories(path_config_file,videofile_path)"
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path)"
]
}
],
@@ -277,6 +310,11 @@
"name": "Colab_DEMO_mouse_openfield.ipynb",
"provenance": []
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
"display_name": "Python [default]",
"language": "python",
diff --git a/examples/COLAB/COLAB_DLC_ModelZoo.ipynb b/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
index e4346296ce..3a48250c18 100644
--- a/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
+++ b/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
@@ -1,317 +1,318 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "colab_type": "text",
- "id": "view-in-github"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RK255E7YoEIt"
- },
- "source": [
- "# **DeepLabCut Model Zoo: (mainly) user-contributed models**\n",
- "\n",
- "🚨 **WARNING** -- this is using the old version from 2020-2023. Please see the SuperAnimal notebook for more features\n",
- "\n",
- "\n",
- "\n",
- "http://modelzoo.deeplabcut.org\n",
- "\n",
- "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n",
- "\n",
- "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n",
- "\n",
- "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n",
- "\n",
- "## **Please consider giving back and labeling a little data to help make each network even better!**\n",
- "\n",
- "We have a WebApp, so no need to install anything, just a few clicks! We'd really appreciate your help!\n",
- " \n",
- "https://contrib.deeplabcut.org/\n",
- "\n",
- "\n",
- "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" in the config.yaml file that will set the video plotting) - see the end of this notebook. You can also use the model in your own projects locally. Please be sure to cite the papers for the model, and http://modelzoo.deeplabcut.org (paper forthcoming!)\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "## **Let's get going: install DeepLabCut into COLAB:**\n",
- "\n",
- "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "q23BzhA6CXxu"
- },
- "outputs": [],
- "source": [
- "#click the play icon (this will take a few minutes to install all the dependencies!)\n",
- "!apt update && apt install cuda-11-8\n",
- "!pip install deeplabcut[tf,modelzoo]"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "zYm6DljQB0Y7"
- },
- "source": [
- "###proTip: be sure to click \"restart runtime button\" if it appears above ^"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "ZT4PwGSbYQEO"
- },
- "source": [
- "## Now let's set the backend & import the DeepLabCut package\n",
- "#### (if colab is buggy/throws an error, just rerun this cell):"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "bvoiWefrYQEP"
- },
- "outputs": [],
- "source": [
- "import os\n",
- "import deeplabcut"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "syweXs88tyuO"
- },
- "source": [
- "## Next, run the cell below to upload your video file from your computer:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "7eqEZYs_CaLy"
- },
- "outputs": [],
- "source": [
- "from google.colab import files\n",
- "\n",
- "uploaded = files.upload()\n",
- "for filepath, content in uploaded.items():\n",
- " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n",
- "video_path = os.path.abspath(filepath)\n",
- "\n",
- "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
- "# manually upload your video via the Files menu to the left\n",
- "# and define `video_path` yourself with right click > copy path on the video."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "YsaqOTkZtf-w"
- },
- "source": [
- "## Select your model from the dropdown menu, then below (optionally) input the name you want for the project:\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "Ih0t7lUjYQEd"
- },
- "outputs": [],
- "source": [
- "import ipywidgets as widgets\n",
- "from IPython.display import display\n",
- "\n",
- "model_options = deeplabcut.create_project.modelzoo.Modeloptions\n",
- "model_selection = widgets.Dropdown(\n",
- " options=model_options,\n",
- " value=model_options[0],\n",
- " description=\"Choose a DLC ModelZoo model!\",\n",
- " disabled=False\n",
- ")\n",
- "display(model_selection)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "UV0QXswGCFrI"
- },
- "outputs": [],
- "source": [
- "project_name = 'myDLC_modelZoo'\n",
- "your_name = 'teamDLC'\n",
- "model2use = model_selection.value\n",
- "videotype = os.path.splitext(video_path)[-1].lstrip('.') #or MOV, or avi, whatever you uploaded!"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "JQxko-t3uMVO"
- },
- "source": [
- "## Attention on this step !!\n",
- "- Please note that for optimal performance your videos should contain frames that are around ~300-600 pixels (on one edge). If you have a larger video (like from an iPhone, first downsize by running this please! :)\n",
- "\n",
- "- Thus, if you're using an iPhone, or such, you'll need to downsample the video first by running the code below**\n",
- "\n",
- "(no need to edit it unless you want to change the size)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "WpAX3BKY94e0"
- },
- "outputs": [],
- "source": [
- "video_path = deeplabcut.DownSampleVideo(video_path, width=300)\n",
- "print(video_path)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "KJm_Vbx-s5OY"
- },
- "source": [
- "## Lastly, run the cell below to create a pretrained project, analyze your video with your selected pretrained network, plot trajectories, and create a labeled video!:\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "T9MGgAdIFKPY"
- },
- "outputs": [],
- "source": [
- "config_path, train_config_path = deeplabcut.create_pretrained_project(\n",
- " project_name,\n",
- " your_name,\n",
- " [video_path],\n",
- " videotype=videotype,\n",
- " model=model2use,\n",
- " analyzevideo=True,\n",
- " createlabeledvideo=True,\n",
- " copy_videos=True, #must leave copy_videos=True\n",
- ")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "WS-KxhBMvEBj"
- },
- "source": [
- "Now, you can move this project from Colab (i.e. download it to your GoogleDrive), and use it like a normal standard project!\n",
- "\n",
- "You can analyze more videos, extract outliers, refine then, and/or then add new key points + label new frames, and retrain if desired. We hope this gives you a good launching point for your work!\n",
- "\n",
- "###Happy DeepLabCutting! Welcome to the Zoo :)\n",
- "\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "KPOqiLmo6d7t"
- },
- "source": [
- "## More advanced options:\n",
- "\n",
- "- If you would now like to customize the video/plots - i.e., color, dot size, threshold for the point to be plotted (pcutoff), please simply edit the \"config.yaml\" file by updating the values below:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "yGLNVK1q6rIp"
- },
- "outputs": [],
- "source": [
- "# Updating the plotting within the config.yaml file (without opening it ;):\n",
- "edits = {\n",
- " 'dotsize': 7, # size of the dots!\n",
- " 'colormap': 'spring', # any matplotlib colormap!\n",
- " 'pcutoff': 0.5, # the higher the more conservative the plotting!\n",
- "}\n",
- "deeplabcut.auxiliaryfunctions.edit_config(config_path, edits)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "Vlc0wZgB7R5e"
- },
- "outputs": [],
- "source": [
- "# re-create the labeled video (first you will need to delete in the folder to the LEFT!):\n",
- "project_path = os.path.dirname(config_path)\n",
- "full_video_path = os.path.join(\n",
- " project_path,\n",
- " 'videos',\n",
- " os.path.basename(video_path),\n",
- ")\n",
- "\n",
- "#filter predictions (should already be done above ;):\n",
- "deeplabcut.filterpredictions(config_path, [full_video_path], videotype=videotype)\n",
- "\n",
- "#re-create the video with your edits!\n",
- "deeplabcut.create_labeled_video(config_path, [full_video_path], videotype=videotype, filtered=True)"
- ]
- }
- ],
- "metadata": {
- "colab": {
- "include_colab_link": true,
- "name": "Copy of COLAB_DLC_ModelZoo.ipynb",
- "provenance": [],
- "toc_visible": true
- },
- "gpuClass": "standard",
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.7.7"
- }
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
},
- "nbformat": 4,
- "nbformat_minor": 0
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "RK255E7YoEIt"
+ },
+ "source": [
+ "# DeepLabCut Model Zoo user-contributed models\n",
+ "\n",
+ "🚨 **WARNING** -- This is using the old version from 2020-2023 with user-supplied models. Please see the SuperAnimal notebook if you want to use our Foundational Models for Quadrupeds or mice.\n",
+ "\n",
+ "\n",
+ "\n",
+ "http://modelzoo.deeplabcut.org\n",
+ "\n",
+ "You can use this notebook to analyze videos with pretrained networks from our model zoo - NO local installation of DeepLabCut is needed!\n",
+ "\n",
+ "- **What you need:** a video of your favorite dog, cat, human, etc: check the list of currently available models here: http://modelzoo.deeplabcut.org\n",
+ "\n",
+ "- **What to do:** (1) in the top right corner, click \"CONNECT\". Then, just hit run (play icon) on each cell below and follow the instructions!\n",
+ "\n",
+ "## **Please consider giving back and labeling a little data to help make each network even better!**\n",
+ "\n",
+ "We have a WebApp, so no need to install anything, just a few clicks! We'd really appreciate your help!\n",
+ " \n",
+ "https://contrib.deeplabcut.org/\n",
+ "\n",
+ "\n",
+ "- **Note, if you performance is less that you would like:** firstly check the labeled_video parameters (i.e. \"pcutoff\" in the config.yaml file that will set the video plotting) - see the end of this notebook. You can also use the model in your own projects locally. Please be sure to cite the papers for the model, and http://modelzoo.deeplabcut.org (paper forthcoming!)\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "## **Let's get going: install DeepLabCut into COLAB:**\n",
+ "\n",
+ "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Install the latest version of DeepLabCut\n",
+ "!pip install --pre \"deeplabcut[tf,modelzoo]\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Important - Restart the Runtime for the updated packages to be imported!\n",
+ "\n",
+ "PLEASE, click \"restart runtime\" from the output above before proceeding!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "ZT4PwGSbYQEO"
+ },
+ "source": [
+ "## Now let's set the backend & import the DeepLabCut package\n",
+ "### (if colab is buggy/throws an error, just rerun this cell):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "bvoiWefrYQEP"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "\n",
+ "import deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "syweXs88tyuO"
+ },
+ "source": [
+ "## Next, run the cell below to upload your video file from your computer:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "7eqEZYs_CaLy"
+ },
+ "outputs": [],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f'User uploaded file \"{filepath}\" with length {len(content)} bytes')\n",
+ "video_path = os.path.abspath(filepath)\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your video via the Files menu to the left\n",
+ "# and define `video_path` yourself with right click > copy path on the video."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "YsaqOTkZtf-w"
+ },
+ "source": [
+ "## Select your model from the dropdown menu, then below (optionally) input the name you want for the project:\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Ih0t7lUjYQEd"
+ },
+ "outputs": [],
+ "source": [
+ "import ipywidgets as widgets\n",
+ "from IPython.display import display\n",
+ "\n",
+ "model_options = deeplabcut.create_project.modelzoo.Modeloptions\n",
+ "model_selection = widgets.Dropdown(\n",
+ " options=model_options, value=model_options[0], description=\"Choose a DLC ModelZoo model!\", disabled=False\n",
+ ")\n",
+ "display(model_selection)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "UV0QXswGCFrI"
+ },
+ "outputs": [],
+ "source": [
+ "project_name = \"myDLC_modelZoo\"\n",
+ "your_name = \"teamDLC\"\n",
+ "model2use = model_selection.value\n",
+ "videotype = os.path.splitext(video_path)[-1].lstrip(\".\") # or MOV, or avi, whatever you uploaded!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "JQxko-t3uMVO"
+ },
+ "source": [
+ "## Attention on this step !!\n",
+ "- Please note that for optimal performance your videos should contain frames that are around ~300-600 pixels (on one edge). If you have a larger video (like from an iPhone, first downsize by running this please! :)\n",
+ "\n",
+ "- Thus, if you're using an iPhone, or such, you'll need to downsample the video first by running the code below**\n",
+ "\n",
+ "(no need to edit it unless you want to change the size)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "WpAX3BKY94e0"
+ },
+ "outputs": [],
+ "source": [
+ "video_path = deeplabcut.DownSampleVideo(video_path, width=300)\n",
+ "print(video_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "KJm_Vbx-s5OY"
+ },
+ "source": [
+ "## Lastly, run the cell below to create a pretrained project, analyze your video with your selected pretrained network, plot trajectories, and create a labeled video!:\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "T9MGgAdIFKPY"
+ },
+ "outputs": [],
+ "source": [
+ "config_path, train_config_path = deeplabcut.create_pretrained_project(\n",
+ " project_name,\n",
+ " your_name,\n",
+ " [video_path],\n",
+ " videotype=videotype,\n",
+ " model=model2use,\n",
+ " analyzevideo=True,\n",
+ " createlabeledvideo=True,\n",
+ " copy_videos=True, # must leave copy_videos=True\n",
+ " engine=deeplabcut.Engine.TF,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "WS-KxhBMvEBj"
+ },
+ "source": [
+ "Now, you can move this project from Colab (i.e. download it to your GoogleDrive), and use it like a normal standard project!\n",
+ "\n",
+ "You can analyze more videos, extract outliers, refine then, and/or then add new key points + label new frames, and retrain if desired. We hope this gives you a good launching point for your work!\n",
+ "\n",
+ "###Happy DeepLabCutting! Welcome to the Zoo :)\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "KPOqiLmo6d7t"
+ },
+ "source": [
+ "## More advanced options:\n",
+ "\n",
+ "- If you would now like to customize the video/plots - i.e., color, dot size, threshold for the point to be plotted (pcutoff), please simply edit the \"config.yaml\" file by updating the values below:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "yGLNVK1q6rIp"
+ },
+ "outputs": [],
+ "source": [
+ "# Updating the plotting within the config.yaml file (without opening it ;):\n",
+ "edits = {\n",
+ " \"dotsize\": 7, # size of the dots!\n",
+ " \"colormap\": \"spring\", # any matplotlib colormap!\n",
+ " \"pcutoff\": 0.5, # the higher the more conservative the plotting!\n",
+ "}\n",
+ "deeplabcut.auxiliaryfunctions.edit_config(config_path, edits)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Vlc0wZgB7R5e"
+ },
+ "outputs": [],
+ "source": [
+ "# re-create the labeled video (first you will need to delete in the folder to the LEFT!):\n",
+ "project_path = os.path.dirname(config_path)\n",
+ "full_video_path = os.path.join(\n",
+ " project_path,\n",
+ " \"videos\",\n",
+ " os.path.basename(video_path),\n",
+ ")\n",
+ "\n",
+ "# filter predictions (should already be done above ;):\n",
+ "deeplabcut.filterpredictions(config_path, [full_video_path], videotype=videotype)\n",
+ "\n",
+ "# re-create the video with your edits!\n",
+ "deeplabcut.create_labeled_video(config_path, [full_video_path], videotype=videotype, filtered=True)"
+ ]
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "include_colab_link": true,
+ "name": "Copy of COLAB_DLC_ModelZoo.ipynb",
+ "provenance": [],
+ "toc_visible": true
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-10-02",
+ "last_metadata_updated": "2026-03-06"
+ },
+ "gpuClass": "standard",
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.7.7"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
diff --git a/examples/COLAB/COLAB_HumanPose_with_RTMPose.ipynb b/examples/COLAB/COLAB_HumanPose_with_RTMPose.ipynb
new file mode 100644
index 0000000000..1e0ec0865f
--- /dev/null
+++ b/examples/COLAB/COLAB_HumanPose_with_RTMPose.ipynb
@@ -0,0 +1,1175 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "t3P1R5BTwud1"
+ },
+ "source": [
+ " \n",
+ "\n",
+ "# DeepLabCut RTMPose human pose estimation demo"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "tJm8QpTzyAEe"
+ },
+ "source": [
+ "Some useful links:\n",
+ "\n",
+ "- DeepLabCut's GitHub: [github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/tree/main)\n",
+ "- DeepLabCut's Documentation: [deeplabcut.github.io/DeepLabCut](https://deeplabcut.github.io/DeepLabCut/README.html)\n",
+ "\n",
+ "This notebook illustrates how to use the cloud to run pose estimation on humans using a pre-trained [RTMPose](https://arxiv.org/abs/2303.07399) model. **⚠️Note: It uses DeepLabCut's low-level interface, so may be suited for more experienced users.⚠️**\n",
+ "\n",
+ "RTMPose is a top-down pose estimation model, which means that bounding boxes must be obtained for individuals (which is usually done through an [object detection model](https://en.wikipedia.org/wiki/Object_detection)) before running pose estimation. We obtain bounding boxes using a pre-trained object detector provided by [`torchvision`](https://pytorch.org/vision/main/models.html#object-detection-instance-segmentation-and-person-keypoint-detection).\n",
+ "\n",
+ "## Selecting the Runtime and Installing DeepLabCut\n",
+ "\n",
+ "**First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\".**\n",
+ "\n",
+ "Next, we need to install DeepLabCut and its dependencies."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Aj7Fgm0Xx_fS"
+ },
+ "outputs": [],
+ "source": [
+ "# this will take a couple of minutes to install all the dependencies!\n",
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "twiCWHbgzbwH"
+ },
+ "source": [
+ "**(Be sure to click \"RESTART RUNTIME\" if it is displayed above before moving on !) You will see this button at the output of the cells above ^.**"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "x6DugzWMzGoj"
+ },
+ "source": [
+ "## Importing Packages and Downloading Model Snapshots"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Y7jKbk_mzPJR"
+ },
+ "source": [
+ "Next, we'll need to import `deeplabcut`, `huggingface_hub` and other dependencies needed to run the demo."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "gbXwpGKXzF98",
+ "outputId": "d7cc8390-e76a-4cc6-b945-42f0951c8d01"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Loading DLC 3.0.0rc10...\n",
+ "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n"
+ ]
+ }
+ ],
+ "source": [
+ "from pathlib import Path\n",
+ "\n",
+ "import huggingface_hub\n",
+ "import matplotlib.collections as collections\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "import torchvision.models.detection as detection\n",
+ "from PIL import Image\n",
+ "from tqdm import tqdm\n",
+ "\n",
+ "import deeplabcut.pose_estimation_pytorch as dlc_torch"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "6KWKmWRxzX5R"
+ },
+ "source": [
+ "We can now download the pre-trained RTMPose model weights with which we'll run pose estimation."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "L_V11iCszw3s",
+ "outputId": "8b010e6c-27f5-46ad-f713-2fd07effa3b1"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/huggingface_hub/utils/_auth.py:94: UserWarning: \n",
+ "The secret `HF_TOKEN` does not exist in your Colab secrets.\n",
+ "To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.\n",
+ "You will be able to reuse this secret in all of your notebooks.\n",
+ "Please note that authentication is recommended but still optional to access public models or datasets.\n",
+ " warnings.warn(\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Folder in COLAB where snapshots will be saved\n",
+ "model_files = Path(\"hf_files\").resolve()\n",
+ "model_files.mkdir(exist_ok=True)\n",
+ "\n",
+ "# Download the snapshot and model configuration file\n",
+ "# This is generic code to download any snapshot from HuggingFace\n",
+ "# To download DeepLabCut SuperAnimal or Model Zoo models, check\n",
+ "# out dlclibrary!\n",
+ "path_model_config = Path(\n",
+ " huggingface_hub.hf_hub_download(\n",
+ " \"DeepLabCut/HumanBody\",\n",
+ " \"rtmpose-x_simcc-body7_pytorch_config.yaml\",\n",
+ " local_dir=model_files,\n",
+ " )\n",
+ ")\n",
+ "path_snapshot = Path(\n",
+ " huggingface_hub.hf_hub_download(\n",
+ " \"DeepLabCut/HumanBody\",\n",
+ " \"rtmpose-x_simcc-body7.pt\",\n",
+ " local_dir=model_files,\n",
+ " )\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "eEqukXXy0coy"
+ },
+ "source": [
+ "We'll now also define some parameters that we'll later use to plot predictions:\n",
+ "\n",
+ "- a colormap for the keypoints to plot\n",
+ "- a colormap for the limbs of the skeleton\n",
+ "- a skeleton for the model\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {
+ "id": "Tam4rfJK0c_b"
+ },
+ "outputs": [],
+ "source": [
+ "cmap_keypoints = plt.get_cmap(\"rainbow\")\n",
+ "cmap_skeleton = plt.get_cmap(\"rainbow_r\")\n",
+ "\n",
+ "bodyparts2connect = [\n",
+ " (\"right_ankle\", \"right_knee\"),\n",
+ " (\"right_knee\", \"right_hip\"),\n",
+ " (\"left_ankle\", \"left_knee\"),\n",
+ " (\"left_hip\", \"left_knee\"),\n",
+ " (\"left_hip\", \"right_hip\"),\n",
+ " (\"right_shoulder\", \"right_hip\"),\n",
+ " (\"left_shoulder\", \"left_hip\"),\n",
+ " (\"left_shoulder\", \"right_shoulder\"),\n",
+ " (\"left_shoulder\", \"left_elbow\"),\n",
+ " (\"right_shoulder\", \"right_elbow\"),\n",
+ " (\"left_elbow\", \"left_wrist\"),\n",
+ " (\"right_elbow\", \"right_wrist\"),\n",
+ " (\"right_eye\", \"left_ear\"),\n",
+ " (\"left_eye\", \"right_eye\"),\n",
+ " (\"left_eye\", \"left_ear\"),\n",
+ " (\"right_eye\", \"right_ear\"),\n",
+ " (\"left_ear\", \"left_shoulder\"),\n",
+ " (\"right_ear\", \"right_shoulder\"),\n",
+ " (\"left_shoulder\", \"left_elbow\"),\n",
+ " (\"right_shoulder\", \"right_elbow\"),\n",
+ "]\n",
+ "skeleton = [\n",
+ " [16, 14],\n",
+ " [14, 12],\n",
+ " [17, 15],\n",
+ " [15, 13],\n",
+ " [12, 13],\n",
+ " [6, 12],\n",
+ " [7, 13],\n",
+ " [6, 7],\n",
+ " [6, 8],\n",
+ " [7, 9],\n",
+ " [8, 10],\n",
+ " [9, 11],\n",
+ " [2, 3],\n",
+ " [1, 2],\n",
+ " [1, 3],\n",
+ " [2, 4],\n",
+ " [3, 5],\n",
+ " [4, 6],\n",
+ " [5, 7],\n",
+ "]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "cCxkkd-b0EJq"
+ },
+ "source": [
+ "## Running Inference on Images"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "dotn_xN-05gh"
+ },
+ "source": [
+ "First, let's upload some images to run inference on. To do so, you can just run the cell below."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 92
+ },
+ "id": "mZtikE1H0D34",
+ "outputId": "3d47314f-3ed0-40b2-e54d-2677feef9943"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " \n",
+ " Upload widget is only available when the cell has been executed in the\n",
+ " current browser session. Please rerun this cell to enable.\n",
+ " \n",
+ " "
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Saving taylor_swift.jpg to taylor_swift.jpg\n",
+ "User uploaded file 'taylor_swift.jpg' with length 46915 bytes\n"
+ ]
+ }
+ ],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "# JPG or PNG is recommended:\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n",
+ "\n",
+ "image_paths = [Path(filepath).resolve() for filepath in uploaded.keys()]\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your image via the Files menu to the left and define\n",
+ "# `image_paths` yourself with right `click` > `copy path` on the image:\n",
+ "#\n",
+ "# image_paths = [\n",
+ "# Path(\"/path/to/my/image_000.png\"),\n",
+ "# Path(\"/path/to/my/image_001.png\"),\n",
+ "# ]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "nj-HtOBSwtdk",
+ "outputId": "eb5f3b18-cc89-4dd1-a58e-6c39c62582af"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running object detection\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 1/1 [00:00<00:00, 1.95it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running pose estimation\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "1it [00:00, 78.27it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Saving the predictions to a CSV file\n",
+ "Done!\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Define the device on which the models will run\n",
+ "device = \"cuda\" # e.g. cuda, cpu\n",
+ "\n",
+ "# The maximum number of detections to keep in an image\n",
+ "max_detections = 10\n",
+ "\n",
+ "#############################################\n",
+ "# Run a pretrained detector to get bounding boxes\n",
+ "\n",
+ "# Load the detector from torchvision\n",
+ "weights = detection.FasterRCNN_MobileNet_V3_Large_FPN_Weights.DEFAULT\n",
+ "detector = detection.fasterrcnn_mobilenet_v3_large_fpn(\n",
+ " weights=weights,\n",
+ " box_score_thresh=0.6,\n",
+ ")\n",
+ "detector.eval()\n",
+ "detector.to(device)\n",
+ "preprocess = weights.transforms()\n",
+ "\n",
+ "# The context is a list containing the bounding boxes predicted\n",
+ "# for each image; it will be given to the RTMPose model alongside\n",
+ "# the images.\n",
+ "context = []\n",
+ "\n",
+ "print(\"Running object detection\")\n",
+ "with torch.no_grad():\n",
+ " for image_path in tqdm(image_paths):\n",
+ " image = Image.open(image_path).convert(\"RGB\")\n",
+ " batch = [preprocess(image).to(device)]\n",
+ " predictions = detector(batch)[0]\n",
+ " bboxes = predictions[\"boxes\"].cpu().numpy()\n",
+ " labels = predictions[\"labels\"].cpu().numpy()\n",
+ "\n",
+ " # Obtain the bounding boxes predicted for humans\n",
+ " human_bboxes = [bbox for bbox, label in zip(bboxes, labels, strict=False) if label == 1]\n",
+ "\n",
+ " # Convert bounding boxes to xywh format\n",
+ " bboxes = np.zeros((0, 4))\n",
+ " if len(human_bboxes) > 0:\n",
+ " bboxes = np.stack(human_bboxes)\n",
+ " bboxes[:, 2] -= bboxes[:, 0]\n",
+ " bboxes[:, 3] -= bboxes[:, 1]\n",
+ "\n",
+ " # Only keep the best N detections\n",
+ " bboxes = bboxes[:max_detections]\n",
+ "\n",
+ " context.append({\"bboxes\": bboxes})\n",
+ "\n",
+ "\n",
+ "#############################################\n",
+ "# Run inference on the images\n",
+ "pose_cfg = dlc_torch.config.read_config_as_dict(path_model_config)\n",
+ "runner = dlc_torch.get_pose_inference_runner(\n",
+ " pose_cfg,\n",
+ " snapshot_path=path_snapshot,\n",
+ " batch_size=16,\n",
+ " max_individuals=max_detections,\n",
+ ")\n",
+ "\n",
+ "print(\"Running pose estimation\")\n",
+ "predictions = runner.inference(tqdm(zip(image_paths, context, strict=False)))\n",
+ "\n",
+ "\n",
+ "#############################################\n",
+ "# Create a DataFrame with the predictions, and save them to a CSV file.\n",
+ "print(\"Saving the predictions to a CSV file\")\n",
+ "df = dlc_torch.build_predictions_dataframe(\n",
+ " scorer=\"rtmpose-body7\",\n",
+ " predictions={\n",
+ " img_path: img_predictions for img_path, img_predictions in zip(image_paths, predictions, strict=False)\n",
+ " },\n",
+ " parameters=dlc_torch.PoseDatasetParameters(\n",
+ " bodyparts=pose_cfg[\"metadata\"][\"bodyparts\"],\n",
+ " unique_bpts=pose_cfg[\"metadata\"][\"unique_bodyparts\"],\n",
+ " individuals=[f\"idv_{i}\" for i in range(max_detections)],\n",
+ " ),\n",
+ ")\n",
+ "\n",
+ "# Save to CSV\n",
+ "df.to_csv(\"image_predictions.csv\")\n",
+ "\n",
+ "print(\"Done!\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "pWtdL4U52OBJ"
+ },
+ "source": [
+ "Finally, we can plot the predictions!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 447
+ },
+ "id": "3slKu6Lr2MUh",
+ "outputId": "ef7d938c-39fc-473a-9b88-6169cbfbc567"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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\n",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "#############################################\n",
+ "# Unpack and plot predictions\n",
+ "plot_skeleton = True\n",
+ "plot_pose_markers = True\n",
+ "plot_bounding_boxes = True\n",
+ "marker_size = 12\n",
+ "\n",
+ "for image_path, image_predictions in zip(image_paths, predictions, strict=False):\n",
+ " image = Image.open(image_path).convert(\"RGB\")\n",
+ "\n",
+ " pose = image_predictions[\"bodyparts\"]\n",
+ " bboxes = image_predictions[\"bboxes\"]\n",
+ " num_individuals, num_bodyparts = pose.shape[:2]\n",
+ "\n",
+ " fig, ax = plt.subplots(figsize=(8, 8))\n",
+ " ax.imshow(image)\n",
+ " ax.set_xlim(0, image.width)\n",
+ " ax.set_ylim(image.height, 0)\n",
+ " ax.axis(\"off\")\n",
+ " for idv_pose in pose:\n",
+ " if plot_skeleton:\n",
+ " bones = []\n",
+ " for bpt_1, bpt_2 in skeleton:\n",
+ " bones.append([idv_pose[bpt_1 - 1, :2], idv_pose[bpt_2 - 1, :2]])\n",
+ "\n",
+ " bone_colors = cmap_skeleton\n",
+ " if not isinstance(cmap_skeleton, str):\n",
+ " bone_colors = cmap_skeleton(np.linspace(0, 1, len(skeleton)))\n",
+ "\n",
+ " ax.add_collection(collections.LineCollection(bones, colors=bone_colors))\n",
+ "\n",
+ " if plot_pose_markers:\n",
+ " ax.scatter(\n",
+ " idv_pose[:, 0],\n",
+ " idv_pose[:, 1],\n",
+ " c=list(range(num_bodyparts)),\n",
+ " cmap=\"rainbow\",\n",
+ " s=marker_size,\n",
+ " )\n",
+ "\n",
+ " if plot_bounding_boxes:\n",
+ " for x, y, w, h in bboxes:\n",
+ " ax.plot(\n",
+ " [x, x + w, x + w, x, x],\n",
+ " [y, y, y + h, y + h, y],\n",
+ " c=\"r\",\n",
+ " )\n",
+ "\n",
+ " plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "wO18A_3m5Spk"
+ },
+ "source": [
+ "## Running Inference on a Video\n",
+ "\n",
+ "Running pose inference on a video is very similar! First, upload a video to Google Drive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 92
+ },
+ "id": "d9a7gSe15bCa",
+ "outputId": "698b180c-cd8f-4d17-9c71-f8e58f93631b"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ " \n",
+ " \n",
+ " Upload widget is only available when the cell has been executed in the\n",
+ " current browser session. Please rerun this cell to enable.\n",
+ " \n",
+ " "
+ ],
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Saving taylor-dancing.mov to taylor-dancing.mov\n",
+ "User uploaded file 'taylor-dancing.mov' with length 1415324 bytes\n"
+ ]
+ }
+ ],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n",
+ "\n",
+ "\n",
+ "video_path = [Path(filepath).resolve() for filepath in uploaded.keys()][0]\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your video via the Files menu to the left and define\n",
+ "# `video_path` yourself with right `click` > `copy path` on the video:\n",
+ "#\n",
+ "# video_path = Path(\"/path/to/my/video.mp4\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "I885B01359qu",
+ "outputId": "0affdeda-a10b-4849-b3cd-edf1cb202b52"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running object detection\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ " 81%|████████▏ | 66/81 [00:02<00:00, 25.37it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running pose estimation\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ " 81%|████████▏ | 66/81 [00:01<00:00, 53.25it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Saving the predictions to a CSV file\n",
+ "Done!\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Define the device on which the models will run\n",
+ "device = \"cuda\" # e.g. cuda, cpu\n",
+ "\n",
+ "# The maximum number of individuals to detect in an image\n",
+ "max_detections = 30\n",
+ "\n",
+ "\n",
+ "#############################################\n",
+ "# Create a video iterator\n",
+ "video = dlc_torch.VideoIterator(video_path)\n",
+ "\n",
+ "\n",
+ "#############################################\n",
+ "# Run a pretrained detector to get bounding boxes\n",
+ "\n",
+ "# Load the detector from torchvision\n",
+ "weights = detection.FasterRCNN_MobileNet_V3_Large_FPN_Weights.DEFAULT\n",
+ "detector = detection.fasterrcnn_mobilenet_v3_large_fpn(\n",
+ " weights=weights,\n",
+ " box_score_thresh=0.6,\n",
+ ")\n",
+ "detector.eval()\n",
+ "detector.to(device)\n",
+ "preprocess = weights.transforms()\n",
+ "\n",
+ "# The context is a list containing the bounding boxes predicted for each frame\n",
+ "# in the video.\n",
+ "context = []\n",
+ "\n",
+ "print(\"Running object detection\")\n",
+ "with torch.no_grad():\n",
+ " for frame in tqdm(video):\n",
+ " batch = [preprocess(Image.fromarray(frame)).to(device)]\n",
+ " predictions = detector(batch)[0]\n",
+ " bboxes = predictions[\"boxes\"].cpu().numpy()\n",
+ " labels = predictions[\"labels\"].cpu().numpy()\n",
+ "\n",
+ " # Obtain the bounding boxes predicted for humans\n",
+ " human_bboxes = [bbox for bbox, label in zip(bboxes, labels, strict=False) if label == 1]\n",
+ "\n",
+ " # Convert bounding boxes to xywh format\n",
+ " bboxes = np.zeros((0, 4))\n",
+ " if len(human_bboxes) > 0:\n",
+ " bboxes = np.stack(human_bboxes)\n",
+ " bboxes[:, 2] -= bboxes[:, 0]\n",
+ " bboxes[:, 3] -= bboxes[:, 1]\n",
+ "\n",
+ " # Only keep the top N bounding boxes\n",
+ " bboxes = bboxes[:max_detections]\n",
+ "\n",
+ " context.append({\"bboxes\": bboxes})\n",
+ "\n",
+ "# Set the context for the video\n",
+ "video.set_context(context)\n",
+ "\n",
+ "\n",
+ "#############################################\n",
+ "# Run inference on the images (in this case a single image)\n",
+ "pose_cfg = dlc_torch.config.read_config_as_dict(path_model_config)\n",
+ "runner = dlc_torch.get_pose_inference_runner(\n",
+ " pose_cfg,\n",
+ " snapshot_path=path_snapshot,\n",
+ " batch_size=16,\n",
+ " max_individuals=max_detections,\n",
+ ")\n",
+ "\n",
+ "print(\"Running pose estimation\")\n",
+ "predictions = runner.inference(tqdm(video))\n",
+ "\n",
+ "\n",
+ "print(\"Saving the predictions to a CSV file\")\n",
+ "df = dlc_torch.build_predictions_dataframe(\n",
+ " scorer=\"rtmpose-body7\",\n",
+ " predictions={idx: img_predictions for idx, img_predictions in enumerate(predictions)},\n",
+ " parameters=dlc_torch.PoseDatasetParameters(\n",
+ " bodyparts=pose_cfg[\"metadata\"][\"bodyparts\"],\n",
+ " unique_bpts=pose_cfg[\"metadata\"][\"unique_bodyparts\"],\n",
+ " individuals=[f\"idv_{i}\" for i in range(max_detections)],\n",
+ " ),\n",
+ ")\n",
+ "df.to_csv(\"video_predictions.csv\")\n",
+ "\n",
+ "print(\"Done!\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "altka3NGB_su"
+ },
+ "source": [
+ "Finally, we can plot the predictions on the video! The labeled video output is saved in the `\"video_predictions.mp4\"` file, and can be downloaded to be viewed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "xRWxH0gO6oPg",
+ "outputId": "c2cc9025-7741-4403-d5cc-c62470a4ba74"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/utils/make_labeled_video.py:146: FutureWarning: DataFrame.groupby with axis=1 is deprecated. Do `frame.T.groupby(...)` without axis instead.\n",
+ " Dataframe.groupby(level=\"individuals\", axis=1).size().values // 3\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Duration of video [s]: 1.57, recorded with 51.7 fps!\n",
+ "Overall # of frames: 81 with cropped frame dimensions: 828 768\n",
+ "Generating frames and creating video.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 66/66 [00:01<00:00, 35.27it/s]\n"
+ ]
+ }
+ ],
+ "source": [
+ "from deeplabcut.utils.make_labeled_video import CreateVideo\n",
+ "from deeplabcut.utils.video_processor import VideoProcessorCV\n",
+ "\n",
+ "video_output_path = \"video_predictions.mp4\"\n",
+ "\n",
+ "clip = VideoProcessorCV(str(video_path), sname=video_output_path, codec=\"mp4v\")\n",
+ "CreateVideo(\n",
+ " clip,\n",
+ " df,\n",
+ " pcutoff=0.4,\n",
+ " dotsize=3,\n",
+ " colormap=\"rainbow\",\n",
+ " bodyparts2plot=pose_cfg[\"metadata\"][\"bodyparts\"],\n",
+ " trailpoints=0,\n",
+ " cropping=False,\n",
+ " x1=0,\n",
+ " x2=clip.w,\n",
+ " y1=0,\n",
+ " y2=clip.h,\n",
+ " bodyparts2connect=bodyparts2connect,\n",
+ " skeleton_color=\"w\",\n",
+ " draw_skeleton=True,\n",
+ " displaycropped=True,\n",
+ " color_by=\"bodypart\",\n",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "gpuType": "T4",
+ "include_colab_link": true,
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb
new file mode 100644
index 0000000000..7e77e835d2
--- /dev/null
+++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb
@@ -0,0 +1,2228 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "5SSZpZUu0Z4S"
+ },
+ "source": [
+ "# DeepLabCut Model Zoo: SuperAnimal models\n",
+ "\n",
+ "\n",
+ "\n",
+ "# 🦄 SuperAnimal in DeepLabCut PyTorch! 🔥\n",
+ "\n",
+ "This notebook demos how to use our SuperAnimal models within DeepLabCut 3.0! Please read more in [Ye et al. Nature Communications 2024](https://www.nature.com/articles/s41467-024-48792-2) about the available SuperAnimal models, and follow along below!\n",
+ "\n",
+ "### **Let's get going: install the latest version of DeepLabCut into COLAB:**\n",
+ "\n",
+ "*Also, be sure you are connected to a GPU: go to menu, click Runtime > Change Runtime Type > select \"GPU\"*\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000
+ },
+ "id": "AjET5cJE5UYM",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "290a589f-a063-4933-d315-e13052ec1024"
+ },
+ "outputs": [],
+ "source": [
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "5h0vq6E50Z4W"
+ },
+ "source": [
+ "**PLEASE, click \"restart runtime\" from the output above before proceeding!**"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "LvnlIvQm0Z4X",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "ef4fd2ed-4569-41d4-b78a-8bf5ae9a0e6b"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "from pathlib import Path\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import pandas as pd\n",
+ "from PIL import Image\n",
+ "\n",
+ "import deeplabcut\n",
+ "import deeplabcut.utils.auxiliaryfunctions as auxiliaryfunctions\n",
+ "from deeplabcut.modelzoo import build_weight_init\n",
+ "from deeplabcut.modelzoo.utils import (\n",
+ " create_conversion_table,\n",
+ " read_conversion_table_from_csv,\n",
+ ")\n",
+ "from deeplabcut.modelzoo.video_inference import video_inference_superanimal\n",
+ "from deeplabcut.pose_estimation_pytorch.apis import (\n",
+ " superanimal_analyze_images,\n",
+ ")\n",
+ "from deeplabcut.utils.pseudo_label import keypoint_matching"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "UeXjmtu40Z4X"
+ },
+ "source": [
+ "## Zero-shot Image & Video Inference\n",
+ "SuperAnimal models are foundation animal pose models. They can be used for zero-shot predictions without further training on the data.\n",
+ "In this section, we show how to use SuperAnimal models to predict pose from images (given an image folder) and output the predicted images (with pose) into another destination folder."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "FvFzntDMxPoL"
+ },
+ "source": [
+ "### Zero-shot image inference\n",
+ "\n",
+ "If you have a single Image you want to test, upload it here!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "NbDsZQfsxPoL"
+ },
+ "source": [
+ "#### Upload the images you want to predict"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "c4yfTj7r0Z4Y",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n",
+ "image_path = os.path.abspath(filepath)\n",
+ "image_name = os.path.splitext(image_path)[0]\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your video via the Files menu to the left\n",
+ "# and define `image_path` yourself with right click > copy path on the image:\n",
+ "#\n",
+ "# image_path = \"/path/to/my/image.png\"\n",
+ "# image_name = os.path.splitext(image_path)[0]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Jashzdjb0Z4Y"
+ },
+ "source": [
+ "#### Select a SuperAnimal name and corresponding model architecture\n",
+ "\n",
+ "Check Our Docs on [SuperAnimals](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/ModelZoo.md) to learn more!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "uH9LXig90Z4Y",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# @markdown ---\n",
+ "# @markdown SuperAnimal Configurations\n",
+ "superanimal_name = \"superanimal_topviewmouse\" # @param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n",
+ "model_name = \"hrnet_w32\" # @param [\"hrnet_w32\", \"resnet_50\"]\n",
+ "detector_name = (\n",
+ " \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"]\n",
+ ")\n",
+ "\n",
+ "# @markdown ---\n",
+ "# @markdown What is the maximum number of animals you expect to have in an image\n",
+ "max_individuals = 3 # @param {type:\"slider\", min:1, max:30, step:1}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "OmJtVmHq0Z4Y",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# Note you need to enter max_individuals correctly to get the correct number of predictions in the image.\n",
+ "_ = superanimal_analyze_images(\n",
+ " superanimal_name,\n",
+ " model_name,\n",
+ " detector_name,\n",
+ " image_path,\n",
+ " max_individuals,\n",
+ " out_folder=\"/content/\",\n",
+ " close_figure_after_save=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "6VEjHu-00Z4Y"
+ },
+ "source": [
+ "### Zero-shot Video Inference\n",
+ "\n",
+ "This can be done with or without video adaptation (faster, but not self-supervised fine-tuned on your data!)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "qGoAhxZOxPoM"
+ },
+ "source": [
+ "#### Upload a video you want to predict"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "PK3efA0I0Z4Y",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "from google.colab import files\n",
+ "\n",
+ "uploaded = files.upload()\n",
+ "for filepath, content in uploaded.items():\n",
+ " print(f\"User uploaded file '{filepath}' with length {len(content)} bytes\")\n",
+ "video_path = os.path.abspath(filepath)\n",
+ "video_name = os.path.splitext(video_path)[0]\n",
+ "\n",
+ "# If this cell fails (e.g., when using Safari in place of Google Chrome),\n",
+ "# manually upload your video via the Files menu to the left\n",
+ "# and define `video_path` yourself with right click > copy path on the video."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "JoA-RATSICj_"
+ },
+ "source": [
+ "#### Choose the superanimal and the model name"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "OiRAP9XD0Z4Z",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# @markdown ---\n",
+ "# @markdown SuperAnimal Configurations\n",
+ "superanimal_name = \"superanimal_topviewmouse\" # @param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n",
+ "model_name = \"hrnet_w32\" # @param [\"hrnet_w32\", \"resnet_50\"]\n",
+ "detector_name = (\n",
+ " \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"]\n",
+ ")\n",
+ "\n",
+ "# @markdown ---\n",
+ "# @markdown What is the maximum number of animals you expect to have in an image\n",
+ "max_individuals = 3 # @param {type:\"slider\", min:1, max:30, step:1}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Zv3v0QgSJNOg"
+ },
+ "source": [
+ "#### Zero-shot Video Inference without video adaptation\n",
+ "\n",
+ "The labeled video (and pose predictions for the video) are saved in `\"/content/\"`, with the labeled video name being `{your_video_name}_superanimal_{superanimal_name}_hrnetw32_labeled.mp4`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "poqynL0UJTBp",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "_ = video_inference_superanimal(\n",
+ " videos=video_path,\n",
+ " superanimal_name=superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " video_adapt=False,\n",
+ " max_individuals=max_individuals,\n",
+ " dest_folder=\"/content/\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Z8Z5GSti0Z4Z"
+ },
+ "source": [
+ "#### Zero-shot Video Inference with video adaptation (unsupervised)\n",
+ "\n",
+ "The labeled video (and pose predictions for the video) are saved in `\"/content/\"`, with the labeled video name being `{your_video_name}_superanimal_{superanimal_name}_hrnetw32_labeled_after_adapt.mp4`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "5mhOmtzw0Z4Z",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "_ = video_inference_superanimal(\n",
+ " videos=[video_path],\n",
+ " superanimal_name=superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " video_adapt=True,\n",
+ " max_individuals=max_individuals,\n",
+ " pseudo_threshold=0.1,\n",
+ " bbox_threshold=0.9,\n",
+ " detector_epochs=1,\n",
+ " pose_epochs=1,\n",
+ " dest_folder=\"/content/\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "br3pwGf40Z4a"
+ },
+ "source": [
+ "## Training with SuperAnimal\n",
+ "\n",
+ "In this section, we compare different ways to train models in DeepLabCut 3.0, with or without using SuperAnimal-pretrained models.\n",
+ "You can compare the evaluation results and get a sense of each baseline. We have following baselines:\n",
+ "\n",
+ "- ImageNet transfer learning (training without superanimal)\n",
+ "- SuperAnimal transfer learning (baseline 1)\n",
+ "- SuperAnimal naive fine-tuning (baseline 2)\n",
+ "- SuperAnimal memory-replay fine-tuning (baseline3)\n",
+ "\n",
+ "This is done on one of your DeepLabCut projects! If you don't have a DeepLabCut project that you can use SuperAnimal models with, you can always using the example openfield dataset [available in the DeepLabCut repository](https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/openfield-Pranav-2018-10-30) or the Tri-Mouse dataset available on [Zenodo](https://zenodo.org/records/5851157)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "yPy5VgDDhD6o"
+ },
+ "source": [
+ "### Preparing the DeepLabCut Project\n",
+ "\n",
+ "First, place your DeepLabCut project folder into you google drive! \"i.e. move the folder named \"Project-YourName-TheDate\" into Google Drive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "SXzBBV8ehDR9",
+ "outputId": "90d61c19-400b-4e5d-8ac9-63680d72cdb5"
+ },
+ "outputs": [],
+ "source": [
+ "# Now, let's link to your GoogleDrive. Run this cell and follow the\n",
+ "# authorization instructions:\n",
+ "\n",
+ "from google.colab import drive\n",
+ "\n",
+ "drive.mount(\"/content/drive\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "-QmTftBMo4h6"
+ },
+ "source": [
+ "You will need to edit the project path in the config.yaml file to be set to your Google Drive link!\n",
+ "\n",
+ "Typically, this will be in the format: `/content/drive/MyDrive/yourProjectFolderName`. You can obtain this path by going to the file navigator in the left pane, finding your DeepLabCut project folder, clicking on the vertical `...` next to the folder name and selecting \"Copy path\".\n",
+ "\n",
+ "If the `drive` folder is not immediately visible after mounting the drive, refresh the available files!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "_iFFEYAB7Uum"
+ },
+ "outputs": [],
+ "source": [
+ "# TODO: Update the `project_path` to be the path of your DeepLabCut project!\n",
+ "project_path = Path(\"/content/drive/MyDrive/my-project-2024-07-17\")\n",
+ "config_path = str(project_path / \"config.yaml\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "HZTG3Eo475w0"
+ },
+ "source": [
+ "Then, use the panel below to select the appropriate SuperAnimal model for your project (don't forget to run the cell)!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "t8NtCy1Jo0bu"
+ },
+ "outputs": [],
+ "source": [
+ "# @markdown ---\n",
+ "# @markdown SuperAnimal Configurations\n",
+ "superanimal_name = \"superanimal_topviewmouse\" # @param [\"superanimal_topviewmouse\", \"superanimal_quadruped\"]\n",
+ "model_name = \"hrnet_w32\" # @param [\"hrnet_w32\", \"resnet_50\"]\n",
+ "detector_name = (\n",
+ " \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"]\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "BPvoL9uZ0Z4a"
+ },
+ "source": [
+ "### Comparison between different training baselines\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "eVmpaLdB0Z4a"
+ },
+ "source": [
+ "Definition of data split: the unique combination of training images and testing images.\n",
+ "We create a data split named split 0. All baselines will share the data split to make fair comparisons.\n",
+ "- split 0 -> shared by all baselines\n",
+ "- shuffle 0 (split0) -> imagenet transfer learning\n",
+ "- shuffle 1 (split0) -> superanimal transfer learning\n",
+ "- shuffle 2 (split0) -> superanimal naive fine-tuning\n",
+ "- shuffle 3 (split0) -> superanimal memory-replay fine-tuning"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "WofR2jytxPoR"
+ },
+ "source": [
+ "### What is the difference between baselines?\n",
+ "\n",
+ "**Transfer learning** For canonical task-agnostic transfer learning,\n",
+ "the encoder learns universal visual features from a large pre-training dataset, and a randomly\n",
+ "initialized decoder is used to learn the pose from the downstream dataset.\n",
+ "\n",
+ "**Fine-tuning** For task aware\n",
+ "fine-tuning, both encoder and decoder learn task-related visual-pose features\n",
+ "in the pre-training datasets, and the decoder is fine-tuned to update pose\n",
+ "priors in downstream datasets. Crucially, the network has pose-estimation-specific\n",
+ "weights\n",
+ "\n",
+ "**ImageNet transfer-learning** The encoder was pre-trained from ImageNet. The decoder is trained from scratch in the downstream tasks\n",
+ "\n",
+ "**SuperAnimal transfer-learning** The encoder was pre-trained first from ImageNet, then in pose datasets we colleceted. Then decoder is trained from scratch in downstream tasks.\n",
+ "\n",
+ "**SuperAnimal naive fine-tuning** Both the encoder and the decoder were pre-trained in pose datasets we collected. In downstream datasets, we only finetune convolutional channels that correspond to the annotated keypoints in the downstream datasets. This introduces catastrophic forgetting in keypoints that are not annotated in the downstream datasets.\n",
+ "\n",
+ "**SuperAnimal memory-replay fine-tuning** If we apply fine-tuning with SuperAnimal without further cares, the models will forget about keypoints that are not annotated in the downstream datasets. To mitigate this, we mix the annotations and zero-shot predictions of SuperAnimal models to create a dataset that 'replays' the memory of the SuperAnimal keypoints.\n",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "AgIsUu6v0Z4a",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "imagenet_transfer_learning_shuffle = 0\n",
+ "superanimal_transfer_learning_shuffle = 1\n",
+ "superanimal_naive_finetune_shuffle = 2\n",
+ "superanimal_memory_replay_shuffle = 3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "kuKcxM8F0Z4a",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "c7df2943-1e2c-4b85-c20d-8b94a8aabd75"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_training_dataset(\n",
+ " config_path,\n",
+ " Shuffles=[imagenet_transfer_learning_shuffle],\n",
+ " net_type=f\"top_down_{model_name}\",\n",
+ " detector_type=detector_name,\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " userfeedback=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "_6RncQbr0Z4a"
+ },
+ "source": [
+ "### ImageNet transfer learning\n",
+ "\n",
+ "Historically, the transfer learning using ImageNet weights strategies assumed no “animal pose task priors” in the pretrained\n",
+ "model, a paradigm adopted from previous task-agnostic transfer learning.\n",
+ "\n",
+ "You can change the number of epochs you want to train for. How long training will take depends on many parameters, including the number of images in your dataset, the resolution of the images, and the number of epochs you train for."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000,
+ "referenced_widgets": [
+ "7ed11ae2a4be462da84ff716e0725af0",
+ "0f0ed94a863f49b9b85d0a18fa8ce2a5",
+ "343f2670d37c4bf18859238c3d81d419",
+ "d104ae21091e4f10a7de18e191b9f04d",
+ "5dcbd8f3fb6148cca6cfc72b20ce49bd",
+ "e1675e53ca9a4da8acf6c16fba7a2578",
+ "3d2996e10f96404baf24d2c4215b75a1",
+ "b988f87e676840ee98daa3d996c9ddbc",
+ "1779b84e748b4989a8ed53434c30016f",
+ "d37cf6fe7c444bc2a2568c3407389ea8",
+ "2cef5e028d2e40a6bba7400be922d0c2"
+ ]
+ },
+ "id": "H2z8kM340Z4a",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "75cc2c95-2ac7-4354-9134-4847937e15ce"
+ },
+ "outputs": [],
+ "source": [
+ "# Note we skip the detector training to save time.\n",
+ "# For Top-Down models, the evaluation is by default using ground-truth bounding\n",
+ "# boxes. But to train a model that can be used to inference videos and images,\n",
+ "# you have to set detector_epochs > 0.\n",
+ "\n",
+ "deeplabcut.train_network(\n",
+ " config_path,\n",
+ " detector_epochs=0,\n",
+ " epochs=50,\n",
+ " save_epochs=10,\n",
+ " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n",
+ " displayiters=10,\n",
+ " shuffle=imagenet_transfer_learning_shuffle,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "J-udMck7nDbG"
+ },
+ "source": [
+ "Now let's evaluate the performance of our trained models."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "TDHMdKz4m_16",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "1d38fb84-7f4c-45d1-dbcd-fd7117ca4dad"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.evaluate_network(config_path, Shuffles=[imagenet_transfer_learning_shuffle])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "0GIFWU-MxPoR"
+ },
+ "source": [
+ "### Transfer learning with SuperAnimal weights\n",
+ "\n",
+ "First, we prepare training shuffle for transfer-learning with SuperAnimal weights. As we've already create a shuffle with a train/test split that we want to reuse, we use `deeplabcut.create_training_dataset_from_existing_split` to keep the same train/test indices as in the ImageNet transfer learning shuffle.\n",
+ "\n",
+ "We specify that we want to initialize the model weights with the selected SuperAnimal model, but without keeping the decoding layers (this is called transfer learning)!\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "wOSdZQtOp8qa",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "ea721606-ea9f-444b-cdae-f62cf0ad30be"
+ },
+ "outputs": [],
+ "source": [
+ "weight_init = build_weight_init(\n",
+ " cfg=auxiliaryfunctions.read_config(config_path),\n",
+ " super_animal=superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " with_decoder=False,\n",
+ ")\n",
+ "\n",
+ "deeplabcut.create_training_dataset_from_existing_split(\n",
+ " config_path,\n",
+ " from_shuffle=imagenet_transfer_learning_shuffle,\n",
+ " shuffles=[superanimal_transfer_learning_shuffle],\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " net_type=f\"top_down_{model_name}\",\n",
+ " detector_type=detector_name,\n",
+ " weight_init=weight_init,\n",
+ " userfeedback=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "3qFxlRHixPoR"
+ },
+ "source": [
+ "Then, we launch the training for transfer-learning with SuperAnimal weights."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000,
+ "referenced_widgets": [
+ "9a996c8dc3b34bc5b8805b3687e22b27",
+ "d012b421c189412dabeac84cba4164a7",
+ "1abff22a7c9a416d9166e6b150612171",
+ "7271412c1f0141649a7300dbce2b003c",
+ "3c011813d7cb48588a8d236785d9c24f",
+ "3ea385fe815f4e50a0b81ec299040314",
+ "fe59f6c5ed7b4e2cb87bb60224acdaba",
+ "04370d8302c04c5ca6a351383126193f",
+ "d67c4871543e405fbb576a55f8c9048a",
+ "a6cb25fa67ef4733a720960b3fc8213c",
+ "b73b1b64620d492dbc4eaf4bd83ca23a",
+ "dccbe277cc084ed6aa0b329067b5c69c",
+ "c8b57833d3f946abae69b84075345a54",
+ "bee292213d8645618536fcdf6a491d83",
+ "fbbc8c5b20c7423fb21b74296e0eeb28",
+ "ff0c737c49624b1ea27588611951fc84",
+ "42874cdab4be4dc38b0c33775b27d98c",
+ "e3a185abf8a04edabf32d58bdee10dd1",
+ "7cdcbbf9cb694dbf949e8b7eea8e7836",
+ "2ec06260b237411cabd3de7c37e03b1b",
+ "9f8009429aa34b40a65c998230f20c99",
+ "2a3abfe7867641db9fbfe3ee76854bf4"
+ ]
+ },
+ "id": "W60UgRQWqghn",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "18b931b8-98f4-4539-bf82-1910ff5b7f70"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.train_network(\n",
+ " config_path,\n",
+ " detector_epochs=0,\n",
+ " epochs=50,\n",
+ " save_epochs=10,\n",
+ " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n",
+ " displayiters=10,\n",
+ " shuffle=superanimal_transfer_learning_shuffle,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "XzOWKiOixPoR"
+ },
+ "source": [
+ "Finally, we evaluate the model obtained by transfer-learning with SuperAnimal weights."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "jpO3aIAIsWbz",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "30415e5b-8011-4651-af77-a781ea2b5af7"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.evaluate_network(config_path, Shuffles=[superanimal_transfer_learning_shuffle])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "_Es6RR-_0Z4b"
+ },
+ "source": [
+ "### Fine-tuning with SuperAnimal (without keeping full SuperAnimal keypoints)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "6oo9oJ8XyZrn"
+ },
+ "source": [
+ "#### Setup the weight init and dataset\n",
+ "\n",
+ "First we do keypoint matching. This steps make it possible to understand the correspondence between the existing annotations and SuperAnimal annotations. This step produces 3 outputs\n",
+ "- The confusion matrix\n",
+ "- The conversion table\n",
+ "- Pseudo predictions over the whole dataset"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "fRm62Ji_xPoS"
+ },
+ "source": [
+ "#### What is keypoint matching?\n",
+ "\n",
+ "Because SuperAnimal models have their pre-defined keypoints that are potentially different from your annotations, we proposed this algorithm to minimize the gap between the model and the dataset. We use our model to perform zero-shot inference on the whole dataset. This gives pairs of predictions and ground truth for every image. Then, we cast the matching between models’ predictions (2D coordinates)\n",
+ "and ground truth as bipartitematching using the Euclidean distance as the cost between paired of keypoints. We then solve the matching using the Hungarian algorithm. Thus for every image, we end up getting a matching matrix where 1 counts formatch and 0 counts for non-matching. Because the models’ predictions can be noisy from image to image, we average the aforementioned matching matrix across all the images and perform another bipartite matching, resulting in the final keypoint conversion table between the model and the dataset. Note that the quality of thematching will impact the performance\n",
+ "of the model, especially for zero-shot. In the case where, e.g., the annotation nose is mistakenly converted to keypoint tail and vice versa, the model will have to unlearn the channel that corresponds to nose and tail (see also case study in Mathis et al.)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000
+ },
+ "id": "vEHeuKSKyjA6",
+ "jupyter": {
+ "outputs_hidden": true
+ },
+ "outputId": "5863a81e-e0b9-48c7-f2f9-de14d38e805e"
+ },
+ "outputs": [],
+ "source": [
+ "keypoint_matching(\n",
+ " config_path,\n",
+ " superanimal_name,\n",
+ " model_name,\n",
+ " detector_name,\n",
+ " copy_images=True,\n",
+ ")\n",
+ "\n",
+ "conversion_table_path = project_path / \"memory_replay\" / \"conversion_table.csv\"\n",
+ "confusion_matrix_path = project_path / \"memory_replay\" / \"confusion_matrix.png\"\n",
+ "\n",
+ "# You can visualize the pseudo predictions, or do pose embedding clustering etc.\n",
+ "pseudo_prediction_path = project_path / \"memory_replay\" / \"pseudo_predictions.json\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "sA8yyLgs0zoO"
+ },
+ "source": [
+ "#### Display the confusion matrix\n",
+ "\n",
+ "The x axis lists the keypoints in the existing annotations. The y axis lists the keypoints in SuperAnimal keypoint space. Darker color encodes stronger correspondence between the human annotation and SuperAnimal annotations."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "luDxpD9H0zYZ",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "confusion_matrix_image = Image.open(confusion_matrix_path)\n",
+ "\n",
+ "plt.imshow(confusion_matrix_image)\n",
+ "plt.axis(\"off\") # Hide the axes for better view\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "i0QWikYmy_Mj"
+ },
+ "source": [
+ "#### Display the conversion table\n",
+ "The gt columns represents the keypoint names in the existing dataset. The MasterName represents the corresponding keypoints in SuperAnimal keypoint space."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "CeA-NzDMynYV",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "df = pd.read_csv(conversion_table_path)\n",
+ "df = df.dropna()\n",
+ "\n",
+ "df"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### Adding the Conversion Table to your project's `config.yaml` file\n",
+ "\n",
+ "Once you've run keypoint matching, you can add the conversion table to your project's `config.yaml` file, and edit it if there are some matches you think are wrong. As an example, for a top-view mouse dataset with 4 bodyparts labeled (`'snout', 'leftear', 'rightear', 'tailbase'`), the conversion table mapping project bodyparts to SuperAnimal bodyparts would be added as:\n",
+ "\n",
+ "```yaml\n",
+ "# Conversion tables to fine-tune SuperAnimal weights\n",
+ "SuperAnimalConversionTables:\n",
+ " superanimal_topviewmouse:\n",
+ " snout: nose\n",
+ " leftear: left_ear\n",
+ " rightear: right_ear\n",
+ " tailbase: tail_base\n",
+ "```\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "create_conversion_table(\n",
+ " config=config_path,\n",
+ " super_animal=superanimal_name,\n",
+ " project_to_super_animal=read_conversion_table_from_csv(conversion_table_path),\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "GkfIo8zTxPoS"
+ },
+ "source": [
+ "#### Prepare the training shuffle and weight initialization for (naive) fine-tuning with SuperAnimal weights\n",
+ "\n",
+ "Then, when you call `build_weight_init` with `with_decoder=True`, the conversion table in your project's `config.yaml` is used to get predictions for the correct bodyparts."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "xEeM_hrOu6k8",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "weight_init = build_weight_init(\n",
+ " cfg=auxiliaryfunctions.read_config(config_path),\n",
+ " super_animal=superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " with_decoder=True,\n",
+ ")\n",
+ "\n",
+ "deeplabcut.create_training_dataset_from_existing_split(\n",
+ " config_path,\n",
+ " from_shuffle=imagenet_transfer_learning_shuffle,\n",
+ " shuffles=[superanimal_naive_finetune_shuffle],\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " net_type=f\"top_down_{model_name}\",\n",
+ " detector_type=detector_name,\n",
+ " weight_init=weight_init,\n",
+ " userfeedback=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "gZx6nr-ExPoS"
+ },
+ "source": [
+ "#### Launch the training for (naive) fine-tuning with SuperAnimal"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "c3XAr6uRyXOD",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.train_network(\n",
+ " config_path,\n",
+ " detector_epochs=0,\n",
+ " epochs=50,\n",
+ " save_epochs=10,\n",
+ " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n",
+ " displayiters=10,\n",
+ " shuffle=superanimal_naive_finetune_shuffle,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "oXuRshzhxPoS"
+ },
+ "source": [
+ "#### Evaluate the model obtained by (naive) fine-tuning with SuperAnimal"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "VXfdKS-H2yqw",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.evaluate_network(\n",
+ " config_path,\n",
+ " Shuffles=[superanimal_naive_finetune_shuffle],\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "_nUAMlbZ0Z4b"
+ },
+ "source": [
+ "### Memory-replay fine-tuning with SuperAnimal (keeping full SuperAnimal keypoints)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "n6HPu6RaxPoS"
+ },
+ "source": [
+ "**Catastrophic forgetting** describes a\n",
+ "classic problemin continual learning. Indeed, amodel gradually loses\n",
+ "its ability to solve previous tasks after it learns to solve new ones.\n",
+ "Fine-tuning a SuperAnimal models falls into the category of continual\n",
+ "learning: the downstream dataset defines potentially different\n",
+ "keypoints than those learned by the models. Thus, the models might\n",
+ "forget the keypoints they learned and only pick up those defined in the\n",
+ "target dataset. Here, retraining with the original dataset and the new\n",
+ "one, is not a feasible option as datasets cannot be easily shared and\n",
+ "more computational resources would be required.\n",
+ "To counter that, we treat zero-shot inference of the model as a\n",
+ "memory buffer that stores knowledge from the original model. When\n",
+ "we fine-tune a SuperAnimal model, we replace the model predicted\n",
+ "keypoints with the ground-truth annotations, resulting in hybrid\n",
+ "learning of old and new knowledge. The quality of the zero-shot predictions\n",
+ "can vary and we use the confidence of prediction (0.7) as a\n",
+ "threshold to filter out low-confidence predictions. With the threshold\n",
+ "set to 1, memory replay fine-tuning becomes naive-fine-tuning."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "CSLmjlCIxPoS"
+ },
+ "source": [
+ "#### Prepare training shuffle and weight initialization for memory-replay finetuning with SuperAnimal"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "BKEF76AI0Z4c",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "weight_init = build_weight_init(\n",
+ " cfg=auxiliaryfunctions.read_config(config_path),\n",
+ " super_animal=superanimal_name,\n",
+ " model_name=model_name,\n",
+ " detector_name=detector_name,\n",
+ " with_decoder=True,\n",
+ " memory_replay=True,\n",
+ ")\n",
+ "\n",
+ "deeplabcut.create_training_dataset_from_existing_split(\n",
+ " config_path,\n",
+ " from_shuffle=imagenet_transfer_learning_shuffle,\n",
+ " shuffles=[superanimal_memory_replay_shuffle],\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ " net_type=f\"top_down_{model_name}\",\n",
+ " detector_type=detector_name,\n",
+ " weight_init=weight_init,\n",
+ " userfeedback=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "MKwJiIyKxPoT"
+ },
+ "source": [
+ "#### Launch the training for memory-replay fine-tuning with SuperAnimal"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Ru8tIFmD2Mkv",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.train_network(\n",
+ " config_path,\n",
+ " detector_epochs=0,\n",
+ " epochs=50,\n",
+ " save_epochs=10,\n",
+ " batch_size=64, # if you get a CUDA OOM error when training on a GPU, reduce to 32, 16, ...!\n",
+ " displayiters=10,\n",
+ " shuffle=superanimal_memory_replay_shuffle,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "i-2MBRDjxPoT"
+ },
+ "source": [
+ "#### Evaluate the model obtained by memory-replay finetuning with SuperAnimal"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "sfMcK3gq8WxZ",
+ "jupyter": {
+ "outputs_hidden": true
+ }
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.evaluate_network(config_path, Shuffles=[superanimal_memory_replay_shuffle])"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [
+ "UeXjmtu40Z4X",
+ "FvFzntDMxPoL",
+ "6VEjHu-00Z4Y"
+ ],
+ "gpuType": "T4",
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-10",
+ "last_metadata_updated": "2026-03-06"
+ },
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.13"
+ },
+ "widgets": {
+ "application/vnd.jupyter.widget-state+json": {
+ "04370d8302c04c5ca6a351383126193f": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "0f0ed94a863f49b9b85d0a18fa8ce2a5": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_e1675e53ca9a4da8acf6c16fba7a2578",
+ "placeholder": "",
+ "style": "IPY_MODEL_3d2996e10f96404baf24d2c4215b75a1",
+ "value": "model.safetensors: 100%"
+ }
+ },
+ "1779b84e748b4989a8ed53434c30016f": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "ProgressStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "ProgressStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "bar_color": null,
+ "description_width": ""
+ }
+ },
+ "1abff22a7c9a416d9166e6b150612171": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "FloatProgressModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "FloatProgressModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "ProgressView",
+ "bar_style": "success",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_04370d8302c04c5ca6a351383126193f",
+ "max": 159594859,
+ "min": 0,
+ "orientation": "horizontal",
+ "style": "IPY_MODEL_d67c4871543e405fbb576a55f8c9048a",
+ "value": 159594859
+ }
+ },
+ "2a3abfe7867641db9fbfe3ee76854bf4": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "2cef5e028d2e40a6bba7400be922d0c2": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "2ec06260b237411cabd3de7c37e03b1b": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "ProgressStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "ProgressStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "bar_color": null,
+ "description_width": ""
+ }
+ },
+ "343f2670d37c4bf18859238c3d81d419": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "FloatProgressModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "FloatProgressModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "ProgressView",
+ "bar_style": "success",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_b988f87e676840ee98daa3d996c9ddbc",
+ "max": 165432914,
+ "min": 0,
+ "orientation": "horizontal",
+ "style": "IPY_MODEL_1779b84e748b4989a8ed53434c30016f",
+ "value": 165432914
+ }
+ },
+ "3c011813d7cb48588a8d236785d9c24f": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "3d2996e10f96404baf24d2c4215b75a1": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "3ea385fe815f4e50a0b81ec299040314": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "42874cdab4be4dc38b0c33775b27d98c": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "5dcbd8f3fb6148cca6cfc72b20ce49bd": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "7271412c1f0141649a7300dbce2b003c": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_a6cb25fa67ef4733a720960b3fc8213c",
+ "placeholder": "",
+ "style": "IPY_MODEL_b73b1b64620d492dbc4eaf4bd83ca23a",
+ "value": " 160M/160M [00:00<00:00, 201MB/s]"
+ }
+ },
+ "7cdcbbf9cb694dbf949e8b7eea8e7836": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "7ed11ae2a4be462da84ff716e0725af0": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HBoxModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HBoxModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HBoxView",
+ "box_style": "",
+ "children": [
+ "IPY_MODEL_0f0ed94a863f49b9b85d0a18fa8ce2a5",
+ "IPY_MODEL_343f2670d37c4bf18859238c3d81d419",
+ "IPY_MODEL_d104ae21091e4f10a7de18e191b9f04d"
+ ],
+ "layout": "IPY_MODEL_5dcbd8f3fb6148cca6cfc72b20ce49bd"
+ }
+ },
+ "9a996c8dc3b34bc5b8805b3687e22b27": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HBoxModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HBoxModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HBoxView",
+ "box_style": "",
+ "children": [
+ "IPY_MODEL_d012b421c189412dabeac84cba4164a7",
+ "IPY_MODEL_1abff22a7c9a416d9166e6b150612171",
+ "IPY_MODEL_7271412c1f0141649a7300dbce2b003c"
+ ],
+ "layout": "IPY_MODEL_3c011813d7cb48588a8d236785d9c24f"
+ }
+ },
+ "9f8009429aa34b40a65c998230f20c99": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "a6cb25fa67ef4733a720960b3fc8213c": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "b73b1b64620d492dbc4eaf4bd83ca23a": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "b988f87e676840ee98daa3d996c9ddbc": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "bee292213d8645618536fcdf6a491d83": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "FloatProgressModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "FloatProgressModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "ProgressView",
+ "bar_style": "success",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_7cdcbbf9cb694dbf949e8b7eea8e7836",
+ "max": 517816013,
+ "min": 0,
+ "orientation": "horizontal",
+ "style": "IPY_MODEL_2ec06260b237411cabd3de7c37e03b1b",
+ "value": 517816013
+ }
+ },
+ "c8b57833d3f946abae69b84075345a54": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_42874cdab4be4dc38b0c33775b27d98c",
+ "placeholder": "",
+ "style": "IPY_MODEL_e3a185abf8a04edabf32d58bdee10dd1",
+ "value": "detector.pt: 100%"
+ }
+ },
+ "d012b421c189412dabeac84cba4164a7": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_3ea385fe815f4e50a0b81ec299040314",
+ "placeholder": "",
+ "style": "IPY_MODEL_fe59f6c5ed7b4e2cb87bb60224acdaba",
+ "value": "pose_model.pth: 100%"
+ }
+ },
+ "d104ae21091e4f10a7de18e191b9f04d": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_d37cf6fe7c444bc2a2568c3407389ea8",
+ "placeholder": "",
+ "style": "IPY_MODEL_2cef5e028d2e40a6bba7400be922d0c2",
+ "value": " 165M/165M [00:04<00:00, 41.1MB/s]"
+ }
+ },
+ "d37cf6fe7c444bc2a2568c3407389ea8": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "d67c4871543e405fbb576a55f8c9048a": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "ProgressStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "ProgressStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "bar_color": null,
+ "description_width": ""
+ }
+ },
+ "dccbe277cc084ed6aa0b329067b5c69c": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HBoxModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HBoxModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HBoxView",
+ "box_style": "",
+ "children": [
+ "IPY_MODEL_c8b57833d3f946abae69b84075345a54",
+ "IPY_MODEL_bee292213d8645618536fcdf6a491d83",
+ "IPY_MODEL_fbbc8c5b20c7423fb21b74296e0eeb28"
+ ],
+ "layout": "IPY_MODEL_ff0c737c49624b1ea27588611951fc84"
+ }
+ },
+ "e1675e53ca9a4da8acf6c16fba7a2578": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "e3a185abf8a04edabf32d58bdee10dd1": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "fbbc8c5b20c7423fb21b74296e0eeb28": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "1.5.0",
+ "_view_name": "HTMLView",
+ "description": "",
+ "description_tooltip": null,
+ "layout": "IPY_MODEL_9f8009429aa34b40a65c998230f20c99",
+ "placeholder": "",
+ "style": "IPY_MODEL_2a3abfe7867641db9fbfe3ee76854bf4",
+ "value": " 518M/518M [00:05<00:00, 101MB/s]"
+ }
+ },
+ "fe59f6c5ed7b4e2cb87bb60224acdaba": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "1.5.0",
+ "model_name": "DescriptionStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "1.5.0",
+ "_model_name": "DescriptionStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "StyleView",
+ "description_width": ""
+ }
+ },
+ "ff0c737c49624b1ea27588611951fc84": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "1.2.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "1.2.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "1.2.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "overflow_x": null,
+ "overflow_y": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ }
+ }
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 1
+}
diff --git a/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb b/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb
index 4c4af5e240..00049863f8 100644
--- a/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb
+++ b/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb
@@ -17,8 +17,13 @@
"id": "RK255E7YoEIt"
},
"source": [
- "# DeepLabCut Toolbox - Colab for standard (single animal) projects!\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "# DeepLabCut for your standard (single animal) projects!\n",
+ "\n",
+ "Some useful links:\n",
+ "\n",
+ "- [DeepLabCut's GitHub: github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Single Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html)\n",
+ "\n",
"\n",
"This notebook illustrates how to use the cloud to:\n",
"- create a training set\n",
@@ -48,22 +53,19 @@
"id": "txoddlM8hLKm"
},
"source": [
- "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n"
+ "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n",
+ "\n",
+ "As the COLAB environments were updated to CUDA 12.X and Python 3.11, we need to install DeepLabCut and TensorFlow in a distinct way to get TensorFlow to connect to the GPU."
]
},
{
"cell_type": "code",
"execution_count": null,
- "metadata": {
- "colab": {},
- "colab_type": "code",
- "id": "q23BzhA6CXxu"
- },
+ "metadata": {},
"outputs": [],
"source": [
- "#(this will take a few minutes to install all the dependences!)\n",
- "!apt update && apt install cuda-11-8\n",
- "!pip install \"deeplabcut[tf]\""
+ "# this will take a couple of minutes to install all the dependencies!\n",
+ "!pip install --pre deeplabcut"
]
},
{
@@ -73,7 +75,30 @@
"id": "25wSj6TlVclR"
},
"source": [
- "**(Be sure to click \"RESTART RUNTIME\" if it is displayed above before moving on !)**"
+ "**(Be sure to click \"RESTART RUNTIME\" if it is displayed above before moving on !)** You will see this button at the output of the cells above ^."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "oTwAcbq2-FZz",
+ "outputId": "9cfd8dcf-a0a8-4801-ed1d-fbcd5ec056af"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n"
+ ]
+ }
+ ],
+ "source": [
+ "import deeplabcut"
]
},
{
@@ -98,11 +123,12 @@
},
"outputs": [],
"source": [
- "#Now, let's link to your GoogleDrive. Run this cell and follow the authorization instructions:\n",
- "#(We recommend putting a copy of the github repo in your google drive if you are using the demo \"examples\")\n",
+ "# Now, let's link to your GoogleDrive. Run this cell and follow the authorization instructions:\n",
+ "# (We recommend putting a copy of the github repo in your google drive if you are using the demo \"examples\")\n",
"\n",
"from google.colab import drive\n",
- "drive.mount('/content/drive')"
+ "\n",
+ "drive.mount(\"/content/drive\")"
]
},
{
@@ -114,7 +140,7 @@
"source": [
"YOU WILL NEED TO EDIT THE PROJECT PATH **in the config.yaml file** TO BE SET TO YOUR GOOGLE DRIVE LINK!\n",
"\n",
- "Typically, this will be: /content/drive/My Drive/yourProjectFolderName\n"
+ "Typically, this will be: `/content/drive/My Drive/yourProjectFolderName`\n"
]
},
{
@@ -127,57 +153,25 @@
},
"outputs": [],
"source": [
- "#Setup your project variables:\n",
- "# PLEASE EDIT THESE:\n",
- " \n",
- "ProjectFolderName = 'myproject-teamDLC-2020-03-29'\n",
- "VideoType = 'mp4' \n",
+ "# PLEASE EDIT THIS:\n",
+ "project_folder_name = \"MontBlanc-Daniel-2019-12-16\"\n",
+ "video_type = \"mp4\" # , mp4, MOV, or avi, whatever you uploaded!\n",
"\n",
- "#don't edit these:\n",
- "videofile_path = ['/content/drive/My Drive/'+ProjectFolderName+'/videos/'] #Enter the list of videos or folder to analyze.\n",
- "videofile_path"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "colab": {},
- "colab_type": "code",
- "id": "3K9Ndy1beyfG"
- },
- "outputs": [],
- "source": [
- "import deeplabcut"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "colab": {},
- "colab_type": "code",
- "id": "o4orkg9QTHKK"
- },
- "outputs": [],
- "source": [
- "deeplabcut.__version__"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "colab": {},
- "colab_type": "code",
- "id": "Z7ZlDr3wV4D1"
- },
- "outputs": [],
- "source": [
- "#This creates a path variable that links to your google drive copy\n",
- "#No need to edit this, as you set it up before: \n",
- "path_config_file = '/content/drive/My Drive/'+ProjectFolderName+'/config.yaml'\n",
- "path_config_file"
+ "# No need to edit this, we are going to assume you put videos you want to analyze\n",
+ "# in the \"videos\" folder, but if this is NOT true, edit below:\n",
+ "videofile_path = [f\"/content/drive/My Drive/{project_folder_name}/videos/\"]\n",
+ "print(videofile_path)\n",
+ "\n",
+ "# The prediction files and labeled videos will be saved in this `labeled-videos` folder\n",
+ "# in your project folder; if you want them elsewhere, you can edit this;\n",
+ "# if you want the output files in the same folder as the videos, set this to an empty string.\n",
+ "destfolder = f\"/content/drive/My Drive/{project_folder_name}/labeled-videos\"\n",
+ "\n",
+ "# No need to edit this, as you set it when you passed the ProjectFolderName (above):\n",
+ "path_config_file = f\"/content/drive/My Drive/{project_folder_name}/config.yaml\"\n",
+ "print(path_config_file)\n",
+ "\n",
+ "# This creates a path variable that links to your Google Drive project"
]
},
{
@@ -188,10 +182,12 @@
},
"source": [
"## Create a training dataset:\n",
- "### You must do this step inside of Colab:\n",
+ "\n",
+ "### You must do this step inside of Colab\n",
+ "\n",
"After running this script the training dataset is created and saved in the project directory under the subdirectory **'training-datasets'**\n",
"\n",
- "This function also creates new subdirectories under **dlc-models** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pose_cfg.yaml**.\n",
+ "This function also creates new subdirectories under **dlc-models-pytorch** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pytorch_config.yaml**.\n",
"\n",
"Now it is the time to start training the network!"
]
@@ -207,10 +203,10 @@
},
"outputs": [],
"source": [
- "# Note: if you are using the demo data (i.e. examples/Reaching-Mackenzie-2018-08-30/), first delete the folder called dlc-models! \n",
- "#Then, run this cell. There are many more functions you can set here, including which netowkr to use!\n",
- "#check the docstring for full options you can do!\n",
- "deeplabcut.create_training_dataset(path_config_file, net_type='resnet_50', augmenter_type='imgaug')"
+ "# There are many more functions you can set here, including which network to use!\n",
+ "# Check the docstring for `create_training_dataset` for all options you can use!\n",
+ "\n",
+ "deeplabcut.create_training_dataset(path_config_file, net_type=\"resnet_50\", engine=deeplabcut.Engine.PYTORCH)"
]
},
{
@@ -234,14 +230,26 @@
},
"outputs": [],
"source": [
- "#let's also change the display and save_iters just in case Colab takes away the GPU... \n",
- "#if that happens, you can reload from a saved point. Typically, you want to train to 200,000 + iterations.\n",
- "#more info and there are more things you can set: https://github.com/DeepLabCut/DeepLabCut/wiki/DOCSTRINGS#train_network\n",
+ "# Let's also change the display and save_epochs just in case Colab takes away\n",
+ "# the GPU... If that happens, you can reload from a saved point using the\n",
+ "# `snapshot_path` argument to `deeplabcut.train_network`:\n",
+ "# deeplabcut.train_network(..., snapshot_path=\"/content/.../snapshot-050.pt\")\n",
"\n",
- "deeplabcut.train_network(path_config_file, shuffle=1, displayiters=10,saveiters=500)\n",
+ "# Typically, you want to train to ~200 epochs. We set the batch size to 8 to\n",
+ "# utilize the GPU's capabilities.\n",
"\n",
- "#this will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end (default, 1.03M iterations). \n",
- "#Whichever you chose, you will see what looks like an error message, but it's not an error - don't worry...."
+ "# More info and there are more things you can set:\n",
+ "# https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#g-train-the-network\n",
+ "\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=5,\n",
+ " epochs=200,\n",
+ " batch_size=8,\n",
+ ")\n",
+ "\n",
+ "# This will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end."
]
},
{
@@ -251,7 +259,7 @@
"id": "RiDwIVf5-3H_"
},
"source": [
- "**When you hit \"STOP\" you will get a KeyInterrupt \"error\"! No worries! :)**"
+ "Note, that **when you hit \"STOP\" you will get a `KeyboardInterrupt` \"error\"! No worries! :)**"
]
},
{
@@ -263,7 +271,7 @@
"source": [
"## Start evaluating:\n",
"This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images)\n",
- "and stores the results as .csv file in a subdirectory under **evaluation-results**"
+ "and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**"
]
},
{
@@ -276,11 +284,10 @@
},
"outputs": [],
"source": [
- "%matplotlib notebook\n",
- "deeplabcut.evaluate_network(path_config_file,plotting=True)\n",
+ "deeplabcut.evaluate_network(path_config_file, plotting=True)\n",
"\n",
- "# Here you want to see a low pixel error! Of course, it can only be as good as the labeler, \n",
- "#so be sure your labels are good! (And you have trained enough ;)"
+ "# Here you want to see a low pixel error! Of course, it can only be as\n",
+ "# good as the labeler, so be sure your labels are good!"
]
},
{
@@ -319,7 +326,12 @@
},
"outputs": [],
"source": [
- "deeplabcut.analyze_videos(path_config_file,videofile_path, videotype=VideoType)"
+ "deeplabcut.analyze_videos(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -343,7 +355,12 @@
},
"outputs": [],
"source": [
- "deeplabcut.plot_trajectories(path_config_file,videofile_path, videotype=VideoType)"
+ "deeplabcut.plot_trajectories(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -364,7 +381,7 @@
},
"source": [
"## Create labeled video:\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
]
},
{
@@ -377,7 +394,12 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path, videotype=VideoType)"
+ "deeplabcut.create_labeled_video(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
}
],
@@ -390,6 +412,11 @@
"provenance": [],
"toc_visible": true
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
"display_name": "Python 3.8.12 ('dlc')",
"language": "python",
diff --git a/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb b/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb
similarity index 52%
rename from examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb
rename to examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb
index 16a373586c..08633839fb 100644
--- a/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb
+++ b/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb
@@ -7,7 +7,7 @@
"id": "view-in-github"
},
"source": [
- " "
+ " "
]
},
{
@@ -16,10 +16,15 @@
"id": "RK255E7YoEIt"
},
"source": [
- "# DeepLabCut 2.2+ Toolbox - COLAB\n",
- "\n",
+ "# DeepLabCut for your multi-animal projects!\n",
+ "\n",
+ "Some useful links:\n",
+ "\n",
+ "- [DeepLabCut's GitHub: github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Multi-Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html)\n",
"\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "\n",
"\n",
"This notebook illustrates how to, for multi-animal projects, use the cloud-based GPU to:\n",
"- create a multi-animal training set\n",
@@ -46,46 +51,26 @@
"id": "txoddlM8hLKm"
},
"source": [
- "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "q23BzhA6CXxu"
- },
- "outputs": [],
- "source": [
- "#(this will take a few minutes to install all the dependences!)\n",
- "!apt update && apt install cuda-11-8\n",
- "!pip install \"deeplabcut[tf]\"\n",
- "%reload_ext numpy\n",
- "%reload_ext scipy\n",
- "%reload_ext matplotlib\n",
- "%reload_ext mpl_toolkits"
+ "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n",
+ "\n",
+ "As the COLAB environments were updated to CUDA 12.X and Python 3.11, we need to install DeepLabCut and TensorFlow in a distinct way to get TensorFlow to connect to the GPU."
]
},
{
"cell_type": "code",
"execution_count": null,
- "metadata": {
- "id": "-MVvZ13_FMvP"
- },
+ "metadata": {},
"outputs": [],
"source": [
- "#a few colab specific things needed:\n",
- "!pip install --upgrade scikit-image\n",
- "!pip3 install pickle5"
+ "# this will take a couple of minutes to install all the dependencies!\n",
+ "!pip install --pre deeplabcut"
]
},
{
"cell_type": "markdown",
- "metadata": {
- "id": "bqUEb8TBdpWb"
- },
+ "metadata": {},
"source": [
- "After the package is installed, please click \"restart runtime\" if it appears for DLC changes to take effect in your COLAB environment. You will see this button at the output of the cells above ^."
+ "**(Be sure to click \"RESTART RUNTIME\" if it is displayed above before moving on !)** You will see this button at the output of the cells above ^."
]
},
{
@@ -98,18 +83,9 @@
"id": "oTwAcbq2-FZz",
"outputId": "9cfd8dcf-a0a8-4801-ed1d-fbcd5ec056af"
},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "import deeplabcut\n",
- "import pickle5 as pickle"
+ "import deeplabcut"
]
},
{
@@ -120,10 +96,8 @@
"source": [
"## Link your Google Drive (with your labeled data):\n",
"\n",
- "- This code assumes you locally installed DeepLabCut, created a project, extracted and labeled frames. Be sure to \"check Labels\" to confirm you are happy with your data. As, these frames are the only thing that is used to train your network. 💪 You can find all the docs to do this here: https://deeplabcut.github.io/DeepLabCut\n",
- "\n",
+ "- This code assumes you locally installed DeepLabCut, created a project, extracted and labeled frames. Be sure to \"check Labels\" to confirm you are happy with your data. As, these frames are the only thing that is used to train your network. 💪 You can find all the docs to do this here: [deeplabcut.github.io/DeepLabCut](https://deeplabcut.github.io/DeepLabCut/README.html)\n",
"- Next, place your DLC project folder into you Google Drive- i.e., copy the folder named \"Project-YourName-TheDate\" into Google Drive.\n",
- "\n",
"- Then, click run on the cell below to link this notebook to your Google Drive:"
]
},
@@ -135,11 +109,12 @@
},
"outputs": [],
"source": [
- "#Now, let's link to your Google Drive. Run this cell and follow the authorization instructions:\n",
- "#(We recommend putting a copy of the github repo in your google drive if you are using the demo \"examples\")\n",
+ "# Now, let's link to your GoogleDrive. Run this cell and follow the authorization instructions:\n",
+ "# (We recommend putting a copy of the github repo in your google drive if you are using the demo \"examples\")\n",
"\n",
"from google.colab import drive\n",
- "drive.mount('/content/drive')"
+ "\n",
+ "drive.mount(\"/content/drive\")"
]
},
{
@@ -148,7 +123,9 @@
"id": "Frnj1RVDyEqs"
},
"source": [
- "## Next, edit the few items below, and click run:\n"
+ "## Next, edit the few items below, and click run:\n",
+ "\n",
+ "YOU WILL NEED TO EDIT THE PROJECT PATH **in the `config.yaml` file** TO BE SET TO YOUR GOOGLE DRIVE LINK! Typically, this will be: `/content/drive/My Drive/yourProjectFolderName`\n"
]
},
{
@@ -160,18 +137,24 @@
"outputs": [],
"source": [
"# PLEASE EDIT THIS:\n",
- "ProjectFolderName = 'MontBlanc-Daniel-2019-12-16'\n",
- "VideoType = 'mp4' #, mp4, MOV, or avi, whatever you uploaded!\n",
+ "project_folder_name = \"MontBlanc-Daniel-2019-12-16\"\n",
+ "video_type = \"mp4\" #, mp4, MOV, or avi, whatever you uploaded!\n",
"\n",
+ "# No need to edit this, we are going to assume you put videos you want to analyze\n",
+ "# in the \"videos\" folder, but if this is NOT true, edit below:\n",
+ "videofile_path = [f\"/content/drive/My Drive/{project_folder_name}/videos/\"]\n",
+ "print(videofile_path)\n",
"\n",
- "# No need to edit this, we are going to assume you put videos you want to analyze in the \"videos\" folder, but if this is NOT true, edit below:\n",
- "videofile_path = ['/content/drive/My Drive/'+ProjectFolderName+'/videos/'] #Enter the list of videos or folder to analyze.\n",
- "videofile_path\n",
+ "# The prediction files and labeled videos will be saved in this `labeled-videos` folder\n",
+ "# in your project folder; if you want them elsewhere, you can edit this;\n",
+ "# if you want the output files in the same folder as the videos, set this to an empty string.\n",
+ "destfolder = f\"/content/drive/My Drive/{project_folder_name}/labeled-videos\"\n",
"\n",
- "#No need to edit this, as you set it when you passed the ProjectFolderName (above): \n",
- "path_config_file = '/content/drive/My Drive/'+ProjectFolderName+'/config.yaml'\n",
- "path_config_file\n",
- "#This creates a path variable that links to your google drive project"
+ "#No need to edit this, as you set it when you passed the ProjectFolderName (above):\n",
+ "path_config_file = f\"/content/drive/My Drive/{project_folder_name}/config.yaml\"\n",
+ "print(path_config_file)\n",
+ "\n",
+ "# This creates a path variable that links to your Google Drive project"
]
},
{
@@ -182,8 +165,7 @@
"source": [
"## Create a multi-animal training dataset:\n",
"\n",
- "- more info: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#create-training-dataset\n",
- "\n",
+ "- more info can be [found in the docs](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#create-training-dataset)\n",
"- please check the text below, edit if needed, and then click run (this can take some time):"
]
},
@@ -195,7 +177,7 @@
},
"outputs": [],
"source": [
- "#OPTIONAL LEARNING: did you know you can check what each function does by running with a ?\n",
+ "# OPTIONAL LEARNING: did you know you can check what each function does by running with a ?\n",
"deeplabcut.create_multianimaltraining_dataset?"
]
},
@@ -209,11 +191,15 @@
"outputs": [],
"source": [
"# ATTENTION:\n",
- "#which shuffle do you want to create and train?\n",
- "shuffle = 1 #edit if needed; 1 is the default.\n",
+ "# Which shuffle do you want to create and train?\n",
+ "shuffle = 1 # Edit if needed; 1 is the default.\n",
"\n",
- "#if you labeled on Windows, please set the windows2linux=True:\n",
- "deeplabcut.create_multianimaltraining_dataset(path_config_file, Shuffles=[shuffle], net_type=\"dlcrnet_ms5\",windows2linux=False)"
+ "deeplabcut.create_multianimaltraining_dataset(\n",
+ " path_config_file,\n",
+ " Shuffles=[shuffle],\n",
+ " net_type=\"dlcrnet_ms5\",\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ ")"
]
},
{
@@ -223,8 +209,7 @@
},
"source": [
"## Start training:\n",
- "This function trains the network for a specific shuffle of the training dataset. \n",
- " - more info: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#train-the-network"
+ "This function trains the network for a specific shuffle of the training dataset. More info can be found [in the docs](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#train-the-network)."
]
},
{
@@ -235,14 +220,26 @@
},
"outputs": [],
"source": [
- "#let's also change the display and save_iters just in case Colab takes away the GPU... \n",
- "#Typically, you want to train to 50,000 - 200K iterations.\n",
- "#more info and there are more things you can set: https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/functionDetails.md#g-train-the-network\n",
+ "# Let's also change the display and save_epochs just in case Colab takes away\n",
+ "# the GPU... If that happens, you can reload from a saved point using the\n",
+ "# `snapshot_path` argument to `deeplabcut.train_network`:\n",
+ "# deeplabcut.train_network(..., snapshot_path=\"/content/.../snapshot-050.pt\")\n",
+ "\n",
+ "# Typically, you want to train to ~200 epochs. We set the batch size to 8 to\n",
+ "# utilize the GPU's capabilities.\n",
+ "\n",
+ "# More info and there are more things you can set:\n",
+ "# https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html#g-train-the-network\n",
"\n",
- "deeplabcut.train_network(path_config_file, shuffle=shuffle, displayiters=100,saveiters=1000, maxiters=75000, allow_growth=True)\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=shuffle,\n",
+ " save_epochs=5,\n",
+ " epochs=200,\n",
+ " batch_size=8,\n",
+ ")\n",
"\n",
- "#this will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end (default, 50K iterations). \n",
- "#Whichever you chose, you will see what looks like an error message, but it's not an error - don't worry...."
+ "# This will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end."
]
},
{
@@ -251,7 +248,7 @@
"id": "RiDwIVf5-3H_"
},
"source": [
- "**When you hit \"STOP\" you will get a KeyInterrupt \"error\"! No worries! :)**"
+ "Note, that **when you hit \"STOP\" you will get a `KeyboardInterrupt` \"error\"! No worries! :)**"
]
},
{
@@ -262,13 +259,10 @@
"source": [
"## Start evaluating: \n",
"\n",
- " - First, we evaluate the pose estimation performance.\n",
- "\n",
- "- This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images) and stores the results as .5 and .csv file in a subdirectory under **evaluation-results**\n",
- "\n",
+ "- First, we evaluate the pose estimation performance.\n",
+ "- This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images) and stores the results as .5 and .csv file in a subdirectory under **evaluation-results-pytorch**\n",
"- If the scoremaps do not look accurate, don't proceed to tracklet assembly; please consider (1) adding more data, (2) adding more bodyparts!\n",
- "\n",
- "- more info: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#evaluate-the-trained-network\n",
+ "- More info can be [found in the docs](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#evaluate-the-trained-network)\n",
"\n",
"Here is an example of what you'd aim to see before proceeding:\n",
"\n",
@@ -284,10 +278,11 @@
},
"outputs": [],
"source": [
- "#let's evaluate first:\n",
- "deeplabcut.evaluate_network(path_config_file,Shuffles=[shuffle], plotting=True)\n",
- "#plot a few scoremaps:\n",
- "deeplabcut.extract_save_all_maps(path_config_file, shuffle=shuffle, Indices=[0])"
+ "# Let's evaluate first:\n",
+ "deeplabcut.evaluate_network(path_config_file, Shuffles=[shuffle], plotting=True)\n",
+ "\n",
+ "# plot a few scoremaps:\n",
+ "deeplabcut.extract_save_all_maps(path_config_file, shuffle=shuffle, Indices=[0, 1, 2, 3])"
]
},
{
@@ -323,7 +318,14 @@
"#EDIT OPTION: which video(s) do you want to analyze? You can pass a path or a folder:\n",
"# currently, if you run \"as is\" it assumes you have a video in the DLC project video folder!\n",
"\n",
- "deeplabcut.analyze_videos(path_config_file,videofile_path, shuffle=shuffle, videotype=VideoType)"
+ "deeplabcut.analyze_videos(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " shuffle=shuffle,\n",
+ " videotype=video_type,\n",
+ " auto_track=False,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -332,7 +334,7 @@
"id": "91xBLOcBzGxo"
},
"source": [
- "Optional: Now you have the option to check the raw dections before animals are assembled. To do so, pass a video path:"
+ "Optional: Now you have the option to check the raw detections before animals are tracked. To do so, pass a video path:"
]
},
{
@@ -347,11 +349,13 @@
"## look at the output video; if the pose estimation (i.e. key points)\n",
"## don't look good, don't proceed with tracking - add more data to your training set and re-train!\n",
"\n",
- "#EDIT: let's check a specific video (PLEASE EDIT VIDEO PATH):\n",
- "Specific_videofile = '/content/drive/MyDrive/DeepLabCut_maDLC_DemoData/MontBlanc-Daniel-2019-12-16/videos/short.mov'\n",
+ "# EDIT: let's check a specific video (PLEASE EDIT VIDEO PATH):\n",
+ "specific_videofile = \"/content/drive/MyDrive/DeepLabCut_maDLC_DemoData/MontBlanc-Daniel-2019-12-16/videos/short.mov\"\n",
"\n",
- "#don't edit:\n",
- "deeplabcut.create_video_with_all_detections(path_config_file, [Specific_videofile], shuffle=shuffle)"
+ "# Don't edit:\n",
+ "deeplabcut.create_video_with_all_detections(\n",
+ " path_config_file, [specific_videofile], shuffle=shuffle, destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -360,7 +364,7 @@
"id": "3-OgTJ0Lz20e"
},
"source": [
- "If the resutling video (ends in full.mp4) is not good, we highly recommend adding more data and training again. See here: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#decision-break-point"
+ "If the resulting video (ends in full.mp4) is not good, we highly recommend adding more data and training again. See [here, in the docs](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html#decision-break-point)."
]
},
{
@@ -369,14 +373,15 @@
"id": "PxRLS2_-r55K"
},
"source": [
- "# Next, we will assemble animals using our data-driven optimal graph method:\n",
+ "## Next, we will assemble animals using our data-driven optimal graph method:\n",
"\n",
- "- Here, we will find the optimal graph, which matches the \"data-driven\" method from our paper (Figure adapted from Lauer et al. 2021):\n",
+ "During video analysis, animals are assembled using the optimal graph, which matches the \"data-driven\" method from our paper (Figure adapted from Lauer et al. 2021)\n",
"\n",
"\n",
"\n",
+ "The optimal graph is computed when `evaluate_network` - so make sure you don't skip that step!\n",
"\n",
- "- note, you can set the number of animals you expect to see, so check, edit, then click run:"
+ "**Note**: you can set the number of animals you expect to see, so check, edit, then click run:"
]
},
{
@@ -388,23 +393,37 @@
"outputs": [],
"source": [
"#Check and edit:\n",
- "numAnimals = 4 #how many animals do you expect to find?\n",
- "tracktype= 'box' #box, skeleton, ellipse:\n",
- "#-- ellipse is recommended, unless you have a single-point ma project, then use BOX!\n",
- "\n",
- "#Optional: \n",
- "#imagine you tracked a point that is not useful for assembly, \n",
- "#like a tail tip that is far from the body, consider dropping it for this step (it's still used later)!\n",
- "#To drop it, uncomment the next line TWO lines and add your parts(s):\n",
- "\n",
- "#bodypart= 'Tail_end'\n",
- "#deeplabcut.convert_detections2tracklets(path_config_file, videofile_path, videotype=VideoType, shuffle=shuffle, overwrite=True, ignore_bodyparts=[bodypart])\n",
- "\n",
- "#OR don't drop, just click RUN:\n",
- "deeplabcut.convert_detections2tracklets(path_config_file, videofile_path, videotype=VideoType, \n",
- " shuffle=shuffle, overwrite=True)\n",
- "\n",
- "deeplabcut.stitch_tracklets(path_config_file, videofile_path, shuffle=shuffle, track_method=tracktype, n_tracks=numAnimals)"
+ "num_animals = 4 # How many animals do you expect to find?\n",
+ "track_type= \"box\" # box, skeleton, ellipse\n",
+ "#-- ellipse is recommended, unless you have a single-point MA project, then use BOX!\n",
+ "\n",
+ "# Optional:\n",
+ "# imagine you tracked a point that is not useful for assembly,\n",
+ "# like a tail tip that is far from the body, consider dropping it for this step (it's still used later)!\n",
+ "# To drop it, uncomment the next line TWO lines and add your parts(s):\n",
+ "\n",
+ "# bodypart= 'Tail_end'\n",
+ "# deeplabcut.convert_detections2tracklets(path_config_file, videofile_path, videotype=VideoType, shuffle=shuffle, overwrite=True, ignore_bodyparts=[bodypart])\n",
+ "\n",
+ "# OR don't drop, just click RUN:\n",
+ "deeplabcut.convert_detections2tracklets(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " shuffle=shuffle,\n",
+ " track_method=track_type,\n",
+ " destfolder=destfolder,\n",
+ " overwrite=True,\n",
+ ")\n",
+ "\n",
+ "deeplabcut.stitch_tracklets(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " shuffle=shuffle,\n",
+ " track_method=track_type,\n",
+ " n_tracks=num_animals,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -424,11 +443,14 @@
},
"outputs": [],
"source": [
- "deeplabcut.filterpredictions(path_config_file, \n",
- " videofile_path, \n",
- " shuffle=shuffle,\n",
- " videotype=VideoType, \n",
- " track_method = tracktype)"
+ "deeplabcut.filterpredictions(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " shuffle=shuffle,\n",
+ " videotype=video_type,\n",
+ " track_method=track_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -448,7 +470,14 @@
},
"outputs": [],
"source": [
- "deeplabcut.plot_trajectories(path_config_file, videofile_path, videotype=VideoType, shuffle=shuffle, track_method=tracktype)"
+ "deeplabcut.plot_trajectories(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " shuffle=shuffle,\n",
+ " track_method=track_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
},
{
@@ -467,7 +496,7 @@
},
"source": [
"## Create labeled video:\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
]
},
{
@@ -478,13 +507,17 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,\n",
- " videofile_path, \n",
- " shuffle=shuffle, \n",
- " color_by=\"individual\",\n",
- " videotype=VideoType, \n",
- " save_frames=False,\n",
- " filtered=True)"
+ "deeplabcut.create_labeled_video(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " shuffle=shuffle,\n",
+ " color_by=\"individual\",\n",
+ " videotype=video_type,\n",
+ " save_frames=False,\n",
+ " filtered=True,\n",
+ " track_method=track_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
]
}
],
@@ -496,6 +529,11 @@
"name": "COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb",
"provenance": []
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-02-10",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
diff --git a/examples/COLAB/COLAB_transformer_reID.ipynb b/examples/COLAB/COLAB_transformer_reID.ipynb
index 1e15a01683..12eb4f89ea 100644
--- a/examples/COLAB/COLAB_transformer_reID.ipynb
+++ b/examples/COLAB/COLAB_transformer_reID.ipynb
@@ -7,7 +7,7 @@
"id": "view-in-github"
},
"source": [
- " "
+ " "
]
},
{
@@ -16,12 +16,12 @@
"id": "TGChzLdc-lUJ"
},
"source": [
- "# DeepLabCut 2.2 Toolbox Demo on how to use our Pose Transformer for unsupervised identity tracking of animals\n",
+ "# Demo: How to use our Pose Transformer for unsupervised identity tracking of animals\n",
"\n",
"\n",
"https://github.com/DeepLabCut/DeepLabCut\n",
"\n",
- "### This notebook illustrates how to use the transformer for a multi-animal DeepLabCut (maDLC) Demo 3 mouse project:\n",
+ "### This notebook illustrates how to use the transformer for a multi-animal DeepLabCut (maDLC) Demo tri-mouse project:\n",
"- load our mini-demo data that includes a pretrained model and unlabeled video.\n",
"- analyze a novel video.\n",
"- use the transformer to do unsupervised ID tracking.\n",
@@ -29,31 +29,56 @@
"\n",
"### To create a full maDLC pipeline please see our full docs: https://deeplabcut.github.io/DeepLabCut/README.html\n",
"- Of interest is a full how-to for maDLC: https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html\n",
- "- a quick guide to maDLC: https://deeplabcut.github.io/DeepLabCut/docs/tutorial.html\n",
- "- a demo COLAB for how to use maDLC on your own data: https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb\n",
+ "- a quick guide to maDLC: https://deeplabcut.github.io/DeepLabCut/docs/quick-start/tutorial_maDLC.html\n",
+ "- a demo COLAB for how to use maDLC on your own data: https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb\n",
"\n",
"### To get started, please go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "xOe2hvy85EVP"
+ },
+ "source": [
+ "‼️ **Attention: this demo is for maDLC, which is version 2.2**\n"
+ ]
+ },
{
"cell_type": "code",
"execution_count": null,
"metadata": {
- "id": "HoNN2_0Z9rr_"
+ "id": "NXmLeZBX45Oe"
},
"outputs": [],
"source": [
- "# Install the latest DeepLabCut version:\n",
- "!apt update && apt install cuda-11-8\n",
+ "# Install DLC version 2.2-2.3 (pre DLC3):\n",
"!pip install \"deeplabcut[tf]\""
]
},
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "id": "TlhrVFKN8euh"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "\n",
+ "import deeplabcut"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {
"id": "Wid0GTGMAEnZ"
},
"source": [
+ "## Important - Restart the Runtime for the updated packages to be imported!\n",
+ "\n",
+ "PLEASE, click \"restart runtime\" from the output above before proceeding!\n",
+ "\n",
"No information needs edited in the cells below, you can simply click run on each:\n",
"\n",
"### Download our Demo Project from our server:"
@@ -61,28 +86,41 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 5,
"metadata": {
- "id": "PusLdqbqJi60"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "PusLdqbqJi60",
+ "outputId": "dbe30821-d3a7-443f-de74-6cb0bee49aac"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Downloading demo-me-2021-07-14.zip...\n"
+ ]
+ }
+ ],
"source": [
"# Download our demo project:\n",
- "import requests\n",
"from io import BytesIO\n",
"from zipfile import ZipFile\n",
"\n",
- "url_record = 'https://zenodo.org/api/records/7883589'\n",
+ "import requests\n",
+ "\n",
+ "url_record = \"https://zenodo.org/api/records/7883589\"\n",
"response = requests.get(url_record)\n",
"if response.status_code == 200:\n",
- " file = response.json()['files'][0]\n",
- " title = file['key']\n",
+ " file = response.json()[\"files\"][0]\n",
+ " title = file[\"key\"]\n",
" print(f\"Downloading {title}...\")\n",
- " with requests.get(file['links']['self'], stream=True) as r:\n",
+ " with requests.get(file[\"links\"][\"self\"], stream=True) as r:\n",
" with ZipFile(BytesIO(r.content)) as zf:\n",
- " zf.extractall(path='/content')\n",
+ " zf.extractall(path=\"/content\")\n",
"else:\n",
- " raise ValueError(f'The URL {url_record} could not be reached.')"
+ " raise ValueError(f\"The URL {url_record} could not be reached.\")"
]
},
{
@@ -91,27 +129,144 @@
"id": "8iXtySnQB0BE"
},
"source": [
- "## Analyze a novel 3 mouse video with our maDLC DLCRNet, pretrained on 3 mice data \n",
+ "## Analyze a novel 3 mouse video with our maDLC DLCRNet, pretrained on 3 mice data\n",
"\n",
- "###in one step, since auto_track=True you extract detections and association costs, create tracklets, & stitch them. We can use this to compare to the transformer-guided tracking below.\n"
+ "In one step, since `auto_track=True` you extract detections and association costs, create tracklets, & stitch them. We can use this to compare to the transformer-guided tracking below.\n"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 6,
"metadata": {
"id": "odYrU3o8BSAr"
},
"outputs": [],
"source": [
- "import deeplabcut as dlc\n",
- "import os\n",
- "\n",
"project_path = \"/content/demo-me-2021-07-14\"\n",
"config_path = os.path.join(project_path, \"config.yaml\")\n",
- "video = os.path.join(project_path, \"videos\", \"videocompressed1.mp4\")\n",
- "\n",
- "dlc.analyze_videos(config_path,[video], shuffle=0, videotype=\"mp4\",auto_track=True)"
+ "video = os.path.join(project_path, \"videos\", \"videocompressed1.mp4\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 520
+ },
+ "id": "U_351Hkv81X-",
+ "outputId": "f7c30461-101f-47b6-c04f-15809aa5a4bb"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using snapshot-20000 for model /content/demo-me-2021-07-14/dlc-models/iteration-0/demoJul14-trainset95shuffle0\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/tensorflow/python/keras/engine/base_layer_v1.py:1694: UserWarning: `layer.apply` is deprecated and will be removed in a future version. Please use `layer.__call__` method instead.\n",
+ " warnings.warn('`layer.apply` is deprecated and '\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Activating extracting of PAFs\n",
+ "Starting to analyze % /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Duration of video [s]: 77.67 , recorded with 30.0 fps!\n",
+ "Overall # of frames: 2330 found with (before cropping) frame dimensions: 640 480\n",
+ "Starting to extract posture from the video(s) with batchsize: 8\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 2330/2330 [00:39<00:00, 58.83it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Video Analyzed. Saving results in /content/demo-me-2021-07-14/videos...\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/utils/auxfun_multianimal.py:83: UserWarning: default_track_method` is undefined in the config.yaml file and will be set to `ellipse`.\n",
+ " warnings.warn(\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using snapshot-20000 for model /content/demo-me-2021-07-14/dlc-models/iteration-0/demoJul14-trainset95shuffle0\n",
+ "Processing... /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Analyzing /content/demo-me-2021-07-14/videos/videocompressed1DLC_dlcrnetms5_demoJul14shuffle0_20000.h5\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 2330/2330 [00:02<00:00, 1088.72it/s]\n",
+ "2330it [00:06, 342.29it/s] \n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The tracklets were created (i.e., under the hood deeplabcut.convert_detections2tracklets was run). Now you can 'refine_tracklets' in the GUI, or run 'deeplabcut.stitch_tracklets'.\n",
+ "Processing... /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 4/4 [00:00<00:00, 1488.53it/s]\n",
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/refine_training_dataset/stitch.py:934: FutureWarning: Starting with pandas version 3.0 all arguments of to_hdf except for the argument 'path_or_buf' will be keyword-only.\n",
+ " df.to_hdf(output_name, \"tracks\", format=\"table\", mode=\"w\")\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "The videos are analyzed. Time to assemble animals and track 'em... \n",
+ " Call 'create_video_with_all_detections' to check multi-animal detection quality before tracking.\n",
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames.\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'DLC_dlcrnetms5_demoJul14shuffle0_20000'"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "deeplabcut.analyze_videos(config_path, [video], shuffle=0, videotype=\"mp4\", auto_track=True)"
]
},
{
@@ -134,23 +289,69 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 8,
"metadata": {
- "id": "aTRbuUQ1FBO0"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "aTRbuUQ1FBO0",
+ "outputId": "0d182f64-512d-463d-a997-226c7199b724"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Filtering with median model /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Saving filtered csv poses!\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/post_processing/filtering.py:298: FutureWarning: Starting with pandas version 3.0 all arguments of to_hdf except for the argument 'path_or_buf' will be keyword-only.\n",
+ " data.to_hdf(outdataname, \"df_with_missing\", format=\"table\", mode=\"w\")\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Starting to process video: /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4 and data.\n",
+ "Duration of video [s]: 77.67, recorded with 30.0 fps!\n",
+ "Overall # of frames: 2330 with cropped frame dimensions: 640 480\n",
+ "Generating frames and creating video.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/utils/make_labeled_video.py:140: FutureWarning: DataFrame.groupby with axis=1 is deprecated. Do `frame.T.groupby(...)` without axis instead.\n",
+ " Dataframe.groupby(level=\"individuals\", axis=1).size().values // 3\n",
+ "100%|██████████| 2330/2330 [00:31<00:00, 73.04it/s]\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[True]"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"source": [
- "#Filter the predictions to remove small jitter, if desired:\n",
- "dlc.filterpredictions(config_path,\n",
- " [video],\n",
- " shuffle=0,\n",
- " videotype='mp4',\n",
- " )\n",
- "\n",
- "dlc.create_labeled_video(\n",
+ "# Filter the predictions to remove small jitter, if desired:\n",
+ "deeplabcut.filterpredictions(config_path, [video], shuffle=0, videotype=\"mp4\")\n",
+ "deeplabcut.create_labeled_video(\n",
" config_path,\n",
" [video],\n",
- " videotype='mp4',\n",
+ " videotype=\"mp4\",\n",
" shuffle=0,\n",
" color_by=\"individual\",\n",
" keypoints_only=False,\n",
@@ -182,13 +383,26 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 9,
"metadata": {
- "id": "7w9BDIA7BB_i"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "7w9BDIA7BB_i",
+ "outputId": "a163087d-cbcb-4e4d-f461-2e24ed19a80b"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4 and data.\n",
+ "Plots created! Please check the directory \"plot-poses\" within the video directory\n"
+ ]
+ }
+ ],
"source": [
- "dlc.plot_trajectories(config_path, [video], shuffle=0,videotype='mp4')"
+ "deeplabcut.plot_trajectories(config_path, [video], shuffle=0, videotype=\"mp4\")"
]
},
{
@@ -199,21 +413,108 @@
"source": [
"# Transformer for reID\n",
"\n",
- "while the tracking here is very good without using the transformer, we want to demo the workflow for you! "
+ "while the tracking here is very good without using the transformer, we want to demo the workflow for you!"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 10,
"metadata": {
- "id": "5xlO6TVYxQWc"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "5xlO6TVYxQWc",
+ "outputId": "a433221f-0390-4028-fe68-be0b90adad48"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using snapshot-20000 for model /content/demo-me-2021-07-14/dlc-models/iteration-0/demoJul14-trainset95shuffle0\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/tensorflow/python/keras/engine/base_layer_v1.py:1694: UserWarning: `layer.apply` is deprecated and will be removed in a future version. Please use `layer.__call__` method instead.\n",
+ " warnings.warn('`layer.apply` is deprecated and '\n",
+ "/usr/local/lib/python3.11/dist-packages/tensorflow/python/keras/engine/base_layer_v1.py:1694: UserWarning: `layer.apply` is deprecated and will be removed in a future version. Please use `layer.__call__` method instead.\n",
+ " warnings.warn('`layer.apply` is deprecated and '\n",
+ "/usr/local/lib/python3.11/dist-packages/tensorflow/python/keras/engine/base_layer_v1.py:1694: UserWarning: `layer.apply` is deprecated and will be removed in a future version. Please use `layer.__call__` method instead.\n",
+ " warnings.warn('`layer.apply` is deprecated and '\n",
+ "/usr/local/lib/python3.11/dist-packages/tensorflow/python/keras/engine/base_layer_v1.py:1694: UserWarning: `layer.apply` is deprecated and will be removed in a future version. Please use `layer.__call__` method instead.\n",
+ " warnings.warn('`layer.apply` is deprecated and '\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Activating extracting of PAFs\n",
+ "Starting to analyze % /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Duration of video [s]: 77.67 , recorded with 30.0 fps!\n",
+ "Overall # of frames: 2330 found with (before cropping) frame dimensions: 640 480\n",
+ "Starting to extract posture\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 2330/2330 [01:18<00:00, 29.78it/s]\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "If the tracking is not satisfactory for some videos, consider expanding the training set. You can use the function 'extract_outlier_frames' to extract a few representative outlier frames.\n",
+ "Epoch 10, train acc: 0.61\n",
+ "Epoch 10, test acc 0.45\n",
+ "Epoch 20, train acc: 0.74\n",
+ "Epoch 20, test acc 0.65\n",
+ "Epoch 30, train acc: 0.78\n",
+ "Epoch 30, test acc 0.55\n",
+ "Epoch 40, train acc: 0.76\n",
+ "Epoch 40, test acc 0.50\n",
+ "Epoch 50, train acc: 0.85\n",
+ "Epoch 50, test acc 0.55\n",
+ "Epoch 60, train acc: 0.84\n",
+ "Epoch 60, test acc 0.60\n",
+ "Epoch 70, train acc: 0.85\n",
+ "Epoch 70, test acc 0.55\n",
+ "Epoch 80, train acc: 0.79\n",
+ "Epoch 80, test acc 0.55\n",
+ "Epoch 90, train acc: 0.88\n",
+ "Epoch 90, test acc 0.55\n",
+ "Epoch 100, train acc: 0.84\n",
+ "Epoch 100, test acc 0.55\n",
+ "loading params\n",
+ "Processing... /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "100%|██████████| 4/4 [00:00<00:00, 483.21it/s]\n",
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/refine_training_dataset/stitch.py:934: FutureWarning: Starting with pandas version 3.0 all arguments of to_hdf except for the argument 'path_or_buf' will be keyword-only.\n",
+ " df.to_hdf(output_name, \"tracks\", format=\"table\", mode=\"w\")\n"
+ ]
+ }
+ ],
"source": [
- "dlc.transformer_reID(config_path, [video],\n",
- " shuffle=0, videotype='mp4',\n",
- " track_method='ellipse',n_triplets=100\n",
- " )"
+ "deeplabcut.transformer_reID(\n",
+ " config_path,\n",
+ " [video],\n",
+ " shuffle=0,\n",
+ " videotype=\"mp4\",\n",
+ " track_method=\"ellipse\",\n",
+ " n_triplets=100,\n",
+ ")"
]
},
{
@@ -227,35 +528,86 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 11,
"metadata": {
- "id": "MBMbRFEMxmi4"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "MBMbRFEMxmi4",
+ "outputId": "5ca4357a-c8e1-46c6-ecad-141bfce48cc5"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4 and data.\n",
+ "Plots created! Please check the directory \"plot-poses\" within the video directory\n"
+ ]
+ }
+ ],
"source": [
- "dlc.plot_trajectories(config_path, [video],\n",
- " shuffle=0,videotype='mp4',\n",
- " track_method=\"transformer\"\n",
- " )"
+ "deeplabcut.plot_trajectories(\n",
+ " config_path,\n",
+ " [video],\n",
+ " shuffle=0,\n",
+ " videotype=\"mp4\",\n",
+ " track_method=\"transformer\",\n",
+ ")"
]
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 12,
"metadata": {
- "id": "vx3e-r1CoXaX"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "vx3e-r1CoXaX",
+ "outputId": "46cdbd39-d1f6-4b78-abba-7e979740f2a2"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Starting to process video: /content/demo-me-2021-07-14/videos/videocompressed1.mp4\n",
+ "Loading /content/demo-me-2021-07-14/videos/videocompressed1.mp4 and data.\n",
+ "Duration of video [s]: 77.67, recorded with 30.0 fps!\n",
+ "Overall # of frames: 2330 with cropped frame dimensions: 640 480\n",
+ "Generating frames and creating video.\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/usr/local/lib/python3.11/dist-packages/deeplabcut/utils/make_labeled_video.py:140: FutureWarning: DataFrame.groupby with axis=1 is deprecated. Do `frame.T.groupby(...)` without axis instead.\n",
+ " Dataframe.groupby(level=\"individuals\", axis=1).size().values // 3\n",
+ "100%|██████████| 2330/2330 [00:31<00:00, 73.75it/s]\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[True]"
+ ]
+ },
+ "execution_count": 12,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
"source": [
- "dlc.create_labeled_video(\n",
+ "deeplabcut.create_labeled_video(\n",
" config_path,\n",
" [video],\n",
- " videotype='mp4',\n",
+ " videotype=\"mp4\",\n",
" shuffle=0,\n",
" color_by=\"individual\",\n",
" keypoints_only=False,\n",
" draw_skeleton=True,\n",
- " track_method=\"transformer\"\n",
+ " track_method=\"transformer\",\n",
")"
]
}
@@ -263,8 +615,9 @@
"metadata": {
"accelerator": "GPU",
"colab": {
- "collapsed_sections": [],
+ "gpuType": "A100",
"include_colab_link": true,
+ "machine_shape": "hm",
"name": "COLAB_transformer_reID.ipynb",
"provenance": []
},
diff --git a/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb b/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb
index 92d7eb9ff9..578a69fe01 100644
--- a/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb
+++ b/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb
@@ -50,9 +50,9 @@
"metadata": {},
"outputs": [],
"source": [
- "#Setup your project variables:\n",
- "YourName = 'teamDLC'\n",
- "YourExperimentName = 'testing'"
+ "# Setup your project variables:\n",
+ "YourName = \"teamDLC\"\n",
+ "YourExperimentName = \"testing\""
]
},
{
@@ -75,7 +75,7 @@
}
],
"source": [
- "config_path = deeplabcut.create_new_project_3d(YourExperimentName,YourName,num_cameras=2)"
+ "config_path = deeplabcut.create_new_project_3d(YourExperimentName, YourName, num_cameras=2)"
]
},
{
@@ -93,11 +93,11 @@
"metadata": {},
"outputs": [],
"source": [
- "#If you're loading an already created project, just set the 3D Project config_path variable:\n",
- "#import os\n",
- "#from pathlib import Path\n",
- "#config_path3d = os.path.join(os.getcwd(),'testing3D-DeepLabCutTeam-2019-06-05-3d/config.yaml')\n",
- "#print(config_path3d)"
+ "# If you're loading an already created project, just set the 3D Project config_path variable:\n",
+ "# import os\n",
+ "# from pathlib import Path\n",
+ "# config_path3d = os.path.join(os.getcwd(),'testing3D-DeepLabCutTeam-2019-06-05-3d/config.yaml')\n",
+ "# print(config_path3d)"
]
},
{
@@ -151,7 +151,7 @@
"metadata": {},
"outputs": [],
"source": [
- "deeplabcut.calibrate_cameras(config_path3d, cbrow =9,cbcol =6,calibrate=False,alpha=0.9)"
+ "deeplabcut.calibrate_cameras(config_path, cbrow=9, cbcol=6, calibrate=False, alpha=0.9)"
]
},
{
@@ -179,7 +179,7 @@
"metadata": {},
"outputs": [],
"source": [
- "deeplabcut.calibrate_cameras(config_path3d, cbrow = 9,cbcol = 6, calibrate=True, alpha=0.9)"
+ "deeplabcut.calibrate_cameras(config_path, cbrow=9, cbcol=6, calibrate=True, alpha=0.9)"
]
},
{
@@ -197,10 +197,9 @@
"metadata": {},
"outputs": [],
"source": [
- "import matplotlib\n",
"%matplotlib inline\n",
"\n",
- "deeplabcut.check_undistortion(config_path3d)"
+ "deeplabcut.check_undistortion(config_path)"
]
},
{
@@ -239,12 +238,12 @@
"metadata": {},
"outputs": [],
"source": [
- "# Of course, this does not work on the demo calibration images, \n",
+ "# Of course, this does not work on the demo calibration images,\n",
"# but when you are ready for your own dataset, edit and then run the following!\n",
"\n",
- "video_path = '/home/yourname/videoFolder'\n",
+ "video_path = \"/home/yourname/videoFolder\"\n",
"\n",
- "deeplabcut.triangulate(config_path3d,video_path, videotype='mp4')"
+ "deeplabcut.triangulate(config_path, video_path, videotype=\"mp4\")"
]
},
{
@@ -269,15 +268,20 @@
"metadata": {},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video_3d(config_path,['triangulated_file_folder'],start=50,end=250, trailpoints=3)"
+ "deeplabcut.create_labeled_video_3d(config_path, [\"triangulated_file_folder\"], start=50, end=250, trailpoints=3)"
]
}
],
"metadata": {
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-02-28",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [conda env:DEEPLABCUT_newGUI] *",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DEEPLABCUT_newGUI-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -289,7 +293,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.9.13"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/JUPYTER/Demo_labeledexample_MouseReaching.ipynb b/examples/JUPYTER/Demo_labeledexample_MouseReaching.ipynb
index cc45d9a2a7..549ce4260a 100644
--- a/examples/JUPYTER/Demo_labeledexample_MouseReaching.ipynb
+++ b/examples/JUPYTER/Demo_labeledexample_MouseReaching.ipynb
@@ -8,7 +8,11 @@
},
"source": [
"# DeepLabCut Toolbox - DEMO (mouse reaching)\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "Some resources that can be useful:\n",
+ "\n",
+ "- [github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Single Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html)\n",
"\n",
"#### The notebook accompanies the following user-guide:\n",
"\n",
@@ -35,7 +39,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Import the toolbox:"
+ "## Import the Toolbox and Required Libraries"
]
},
{
@@ -48,6 +52,8 @@
},
"outputs": [],
"source": [
+ "from pathlib import Path\n",
+ "\n",
"import deeplabcut"
]
},
@@ -64,12 +70,17 @@
"metadata": {},
"outputs": [],
"source": [
- "import os\n",
- "# Note that parameters of this project can be seen at: *Reaching-Mackenzie-2018-08-30/config.yaml*\n",
- "from pathlib import Path\n",
+ "# Create a variable to set the config.yaml file path:\n",
+ "# If this path does not point to the project from the URL below,\n",
+ "# edit it to make sure it does:\n",
+ "# https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/Reaching-Mackenzie-2018-08-30\n",
+ "#\n",
+ "# Example - Linux/OSX\n",
+ "# path_config_file = \"/Users/john/DeepLabCut/examples/Reaching-Mackenzie-2018-08-30/config.yaml\"\n",
+ "# Example - Windows\n",
+ "# path_config_file = r\"C:\\DeepLabCut\\examples\\Reaching-Mackenzie-2018-08-30\\config.yaml\"\n",
"\n",
- "#create a variable to set the config.yaml file path:\n",
- "path_config_file = os.path.join(os.getcwd(),'Reaching-Mackenzie-2018-08-30/config.yaml')\n",
+ "path_config_file = str(Path.cwd() / \"Reaching-Mackenzie-2018-08-30\" / \"config.yaml\")\n",
"print(path_config_file)"
]
},
@@ -99,8 +110,8 @@
},
"outputs": [],
"source": [
- "#let's load some demo data, and create a training set \n",
- "#(note, this function is not used when you create your own project):\n",
+ "# Let's load some demo data, and create a training set\n",
+ "# (note, this function is not used when you create your own project):\n",
"\n",
"deeplabcut.load_demo_data(path_config_file)"
]
@@ -115,7 +126,7 @@
},
"outputs": [],
"source": [
- "#Perhaps plot the labels to see how the frames were annotated:\n",
+ "# Perhaps plot the labels to see how the frames were annotated:\n",
"\n",
"deeplabcut.check_labels(path_config_file)"
]
@@ -128,11 +139,12 @@
},
"source": [
"## Start training of Feature Detectors\n",
- "This function trains the network for a specific shuffle of the training dataset. **The user can set various parameters in /Reaching-Mackenzie-2018-08-30/dlc-models/ReachingAug30-trainset95shuffle1/iteration-0/train/pose_cfg.yaml.**\n",
"\n",
- "Training can be stopped at any time. Note that the weights are only stored every 'save_iters' steps. For this demo the it is advisable to store & display the progress very often (i.e. display every 20, save every 100). In practice this is inefficient (in reality, you will train until ~200K, so we save every 50K).\n",
+ "This function trains the network for a specific shuffle of the training dataset. **The user can set various parameters in `.../Reaching-Mackenzie-2018-08-30/dlc-models-pytorch/iteration-0/ReachingAug30-trainset95shuffle1/train/pytorch_config.yaml`**. For more information about the variables that can be set, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/pytorch_config.html)!\n",
"\n",
- "**We recommend just training for 10-20 min, as you aren't running this demo to use DLC, just to work through the steps. In total, this demo should take you LESS THAN 1 HOUR!**"
+ "Training can be stopped at any time. Note that the weights are only stored every 'save_epochs' steps. For this demo the it is advisable to store & display the progress very often (i.e. display every 20, save every 2). In practice this is inefficient (in reality, you will train until ~200, so we save every 10).\n",
+ "\n",
+ "**We recommend just training for 15-20 min, as you aren't running this demo to use DLC, just to work through the steps. In total, this demo should take you LESS THAN 1 HOUR!**"
]
},
{
@@ -142,24 +154,26 @@
"colab": {},
"colab_type": "code",
"id": "jg96O2acywnW",
- "scrolled": false
+ "scrolled": true
},
"outputs": [],
"source": [
- "deeplabcut.train_network(path_config_file, shuffle=1, saveiters=300, displayiters=10)\n",
- "#notice the variables \"saveiters\" and \"dsiplayiters\" that can be set in the function\n",
+ "# notice the variables \"save_epochs\" and \"displayiters\" that can be set in the function\n",
+ "deeplabcut.train_network(path_config_file, shuffle=1, save_epochs=2, displayiters=10)\n",
+ "\n",
+ "# you just need to run this until you get at least 1 snapshot, which is set by: \"save_epochs\"\n",
+ "# (so in this case you could stop after 2 epochs!) How do I stop? Click the STOP button!\n",
"\n",
- "#you just need to run this until you get at least 1 snapshot, which is set by: \"save_iters\" \n",
- "#(so in this case you could stop after 500!) How do I stop? Click the STOP button!\n",
- "# To train until ~2,000 iterations on a CPU should be ~30 min"
+ "# To train until ~50 epochs on a CPU should be ~15 min\n",
+ "# Every 10 epochs, your model will be evaluated. You can keep an eye on model performance\n",
+ "# while the model is being trained."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "*Note, that if it reaches the end (default 1M) or you stop it (by \"stop\" or by CTRL+C), \n",
- "you will see an keyboard interrupt \"error\", but it is not a real error, i.e. you can ignore this.*"
+ "*Note, that if you stop it (by \"stop\" or by CTRL+C), you will see an keyboard interrupt \"error\", but it is not a real error, i.e. you can ignore this.*"
]
},
{
@@ -171,7 +185,7 @@
"source": [
"## Evaluate the trained network\n",
"\n",
- "This function evaluates a trained model for a specific shuffle/shuffles at a particular training state (snapshot) or on all the states. The network is evaluated on the data set (images) and stores the results as .csv file in a subdirectory under **evaluation-results**.\n",
+ "This function evaluates a trained model for a specific shuffle/shuffles at a particular training state (snapshot) or on all the states. The network is evaluated on the data set (images) and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**.\n",
"\n",
"You can change various parameters in the ```config.yaml``` file of this project. For the evaluation one can change pcutoff. This cutoff also influences how likely estimated positions need to be so that they are shown in the plots."
]
@@ -187,14 +201,14 @@
},
"outputs": [],
"source": [
- "deeplabcut.evaluate_network(path_config_file,plotting=True)"
+ "deeplabcut.evaluate_network(path_config_file, plotting=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "**NOTE: depending on your set up sometimes you get some \"matplotlib errors, but these are not important**\n",
+ "**NOTE: depending on your setup sometimes you get some \"matplotlib errors, but these are not important**\n",
"\n",
"Now you can go check out the images. Given the limited data input and it took ~20 mins to test this out, it is not meant to track well, so don't be alarmed. This is just to get you familiar with the workflow... "
]
@@ -223,12 +237,12 @@
"outputs": [],
"source": [
"# Set the video path:\n",
- "#The video can be the one you trained with and new videos that look similar, i.e. same experiments, etc.\n",
+ "# The video can be the one you trained with and new videos that look similar, i.e. same experiments, etc.\n",
"# You can add individual videos, OR just a folder - it will skip videos that are already analyzed once.\n",
"\n",
- "#i.e you can run 'reachingvideo1' and/or 'MovieS2_Perturbation_noLaser_compressed'\n",
+ "# i.e. you can run 'reachingvideo1' and/or 'MovieS2_Perturbation_noLaser_compressed'\n",
"\n",
- "videofile_path = os.path.join(os.getcwd(),'Reaching-Mackenzie-2018-08-30/videos/reachingvideo1.avi') "
+ "videofile_path = str(Path(path_config_file).parent / \"videos\" / \"reachingvideo1.avi\")"
]
},
{
@@ -243,8 +257,9 @@
"outputs": [],
"source": [
"print(\"Start Analyzing the video!\")\n",
- "deeplabcut.analyze_videos(path_config_file,[videofile_path])\n",
- "# this video takes ~ 8 min to analyze with a CPU"
+ "\n",
+ "deeplabcut.analyze_videos(path_config_file, [videofile_path])\n",
+ "# this video takes ~ 1 min to analyze with a CPU"
]
},
{
@@ -279,7 +294,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,[videofile_path], draw_skeleton=True)"
+ "deeplabcut.create_labeled_video(path_config_file, [videofile_path], draw_skeleton=True)"
]
},
{
@@ -305,9 +320,9 @@
"outputs": [],
"source": [
"%matplotlib notebook\n",
- "deeplabcut.plot_trajectories(path_config_file,[videofile_path],showfigures=True)\n",
+ "deeplabcut.plot_trajectories(path_config_file, [videofile_path], showfigures=True)\n",
"\n",
- "#These plots can are interactive and can be customized (see https://matplotlib.org/)"
+ "# These plots are interactive and can be customized (see https://matplotlib.org/)"
]
},
{
@@ -339,11 +354,16 @@
"colab": {},
"colab_type": "code",
"id": "RJGiDKuUywoC",
- "scrolled": false
+ "scrolled": true
},
"outputs": [],
"source": [
- "deeplabcut.extract_outlier_frames(path_config_file,videofile_path,outlieralgorithm='uncertain',p_bound=.2)"
+ "deeplabcut.extract_outlier_frames(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " outlieralgorithm=\"uncertain\",\n",
+ " p_bound=0.2,\n",
+ ")"
]
},
{
@@ -365,7 +385,9 @@
"source": [
"## Manually correct labels\n",
"\n",
- "This step allows the user to correct the labels in the extracted frames. Navigate to the folder with the videos and use the GUI as described in the protocol to update the labels."
+ "This step allows the user to correct the labels in the extracted frames. Navigate to the folder with the videos and use the GUI as described in the protocol to update the labels.\n",
+ "\n",
+ "For documentation regarding the GUI, [look at the docs for `napari-deeplabcut`](https://github.com/DeepLabCut/napari-deeplabcut/tree/main) - and specifically _\"3. Refining labels – the image folder contains a machinelabels-iter<#>.h5 file.\"_!"
]
},
{
@@ -379,9 +401,6 @@
},
"outputs": [],
"source": [
- "#GUI pops up! \n",
- "#sometimes you need to restart the kernel for the GUI to launch.\n",
- "%gui wx\n",
"deeplabcut.refine_labels(path_config_file)"
]
},
@@ -421,7 +440,7 @@
},
"outputs": [],
"source": [
- "#Perhaps plot the labels to see how how all the frames are annotated (including the refined ones)\n",
+ "# Perhaps plot the labels to see how how all the frames are annotated (including the refined ones)\n",
"deeplabcut.check_labels(path_config_file)\n",
"# if they are off, you can load them in the labeling_gui to adjust!"
]
@@ -436,7 +455,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_training_dataset(path_config_file)"
+ "deeplabcut.create_training_dataset(path_config_file, engine=deeplabcut.Engine.PYTORCH)"
]
},
{
@@ -446,7 +465,7 @@
"id": "8fhL6nG2ywoW"
},
"source": [
- "Now one can train the network again... (with the expanded data set)"
+ "Now one can train the network again... (with the expanded data set). We can continue training from the snapshot we already have by using the `snapshot_path` argument - instead of training the model from scratch, it will load the weights we already have and fine-tune them!"
]
},
{
@@ -459,8 +478,31 @@
},
"outputs": [],
"source": [
- "deeplabcut.train_network(path_config_file)"
+ "snapshot_path = ( # Edit me if needed! Select the path to the snapshot to continue training from!\n",
+ " Path(path_config_file).parent\n",
+ " / \"dlc-models-pytorch\"\n",
+ " / \"iteration-0\"\n",
+ " / \"ReachingAug30-trainset95shuffle1\"\n",
+ " / \"train\"\n",
+ " / \"snapshot-best-080.pt\"\n",
+ ")\n",
+ "\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=2,\n",
+ " displayiters=10,\n",
+ " batch_size=8,\n",
+ " snapshot_path=snapshot_path,\n",
+ ")"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
@@ -470,10 +512,15 @@
"provenance": [],
"version": "0.3.2"
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-02-28",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [conda env:DLC2]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DLC2-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -485,7 +532,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.6.9"
+ "version": "3.11.11"
},
"varInspector": {
"cols": {
diff --git a/examples/JUPYTER/Demo_labeledexample_Openfield.ipynb b/examples/JUPYTER/Demo_labeledexample_Openfield.ipynb
index 3b75f5b60a..0d26f986fe 100644
--- a/examples/JUPYTER/Demo_labeledexample_Openfield.ipynb
+++ b/examples/JUPYTER/Demo_labeledexample_Openfield.ipynb
@@ -8,7 +8,11 @@
},
"source": [
"# DeepLabCut Toolbox - Open-Field DEMO\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "Some resources that can be useful:\n",
+ "\n",
+ "- [github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Single Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html)\n",
"\n",
"#### The notebook accompanies the following user-guide:\n",
"\n",
@@ -34,7 +38,7 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -43,65 +47,50 @@
"outputs": [],
"source": [
"# Importing the toolbox (takes several seconds)\n",
+ "from pathlib import Path\n",
+ "\n",
"import deeplabcut"
]
},
{
"cell_type": "code",
- "execution_count": 2,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
"id": "WOEHc0MeywnJ"
},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Loaded, now creating training data...\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30 already exists!\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/labeled-data/short_mp3/CollectedData_Pranav.h5 not found (perhaps not annotated)\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1 already exists!\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1//train already exists!\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1//test already exists!\n",
- "The training dataset is successfully created. Use the function 'train_network' to start training. Happy training!\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "# Loading example data set:\n",
- "import os\n",
+ "# Create a variable to set the config.yaml file path:\n",
+ "# If this path does not point to the project from the URL below,\n",
+ "# edit it to make sure it does:\n",
+ "# https://github.com/DeepLabCut/DeepLabCut/tree/main/examples/openfield-Pranav-2018-10-30\n",
+ "#\n",
+ "# Example - Linux/OSX\n",
+ "# path_config_file = \"/Users/john/DeepLabCut/examples/openfield-Pranav-2018-10-30/config.yaml\"\n",
+ "# Example - Windows\n",
+ "# path_config_file = r\"C:\\DeepLabCut\\examples\\openfield-Pranav-2018-10-30\\config.yaml\"\n",
+ "#\n",
"# Note that parameters of this project can be seen at: *openfield-Pranav-2018-10-30/config.yaml*\n",
- "from pathlib import Path\n",
- "path_config_file = os.path.join(os.getcwd(),'openfield-Pranav-2018-10-30/config.yaml')\n",
+ "\n",
+ "path_config_file = str(Path.cwd() / \"openfield-Pranav-2018-10-30\" / \"config.yaml\")\n",
"deeplabcut.load_demo_data(path_config_file)"
]
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
"id": "ROlflqQLywnP"
},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Creating images with labels by Pranav.\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/labeled-data/m4s1_labeled already exists!\n",
- "They are stored in the following folder: /home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/labeled-data/m4s1_labeled.\n",
- "Attention: /home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/labeled-data/short_mp3 does not appear to have labeled data!\n",
- "If all the labels are ok, then use the function 'create_training_dataset' to create the training dataset!\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "#[OPTIONAL] Perhaps plot the labels to see how the frames were annotated:\n",
- "#(note, this project was created in Linux, so you might have an error in Windows, but this is an optional step)\n",
+ "# [OPTIONAL] Perhaps plot the labels to see how the frames were annotated:\n",
+ "# (note, this project was created in Linux, so you might have an error in Windows, but this is an optional step)\n",
+ "\n",
"deeplabcut.check_labels(path_config_file)"
]
},
@@ -113,141 +102,30 @@
},
"source": [
"## Start training of Feature Detectors\n",
- "This function trains the network for a specific shuffle of the training dataset. The user can set various parameters in */openfield-Pranav-2018-10-30/dlc-models/.../pose_cfg.yaml*. \n",
"\n",
- "Training can be stopped at any time. Note that the weights are only stored every 'save_iters' steps. For this demo the state it is advisable to store & display the progress very often. In practice this is inefficient. "
+ "This function trains the network for a specific shuffle of the training dataset. The user can set various parameters in `/openfield-Pranav-2018-10-30/dlc-models-pytorch/.../pytorch_config.yaml`. For more information about the variables that can be set, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/pytorch_config.html)!\n",
+ "\n",
+ "Training can be stopped at any time. Note that the weights are only stored every 'save_epochs' epochs. For this demo the state it is advisable to store & display the progress very often. In practice this is inefficient. You should see the model start converging around 50 to 60 epochs; you can continue training it longer to improve performance."
]
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
"id": "jg96O2acywnW",
- "scrolled": false
+ "scrolled": true
},
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "Config:\n",
- "{'all_joints': [[0], [1], [2], [3]],\n",
- " 'all_joints_names': ['snout', 'leftear', 'rightear', 'tailbase'],\n",
- " 'batch_size': 1,\n",
- " 'bottomheight': 400,\n",
- " 'crop': True,\n",
- " 'crop_pad': 0,\n",
- " 'cropratio': 0.4,\n",
- " 'dataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/openfield_Pranav95shuffle1.mat',\n",
- " 'dataset_type': 'default',\n",
- " 'display_iters': 1000,\n",
- " 'fg_fraction': 0.25,\n",
- " 'global_scale': 0.8,\n",
- " 'init_weights': '/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt',\n",
- " 'intermediate_supervision': False,\n",
- " 'intermediate_supervision_layer': 12,\n",
- " 'leftwidth': 400,\n",
- " 'location_refinement': True,\n",
- " 'locref_huber_loss': True,\n",
- " 'locref_loss_weight': 0.05,\n",
- " 'locref_stdev': 7.2801,\n",
- " 'log_dir': 'log',\n",
- " 'max_input_size': 1500,\n",
- " 'mean_pixel': [123.68, 116.779, 103.939],\n",
- " 'metadataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/Documentation_data-openfield_95shuffle1.pickle',\n",
- " 'min_input_size': 64,\n",
- " 'minsize': 100,\n",
- " 'mirror': False,\n",
- " 'multi_step': [[0.005, 10000],\n",
- " [0.02, 430000],\n",
- " [0.002, 730000],\n",
- " [0.001, 1030000]],\n",
- " 'net_type': 'resnet_50',\n",
- " 'num_joints': 4,\n",
- " 'optimizer': 'sgd',\n",
- " 'pos_dist_thresh': 17,\n",
- " 'project_path': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30',\n",
- " 'regularize': False,\n",
- " 'rightwidth': 400,\n",
- " 'save_iters': 50000,\n",
- " 'scale_jitter_lo': 0.5,\n",
- " 'scale_jitter_up': 1.25,\n",
- " 'scoremap_dir': 'test',\n",
- " 'shuffle': True,\n",
- " 'snapshot_prefix': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1/train/snapshot',\n",
- " 'stride': 8.0,\n",
- " 'topheight': 400,\n",
- " 'use_gt_segm': False,\n",
- " 'video': False,\n",
- " 'video_batch': False,\n",
- " 'weigh_negatives': False,\n",
- " 'weigh_only_present_joints': False,\n",
- " 'weigh_part_predictions': False,\n",
- " 'weight_decay': 0.0001}\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "INFO:tensorflow:Restoring parameters from /home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt\n",
- "Display_iters overwritten as 10\n",
- "Save_iters overwritten as 100\n",
- "Training parameter:\n",
- "{'stride': 8.0, 'weigh_part_predictions': False, 'weigh_negatives': False, 'fg_fraction': 0.25, 'weigh_only_present_joints': False, 'mean_pixel': [123.68, 116.779, 103.939], 'shuffle': True, 'snapshot_prefix': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1/train/snapshot', 'log_dir': 'log', 'global_scale': 0.8, 'location_refinement': True, 'locref_stdev': 7.2801, 'locref_loss_weight': 0.05, 'locref_huber_loss': True, 'optimizer': 'sgd', 'intermediate_supervision': False, 'intermediate_supervision_layer': 12, 'regularize': False, 'weight_decay': 0.0001, 'mirror': False, 'crop_pad': 0, 'scoremap_dir': 'test', 'dataset_type': 'default', 'use_gt_segm': False, 'batch_size': 1, 'video': False, 'video_batch': False, 'crop': True, 'cropratio': 0.4, 'minsize': 100, 'leftwidth': 400, 'rightwidth': 400, 'topheight': 400, 'bottomheight': 400, 'all_joints': [[0], [1], [2], [3]], 'all_joints_names': ['snout', 'leftear', 'rightear', 'tailbase'], 'dataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/openfield_Pranav95shuffle1.mat', 'display_iters': 1000, 'init_weights': '/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt', 'max_input_size': 1500, 'metadataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/Documentation_data-openfield_95shuffle1.pickle', 'min_input_size': 64, 'multi_step': [[0.005, 10000], [0.02, 430000], [0.002, 730000], [0.001, 1030000]], 'net_type': 'resnet_50', 'num_joints': 4, 'pos_dist_thresh': 17, 'project_path': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30', 'save_iters': 50000, 'scale_jitter_lo': 0.5, 'scale_jitter_up': 1.25}\n",
- "Starting training....\n"
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "iteration: 10 loss: 0.3308 lr: 0.005\n",
- "iteration: 20 loss: 0.0563 lr: 0.005\n",
- "iteration: 30 loss: 0.0417 lr: 0.005\n",
- "iteration: 40 loss: 0.0362 lr: 0.005\n",
- "iteration: 50 loss: 0.0407 lr: 0.005\n",
- "iteration: 60 loss: 0.0461 lr: 0.005\n",
- "iteration: 70 loss: 0.0385 lr: 0.005\n",
- "iteration: 80 loss: 0.0345 lr: 0.005\n",
- "iteration: 90 loss: 0.0314 lr: 0.005\n",
- "iteration: 100 loss: 0.0428 lr: 0.005\n",
- "iteration: 110 loss: 0.0262 lr: 0.005\n",
- "iteration: 120 loss: 0.0255 lr: 0.005\n",
- "iteration: 130 loss: 0.0275 lr: 0.005\n",
- "iteration: 140 loss: 0.0251 lr: 0.005\n",
- "iteration: 150 loss: 0.0221 lr: 0.005\n",
- "iteration: 160 loss: 0.0209 lr: 0.005\n",
- "iteration: 170 loss: 0.0297 lr: 0.005\n",
- "iteration: 180 loss: 0.0325 lr: 0.005\n",
- "iteration: 190 loss: 0.0242 lr: 0.005\n"
- ]
- },
- {
- "ename": "KeyboardInterrupt",
- "evalue": "",
- "output_type": "error",
- "traceback": [
- "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
- "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)",
- "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mdeeplabcut\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtrain_network\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mpath_config_file\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mshuffle\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdisplayiters\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m10\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msaveiters\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m100\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/training.py\u001b[0m in \u001b[0;36mtrain_network\u001b[0;34m(config, shuffle, trainingsetindex, gputouse, max_snapshots_to_keep, autotune, displayiters, saveiters, maxiters)\u001b[0m\n\u001b[1;32m 87\u001b[0m \u001b[0mtrain\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mposeconfigfile\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mdisplayiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0msaveiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mmaxiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mmax_to_keep\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mmax_snapshots_to_keep\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m#pass on path and file name for pose_cfg.yaml!\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 88\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mBaseException\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 89\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 90\u001b[0m \u001b[0;32mfinally\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 91\u001b[0m \u001b[0mos\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mchdir\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstart_path\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/training.py\u001b[0m in \u001b[0;36mtrain_network\u001b[0;34m(config, shuffle, trainingsetindex, gputouse, max_snapshots_to_keep, autotune, displayiters, saveiters, maxiters)\u001b[0m\n\u001b[1;32m 85\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 86\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 87\u001b[0;31m \u001b[0mtrain\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mstr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mposeconfigfile\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mdisplayiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0msaveiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mmaxiters\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mmax_to_keep\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mmax_snapshots_to_keep\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m#pass on path and file name for pose_cfg.yaml!\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 88\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mBaseException\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 89\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/train.py\u001b[0m in \u001b[0;36mtrain\u001b[0;34m(config_yaml, displayiters, saveiters, maxiters, max_to_keep)\u001b[0m\n\u001b[1;32m 140\u001b[0m \u001b[0mcurrent_lr\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlr_gen\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget_lr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mit\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 141\u001b[0m [_, loss_val, summary] = sess.run([train_op, total_loss, merged_summaries],\n\u001b[0;32m--> 142\u001b[0;31m feed_dict={learning_rate: current_lr})\n\u001b[0m\u001b[1;32m 143\u001b[0m \u001b[0mcum_loss\u001b[0m \u001b[0;34m+=\u001b[0m \u001b[0mloss_val\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 144\u001b[0m \u001b[0mtrain_writer\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0madd_summary\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msummary\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mit\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36mrun\u001b[0;34m(self, fetches, feed_dict, options, run_metadata)\u001b[0m\n\u001b[1;32m 898\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 899\u001b[0m result = self._run(None, fetches, feed_dict, options_ptr,\n\u001b[0;32m--> 900\u001b[0;31m run_metadata_ptr)\n\u001b[0m\u001b[1;32m 901\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mrun_metadata\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 902\u001b[0m \u001b[0mproto_data\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtf_session\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mTF_GetBuffer\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mrun_metadata_ptr\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36m_run\u001b[0;34m(self, handle, fetches, feed_dict, options, run_metadata)\u001b[0m\n\u001b[1;32m 1133\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mfinal_fetches\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0mfinal_targets\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0mhandle\u001b[0m \u001b[0;32mand\u001b[0m \u001b[0mfeed_dict_tensor\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1134\u001b[0m results = self._do_run(handle, final_targets, final_fetches,\n\u001b[0;32m-> 1135\u001b[0;31m feed_dict_tensor, options, run_metadata)\n\u001b[0m\u001b[1;32m 1136\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1137\u001b[0m \u001b[0mresults\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36m_do_run\u001b[0;34m(self, handle, target_list, fetch_list, feed_dict, options, run_metadata)\u001b[0m\n\u001b[1;32m 1314\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mhandle\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1315\u001b[0m return self._do_call(_run_fn, feeds, fetches, targets, options,\n\u001b[0;32m-> 1316\u001b[0;31m run_metadata)\n\u001b[0m\u001b[1;32m 1317\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1318\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_do_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0m_prun_fn\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mhandle\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfeeds\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfetches\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36m_do_call\u001b[0;34m(self, fn, *args)\u001b[0m\n\u001b[1;32m 1320\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_do_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfn\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1321\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1322\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1323\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0merrors\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mOpError\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1324\u001b[0m \u001b[0mmessage\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcompat\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mas_text\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0me\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmessage\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36m_run_fn\u001b[0;34m(feed_dict, fetch_list, target_list, options, run_metadata)\u001b[0m\n\u001b[1;32m 1305\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_extend_graph\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1306\u001b[0m return self._call_tf_sessionrun(\n\u001b[0;32m-> 1307\u001b[0;31m options, feed_dict, fetch_list, target_list, run_metadata)\n\u001b[0m\u001b[1;32m 1308\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1309\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_prun_fn\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mhandle\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfeed_dict\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfetch_list\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;32m/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/tensorflow/python/client/session.py\u001b[0m in \u001b[0;36m_call_tf_sessionrun\u001b[0;34m(self, options, feed_dict, fetch_list, target_list, run_metadata)\u001b[0m\n\u001b[1;32m 1407\u001b[0m return tf_session.TF_SessionRun_wrapper(\n\u001b[1;32m 1408\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_session\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0moptions\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfeed_dict\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfetch_list\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtarget_list\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1409\u001b[0;31m run_metadata)\n\u001b[0m\u001b[1;32m 1410\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1411\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0merrors\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mraise_exception_on_not_ok_status\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0mstatus\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
- "\u001b[0;31mKeyboardInterrupt\u001b[0m: "
- ]
- }
- ],
+ "outputs": [],
"source": [
- "deeplabcut.train_network(path_config_file, shuffle=1, displayiters=10, saveiters=100)"
+ "# notice the variables \"save_epochs\" and \"displayiters\" that can be set in the function\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=2,\n",
+ " displayiters=5,\n",
+ ")"
]
},
{
@@ -267,98 +145,23 @@
"source": [
"## Evaluate a trained network\n",
"\n",
- "This function evaluates a trained model for a specific shuffle/shuffles at a particular training state (snapshot) or on all the states. The network is evaluated on the data set (images) and stores the results as .csv file in a subdirectory under **evaluation-results**.\n",
+ "This function evaluates a trained model for a specific shuffle/shuffles at a particular training state (snapshot) or on all the states. The network is evaluated on the data set (images) and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**.\n",
"\n",
"You can change various parameters in the ```config.yaml``` file of this project. For evaluation all the model descriptors (Task, TrainingFraction, Date etc.) are important. For the evaluation one can change pcutoff. This cutoff also influences how likely estimated positions need to be so that they are shown in the plots. One can furthermore, change the colormap and dotsize for those graphs."
]
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
"id": "kuprPKDdywne",
"scrolled": false
},
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "Config:\n",
- "{'all_joints': [[0], [1], [2], [3]],\n",
- " 'all_joints_names': ['snout', 'leftear', 'rightear', 'tailbase'],\n",
- " 'batch_size': 1,\n",
- " 'bottomheight': 400,\n",
- " 'crop': True,\n",
- " 'crop_pad': 0,\n",
- " 'cropratio': 0.4,\n",
- " 'dataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/openfield_Pranav95shuffle1.mat',\n",
- " 'dataset_type': 'default',\n",
- " 'display_iters': 1000,\n",
- " 'fg_fraction': 0.25,\n",
- " 'global_scale': 0.8,\n",
- " 'init_weights': '/home/mackenzie/anaconda3/envs/DLC2/lib/python3.6/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt',\n",
- " 'intermediate_supervision': False,\n",
- " 'intermediate_supervision_layer': 12,\n",
- " 'leftwidth': 400,\n",
- " 'location_refinement': True,\n",
- " 'locref_huber_loss': True,\n",
- " 'locref_loss_weight': 0.05,\n",
- " 'locref_stdev': 7.2801,\n",
- " 'log_dir': 'log',\n",
- " 'max_input_size': 1500,\n",
- " 'mean_pixel': [123.68, 116.779, 103.939],\n",
- " 'metadataset': 'training-datasets/iteration-0/UnaugmentedDataSet_openfieldOct30/Documentation_data-openfield_95shuffle1.pickle',\n",
- " 'min_input_size': 64,\n",
- " 'minsize': 100,\n",
- " 'mirror': False,\n",
- " 'multi_step': [[0.005, 10000],\n",
- " [0.02, 430000],\n",
- " [0.002, 730000],\n",
- " [0.001, 1030000]],\n",
- " 'net_type': 'resnet_50',\n",
- " 'num_joints': 4,\n",
- " 'optimizer': 'sgd',\n",
- " 'pos_dist_thresh': 17,\n",
- " 'project_path': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30',\n",
- " 'regularize': False,\n",
- " 'rightwidth': 400,\n",
- " 'save_iters': 50000,\n",
- " 'scale_jitter_lo': 0.5,\n",
- " 'scale_jitter_up': 1.25,\n",
- " 'scoremap_dir': 'test',\n",
- " 'shuffle': True,\n",
- " 'snapshot_prefix': '/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/dlc-models/iteration-0/openfieldOct30-trainset95shuffle1/test/snapshot',\n",
- " 'stride': 8.0,\n",
- " 'topheight': 400,\n",
- " 'use_gt_segm': False,\n",
- " 'video': False,\n",
- " 'video_batch': False,\n",
- " 'weigh_negatives': False,\n",
- " 'weigh_only_present_joints': False,\n",
- " 'weigh_part_predictions': False,\n",
- " 'weight_decay': 0.0001}\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/evaluation-results/ already exists!\n",
- "/home/mackenzie/DEEPLABCUT/3D/DeepLabCut2.0-master/examples/openfield-Pranav-2018-10-30/evaluation-results/iteration-0/openfieldOct30-trainset95shuffle1 already exists!\n",
- "Running DeepCut_resnet50_openfieldOct30shuffle1_2400 with # of trainingiterations: 2400\n",
- "This net has already been evaluated!\n",
- "The network is evaluated and the results are stored in the subdirectory 'evaluation_results'.\n",
- "If it generalizes well, choose the best model for prediction and update the config file with the appropriate index for the 'snapshotindex'.\n",
- "Use the function 'analyze_video' to make predictions on new videos.\n",
- "Otherwise consider retraining the network (see DeepLabCut workflow Fig 2)\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
- "deeplabcut.evaluate_network(path_config_file,plotting=False)"
+ "deeplabcut.evaluate_network(path_config_file, plotting=False)"
]
},
{
@@ -393,9 +196,7 @@
},
"outputs": [],
"source": [
- "# Creating video path:\n",
- "import os\n",
- "videofile_path = os.path.join(os.getcwd(),'openfield-Pranav-2018-10-30/videos/m3v1mp4.mp4')"
+ "videofile_path = str(Path(path_config_file).parent / \"videos\" / \"m3v1mp4.mp4\")"
]
},
{
@@ -410,8 +211,8 @@
"outputs": [],
"source": [
"print(\"Start analyzing the video!\")\n",
- "#our demo video on a CPU with take ~30 min to analze! GPU is much faster!\n",
- "deeplabcut.analyze_videos(path_config_file,[videofile_path])"
+ "# our demo video on a CPU with take ~5 min to analze! GPU is much faster!\n",
+ "deeplabcut.analyze_videos(path_config_file, [videofile_path])"
]
},
{
@@ -439,7 +240,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,[videofile_path])"
+ "deeplabcut.create_labeled_video(path_config_file, [videofile_path])"
]
},
{
@@ -465,9 +266,13 @@
"outputs": [],
"source": [
"%matplotlib notebook\n",
- "deeplabcut.plot_trajectories(path_config_file,[videofile_path],showfigures=True)\n",
+ "deeplabcut.plot_trajectories(\n",
+ " path_config_file,\n",
+ " [videofile_path],\n",
+ " showfigures=True,\n",
+ ")\n",
"\n",
- "#These plots can are interactive and can be customized (see https://matplotlib.org/)"
+ "# These plots are interactive and can be customized (see https://matplotlib.org/)"
]
},
{
@@ -493,7 +298,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.extract_outlier_frames(path_config_file,[videofile_path])"
+ "deeplabcut.extract_outlier_frames(path_config_file, [videofile_path])"
]
},
{
@@ -515,7 +320,9 @@
"source": [
"## Manually correct labels\n",
"\n",
- "This step allows the user to correct the labels in the extracted frames. Navigate to the folder corresponding to the video 'm3v1mp4' and use the GUI as described in the protocol to update the labels."
+ "This step allows the user to correct the labels in the extracted frames. Navigate to the folder corresponding to the video 'm3v1mp4' and use the GUI as described in the protocol to update the labels.\n",
+ "\n",
+ "For documentation regarding the GUI, [look at the docs for `napari-deeplabcut`](https://github.com/DeepLabCut/napari-deeplabcut/tree/main) - and specifically _\"3. Refining labels – the image folder contains a machinelabels-iter<#>.h5 file.\"_!"
]
},
{
@@ -528,7 +335,6 @@
},
"outputs": [],
"source": [
- "%gui qt6\n",
"deeplabcut.refine_labels(path_config_file)"
]
},
@@ -542,7 +348,7 @@
},
"outputs": [],
"source": [
- "#Perhaps plot the labels to see how how all the frames are annotated (including the refined ones)\n",
+ "# Perhaps plot the labels to see how how all the frames are annotated (including the refined ones)\n",
"deeplabcut.check_labels(path_config_file)"
]
},
@@ -592,7 +398,7 @@
"id": "8fhL6nG2ywoW"
},
"source": [
- "Now one can train the network again... (with the expanded data set)"
+ "Now one can train the network again... (with the expanded data set). We can continue training from the snapshot we already have by using the `snapshot_path` argument - instead of training the model from scratch, it will load the weights we already have and fine-tune them!"
]
},
{
@@ -605,8 +411,31 @@
},
"outputs": [],
"source": [
- "deeplabcut.train_network(path_config_file, shuffle=1)"
+ "snapshot_path = ( # Edit me if needed! Select the path to the snapshot to continue training from!\n",
+ " Path(path_config_file).parent\n",
+ " / \"dlc-models-pytorch\"\n",
+ " / \"iteration-0\"\n",
+ " / \"openfieldOct30-trainset95shuffle1\"\n",
+ " / \"train\"\n",
+ " / \"snapshot-best-080.pt\"\n",
+ ")\n",
+ "\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=2,\n",
+ " displayiters=10,\n",
+ " batch_size=8,\n",
+ " snapshot_path=snapshot_path,\n",
+ ")"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
@@ -616,10 +445,15 @@
"provenance": [],
"version": "0.3.2"
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-02-28",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [conda env:DLC2]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DLC2-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -631,7 +465,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.6.9"
+ "version": "3.11.11"
},
"varInspector": {
"cols": {
diff --git a/examples/JUPYTER/Demo_napari.ipynb b/examples/JUPYTER/Demo_napari.ipynb
index b9ff9512b9..764390965d 100644
--- a/examples/JUPYTER/Demo_napari.ipynb
+++ b/examples/JUPYTER/Demo_napari.ipynb
@@ -54,22 +54,13 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
"id": "jqLZhp7EoEI0"
},
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "/Users/mwmathis/opt/anaconda3/envs/DLC2K/lib/python3.8/site-packages/statsmodels/compat/pandas.py:65: FutureWarning: pandas.Int64Index is deprecated and will be removed from pandas in a future version. Use pandas.Index with the appropriate dtype instead.\n",
- " from pandas import Int64Index as NumericIndex\n"
- ]
- }
- ],
+ "outputs": [],
"source": [
"import deeplabcut"
]
@@ -84,15 +75,20 @@
},
"outputs": [],
"source": [
- "task='Reaching' # Enter the name of your experiment Task\n",
- "experimenter='Mackenzie' # Enter the name of the experimenter\n",
- "video=['/Users/mwmathis/Documents/DeepLabCut/examples/Reaching-Mackenzie-2018-08-30/videos/reachingvideo1.avi'] # Enter the paths of your videos OR FOLDER you want to grab frames from.\n",
+ "task = \"Reaching\" # Enter the name of your experiment Task\n",
+ "experimenter = \"Mackenzie\" # Enter the name of the experimenter\n",
+ "video = [\n",
+ " \"/Users/mwmathis/Documents/DeepLabCut/examples/Reaching-Mackenzie-2018-08-30/videos/reachingvideo1.avi\"\n",
+ "] # Enter the paths of your videos OR FOLDER you want to grab frames from.\n",
"\n",
- "path_config_file=deeplabcut.create_new_project(task,experimenter,video,copy_videos=True) \n",
+ "path_config_file = deeplabcut.create_new_project(task, experimenter, video, copy_videos=True)\n",
"\n",
- "# NOTE: The function returns the path, where your project is. \n",
- "# You could also enter this manually (e.g. if the project is already created and you want to pick up, where you stopped...)\n",
- "#path_config_file = '/home/Mackenzie/Reaching/config.yaml' # Enter the path of the config file that was just created from the above step (check the folder)"
+ "# NOTE: The function returns the path, where your project is.\n",
+ "\n",
+ "# You could also enter this manually (e.g. if the project is already created and you\n",
+ "# want to pick up, where you stopped...): Enter the path of the config file that was\n",
+ "# just created from the above step (check the folder)\n",
+ "# path_config_file = \"/home/Mackenzie/Reaching/config.yaml\""
]
},
{
@@ -148,10 +144,10 @@
},
"outputs": [],
"source": [
- "#there are other ways to grab frames, such as uniformly; please see the paper:\n",
+ "# there are other ways to grab frames, such as uniformly; please see the paper:\n",
"\n",
- "#AUTOMATIC:\n",
- "deeplabcut.extract_frames(path_config_file) "
+ "# AUTOMATIC:\n",
+ "deeplabcut.extract_frames(path_config_file)"
]
},
{
@@ -177,8 +173,8 @@
"# Attention: If you have not installed the napari-dlc plugin, do so now by running this cell:\n",
"!pip install napari-deeplabcut\n",
"\n",
- "#if the plugin does not appear upon launch, consider running in the terminal the above command \n",
- "#within the same conda env and then re-starting kernel in your notebook (Kernel > restart)."
+ "# if the plugin does not appear upon launch, consider running in the terminal the above command\n",
+ "# within the same conda env and then re-starting kernel in your notebook (Kernel > restart)."
]
},
{
@@ -194,6 +190,7 @@
"# napari will pop up! Please go to plugin > deeplabcut to start:\n",
"%gui qt6\n",
"import napari\n",
+ "\n",
"napari.Viewer()"
]
},
@@ -219,7 +216,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.check_labels(path_config_file) #this creates a subdirectory with the frames + your labels"
+ "deeplabcut.check_labels(path_config_file) # this creates a subdirectory with the frames + your labels"
]
},
{
@@ -245,7 +242,7 @@
"\n",
"After running this script the training dataset is created and saved in the project directory under the subdirectory **'training-datasets'**\n",
"\n",
- "This function also creates new subdirectories under **dlc-models** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pose_cfg.yaml**. For most all use cases we have seen, the defaults are perfectly fine.\n",
+ "This function also creates new subdirectories under **dlc-models-pytorch** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pytorch_config.yaml**. For most all use cases we have seen, the defaults are perfectly fine.\n",
"\n",
"Now it is the time to start training the network!"
]
@@ -262,7 +259,7 @@
"outputs": [],
"source": [
"deeplabcut.create_training_dataset(path_config_file)\n",
- "#remember, there are several networks you can pick, the default is resnet-50!"
+ "# remember, there are several networks you can pick, the default is resnet-50!"
]
},
{
@@ -299,7 +296,7 @@
"source": [
"## Start evaluating\n",
"This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images)\n",
- "and stores the results as .csv file in a subdirectory under **evaluation-results**"
+ "and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**"
]
},
{
@@ -338,9 +335,9 @@
},
"outputs": [],
"source": [
- "videofile_path = ['videos/video3.avi','videos/video4.avi'] #Enter a folder OR a list of videos to analyze.\n",
+ "videofile_path = [\"videos/video3.avi\", \"videos/video4.avi\"] # Enter a folder OR a list of videos to analyze.\n",
"\n",
- "deeplabcut.analyze_videos(path_config_file,videofile_path, videotype='.avi')"
+ "deeplabcut.analyze_videos(path_config_file, videofile_path, videotype=\".avi\")"
]
},
{
@@ -374,7 +371,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.extract_outlier_frames(path_config_file,['/videos/video3.avi']) #pass a specific video"
+ "deeplabcut.extract_outlier_frames(path_config_file, [\"/videos/video3.avi\"]) # pass a specific video"
]
},
{
@@ -398,10 +395,11 @@
},
"outputs": [],
"source": [
- "#now you can edit the \"machine-labeled file\" within napari; \n",
- "#just again drop the file and images into the workspace after you load the plugin\n",
+ "# now you can edit the \"machine-labeled file\" within napari;\n",
+ "# just again drop the file and images into the workspace after you load the plugin\n",
"%gui qt6\n",
"import napari\n",
+ "\n",
"napari.Viewer()"
]
},
@@ -432,7 +430,7 @@
},
"outputs": [],
"source": [
- "#NOW, merge this with your original data:\n",
+ "# NOW, merge this with your original data:\n",
"\n",
"deeplabcut.merge_datasets(path_config_file)"
]
@@ -469,7 +467,7 @@
},
"source": [
"## Create labeled video\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. \n",
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. \n",
"\n",
"THIS HAS MANY FUN OPTIONS! \n",
"\n",
@@ -497,7 +495,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
]
},
{
@@ -522,7 +520,7 @@
"outputs": [],
"source": [
"%matplotlib notebook #for making interactive plots.\n",
- "deeplabcut.plot_trajectories(path_config_file,videofile_path)"
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path)"
]
}
],
@@ -533,10 +531,15 @@
"provenance": [],
"version": "0.3.2"
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [conda env:DLC2K]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DLC2K-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -548,7 +551,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.8.12"
+ "version": "3.11.11"
},
"varInspector": {
"cols": {
diff --git a/examples/JUPYTER/Demo_yourowndata.ipynb b/examples/JUPYTER/Demo_yourowndata.ipynb
index 6b0478306f..8a62f53afe 100644
--- a/examples/JUPYTER/Demo_yourowndata.ipynb
+++ b/examples/JUPYTER/Demo_yourowndata.ipynb
@@ -8,7 +8,12 @@
},
"source": [
"# DeepLabCut Toolbox\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "\n",
+ "Some resources that can be useful:\n",
+ "\n",
+ "- [github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)\n",
+ "- [DeepLabCut's Documentation: User Guide for Single Animal projects](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html)\n",
"\n",
"This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
"This shows the most simple code to do so, but many of the functions have additional features, so please check out the overview & the protocol paper!\n",
@@ -52,7 +57,7 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -73,15 +78,25 @@
},
"outputs": [],
"source": [
- "task='Reaching' # Enter the name of your experiment Task\n",
- "experimenter='Mackenzie' # Enter the name of the experimenter\n",
- "video=['videos/video1.avi','videos/video2.avi'] # Enter the paths of your videos OR FOLDER you want to grab frames from.\n",
+ "task = \"Reaching\" # Enter the name of your experiment Task\n",
+ "experimenter = \"Mackenzie\" # Enter the name of the experimenter\n",
+ "video = [\n",
+ " \"videos/video1.avi\",\n",
+ " \"videos/video2.avi\",\n",
+ "] # Enter the paths of your videos OR FOLDER you want to grab frames from.\n",
"\n",
- "path_config_file=deeplabcut.create_new_project(task,experimenter,video,copy_videos=True) \n",
+ "path_config_file = deeplabcut.create_new_project(\n",
+ " task,\n",
+ " experimenter,\n",
+ " video,\n",
+ " copy_videos=True,\n",
+ ")\n",
"\n",
- "# NOTE: The function returns the path, where your project is. \n",
- "# You could also enter this manually (e.g. if the project is already created and you want to pick up, where you stopped...)\n",
- "#path_config_file = '/home/Mackenzie/Reaching/config.yaml' # Enter the path of the config file that was just created from the above step (check the folder)"
+ "# NOTE: The function returns the path, where your project is.\n",
+ "# You could also enter this manually (e.g. if the project is already created\n",
+ "# and you want to pick up where you stopped...)\n",
+ "# Enter the path of the config file that was just created from the above step (check the folder):\n",
+ "# path_config_file = \"/home/Mackenzie/Reaching/config.yaml\""
]
},
{
@@ -101,7 +116,7 @@
},
{
"cell_type": "code",
- "execution_count": 2,
+ "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
@@ -138,10 +153,10 @@
"outputs": [],
"source": [
"%matplotlib inline\n",
- "#there are other ways to grab frames, such as uniformly; please see the paper:\n",
+ "# there are other ways to grab frames, such as uniformly; please see the paper:\n",
"\n",
- "#AUTOMATIC:\n",
- "deeplabcut.extract_frames(path_config_file) "
+ "# AUTOMATIC:\n",
+ "deeplabcut.extract_frames(path_config_file)"
]
},
{
@@ -153,7 +168,9 @@
"source": [
"## Label the extracted frames\n",
"\n",
- "Only videos in the config file can be used to extract the frames. Extracted labels for each video are stored in the project directory under the subdirectory **'labeled-data'**. Each subdirectory is named after the name of the video. The toolbox has a labeling toolbox which could be used for labeling. "
+ "Only videos in the config file can be used to extract the frames. Extracted labels for each video are stored in the project directory under the subdirectory **'labeled-data'**. Each subdirectory is named after the name of the video. The toolbox has a labeling toolbox which could be used for labeling. \n",
+ "\n",
+ "Check out [our `napari-deeplabcut` docs](https://github.com/DeepLabCut/napari-deeplabcut/tree/main) for more information about labelling!"
]
},
{
@@ -173,6 +190,7 @@
"\n",
"%gui qt6\n",
"import napari\n",
+ "\n",
"napari.Viewer()"
]
},
@@ -198,7 +216,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.check_labels(path_config_file) #this creates a subdirectory with the frames + your labels"
+ "deeplabcut.check_labels(path_config_file) # this creates a subdirectory with the frames + your labels"
]
},
{
@@ -224,7 +242,7 @@
"\n",
"After running this script the training dataset is created and saved in the project directory under the subdirectory **'training-datasets'**\n",
"\n",
- "This function also creates new subdirectories under **dlc-models** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pose_cfg.yaml**. For most all use cases we have seen, the defaults are perfectly fine.\n",
+ "This function also creates new subdirectories under **dlc-models-pytorch** and creates a `pytorch_config.yaml` file, defining the model architecture and containing various parameters used for training the network. For most all use cases we have seen, the defaults are perfectly fine. For more information about the variables that can be set, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/pytorch_config.html)!\n",
"\n",
"Now it is the time to start training the network!"
]
@@ -241,7 +259,8 @@
"outputs": [],
"source": [
"deeplabcut.create_training_dataset(path_config_file)\n",
- "#remember, there are several networks you can pick, the default is resnet-50!"
+ "\n",
+ "# remember, there are several networks you can pick, the default is resnet-50!"
]
},
{
@@ -253,6 +272,8 @@
"source": [
"## Start training:\n",
"\n",
+ "The user can set various parameters in `.../project-name/dlc-models-pytorch/.../pytorch_config.yaml`. For more information about the variables that can be set, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/pytorch_config.html)!\n",
+ "\n",
"This function trains the network for a specific shuffle of the training dataset. "
]
},
@@ -278,7 +299,7 @@
"source": [
"## Start evaluating\n",
"This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images)\n",
- "and stores the results as .csv file in a subdirectory under **evaluation-results**"
+ "and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**"
]
},
{
@@ -317,9 +338,9 @@
},
"outputs": [],
"source": [
- "videofile_path = ['videos/video3.avi','videos/video4.avi'] #Enter a folder OR a list of videos to analyze.\n",
+ "videofile_path = [\"videos/video3.avi\", \"videos/video4.avi\"] # Enter a folder OR a list of videos to analyze.\n",
"\n",
- "deeplabcut.analyze_videos(path_config_file,videofile_path, videotype='.avi')"
+ "deeplabcut.analyze_videos(path_config_file, videofile_path, videotype=\".avi\")"
]
},
{
@@ -336,7 +357,7 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
@@ -353,7 +374,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.extract_outlier_frames(path_config_file,['/videos/video3.avi']) #pass a specific video"
+ "deeplabcut.extract_outlier_frames(path_config_file, [\"/videos/video3.avi\"]) # pass a specific video"
]
},
{
@@ -408,7 +429,7 @@
},
"outputs": [],
"source": [
- "#NOW, merge this with your original data:\n",
+ "# NOW, merge this with your original data:\n",
"\n",
"deeplabcut.merge_datasets(path_config_file)"
]
@@ -445,18 +466,38 @@
},
"source": [
"## Create labeled video\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. \n",
+ "\n",
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. \n",
"\n",
"THIS HAS MANY FUN OPTIONS! \n",
"\n",
- "``deeplabcut.create_labeled_video(config, videos, videotype='avi', shuffle=1, trainingsetindex=0, filtered=False, save_frames=False, Frames2plot=None, delete=False, displayedbodyparts='all', codec='mp4v', outputframerate=None, destfolder=None, draw_skeleton=False, trailpoints=0, displaycropped=False)``\n",
+ "```python\n",
+ "deeplabcut.create_labeled_video(\n",
+ " config,\n",
+ " videos,\n",
+ " videotype='avi',\n",
+ " shuffle=1,\n",
+ " trainingsetindex=0,\n",
+ " filtered=False,\n",
+ " save_frames=False,\n",
+ " Frames2plot=None,\n",
+ " delete=False,\n",
+ " displayedbodyparts='all',\n",
+ " codec='mp4v',\n",
+ " outputframerate=None,\n",
+ " destfolder=None,\n",
+ " draw_skeleton=False,\n",
+ " trailpoints=0,\n",
+ " displaycropped=False,\n",
+ ")\n",
+ "```\n",
"\n",
"So please check:"
]
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
@@ -473,7 +514,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
]
},
{
@@ -498,7 +539,7 @@
"outputs": [],
"source": [
"%matplotlib notebook #for making interactive plots.\n",
- "deeplabcut.plot_trajectories(path_config_file,videofile_path)"
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path)"
]
}
],
@@ -509,10 +550,15 @@
"provenance": [],
"version": "0.3.2"
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [conda env:DLC2]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DLC2-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -524,7 +570,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.6.9"
+ "version": "3.11.11"
},
"varInspector": {
"cols": {
diff --git a/examples/JUPYTER/Docker_TrainNetwork_VideoAnalysis.ipynb b/examples/JUPYTER/Docker_TrainNetwork_VideoAnalysis.ipynb
index b562493a97..c9808a0f35 100644
--- a/examples/JUPYTER/Docker_TrainNetwork_VideoAnalysis.ipynb
+++ b/examples/JUPYTER/Docker_TrainNetwork_VideoAnalysis.ipynb
@@ -56,25 +56,11 @@
},
"outputs": [],
"source": [
- "import tensorflow as tf\n",
- "tf.__version__"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "colab": {},
- "colab_type": "code",
- "id": "Pm_PC1Q8lRrH"
- },
- "outputs": [],
- "source": [
- "#let's make sure we see a GPU:\n",
- "#tf.test.gpu_device_name()\n",
- "#or\n",
- "from tensorflow.python.client import device_lib\n",
- "device_lib.list_local_devices()"
+ "import torch\n",
+ "\n",
+ "# Let's make sure we see a GPU:\n",
+ "print(torch.__version__)\n",
+ "print(torch.cuda.is_available())"
]
},
{
@@ -94,10 +80,11 @@
},
"outputs": [],
"source": [
- "#GUIs don't work on in Docker (or the cloud), so label your data locally on your computer! \n",
- "#This notebook is for you to train and run video analysis!\n",
+ "# GUIs don't work on in Docker (or the cloud), so label your data locally on your computer!\n",
+ "# This notebook is for you to train and run video analysis!\n",
"import os\n",
- "os.environ[\"DLClight\"]=\"True\""
+ "\n",
+ "os.environ[\"DLClight\"] = \"True\""
]
},
{
@@ -112,8 +99,7 @@
"outputs": [],
"source": [
"# now we are ready to train!\n",
- "import deeplabcut\n",
- "deeplabcut.__version__"
+ "import deeplabcut"
]
},
{
@@ -133,7 +119,8 @@
},
"outputs": [],
"source": [
- "path_config_file = '/home/mackenzie/DEEPLABCUT/DeepLabCut2.0/examples/Reaching-Mackenzie-2018-08-30/config.yaml' #change to yours!"
+ "# change to yours!\n",
+ "path_config_file = \"/home/mackenzie/DEEPLABCUT/DeepLabCut/examples/Reaching-Mackenzie-2018-08-30/config.yaml\""
]
},
{
@@ -155,11 +142,12 @@
},
"source": [
"## Create a training dataset\n",
- "This function generates the training data information for DeepCut (which requires a mat file) based on the pandas dataframes that hold label information. The user can set the fraction of the training set size (from all labeled image in the hd5 file) in the config.yaml file. While creating the dataset, the user can create multiple shuffles. \n",
+ "\n",
+ "This function generates the training data required for DeepLabCut. The user can set the fraction of the training set size (from all labeled images in the hd5 file) in the `config.yaml` file. While creating the dataset, the user can create multiple shuffles. \n",
"\n",
"After running this script the training dataset is created and saved in the project directory under the subdirectory **'training-datasets'**\n",
"\n",
- "This function also creates new subdirectories under **dlc-models** and appends the project config.yaml file with the correct path to the training and testing pose configuration file. These files hold the parameters for training the network. Such an example file is provided with the toolbox and named as **pose_cfg.yaml**."
+ "This function also creates new subdirectories under **dlc-models-pytorch** and creates a `pytorch_config.yaml` file, defining the model architecture and containing various parameters used for training the network. For most all use cases we have seen, the defaults are perfectly fine. For more information about the variables that can be set, check out the [docs](https://deeplabcut.github.io/DeepLabCut/docs/pytorch/pytorch_config.html)!\n"
]
},
{
@@ -168,16 +156,7 @@
"metadata": {},
"outputs": [],
"source": [
- "deeplabcut.create_training_dataset(path_config_file,Shuffles=[1])"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### now go edit the pose_cfg.yaml to make display_iters: low (i.e. 10), and save_iters: 500 (for demo's)\n",
- "\n",
- "Now it is the time to start training the network!"
+ "deeplabcut.create_training_dataset(path_config_file, Shuffles=[1])"
]
},
{
@@ -202,54 +181,18 @@
},
"outputs": [],
"source": [
- "#reset in case you started a session before...\n",
- "#tf.reset_default_graph()\n",
+ "deeplabcut.train_network(\n",
+ " path_config_file,\n",
+ " shuffle=1,\n",
+ " save_epochs=2,\n",
+ " displayiters=5,\n",
+ ")\n",
"\n",
- "deeplabcut.train_network(path_config_file, shuffle=1, saveiters=1000, displayiters=10)\n",
+ "# This will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it\n",
+ "# hits the end (default, 200 epochs).\n",
"\n",
- "#this will run until you stop it (CTRL+C), or hit \"STOP\" icon, or when it hits the end (default, 1.3M iterations). \n",
- "#Whichever you chose, you will see what looks like an error message, but it's not an error - don't worry....\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Firstly, if the above cell ran, you can stop it with \"stop\" or cntrl-C; you will get a Keyboard Interrupt error (this is fine!)\n",
- "\n",
- "### A couple tips for possible troubleshooting (1): \n",
- "\n",
- "if you get **permission errors** when you run this step (above), first check if the weights downloaded. As some docker containers might not have privileges for this (it can be user specific). They should be under 'init_weights' (see path in the pose_cfg.yaml file). You can enter the DOCKER in the terminal:"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "see more here: https://github.com/MMathisLab/Docker4DeepLabCut2.0#using-the-docker-for-training-and-video-analysis"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "You can \"cd\" in the terminal to this location! i.e. copy and paste this in: **\"cd usr/local/lib/python3.6/dist-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/\n",
- "\"** \n",
- "\n",
- "And if you type \"ls\" to see the list of files, you should see the resnet:\n",
- "**resnet_v1_50.ckpt**\n",
- "\n",
- "If it is not there, run **\"sudo download.sh\"**\n",
- "then change the permissions: **\"sudo chown yourusername:yourusername resnet_v1_50.ckpt\"**\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Troubleshooting (2): \n",
- "if it appears the training does not start (i.e. \"Starting training...\" does not print immediately),\n",
- "then you have another session running on your GPU. Go check \"nvidia-smi\" and look at the process names. You can only have 1 per GPU!)"
+ "# If you end training before it hits the end, you will see what looks like\n",
+ "# an error message, but it's not an error - don't worry...."
]
},
{
@@ -261,7 +204,7 @@
"source": [
"## Start evaluating\n",
"This function evaluates a trained model for a specific shuffle/shuffles at a particular state or all the states on the data set (images)\n",
- "and stores the results as .csv file in a subdirectory under **evaluation-results**"
+ "and stores the results as .csv file in a subdirectory under **evaluation-results-pytorch**"
]
},
{
@@ -277,7 +220,8 @@
"source": [
"deeplabcut.evaluate_network(path_config_file)\n",
"\n",
- "# Here you want to see a low pixel error! Of course, it can only be as good as the labeler, so be sure your labels are good!"
+ "# Here you want to see a low pixel error! Of course, it can only\n",
+ "# be as good as the labeler, so be sure your labels are good!"
]
},
{
@@ -317,8 +261,10 @@
},
"outputs": [],
"source": [
- "videofile_path = ['/home/mackenzie/DEEPLABCUT/DeepLabCut2.0/examples/Reaching-Mackenzie-2018-08-30/videos/MovieS2_Perturbation_noLaser_compressed.avi'] #Enter the list of videos to analyze.\n",
- "deeplabcut.analyze_videos(path_config_file,videofile_path)"
+ "videofile_path = [\n",
+ " \"/home/mackenzie/DEEPLABCUT/DeepLabCut/examples/Reaching-Mackenzie-2018-08-30/videos/MovieS2_Perturbation_noLaser_compressed.avi\"\n",
+ "] # Enter the list of videos to analyze.\n",
+ "deeplabcut.analyze_videos(path_config_file, videofile_path)"
]
},
{
@@ -329,7 +275,7 @@
},
"source": [
"## Create labeled video\n",
- "This function is for visualiztion purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
+ "This function is for visualization purpose and can be used to create a video in .mp4 format with labels predicted by the network. This video is saved in the same directory where the original video resides. "
]
},
{
@@ -343,7 +289,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
]
},
{
@@ -369,10 +315,9 @@
"outputs": [],
"source": [
"%matplotlib notebook \n",
- "#for making interactive plots.\n",
- "#deeplabcut.plot_trajectories(path_config_file,videofile_path, plotting=True)\n",
- "\n",
- "deeplabcut.plot_trajectories(path_config_file,videofile_path,showfigures=True)"
+ "# for making interactive plots.\n",
+ "# deeplabcut.plot_trajectories(path_config_file, videofile_path, plotting=True)\n",
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path, showfigures=True)"
]
}
],
@@ -386,8 +331,13 @@
"toc_visible": true,
"version": "0.3.2"
},
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "last_metadata_updated": "2026-03-06"
+ },
"kernelspec": {
- "display_name": "Python [default]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
@@ -401,7 +351,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.6.6"
+ "version": "3.11.11"
},
"varInspector": {
"cols": {
diff --git a/examples/openfield-Pranav-2018-10-30/config.yaml b/examples/openfield-Pranav-2018-10-30/config.yaml
index 64c2ce17b2..ed8c31fdf3 100644
--- a/examples/openfield-Pranav-2018-10-30/config.yaml
+++ b/examples/openfield-Pranav-2018-10-30/config.yaml
@@ -2,10 +2,17 @@
Task: openfield
scorer: Pranav
date: Oct30
+multianimalproject:
+identity:
+
# Project path (change when moving around)
project_path: WILL BE AUTOMATICALLY UPDATED BY DEMO CODE
+# Default DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)
+engine: pytorch
+
+
# Annotation data set configuration (and individual video cropping parameters)
video_sets:
WILL BE AUTOMATICALLY UPDATED BY DEMO CODE:
@@ -16,23 +23,33 @@ bodyparts:
- rightear
- tailbase
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
start: 0
stop: 1
numframes2pick: 20
+
# Plotting configuration
+skeleton: []
+skeleton_color: black
pcutoff: 0.4
dotsize: 8
alphavalue: 0.7
colormap: jet
+
# Training,Evaluation and Analysis configuration
TrainingFraction:
- 0.95
iteration: 0
default_net_type: resnet_50
+default_augmenter: imgaug
snapshotindex: -1
+detector_snapshotindex: -1
batch_size: 4
+detector_batch_size: 1
+
# Cropping Parameters (for analysis and outlier frame detection)
cropping: false
@@ -42,8 +59,18 @@ x2: 640
y1: 277
y2: 624
+
# Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
corner2move2:
- 50
- 50
move2corner: true
+
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
+ superanimal_topviewmouse:
+ snout: nose
+ leftear: left_ear
+ rightear: right_ear
+ tailbase: tail_base
diff --git a/examples/stereo_example.zip b/examples/stereo_example.zip
new file mode 100644
index 0000000000..85b591cbe0
Binary files /dev/null and b/examples/stereo_example.zip differ
diff --git a/examples/test.sh b/examples/test.sh
index 427482f179..b9be0a511d 100755
--- a/examples/test.sh
+++ b/examples/test.sh
@@ -6,14 +6,14 @@ rm -r OUT
cd ..
pip uninstall deeplabcut
python3 setup.py sdist bdist_wheel
-pip install dist/deeplabcut-2.3.9-py3-none-any.whl
+pip install dist/deeplabcut-3.0.0-none-any.whl
cd examples
-python3 testscript.py
+python3 testscript_tensorflow_single_animal.py
python3 testscript_3d.py #does not work in container
#python3 testscript_mobilenets.py
-python3 testscript_multianimal.py
+python3 testscript_tensorflow_multi_animal.py
#python3 testscript_openfielddata_netcomparison.py
#python3 testscript_openfielddata_augmentationcomparison.py
diff --git a/examples/testscript_3d.py b/examples/testscript_3d.py
index dda11770cd..a31c034a9f 100644
--- a/examples/testscript_3d.py
+++ b/examples/testscript_3d.py
@@ -20,13 +20,15 @@
This script tests various functionalities in an automatic way.
It produces nothing of interest scientifically.
"""
-import os, deeplabcut
-import zipfile, urllib.request, shutil
-from datetime import datetime as dt
+
import glob
-from pathlib import Path
+import os
+import shutil
import subprocess
+import zipfile
+from pathlib import Path
+import deeplabcut
if __name__ == "__main__":
print("Imported DLC!")
@@ -34,12 +36,11 @@
scorer = "Alex" # Enter the name of the experimenter/labeler
num_cameras = 2 # Enter the number of cameras
- basepath = str(Path(os.path.realpath(__file__)).parents[1])
+ basepath = str(Path(os.path.realpath(__file__)).parents[0])
videoname = "reachingvideo1"
video = [
os.path.join(
basepath,
- "examples",
"Reaching-Mackenzie-2018-08-30",
"videos",
videoname + ".avi",
@@ -88,7 +89,7 @@
output2,
]
)
- except:
+ except Exception:
pass
"""
@@ -103,8 +104,8 @@
# checking if 2d test project is available
try:
config = glob.glob(os.path.join(basepath, "TEST*", "config.yaml"))[-1]
- except:
- raise RuntimeError("Please run the testscript.py first before testing for 3d")
+ except Exception as e:
+ raise RuntimeError("Please run the testscript_tensorflow_single_animal.py first before testing for 3d") from e
dfolder = None
@@ -121,10 +122,8 @@
cfg["skeleton"] = [["bodypart1", "bodypart2"], ["objectA", "bodypart3"]]
deeplabcut.auxiliaryfunctions.write_config_3d(path_config_file, cfg)
- except:
- raise (
- "Please delete the project and re-try."
- ) # otherwise the cfg is an empty array!
+ except Exception as e:
+ raise RuntimeError("Please delete the project and re-try.") from e # otherwise the cfg is an empty array!
"""
# Creating the name of the project
@@ -138,11 +137,8 @@
project_name = path_config_file.split(os.sep)[-2]
os.chdir(os.path.join(project_name, "calibration_images"))
- # Downloading the calibration images
- url = "http://www.vision.caltech.edu/bouguetj/calib_doc/htmls/stereo_example.zip"
- file_name = "stereo_example.zip"
- with urllib.request.urlopen(url) as response, open(file_name, "wb") as out_file:
- shutil.copyfileobj(response, out_file)
+
+ file_name = os.path.join(basepath, "stereo_example.zip")
with zipfile.ZipFile(file_name) as zf:
zf.extractall()
@@ -150,7 +146,8 @@
cwd = os.getcwd()
[os.remove(file) for file in os.listdir(cwd) if not file.endswith(".jpg")]
- # change the file names for calibration images to match the name of cameras in config.yaml file.i.e. camera-1 and camera-2
+ # change the file names for calibration images to match the name of
+ # cameras in config.yaml file.i.e. camera-1 and camera-2
cam1_images = glob.glob(os.path.join(cwd, "left*.jpg"))
cam2_images = glob.glob(os.path.join(cwd, "right*.jpg"))
# Sorting images
@@ -159,13 +156,13 @@
for idx, name in enumerate(cam1_images):
os.rename(
name,
- os.path.join(cwd, str("camera-1_" + "{0:0=2d}".format(idx + 1) + ".jpg")),
+ os.path.join(cwd, str("camera-1_" + f"{idx + 1:0=2d}" + ".jpg")),
)
for idx, name in enumerate(cam2_images):
os.rename(
name,
- os.path.join(cwd, str("camera-2_" + "{0:0=2d}".format(idx + 1) + ".jpg")),
+ os.path.join(cwd, str("camera-2_" + f"{idx + 1:0=2d}" + ".jpg")),
)
# Removing some of the images where the corner was not detected
@@ -180,13 +177,11 @@
print("TRIANGULATING")
video_dir = os.path.join(os.path.dirname(basepath), folder)
- deeplabcut.auxiliaryfunctions.edit_config(
- path_config_file, edits={"pcutoff": 0.1}
- ) # otherwise get all-nan slices
+ deeplabcut.auxiliaryfunctions.edit_config(path_config_file, edits={"pcutoff": 0.1}) # otherwise get all-nan slices
deeplabcut.triangulate(path_config_file, video_dir, save_as_csv=True)
print("CREATING LABELED VIDEO 3-D")
- deeplabcut.create_labeled_video_3d(path_config_file, [video_dir], start=5, end=10)
+ deeplabcut.create_labeled_video_3d(path_config_file, [video_dir], start=5, end=10, video_extensions=".avi")
# output_path = [os.path.join(basepath,folder)]
# deeplabcut.create_labeled_video_3d(path_config_file,output_path,start=5,end=10)
diff --git a/examples/testscript_deterministicwithResNet152.py b/examples/testscript_deterministicwithResNet152.py
index dbaf8c67b8..01e033ee65 100644
--- a/examples/testscript_deterministicwithResNet152.py
+++ b/examples/testscript_deterministicwithResNet152.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
@@ -38,23 +37,19 @@
It produces nothing of interest scientifically.
"""
-task = "TEST-deterministic" # Enter the name of your experiment Task
-scorer = "Alex" # Enter the name of the experimenter/labeler
-
+import os
-import os, subprocess, deeplabcut
-from pathlib import Path
-import pandas as pd
import numpy as np
+import pandas as pd
+import deeplabcut
+
+task = "TEST-deterministic" # Enter the name of your experiment Task
+scorer = "Alex" # Enter the name of the experimenter/labeler
print("Imported DLC!")
basepath = os.path.dirname(os.path.abspath("testscript.py"))
videoname = "reachingvideo1"
-video = [
- os.path.join(
- basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi"
- )
-]
+video = [os.path.join(basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi")]
# to test destination folder:
# dfolder=basepath
@@ -106,7 +101,7 @@
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -118,7 +113,8 @@
print("CREATING TRAININGSET")
deeplabcut.create_training_dataset(path_config_file)
-# posefile=os.path.join(cfg['project_path'],'dlc-models/iteration-'+str(cfg['iteration'])+'/'+ cfg['Task'] + cfg['date'] + '-trainset' + str(int(cfg['TrainingFraction'][0] * 100)) + 'shuffle' + str(1),'train/pose_cfg.yaml')
+# posefile=os.path.join(cfg['project_path'],'dlc-models/iteration-'+str(cfg['iteration'])+'/'+ cfg['Task'] + cfg['date']
+# + '-trainset' + str(int(cfg['TrainingFraction'][0] * 100)) + 'shuffle' + str(1),'train/pose_cfg.yaml')
shuffle = 1
posefile, _, _ = deeplabcut.return_train_network_path(path_config_file, shuffle=shuffle)
diff --git a/examples/testscript_mobilenets.py b/examples/testscript_mobilenets.py
index e758b14c5a..18342cb347 100644
--- a/examples/testscript_mobilenets.py
+++ b/examples/testscript_mobilenets.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
@@ -15,25 +14,27 @@
@author: alex
DEVELOPERS:
-This script tests various functionalities (creating project ,training, evaluating, outlierextraction, retraining...) in an automatic way.
+This script tests various functionalities (creating project ,training, evaluating, outlierextraction, retraining...) in
+an automatic way.
For that purpose, it trains ResNet and MobileNet briefly on a "fake" dataset.
It should take about 4:15 minutes to run this in a CPU. (incl. downloading the ResNet + MobileNet weights)
It produces nothing of interest scientifically.
"""
+
import os
os.environ["DLClight"] = "True"
-import deeplabcut
from pathlib import Path
-import pandas as pd
+
import numpy as np
+import pandas as pd
+
+import deeplabcut
-def Cuttrainingschedule(
- path_config_file, shuffle, trainingsetindex=0, initweights="imagenet", lastvalue=10
-):
+def Cuttrainingschedule(path_config_file, shuffle, trainingsetindex=0, initweights="imagenet", lastvalue=10):
cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file)
posefile = os.path.join(
cfg["project_path"],
@@ -72,7 +73,7 @@ def Cuttrainingschedule(
)
print("CHANGING training parameters to end quickly!")
- DLC_config = deeplabcut.auxiliaryfunctions.edit_config(posefile, edits)
+ deeplabcut.auxiliaryfunctions.edit_config(posefile, edits)
return
@@ -82,11 +83,7 @@ def Cuttrainingschedule(
print("Imported DLC!")
basepath = os.path.dirname(os.path.realpath(__file__))
videoname = "reachingvideo1"
- video = [
- os.path.join(
- basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi"
- )
- ]
+ video = [os.path.join(basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi")]
# to test destination folder:
dfolder = os.path.join(basepath, "OUT")
@@ -96,9 +93,7 @@ def Cuttrainingschedule(
augmenter_type = "tensorpack" # imgaug'
print("CREATING PROJECT")
- path_config_file = deeplabcut.create_new_project(
- task, scorer, video, copy_videos=True
- )
+ path_config_file = deeplabcut.create_new_project(task, scorer, video, copy_videos=True)
cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file)
cfg["numframes2pick"] = 5
@@ -143,7 +138,7 @@ def Cuttrainingschedule(
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -153,9 +148,7 @@ def Cuttrainingschedule(
print("Plot labels...")
deeplabcut.check_labels(path_config_file)
- for shuffle, net_type in enumerate(
- ["mobilenet_v2_0.35", "resnet_50"]
- ): #'mobilenet_v2_1.0']): # 'resnet_50']):
+ for shuffle, net_type in enumerate(["mobilenet_v2_0.35", "resnet_50"]): #'mobilenet_v2_1.0']): # 'resnet_50']):
"""
if shuffle==0:
keepdeconvweights=True
@@ -164,9 +157,7 @@ def Cuttrainingschedule(
"""
print("CREATING TRAININGSET", net_type)
if "resnet_50" == net_type: # this tests the default condition...
- deeplabcut.create_training_dataset(
- path_config_file, Shuffles=[shuffle], augmenter_type=augmenter_type
- )
+ deeplabcut.create_training_dataset(path_config_file, Shuffles=[shuffle], augmenter_type=augmenter_type)
else:
deeplabcut.create_training_dataset(
path_config_file,
@@ -200,7 +191,7 @@ def Cuttrainingschedule(
shuffle=shuffle,
save_as_csv=True,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("CREATE VIDEO")
@@ -209,7 +200,7 @@ def Cuttrainingschedule(
[newvideo],
shuffle=shuffle,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("Making plots")
@@ -218,7 +209,7 @@ def Cuttrainingschedule(
[newvideo],
shuffle=shuffle,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("EXTRACT OUTLIERS")
@@ -230,7 +221,7 @@ def Cuttrainingschedule(
epsilon=0,
automatic=True,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
file = os.path.join(
cfg["project_path"],
@@ -242,9 +233,7 @@ def Cuttrainingschedule(
print("RELABELING")
DF = pd.read_hdf(file, "df_with_missing")
DLCscorer = np.unique(DF.columns.get_level_values(0))[0]
- DF.columns.set_levels(
- [scorer.replace(DLCscorer, scorer)], level=0, inplace=True
- )
+ DF.columns.set_levels([scorer.replace(DLCscorer, scorer)], level=0, inplace=True)
DF = DF.drop("likelihood", axis=1, level=2)
DF.to_csv(
os.path.join(
@@ -261,7 +250,7 @@ def Cuttrainingschedule(
vname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -270,17 +259,11 @@ def Cuttrainingschedule(
deeplabcut.merge_datasets(path_config_file)
print("CREATING TRAININGSET")
- deeplabcut.create_training_dataset(
- path_config_file, Shuffles=[shuffle], net_type=net_type
- )
- Cuttrainingschedule(
- path_config_file, shuffle, lastvalue=stoptrain, initweights="previteration"
- )
+ deeplabcut.create_training_dataset(path_config_file, Shuffles=[shuffle], net_type=net_type)
+ Cuttrainingschedule(path_config_file, shuffle, lastvalue=stoptrain, initweights="previteration")
print("TRAINING from previous snapshot!!!!!")
- deeplabcut.train_network(
- path_config_file, shuffle=shuffle, keepdeconvweights=keepdeconvweights
- )
+ deeplabcut.train_network(path_config_file, shuffle=shuffle, keepdeconvweights=keepdeconvweights)
print("ANALYZING some individual frames")
deeplabcut.analyze_time_lapse_frames(
diff --git a/examples/testscript_openfielddata.py b/examples/testscript_openfielddata.py
index 760464a24a..8faf1c3a5d 100644
--- a/examples/testscript_openfielddata.py
+++ b/examples/testscript_openfielddata.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
@@ -10,8 +9,7 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Created on Mon Nov 5 18:06:13 2018
+"""Created on Mon Nov 5 18:06:13 2018.
@author: alex
@@ -27,17 +25,17 @@
Results for 15001 training iterations: 95 1 train error: 2.89 pixels. Test error: 2.81 pixels.
With pcutoff of 0.1 train error: 2.89 pixels. Test error: 2.81 pixels
-The analysis of the video takes 41 seconds (batch size 32) and creating the frames 8 seconds (+ a few seconds for ffmpeg) to create the video.
+The analysis of the video takes 41 seconds (batch size 32) and creating the frames 8 seconds (+ a few seconds for
+ffmpeg) to create the video.
"""
-import deeplabcut
+
import os
+import deeplabcut
if __name__ == "__main__":
# Loading example data set
- path_config_file = os.path.join(
- os.getcwd(), "openfield-Pranav-2018-10-30/config.yaml"
- )
+ path_config_file = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30/config.yaml")
deeplabcut.load_demo_data(path_config_file)
shuffle = 13
@@ -45,9 +43,7 @@
cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file)
# example how to set pose config variables:
- posefile, _, _ = deeplabcut.return_train_network_path(
- path_config_file, shuffle=shuffle
- )
+ posefile, _, _ = deeplabcut.return_train_network_path(path_config_file, shuffle=shuffle)
edits = {"save_iters": 15000, "display_iters": 1000, "multi_step": [[0.005, 15001]]}
DLC_config = deeplabcut.auxiliaryfunctions.edit_config(posefile, edits)
@@ -58,12 +54,8 @@
deeplabcut.evaluate_network(path_config_file, Shuffles=[shuffle], plotting=True)
print("Analyze Video")
- videofile_path = os.path.join(
- os.getcwd(), "openfield-Pranav-2018-10-30", "videos", "m3v1mp4.mp4"
- )
- deeplabcut.analyze_videos(
- path_config_file, [videofile_path], shuffle=shuffle
- ) # ,videotype='.mp4')
+ videofile_path = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30", "videos", "m3v1mp4.mp4")
+ deeplabcut.analyze_videos(path_config_file, [videofile_path], shuffle=shuffle) # ,videotype='.mp4')
print("Create Labeled Video")
deeplabcut.create_labeled_video(
diff --git a/examples/testscript_openfielddata_augmentationcomparison.py b/examples/testscript_openfielddata_augmentationcomparison.py
index c98b1944e4..6e88425d40 100644
--- a/examples/testscript_openfielddata_augmentationcomparison.py
+++ b/examples/testscript_openfielddata_augmentationcomparison.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
@@ -10,9 +9,7 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-
-This is a test script to compare the loaders and models.
+"""This is a test script to compare the loaders and models.
This script creates one identical splits for the openfield test dataset and trains it with imgaug (default), scalecrop
and the tensorpack loader. We also compare 3 backbones (mobilenet, resnet, efficientnet)
@@ -52,15 +49,13 @@
Notice: despite the higher RMSE for imgaug due to the augmentation,
the network performs much better on the testvideo (see Neuron Primer: https://www.cell.com/neuron/pdf/S0896-6273(20)30717-0.pdf)
-
"""
-
import os
os.environ["CUDA_VISIBLE_DEVICES"] = str(0)
+
import deeplabcut
-import numpy as np
# Loading example data set
path_config_file = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30/config.yaml")
@@ -82,9 +77,7 @@
)
for idx, shuffle in enumerate(Shuffles):
- posefile, _, _ = deeplabcut.return_train_network_path(
- path_config_file, shuffle=shuffle
- )
+ posefile, _, _ = deeplabcut.return_train_network_path(path_config_file, shuffle=shuffle)
# Setting specific parameters for training
if idx % 3 == 0: # imgaug
@@ -115,9 +108,7 @@
print("Analyze Video")
- videofile_path = os.path.join(
- os.getcwd(), "openfield-Pranav-2018-10-30", "videos", "m3v1mp4.mp4"
- )
+ videofile_path = os.path.join(os.getcwd(), "openfield-Pranav-2018-10-30", "videos", "m3v1mp4.mp4")
deeplabcut.analyze_videos(path_config_file, [videofile_path], shuffle=shuffle)
diff --git a/examples/testscript_pretrained_models.py b/examples/testscript_pretrained_models.py
index 3b09d0bc26..3a668c5caa 100644
--- a/examples/testscript_pretrained_models.py
+++ b/examples/testscript_pretrained_models.py
@@ -8,32 +8,26 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Testscript human network
+"""Testscript human network."""
-"""
-import os, subprocess, deeplabcut
-from pathlib import Path
-import pandas as pd
-import numpy as np
+import os
+
+import deeplabcut
Task = "human_dancing"
YourName = "teamDLC"
MODEL_NAME = "horse_sideview" # full_human"
-basepath = os.path.dirname(os.path.abspath("testscript.py"))
+basepath = os.path.dirname(os.path.abspath("testscript_tensorflow_single_animal.py"))
videoname = "reachingvideo1"
-video = [
- os.path.join(
- basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi"
- )
-]
+video = [os.path.join(basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi")]
# legacy mode:
"""
configfile, path_train_config=deeplabcut.create_pretrained_human_project(Task, YourName,video,
videotype='avi', analyzevideo=True,
- createlabeledvideo=True, copy_videos=False) #must leave copy_videos=True
+ createlabeledvideo=True, copy_videos=False)
+ #must leave copy_videos=True
"""
# new way:
configfile, path_train_config = deeplabcut.create_pretrained_project(
@@ -41,10 +35,11 @@
YourName,
video,
model=MODEL_NAME,
- videotype="avi",
+ video_extensions="avi",
analyzevideo=True,
createlabeledvideo=True,
copy_videos=False,
+ engine=deeplabcut.Engine.TF,
) # must leave copy_videos=True
@@ -90,7 +85,7 @@
videoname,
"CollectedData_" + cfg["scorer"] + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -166,7 +161,7 @@
videoname,
"CollectedData_" + cfg["scorer"] + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
diff --git a/examples/testscript_pytorch_multi_animal.py b/examples/testscript_pytorch_multi_animal.py
new file mode 100644
index 0000000000..4a4de3c55a
--- /dev/null
+++ b/examples/testscript_pytorch_multi_animal.py
@@ -0,0 +1,139 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Testscript for single animal PyTorch projects."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from utils import (
+ SyntheticProjectParameters,
+ cleanup,
+ create_fake_project,
+ log_step,
+ run,
+)
+
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.compat import Engine
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ is_model_cond_top_down,
+ is_model_top_down,
+)
+
+
+def main(
+ net_types: list[str],
+ params: SyntheticProjectParameters,
+ epochs: int = 1,
+ top_down_epochs: int = 1,
+ detector_epochs: int = 1,
+ save_epochs: int = 1,
+ batch_size: int = 1,
+ detector_batch_size: int = 1,
+ max_snapshots_to_keep: int = 5,
+ device: str = "cpu",
+ logger: dict | None = None,
+ conditions_shuffle: int = 0,
+ create_labeled_videos: bool = False,
+ delete_after_test_run: bool = False,
+) -> None:
+ project_path = Path("synthetic-data-niels-multi-animal").resolve()
+ config_path = project_path / "config.yaml"
+ create_fake_project(path=project_path, params=params)
+
+ engine = Engine.PYTORCH
+ cfg = af.read_config(config_path)
+ trainset_index = 0
+ train_frac = cfg["TrainingFraction"][trainset_index]
+ try:
+ for net_type in net_types:
+ epochs_ = epochs
+ if is_model_top_down(net_type):
+ epochs_ = top_down_epochs
+ try:
+ pytorch_cfg_updates = {
+ "train_settings.display_iters": 50,
+ "train_settings.epochs": epochs_,
+ "train_settings.batch_size": batch_size,
+ "train_settings.dataloader_workers": 0,
+ "runner.device": device,
+ "runner.snapshots.save_epochs": save_epochs,
+ "runner.snapshots.max_snapshots": max_snapshots_to_keep,
+ "detector.train_settings.display_iters": 1,
+ "detector.train_settings.epochs": detector_epochs,
+ "detector.train_settings.batch_size": detector_batch_size,
+ "detector.train_settings.dataloader_workers": 0,
+ "detector.runner.snapshots.save_epochs": save_epochs,
+ "detector.runner.snapshots.max_snapshots": max_snapshots_to_keep,
+ "logger": logger,
+ }
+ if is_model_cond_top_down(net_type):
+ pytorch_cfg_updates["inference.conditions.shuffle"] = conditions_shuffle
+ pytorch_cfg_updates["inference.conditions.snapshot_index"] = -1
+ run(
+ config_path=config_path,
+ train_fraction=train_frac,
+ trainset_index=trainset_index,
+ net_type=net_type,
+ videos=[str(project_path / "videos" / "video.mp4")],
+ device=device,
+ engine=engine,
+ pytorch_cfg_updates=pytorch_cfg_updates,
+ create_labeled_videos=create_labeled_videos,
+ )
+ except Exception as err:
+ log_step(f"FAILED TO RUN {net_type}")
+ log_step(str(err))
+ log_step("Continuing to next model")
+ raise err
+
+ finally:
+ if delete_after_test_run:
+ cleanup(project_path)
+
+
+if __name__ == "__main__":
+ wandb_logger = {
+ "type": "WandbLogger",
+ "project_name": "testscript-dev",
+ "run_name": "test-logging",
+ }
+ net_types = [
+ "top_down_resnet_50",
+ "resnet_50",
+ "dekr_w32",
+ "rtmpose_m",
+ "ctd_coam_w32",
+ ]
+ main(
+ net_types=net_types,
+ params=SyntheticProjectParameters(
+ multianimal=True,
+ num_bodyparts=4,
+ num_individuals=3,
+ num_unique=0,
+ num_frames=25,
+ frame_shape=(256, 256),
+ ),
+ batch_size=2,
+ detector_batch_size=2,
+ epochs=8,
+ top_down_epochs=2,
+ detector_epochs=10,
+ save_epochs=4,
+ max_snapshots_to_keep=2,
+ device="cpu", # "cpu", "cuda:0", "mps"
+ logger=None,
+ conditions_shuffle=net_types.index("resnet_50") + 1, # shuffles start at index 1
+ create_labeled_videos=True,
+ delete_after_test_run=True,
+ )
diff --git a/examples/testscript_pytorch_single_animal.py b/examples/testscript_pytorch_single_animal.py
new file mode 100644
index 0000000000..8536f2be12
--- /dev/null
+++ b/examples/testscript_pytorch_single_animal.py
@@ -0,0 +1,110 @@
+"""Testscript for single animal PyTorch projects."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from utils import (
+ SyntheticProjectParameters,
+ cleanup,
+ copy_project_for_test,
+ create_fake_project,
+ log_step,
+ run,
+)
+
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.compat import Engine
+
+
+def main(
+ synthetic_data: bool,
+ net_types: list[str],
+ epochs: int = 1,
+ save_epochs: int = 1,
+ max_snapshots_to_keep: int = 5,
+ batch_size: int = 1,
+ device: str = "cpu",
+ logger: dict | None = None,
+ synthetic_data_params: SyntheticProjectParameters = None,
+ create_labeled_videos: bool = False,
+ delete_after_test_run: bool = False,
+) -> None:
+ if synthetic_data_params is None:
+ synthetic_data_params = SyntheticProjectParameters(
+ multianimal=False,
+ num_bodyparts=6,
+ )
+ engine = Engine.PYTORCH
+ if synthetic_data:
+ project_path = Path("synthetic-data-niels-single-animal").resolve()
+ videos = [str(project_path / "videos" / "video.mp4")]
+ create_fake_project(path=project_path, params=synthetic_data_params)
+
+ else:
+ project_path = copy_project_for_test()
+ videos = [str(project_path / "videos" / "m3v1mp4.mp4")]
+
+ config_path = project_path / "config.yaml"
+ cfg = af.read_config(config_path)
+ trainset_index = 0
+ train_frac = cfg["TrainingFraction"][trainset_index]
+ try:
+ for net_type in net_types:
+ try:
+ run(
+ config_path=config_path,
+ train_fraction=train_frac,
+ trainset_index=trainset_index,
+ net_type=net_type,
+ videos=videos,
+ device=device,
+ engine=engine,
+ pytorch_cfg_updates={
+ "train_settings.display_iters": 50,
+ "train_settings.epochs": epochs,
+ "train_settings.batch_size": batch_size,
+ "runner.device": device,
+ "runner.snapshots.save_epochs": save_epochs,
+ "runner.snapshots.max_snapshots": max_snapshots_to_keep,
+ "logger": logger,
+ },
+ create_labeled_videos=create_labeled_videos,
+ )
+
+ except Exception as err:
+ log_step(f"FAILED TO RUN {net_type}")
+ log_step(str(err))
+ log_step("Continuing to next model")
+ raise err
+ finally:
+ if delete_after_test_run:
+ cleanup(project_path)
+
+
+if __name__ == "__main__":
+ wandb_logger = {
+ "type": "WandbLogger",
+ "project_name": "testscript-dev",
+ "run_name": "test-logging",
+ }
+ main(
+ synthetic_data=True,
+ net_types=["cspnext_m", "resnet_50", "hrnet_w32"],
+ batch_size=4,
+ epochs=8,
+ save_epochs=2,
+ max_snapshots_to_keep=2,
+ device="cpu", # "cpu", "cuda:0", "mps"
+ logger=None,
+ synthetic_data_params=SyntheticProjectParameters(
+ multianimal=False,
+ num_bodyparts=4,
+ num_individuals=1,
+ num_unique=0,
+ num_frames=12,
+ frame_shape=(128, 128),
+ ),
+ create_labeled_videos=True,
+ delete_after_test_run=True,
+ )
diff --git a/examples/testscript_superanimal_adaptation.py b/examples/testscript_superanimal_adaptation.py
index 02a660313d..42d6e2fba9 100644
--- a/examples/testscript_superanimal_adaptation.py
+++ b/examples/testscript_superanimal_adaptation.py
@@ -8,19 +8,16 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Test script for super animal adaptation
-"""
-import deeplabcut
+"""Test script for super animal adaptation."""
+
import os
+import deeplabcut
if __name__ == "__main__":
basepath = os.path.dirname(os.path.realpath(__file__))
videoname = "m3v1mp4"
- video = os.path.join(
- basepath, "openfield-Pranav-2018-10-30", "videos", videoname + ".mp4"
- )
+ video = os.path.join(basepath, "openfield-Pranav-2018-10-30", "videos", videoname + ".mp4")
video = deeplabcut.ShortenVideo(
video,
start="00:00:00",
@@ -31,12 +28,14 @@
print("adaptation training for superanimal_topviewmouse")
superanimal_name = "superanimal_topviewmouse"
- videotype = ".mp4"
+ video_extensions = ".mp4"
scale_list = [200, 300, 400]
deeplabcut.video_inference_superanimal(
[video],
superanimal_name,
- videotype=".mp4",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_extensions=".mp4",
video_adapt=True,
scale_list=scale_list,
pcutoff=0.1,
diff --git a/examples/testscript_superanimal_create_pretrained_project.py b/examples/testscript_superanimal_create_pretrained_project.py
new file mode 100644
index 0000000000..b697a3e285
--- /dev/null
+++ b/examples/testscript_superanimal_create_pretrained_project.py
@@ -0,0 +1,38 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Testscript for creating a pretrained project from a super animal model."""
+
+import glob
+import shutil
+from pathlib import Path
+
+import deeplabcut
+
+if __name__ == "__main__":
+ superanimal_name = "superanimal_quadruped"
+ working_dir = Path(__file__).resolve().parent
+ video_dir = working_dir / "openfield-Pranav-2018-10-30/videos/m3v1mp4.mp4"
+ project_name = "pretrained"
+
+ deeplabcut.create_pretrained_project(
+ project_name,
+ "max",
+ [str(video_dir)],
+ engine=deeplabcut.Engine.PYTORCH,
+ )
+
+ dirs_to_delete = glob.glob(f"{working_dir}/{project_name}*")
+
+ # Delete directories
+ for directory in dirs_to_delete:
+ shutil.rmtree(directory)
+
+ print("Test passed!")
diff --git a/examples/testscript_superanimal_inference.py b/examples/testscript_superanimal_inference.py
index b4b49c42b6..4db84b2b91 100644
--- a/examples/testscript_superanimal_inference.py
+++ b/examples/testscript_superanimal_inference.py
@@ -8,22 +8,16 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Testscript for super animal inference
+"""Testscript for super animal inference."""
-"""
-import deeplabcut
import os
+import deeplabcut
if __name__ == "__main__":
basepath = os.path.dirname(os.path.realpath(__file__))
videoname = "reachingvideo1"
- video = [
- os.path.join(
- basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi"
- )
- ]
+ video = [os.path.join(basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi")]
print("testing superanimal_topviewmouse")
superanimal_name = "superanimal_topviewmouse"
@@ -31,7 +25,9 @@
deeplabcut.video_inference_superanimal(
video,
superanimal_name,
- videotype=".avi",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_extensions=".avi",
scale_list=scale_list,
)
@@ -40,6 +36,8 @@
deeplabcut.video_inference_superanimal(
video,
superanimal_name,
- videotype=".avi",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_extensions=".avi",
scale_list=scale_list,
)
diff --git a/examples/testscript_superanimal_transfer_learning.py b/examples/testscript_superanimal_transfer_learning.py
index 1f36f99074..6d9c347e4f 100644
--- a/examples/testscript_superanimal_transfer_learning.py
+++ b/examples/testscript_superanimal_transfer_learning.py
@@ -8,24 +8,33 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-"""
-Test script for super animal adaptation
-"""
-import deeplabcut
+"""Test script for super animal adaptation."""
+
import os
+import deeplabcut
+from deeplabcut.modelzoo.weight_initialization import build_weight_init
+
print(deeplabcut.__file__)
if __name__ == "__main__":
-
superanimal_name = "superanimal_topviewmouse"
basepath = os.path.dirname(os.path.realpath(__file__))
config_path = os.path.join(basepath, "openfield-Pranav-2018-10-30", "config.yaml")
+ model_name = "hrnet_w32"
+ detector_name = "fasterrcnn_resnet50_fpn_v2"
- deeplabcut.create_training_dataset(config_path, superanimal_name=superanimal_name)
+ weight_init = build_weight_init(
+ cfg=config_path,
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=detector_name,
+ with_decoder=False,
+ )
+ deeplabcut.create_training_dataset(config_path, weight_init=weight_init)
deeplabcut.train_network(
config_path,
- maxiters=10,
+ epochs=1,
superanimal_name=superanimal_name,
superanimal_transfer_learning=True,
)
diff --git a/examples/testscript_multianimal.py b/examples/testscript_tensorflow_multi_animal.py
similarity index 81%
rename from examples/testscript_multianimal.py
rename to examples/testscript_tensorflow_multi_animal.py
index f1215d13e5..f5a69cef33 100644
--- a/examples/testscript_multianimal.py
+++ b/examples/testscript_tensorflow_multi_animal.py
@@ -9,16 +9,22 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
-import deeplabcut
+import pickle
+import random
+from pathlib import Path
+
+import matplotlib
import numpy as np
import pandas as pd
-import pickle
+
+matplotlib.use("Agg") # Non-interactive backend, for CI/CD on Windows
+
+import deeplabcut
+from deeplabcut.core.engine import Engine
from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
from deeplabcut.utils.auxfun_videos import VideoReader
-import random
-from pathlib import Path
-MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0", "mobilenet_v2_0.35"]
+MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0"]
N_ITER = 5
@@ -31,6 +37,7 @@
SCORER = "dlc_team"
NUM_FRAMES = 5
TRAIN_SIZE = 0.8
+ ENGINE = Engine.TF
# NET = "dlcr101_ms5"
NET = "dlcrnet_ms5"
@@ -42,14 +49,10 @@
DESTFOLDER = basepath
video = "m3v1mp4"
- video_path = os.path.join(
- basepath, "openfield-Pranav-2018-10-30", "videos", video + ".mp4"
- )
+ video_path = os.path.join(basepath, "openfield-Pranav-2018-10-30", "videos", video + ".mp4")
print("Creating project...")
- config_path = deeplabcut.create_new_project(
- TASK, SCORER, [video_path], copy_videos=True, multianimal=True
- )
+ config_path = deeplabcut.create_new_project(TASK, SCORER, [video_path], copy_videos=True, multianimal=True)
print("Project created.")
@@ -78,33 +81,27 @@
bodyparts_single,
bodyparts_multi,
) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
- animals_id = [i for i in range(n_animals) for _ in bodyparts_multi] + [
- n_animals
- ] * len(bodyparts_single)
- map_ = dict(zip(range(len(animals)), animals))
+ animals_id = [i for i in range(n_animals) for _ in bodyparts_multi] + [n_animals] * len(bodyparts_single)
+ map_ = dict(zip(range(len(animals)), animals, strict=False))
individuals = [map_[ind] for ind in animals_id for _ in range(2)]
scorer = [SCORER] * len(individuals)
coords = ["x", "y"] * len(animals_id)
- bodyparts = [
- bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(2)
- ]
+ bodyparts = [bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(2)]
bodyparts += [bp for bp in bodyparts_single for _ in range(2)]
columns = pd.MultiIndex.from_arrays(
[scorer, individuals, bodyparts, coords],
names=["scorer", "individuals", "bodyparts", "coords"],
)
- index = [
- os.path.join(rel_folder, image)
- for image in auxiliaryfunctions.grab_files_in_folder(image_folder, "png")
- ]
- fake_data = np.tile(
- np.repeat(50 * np.arange(len(animals_id)) + 50, 2), (len(index), 1)
- )
+ index = [os.path.join(rel_folder, image) for image in auxiliaryfunctions.grab_files_in_folder(image_folder, "png")]
+ fake_data = np.tile(np.repeat(50 * np.arange(len(animals_id)) + 50, 2), (len(index), 1))
df = pd.DataFrame(fake_data, index=index, columns=columns)
output_path = os.path.join(image_folder, f"CollectedData_{SCORER}.csv")
df.to_csv(output_path)
df.to_hdf(
- output_path.replace("csv", "h5"), "df_with_missing", format="table", mode="w"
+ output_path.replace("csv", "h5"),
+ key="df_with_missing",
+ format="table",
+ mode="w",
)
print("Artificial data created.")
@@ -114,7 +111,10 @@
print("Creating train dataset...")
deeplabcut.create_multianimaltraining_dataset(
- config_path, net_type=NET, crop_size=(200, 200)
+ config_path,
+ net_type=NET,
+ crop_size=(200, 200),
+ engine=ENGINE,
)
print("Train dataset created.")
@@ -134,7 +134,7 @@
print("Editing pose config...")
model_folder = auxiliaryfunctions.get_model_folder(
- TRAIN_SIZE, 1, cfg, cfg["project_path"]
+ TRAIN_SIZE, 1, cfg, engine=ENGINE, modelprefix=cfg["project_path"]
)
pose_config_path = os.path.join(model_folder, "train", "pose_cfg.yaml")
edits = {
@@ -153,9 +153,7 @@
print("Network trained.")
print("Evaluating network...")
- deeplabcut.evaluate_network(
- config_path, plotting=True, per_keypoint_evaluation=True
- )
+ deeplabcut.evaluate_network(config_path, plotting=True, per_keypoint_evaluation=True)
print("Network evaluated....")
@@ -194,9 +192,7 @@
print("Video created.")
print("Convert detections to tracklets...")
- deeplabcut.convert_detections2tracklets(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.convert_detections2tracklets(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Tracklets created...")
h5path = os.path.splitext(new_video_path)[0] + scorer + "_el.h5"
try:
@@ -213,9 +209,7 @@
individuals = [map_[ind] for ind in animals_id for _ in range(3)]
scorer = [SCORER] * len(individuals)
coords = ["x", "y", "likelihood"] * len(animals_id)
- bodyparts = [
- bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(3)
- ]
+ bodyparts = [bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(3)]
bodyparts += [bp for bp in bodyparts_single for _ in range(3)]
columns = pd.MultiIndex.from_arrays(
[scorer, individuals, bodyparts, coords],
@@ -227,9 +221,7 @@
df.to_hdf(h5path, key="data")
print("Plotting trajectories...")
- deeplabcut.plot_trajectories(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.plot_trajectories(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Trajectory plotted.")
print("Creating labeled video...")
@@ -244,15 +236,11 @@
print("Labeled video created.")
print("Filtering predictions...")
- deeplabcut.filterpredictions(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.filterpredictions(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Predictions filtered.")
print("Extracting outlier frames...")
- deeplabcut.extract_outlier_frames(
- config_path, [new_video_path], "mp4", automatic=True, track_method=TESTTRACKER
- )
+ deeplabcut.extract_outlier_frames(config_path, [new_video_path], "mp4", automatic=True, track_method=TESTTRACKER)
print("Outlier frames extracted.")
vname = Path(new_video_path).stem
@@ -284,23 +272,21 @@
vname,
"CollectedData_" + SCORER + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
)
print("MERGING")
deeplabcut.merge_datasets(config_path) # iteration + 1
print("CREATING TRAININGSET updated training set")
- deeplabcut.create_training_dataset(config_path, net_type=NET)
+ deeplabcut.create_training_dataset(config_path, net_type=NET, engine=ENGINE)
print("Training network...")
deeplabcut.train_network(config_path, maxiters=N_ITER)
print("Network trained.")
print("Evaluating network...")
- deeplabcut.evaluate_network(
- config_path, plotting=True, per_keypoint_evaluation=True
- )
+ deeplabcut.evaluate_network(config_path, plotting=True, per_keypoint_evaluation=True)
print("Network evaluated....")
@@ -320,16 +306,16 @@
deeplabcut.export_model(config_path, shuffle=1, make_tar=False)
print("Merging datasets...")
- trainIndices, testIndices = deeplabcut.mergeandsplit(
- config_path, trainindex=0, uniform=True
- )
+ trainIndices, testIndices = deeplabcut.mergeandsplit(config_path, trainindex=0, uniform=True)
print("Creating two identical splits...")
deeplabcut.create_multianimaltraining_dataset(
config_path,
Shuffles=[4, 5],
+ net_type=NET,
trainIndices=[trainIndices, trainIndices],
testIndices=[testIndices, testIndices],
+ engine=ENGINE,
)
print("ALL DONE!!! - default multianimal cases are functional.")
diff --git a/examples/testscript.py b/examples/testscript_tensorflow_single_animal.py
similarity index 82%
rename from examples/testscript.py
rename to examples/testscript_tensorflow_single_animal.py
index 3e6c57e2d5..65869d3b38 100644
--- a/examples/testscript.py
+++ b/examples/testscript_tensorflow_single_animal.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
#
# DeepLabCut Toolbox (deeplabcut.org)
# © A. & M.W. Mathis Labs
@@ -22,36 +21,36 @@
It produces nothing of interest scientifically.
"""
+
import os
-import deeplabcut
import platform
-import scipy.io as sio
-import subprocess
+import random
from pathlib import Path
+import matplotlib
import numpy as np
import pandas as pd
+import scipy.io as sio
+import deeplabcut
+from deeplabcut.core.engine import Engine
from deeplabcut.utils import auxiliaryfunctions
-import random
+matplotlib.use("Agg") # Non-interactive backend, for CI/CD on Windows
USE_SHELVE = random.choice([True, False])
-MODELS = ["resnet_50", "efficientnet-b0", "mobilenet_v2_0.35"]
+MODELS = ["resnet_50", "efficientnet-b0"]
if __name__ == "__main__":
task = "TEST" # Enter the name of your experiment Task
scorer = "Alex" # Enter the name of the experimenter/labeler
+ engine = Engine.TF
print("Imported DLC!")
basepath = os.path.dirname(os.path.realpath(__file__))
videoname = "reachingvideo1"
- video = [
- os.path.join(
- basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi"
- )
- ]
+ video = [os.path.join(basepath, "Reaching-Mackenzie-2018-08-30", "videos", videoname + ".avi")]
# For testing a color video:
# videoname='baby4hin2min'
@@ -71,12 +70,11 @@
else:
augmenter_type3 = "tensorpack" # Does not work on WINDOWS
- N_ITER = 5
+ N_ITER = 6
+ SAVE_ITER = 3
print("CREATING PROJECT")
- path_config_file = deeplabcut.create_new_project(
- task, scorer, video, copy_videos=True
- )
+ path_config_file = deeplabcut.create_new_project(task, scorer, video, copy_videos=True)
cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file)
cfg["numframes2pick"] = 5
@@ -91,6 +89,7 @@
print("CREATING-SOME LABELS FOR THE FRAMES")
frames = os.listdir(os.path.join(cfg["project_path"], "labeled-data", videoname))
+ frames = [fn for fn in frames if fn.endswith(".png")]
# As this next step is manual, we update the labels by putting them on the diagonal (fixed for all frames)
for index, bodypart in enumerate(cfg["bodyparts"]):
columnindex = pd.MultiIndex.from_product(
@@ -122,7 +121,7 @@
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -133,7 +132,10 @@
print("CREATING TRAININGSET")
deeplabcut.create_training_dataset(
- path_config_file, net_type=NET, augmenter_type=augmenter_type
+ path_config_file,
+ net_type=NET,
+ augmenter_type=augmenter_type,
+ engine=engine,
)
# Check the training image paths are correctly stored as arrays of strings
@@ -166,7 +168,7 @@
)
DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile)
- DLC_config["save_iters"] = N_ITER
+ DLC_config["save_iters"] = SAVE_ITER
DLC_config["display_iters"] = 2
print("CHANGING training parameters to end quickly!")
@@ -177,7 +179,14 @@
print("EVALUATE")
deeplabcut.evaluate_network(
- path_config_file, plotting=True, per_keypoint_evaluation=True
+ path_config_file,
+ plotting=True,
+ per_keypoint_evaluation=True,
+ snapshots_to_evaluate=[
+ "snapshot-3",
+ "snapshot-5",
+ "snapshot-6",
+ ], # snapshot-5 intentionally missing :)
)
# deeplabcut.evaluate_network(path_config_file,plotting=True,trainingsetindex=33)
print("CUT SHORT VIDEO AND ANALYZE (with dynamic cropping!)")
@@ -193,10 +202,10 @@
outsuffix="short",
outpath=os.path.join(cfg["project_path"], "videos"),
)
- except: # if ffmpeg is broken/missing
+ except Exception: # if ffmpeg is broken/missing
print("using alternative method")
newvideo = os.path.join(cfg["project_path"], "videos", videoname + "short.mp4")
- from moviepy.editor import VideoFileClip, VideoClip
+ from moviepy.editor import VideoClip, VideoFileClip
clip = VideoFileClip(video[0])
clip.reader.initialize()
@@ -218,14 +227,11 @@ def make_frame(t):
)
print("analyze again...")
- deeplabcut.analyze_videos(
- path_config_file, [newvideo], save_as_csv=True, destfolder=DESTFOLDER
- )
+ deeplabcut.analyze_videos(path_config_file, [newvideo], save_as_csv=True, destfolder=DESTFOLDER)
print("CREATE VIDEO")
- deeplabcut.create_labeled_video(
- path_config_file, [newvideo], destfolder=DESTFOLDER, save_frames=True
- )
+ successful = deeplabcut.create_labeled_video(path_config_file, [newvideo], destfolder=DESTFOLDER, save_frames=True)
+ assert all(successful), "Failed to create a labeled video!"
print("Making plots")
deeplabcut.plot_trajectories(path_config_file, [newvideo], destfolder=DESTFOLDER)
@@ -275,16 +281,14 @@ def make_frame(t):
vname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
)
print("MERGING")
deeplabcut.merge_datasets(path_config_file) # iteration + 1
print("CREATING TRAININGSET")
- deeplabcut.create_training_dataset(
- path_config_file, net_type=NET, augmenter_type=augmenter_type2
- )
+ deeplabcut.create_training_dataset(path_config_file, net_type=NET, augmenter_type=augmenter_type2, engine=engine)
cfg = deeplabcut.auxiliaryfunctions.read_config(path_config_file)
posefile = os.path.join(
@@ -301,7 +305,7 @@ def make_frame(t):
"train/pose_cfg.yaml",
)
DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile)
- DLC_config["save_iters"] = N_ITER
+ DLC_config["save_iters"] = SAVE_ITER
DLC_config["display_iters"] = 1
print("CHANGING training parameters to end quickly!")
@@ -320,11 +324,9 @@ def make_frame(t):
outpath=os.path.join(cfg["project_path"], "videos"),
)
- except: # if ffmpeg is broken
- newvideo2 = os.path.join(
- cfg["project_path"], "videos", videoname + "short2.mp4"
- )
- from moviepy.editor import VideoFileClip, VideoClip
+ except Exception: # if ffmpeg is broken
+ newvideo2 = os.path.join(cfg["project_path"], "videos", videoname + "short2.mp4")
+ from moviepy.editor import VideoClip, VideoFileClip
clip = VideoFileClip(video[0])
clip.reader.initialize()
@@ -349,27 +351,25 @@ def make_frame(t):
)
print("Extracting skeleton distances, filter and plot filtered output")
- deeplabcut.analyzeskeleton(
- path_config_file, [newvideo2], save_as_csv=True, destfolder=DESTFOLDER
- )
+ deeplabcut.analyzeskeleton(path_config_file, [newvideo2], save_as_csv=True, destfolder=DESTFOLDER)
deeplabcut.filterpredictions(path_config_file, [newvideo2])
- deeplabcut.create_labeled_video(
+ successful = deeplabcut.create_labeled_video(
path_config_file,
[newvideo2],
destfolder=DESTFOLDER,
displaycropped=True,
filtered=True,
)
+ assert all(successful), "Failed to create a labeled video!"
print("Creating a Johansson video!")
- deeplabcut.create_labeled_video(
+ successful = deeplabcut.create_labeled_video(
path_config_file, [newvideo2], destfolder=DESTFOLDER, keypoints_only=True
)
+ assert all(successful), "Failed to create a labeled video!"
- deeplabcut.plot_trajectories(
- path_config_file, [newvideo2], destfolder=DESTFOLDER, filtered=True
- )
+ deeplabcut.plot_trajectories(path_config_file, [newvideo2], destfolder=DESTFOLDER, filtered=True)
print("ALL DONE!!! - default cases without Tensorpack loader are functional.")
@@ -381,6 +381,7 @@ def make_frame(t):
Shuffles=[2],
net_type=NET,
augmenter_type=augmenter_type3,
+ engine=engine,
)
posefile = os.path.join(
@@ -406,9 +407,7 @@ def make_frame(t):
deeplabcut.auxiliaryfunctions.write_plainconfig(posefile, DLC_config)
print("TRAINING shuffle 2, with smaller allocated memory")
- deeplabcut.train_network(
- path_config_file, shuffle=2, allow_growth=True, maxiters=updated_max_iters
- )
+ deeplabcut.train_network(path_config_file, shuffle=2, allow_growth=True, maxiters=updated_max_iters)
print("ANALYZING some individual frames")
deeplabcut.analyze_time_lapse_frames(
@@ -420,9 +419,7 @@ def make_frame(t):
deeplabcut.export_model(path_config_file, shuffle=2, make_tar=False)
print("Merging datasets...")
- trainIndices, testIndices = deeplabcut.mergeandsplit(
- path_config_file, trainindex=0, uniform=True
- )
+ trainIndices, testIndices = deeplabcut.mergeandsplit(path_config_file, trainindex=0, uniform=True)
print("Creating two identical splits...")
deeplabcut.create_training_dataset(
@@ -430,6 +427,7 @@ def make_frame(t):
Shuffles=[4, 5],
trainIndices=[trainIndices, trainIndices],
testIndices=[testIndices, testIndices],
+ engine=engine,
)
print("ALL DONE!!! - default cases are functional.")
diff --git a/examples/testscript_transreid.py b/examples/testscript_transreid.py
index b3467832ef..cb10de7f26 100644
--- a/examples/testscript_transreid.py
+++ b/examples/testscript_transreid.py
@@ -9,14 +9,16 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
-import deeplabcut
-import numpy as np
-import pandas as pd
import pickle
-from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
import random
from pathlib import Path
+import numpy as np
+import pandas as pd
+
+import deeplabcut
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+
# MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0", "mobilenet_v2_0.35"]
MODELS = [
"dlcrnet_ms5",
@@ -43,14 +45,10 @@
DESTFOLDER = basepath
video = "m3v1mp4"
- video_path = os.path.join(
- basepath, "openfield-Pranav-2018-10-30", "videos", video + ".mp4"
- )
+ video_path = os.path.join(basepath, "openfield-Pranav-2018-10-30", "videos", video + ".mp4")
print("Creating project...")
- config_path = deeplabcut.create_new_project(
- TASK, SCORER, [video_path], copy_videos=True, multianimal=True
- )
+ config_path = deeplabcut.create_new_project(TASK, SCORER, [video_path], copy_videos=True, multianimal=True)
print("Project created.")
@@ -79,34 +77,23 @@
bodyparts_single,
bodyparts_multi,
) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
- animals_id = [i for i in range(n_animals) for _ in bodyparts_multi] + [
- n_animals
- ] * len(bodyparts_single)
- map_ = dict(zip(range(len(animals)), animals))
+ animals_id = [i for i in range(n_animals) for _ in bodyparts_multi] + [n_animals] * len(bodyparts_single)
+ map_ = dict(zip(range(len(animals)), animals, strict=False))
individuals = [map_[ind] for ind in animals_id for _ in range(2)]
scorer = [SCORER] * len(individuals)
coords = ["x", "y"] * len(animals_id)
- bodyparts = [
- bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(2)
- ]
+ bodyparts = [bp for _ in range(n_animals) for bp in bodyparts_multi for _ in range(2)]
bodyparts += [bp for bp in bodyparts_single for _ in range(2)]
columns = pd.MultiIndex.from_arrays(
[scorer, individuals, bodyparts, coords],
names=["scorer", "individuals", "bodyparts", "coords"],
)
- index = [
- os.path.join(rel_folder, image)
- for image in auxiliaryfunctions.grab_files_in_folder(image_folder, "png")
- ]
- fake_data = np.tile(
- np.repeat(50 * np.arange(len(animals_id)) + 50, 2), (len(index), 1)
- )
+ index = [os.path.join(rel_folder, image) for image in auxiliaryfunctions.grab_files_in_folder(image_folder, "png")]
+ fake_data = np.tile(np.repeat(50 * np.arange(len(animals_id)) + 50, 2), (len(index), 1))
df = pd.DataFrame(fake_data, index=index, columns=columns)
output_path = os.path.join(image_folder, f"CollectedData_{SCORER}.csv")
df.to_csv(output_path)
- df.to_hdf(
- output_path.replace("csv", "h5"), "df_with_missing", format="table", mode="w"
- )
+ df.to_hdf(output_path.replace("csv", "h5"), key="df_with_missing", format="table", mode="w")
print("Artificial data created.")
print("Checking labels...")
@@ -114,9 +101,7 @@
print("Labels checked.")
print("Creating train dataset...")
- deeplabcut.create_multianimaltraining_dataset(
- config_path, net_type=NET, crop_size=(200, 200)
- )
+ deeplabcut.create_multianimaltraining_dataset(config_path, net_type=NET, crop_size=(200, 200))
print("Train dataset created.")
# Check the training image paths are correctly stored as arrays of strings
@@ -134,9 +119,7 @@
assert all(len(pickledata[i]["joints"]) == 3 for i in range(num_images))
print("Editing pose config...")
- model_folder = auxiliaryfunctions.get_model_folder(
- TRAIN_SIZE, 1, cfg, cfg["project_path"]
- )
+ model_folder = auxiliaryfunctions.get_model_folder(TRAIN_SIZE, 1, cfg, cfg["project_path"])
pose_config_path = os.path.join(model_folder, "train", "pose_cfg.yaml")
edits = {
"global_scale": 0.5,
@@ -191,9 +174,7 @@
print("Video created.")
print("Convert detections to tracklets...")
- deeplabcut.convert_detections2tracklets(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.convert_detections2tracklets(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Tracklets created...")
### adding it here
@@ -202,9 +183,7 @@
trainposeconfigfile,
testposeconfigfile,
snapshotfolder,
- ) = deeplabcut.return_train_network_path(
- config_path, shuffle=1, modelprefix=modelprefix, trainingsetindex=0
- )
+ ) = deeplabcut.return_train_network_path(config_path, shuffle=1, modelprefix=modelprefix, trainingsetindex=0)
print("Creating triplet dataset")
@@ -212,7 +191,7 @@
config_path,
[new_video_path],
TESTTRACKER,
- videotype="mp4",
+ video_extensions="mp4",
)
train_epochs = 10
@@ -230,9 +209,7 @@
ckpt_folder=snapshotfolder,
)
- transformer_checkpoint = os.path.join(
- snapshotfolder, f"dlc_transreid_{train_epochs}.pth"
- )
+ transformer_checkpoint = os.path.join(snapshotfolder, f"dlc_transreid_{train_epochs}.pth")
print("Stitching tracklets based on transformer")
@@ -245,9 +222,7 @@
)
print("Plotting trajectories...")
- deeplabcut.plot_trajectories(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.plot_trajectories(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Trajectory plotted.")
print("Creating labeled video...")
@@ -262,15 +237,11 @@
print("Labeled video created.")
print("Filtering predictions...")
- deeplabcut.filterpredictions(
- config_path, [new_video_path], "mp4", track_method=TESTTRACKER
- )
+ deeplabcut.filterpredictions(config_path, [new_video_path], "mp4", track_method=TESTTRACKER)
print("Predictions filtered.")
print("Extracting outlier frames...")
- deeplabcut.extract_outlier_frames(
- config_path, [new_video_path], "mp4", automatic=True, track_method=TESTTRACKER
- )
+ deeplabcut.extract_outlier_frames(config_path, [new_video_path], "mp4", automatic=True, track_method=TESTTRACKER)
print("Outlier frames extracted.")
vname = Path(new_video_path).stem
@@ -303,7 +274,7 @@
vname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -329,7 +300,7 @@
config_path,
[new_video_path],
shuffle=3,
- videotype="mp4",
+ video_extensions="mp4",
save_as_csv=True,
destfolder=DESTFOLDER,
cropping=[0, 50, 0, 50],
@@ -345,7 +316,7 @@
deeplabcut.transformer_reID(
config_path,
[new_video_path],
- videotype="mp4",
+ video_extensions="mp4",
shuffle=3,
n_tracks=n_tracks,
track_method=TESTTRACKER,
@@ -359,7 +330,7 @@
deeplabcut.create_labeled_video(
config_path,
[new_video_path],
- videotype="mp4",
+ video_extensions="mp4",
shuffle=3,
track_method="ellipse",
destfolder=DESTFOLDER,
@@ -368,7 +339,7 @@
deeplabcut.create_labeled_video(
config_path,
[new_video_path],
- videotype="mp4",
+ video_extensions="mp4",
shuffle=3,
track_method="transformer",
destfolder=DESTFOLDER,
diff --git a/examples/utils.py b/examples/utils.py
new file mode 100644
index 0000000000..9390cea392
--- /dev/null
+++ b/examples/utils.py
@@ -0,0 +1,438 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import shutil
+import string
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import matplotlib
+
+matplotlib.use("Agg") # Non-interactive backend, for CI/CD on Windows
+
+import cv2
+import numpy as np
+import pandas as pd
+from PIL import Image
+
+import deeplabcut
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.compat import Engine
+from deeplabcut.generate_training_dataset import get_existing_shuffle_indices
+
+
+def log_step(message: Any) -> None:
+ print(100 * "-")
+ print(str(message))
+ print(100 * "-")
+
+
+def cleanup(test_path: Path) -> None:
+ if test_path.exists():
+ shutil.rmtree(test_path)
+
+
+@dataclass(frozen=True)
+class SyntheticProjectParameters:
+ multianimal: bool
+ num_bodyparts: int
+ num_frames: int = 10
+ num_individuals: int = 1
+ num_unique: int = 0
+ identity: bool = False
+ frame_shape: tuple[int, int] = (480, 640)
+
+ def bodyparts(self) -> list[str]:
+ return [i for i in string.ascii_lowercase[: self.num_bodyparts]]
+
+ def unique(self) -> list[str]:
+ return [f"unique_{i}" for i in string.ascii_lowercase[: self.num_unique]]
+
+ def individuals(self) -> list[str]:
+ return [f"animal_{i}" for i in range(self.num_individuals)]
+
+
+def sample_pose_random(
+ gen: np.random.Generator,
+ num_individuals: int,
+ num_bodyparts: int,
+ num_unique: int,
+ img_h: int,
+ img_w: int,
+) -> np.ndarray:
+ """Fully random pose sampling."""
+ xs = gen.choice(img_w, size=(num_individuals, num_bodyparts), replace=False)
+ ys = gen.choice(img_h, size=(num_individuals, num_bodyparts), replace=False)
+ pose = np.stack([xs, ys], axis=-1)
+
+ image_data = pose.reshape(-1)
+ if num_unique > 0:
+ unique_pose = np.stack(
+ [
+ gen.choice(img_w, size=(1, num_unique), replace=False),
+ gen.choice(img_h, size=(1, num_unique), replace=False),
+ ],
+ axis=-1,
+ )
+ image_data = np.concatenate([image_data, unique_pose.reshape(-1)])
+ return image_data
+
+
+def sample_pose_from_center(
+ center_xs: np.ndarray,
+ center_ys: np.ndarray,
+ num_individuals: int,
+ num_bodyparts: int,
+ num_unique: int,
+ radius: int = 25,
+) -> np.ndarray:
+ """Sample keypoints from the center of each individual."""
+ pose = np.zeros((num_individuals, num_bodyparts, 2))
+ for i, (xc, yc) in enumerate(zip(center_xs, center_ys, strict=False)):
+ if i < num_individuals:
+ x_start, x_end = xc - radius + 1, xc + radius - 1
+ y_start, y_end = yc - radius + 1, yc + radius - 1
+ pose[i, :, 0] = np.linspace(start=x_start, stop=x_end, num=num_bodyparts)
+ pose[i, :, 1] = np.linspace(start=y_start, stop=y_end, num=num_bodyparts)
+
+ image_data = pose.reshape(-1)
+ if num_unique > 0:
+ xc, yc = center_xs[-1], center_ys[-1]
+ x_start, x_end = xc - radius + 1, xc + radius - 1
+ y_start, y_end = yc - radius + 1, yc + radius - 1
+ unique_pose = np.zeros((1, num_unique, 2))
+ unique_pose[0, :, 0] = np.linspace(start=x_start, stop=x_end, num=num_unique)
+ unique_pose[0, :, 1] = np.linspace(start=y_start, stop=y_end, num=num_unique)
+ image_data = np.concatenate([image_data, unique_pose.reshape(-1)])
+ return image_data
+
+
+def gen_fake_data(
+ scorer: str,
+ video_name: str,
+ params: SyntheticProjectParameters,
+) -> pd.DataFrame:
+ kpt_entries = ["x", "y"]
+ col_names = ["scorer", "individuals", "bodyparts", "coords"]
+ col_values = []
+ for i in params.individuals():
+ for b in params.bodyparts():
+ col_values += [(scorer, i, b, entry) for entry in kpt_entries]
+
+ for unique_bpt in params.unique():
+ col_values += [(scorer, "single", unique_bpt, entry) for entry in kpt_entries]
+
+ index_data = []
+ pose_data = []
+ gen = np.random.default_rng(seed=0)
+
+ # sample starting points for each individual
+ img_h, img_w = params.frame_shape[:2]
+ radius = 8
+ center_xs = gen.choice(
+ np.arange(radius, img_w - radius),
+ size=params.num_individuals + 1, # in case unique bodyparts
+ replace=False,
+ )
+ center_ys = gen.choice(
+ np.arange(radius, img_h - radius),
+ size=params.num_individuals + 1, # in case unique bodyparts
+ replace=False,
+ )
+
+ for frame_index in range(params.num_frames):
+ index_data.append(("labeled-data", video_name, f"img{frame_index:04}.png"))
+ pose_data.append(
+ sample_pose_from_center(
+ center_xs,
+ center_ys,
+ num_individuals=params.num_individuals,
+ num_bodyparts=params.num_bodyparts,
+ num_unique=params.num_unique,
+ radius=radius,
+ )
+ )
+ mvt_x = gen.integers(low=-1, high=4, size=center_xs.size)
+ mvt_y = gen.integers(low=-1, high=4, size=center_ys.size)
+ center_xs = np.clip(center_xs + mvt_x, radius, img_w - radius)
+ center_ys = np.clip(center_ys + mvt_y, radius, img_h - radius)
+
+ pose = np.stack(pose_data)
+ pose[params.num_frames // 2, :] = np.nan # add missing row in a frame
+ for idv in range(params.num_individuals):
+ idv_start = 2 * params.num_bodyparts * idv
+ idv_end = 2 * params.num_bodyparts * (idv + 1)
+ if params.num_frames > idv + 1:
+ pose[idv + 1, idv_start:idv_end] = np.nan
+
+ for bpt in range(params.num_bodyparts):
+ frame_idx = 1 + params.num_individuals + bpt
+ idv_idx = bpt % params.num_individuals
+ offset = 2 * params.num_bodyparts * idv_idx
+ bpt_start, bpt_end = 2 * bpt + offset, 2 * (bpt + 1) + offset
+ if params.num_frames + 1 > frame_idx:
+ pose[frame_idx, bpt_start:bpt_end] = np.nan
+
+ return pd.DataFrame(
+ pose,
+ index=pd.MultiIndex.from_tuples(index_data),
+ columns=pd.MultiIndex.from_tuples(col_values, names=col_names),
+ )
+
+
+def gen_fake_image(
+ project_root: Path,
+ row: pd.Series,
+ params: SyntheticProjectParameters,
+ radius: int = 5,
+):
+ img_h, img_w = params.frame_shape
+ image_array = np.zeros((*params.frame_shape, 3), dtype=np.uint8)
+ for i, idv in enumerate(params.individuals()):
+ r = int(255 * (i + 1) / params.num_individuals)
+ if "individuals" in row.index.names:
+ idv_data = row.droplevel("scorer").loc[idv]
+ else:
+ idv_data = row.droplevel("scorer")
+
+ keypoints = idv_data.to_numpy().reshape((-1, 2))
+ if not np.all(np.isnan(keypoints)):
+ idv_center = np.nanmean(keypoints, axis=0)
+ x, y = int(idv_center[0]), int(idv_center[1])
+ xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius)
+ ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius)
+ image_array[ymin:ymax, xmin:xmax, 0] = r
+
+ for j, bpt in enumerate(params.bodyparts()):
+ g = int(255 * (j + 1) / params.num_bodyparts)
+
+ bpt_data = idv_data.loc[bpt]
+ if np.all(~pd.isnull(bpt_data)):
+ x, y = int(bpt_data.x), int(bpt_data.y)
+ xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius)
+ ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius)
+ image_array[ymin:ymax, xmin:xmax, 0] = r
+ image_array[ymin:ymax, xmin:xmax, 1] = g
+
+ if params.num_unique > 0:
+ unique_data = row.droplevel("scorer").loc["single"]
+ for i, unique_bpt in enumerate(params.unique()):
+ bpt_data = unique_data.loc[unique_bpt]
+ if np.all(~pd.isnull(bpt_data)):
+ x, y = int(bpt_data.x), int(bpt_data.y)
+ xmin, xmax = max(0, x - radius), min(img_w - 1, x + radius)
+ ymin, ymax = max(0, y - radius), min(img_h - 1, y + radius)
+ image_array[ymin:ymax, xmin:xmax, 2] = int(255 * (i + 1) / params.num_unique)
+
+ img = Image.fromarray(image_array)
+ img.save(project_root / Path(*row.name))
+
+
+def generate_video_from_images(image_dir: Path, output_video: Path) -> None:
+ images = [p for p in image_dir.iterdir() if p.is_file() and p.suffix == ".png"]
+ images = sorted(images, key=lambda f: f.stem)
+ if len(images) == 0:
+ return
+
+ height, width, channels = cv2.imread(str(images[0])).shape
+ fourcc = cv2.VideoWriter_fourcc(*"MJPG")
+ out = cv2.VideoWriter(str(output_video), fourcc, 10, (width, height))
+ for img_path in images:
+ img = cv2.imread(str(img_path))
+ out.write(img)
+ out.release()
+
+
+def create_fake_project(path: Path, params: SyntheticProjectParameters) -> None:
+ if path.exists():
+ raise ValueError("Cannot create a fake project at an existing path")
+
+ scorer = "synthetic"
+ video_name = "cat"
+ path.mkdir(parents=True, exist_ok=False)
+ config = {
+ "Task": "synthetic",
+ "scorer": scorer,
+ "date": "Nov11",
+ "multianimalproject": params.multianimal,
+ "identity": params.identity,
+ "project_path": str(path / "config.yaml"),
+ "TrainingFraction": [0.8],
+ "iteration": 0,
+ "default_net_type": "resnet_50",
+ "default_augmenter": "default",
+ "default_track_method": "ellipse",
+ "snapshotindex": "all",
+ "batch_size": 8,
+ "pcutoff": 0.6,
+ "video_sets": {
+ str(path / "videos" / video_name): {
+ "crop": (0, params.frame_shape[1], 0, params.frame_shape[0]),
+ },
+ },
+ "start": 0,
+ "stop": 1,
+ "numframes2pick": 10,
+ "dotsize": 4,
+ "alphavalue": 1.0,
+ "colormap": "rainbow",
+ }
+ if not params.multianimal:
+ config["bodyparts"] = params.bodyparts()
+ assert params.num_individuals == 1
+ assert params.num_unique == 0
+ else:
+ config["bodyparts"] = "MULTI!"
+ config["multianimalbodyparts"] = params.bodyparts()
+ config["uniquebodyparts"] = params.unique()
+ config["individuals"] = params.individuals()
+
+ af.write_config(str(path / "config.yaml"), config)
+ image_dir = path / "labeled-data" / video_name
+ image_dir.mkdir(parents=True, exist_ok=False)
+
+ df = gen_fake_data(
+ scorer=scorer,
+ video_name=video_name,
+ params=params,
+ )
+ print("SYNTHETIC DATA:")
+ print(df)
+ print("\n")
+ if not params.multianimal:
+ df.columns = df.columns.droplevel("individuals")
+
+ df.to_hdf(image_dir / f"CollectedData_{scorer}.h5", key="df_with_missing")
+ df.to_csv(image_dir / f"CollectedData_{scorer}.csv")
+
+ for idx in range(params.num_frames):
+ gen_fake_image(path, df.iloc[idx], params=params, radius=5)
+
+ output_video = path / "videos" / "video.mp4"
+ output_video.parent.mkdir(exist_ok=True)
+ generate_video_from_images(image_dir, output_video)
+
+
+def copy_project_for_test() -> Path:
+ data_path = Path.cwd() / "openfield-Pranav-2018-10-30"
+ test_path = Path.cwd() / "pytorch-testscript1234-openfield-Pranav-2018-10-30"
+ if not test_path.exists():
+ shutil.copytree(data_path, test_path)
+
+ project_config = af.read_config(str(test_path / "config.yaml"))
+ videos = list(project_config["video_sets"].keys())
+ video = videos[0]
+ crop = project_config["video_sets"][video]
+ project_config["video_sets"] = {str(test_path / "videos" / "m3v1mp4.mp4"): crop}
+ af.write_config(str(test_path / "config.yaml"), project_config)
+ return test_path
+
+
+def run(
+ config_path: Path,
+ train_fraction: float,
+ trainset_index: int,
+ net_type: str,
+ videos: list[str],
+ device: str,
+ engine: Engine = Engine.PYTORCH,
+ pytorch_cfg_updates: dict | None = None,
+ create_labeled_videos: bool = False,
+) -> None:
+ times = [time.time()]
+ log_step(f"Testing with net type {net_type}")
+ log_step("Creating the training dataset")
+ deeplabcut.create_training_dataset(str(config_path), net_type=net_type, engine=engine)
+ existing_shuffles = get_existing_shuffle_indices(config_path, train_fraction=train_fraction, engine=engine)
+ shuffle_index = existing_shuffles[-1]
+
+ log_step(f"Starting training for train_frac {train_fraction}, shuffle {shuffle_index}")
+ deeplabcut.train_network(
+ config=str(config_path),
+ shuffle=shuffle_index,
+ trainingsetindex=trainset_index,
+ device=device,
+ pytorch_cfg_updates=pytorch_cfg_updates,
+ )
+ times.append(time.time())
+ log_step(f"Train time: {times[-1] - times[-2]} seconds")
+
+ log_step(f"Starting evaluation for train_frac {train_fraction}, shuffle {shuffle_index}")
+ deeplabcut.evaluate_network(
+ config=str(config_path),
+ Shuffles=[shuffle_index],
+ trainingsetindex=trainset_index,
+ device=device,
+ plotting=True,
+ per_keypoint_evaluation=True,
+ )
+ times.append(time.time())
+ log_step(f"Evaluation time: {times[-1] - times[-2]} seconds")
+
+ if len(videos) > 0:
+ log_step(f"Analyzing videos for {train_fraction}, shuffle {shuffle_index}")
+ video_kwargs = dict(videos=videos, shuffle=shuffle_index, trainingsetindex=trainset_index)
+ deeplabcut.analyze_videos(str(config_path), **video_kwargs, device=device, auto_track=False)
+ times.append(time.time())
+ log_step(f"Video analysis time: {times[-1] - times[-2]} seconds")
+ log_step(f"Total test time: {times[-1] - times[0]} seconds")
+
+ cfg = af.read_config(config_path)
+ if cfg.get("multianimalproject"):
+ if create_labeled_videos:
+ deeplabcut.create_video_with_all_detections(str(config_path), **video_kwargs)
+
+ # relaxed tracking parameters
+ deeplabcut.convert_detections2tracklets(
+ str(config_path),
+ **video_kwargs,
+ inferencecfg=dict(
+ boundingboxslack=10,
+ iou_threshold=0.2,
+ max_age=5,
+ method="m1",
+ min_hits=1,
+ minimalnumberofconnections=2,
+ pafthreshold=0.1,
+ pcutoff=0.1,
+ topktoretain=3,
+ variant=0,
+ withid=False,
+ ),
+ )
+ deeplabcut.stitch_tracklets(str(config_path), **video_kwargs, min_length=3)
+
+ if create_labeled_videos:
+ log_step(f"Making labeled video, {train_fraction}, shuffle={shuffle_index}")
+ results = deeplabcut.create_labeled_video(
+ config=str(config_path),
+ videos=videos,
+ shuffle=shuffle_index,
+ trainingsetindex=trainset_index,
+ )
+ assert all(results), f"Failed to create some labeled video for {videos}"
+
+
+if __name__ == "__main__":
+ create_fake_project(
+ path=Path("synthetic-data-niels"),
+ params=SyntheticProjectParameters(
+ multianimal=True,
+ num_bodyparts=4,
+ num_individuals=3,
+ num_unique=1,
+ num_frames=50,
+ frame_shape=(128, 256),
+ ),
+ )
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000..a578f17dd9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,218 @@
+[build-system]
+build-backend = "setuptools.build_meta"
+requires = [ "setuptools>=61" ]
+
+[project]
+name = "deeplabcut"
+version = "3.0.0"
+description = "Markerless pose-estimation of user-defined features with deep learning"
+readme = { file = "README.md", content-type = "text/markdown" }
+keywords = [
+ "animal behavior",
+ "markerless tracking",
+ "neuroscience",
+ "pose estimation",
+]
+license = { text = "LGPL-3.0-or-later" }
+requires-python = ">=3.10"
+classifiers = [
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Topic :: Scientific/Engineering :: Image Processing",
+]
+dependencies = [
+ "albumentations<=1.4.3",
+ "dlclibrary>=0.0.12",
+ "einops",
+ "filelock>=3.12,<3.16",
+ "filterpy>=1.4.4",
+ "h5py>=3.15.1; platform_system=='Darwin'",
+ "huggingface-hub>=0.23",
+ "imageio-ffmpeg",
+ "imgaug>=0.4",
+ "matplotlib>=3.3,<3.9,!=3.7,!=3.7.1",
+ "networkx>=2.6",
+ "numba>=0.54",
+ "numpy>=1.18.5,<2",
+ "packaging>=26",
+ "pandas[hdf5,performance]>=2.2,<3",
+ "pillow>=7.1",
+ "pycocotools",
+ "pydantic>=2,<3",
+ "pyyaml",
+ "ruamel-yaml>=0.15",
+ "scikit-image>=0.17",
+ "scikit-learn>=1",
+ "scipy>=1.9",
+ "statsmodels>=0.11",
+ "tables>3.8",
+ "timm",
+ "torch>=2",
+ "torchvision",
+ "tqdm",
+]
+[[project.authors]]
+name = "M-Lab of Adaptive Intelligence"
+email = "mackenzie@deeplabcut.org"
+[[project.authors]]
+name = "Mathis Group for Computational Neuroscience and AI"
+email = "alexander@deeplabcut.org"
+[project.optional-dependencies]
+gui = [
+ "napari-deeplabcut>=0.3.1",
+ "pyside6; platform_system!='Linux' or platform_machine!='x86_64'",
+ # Avoid 6.10.0 only on Linux x86_64 (fails for older glib versions)
+ "pyside6<6.10; platform_system=='Linux' and platform_machine=='x86_64'",
+ "qdarkstyle==3.1",
+]
+openvino = [ "openvino-dev==2022.1" ]
+docs = [
+ "jupyter-book==1.0.4.post1",
+ "numpydoc",
+ "sphinxcontrib-mermaid",
+]
+fmpose3d = [ "fmpose3d>=0.0.8" ]
+# Use only one of [tf, tf-cu11, tf-cu12, tf-latest]. Do not combine extras.
+tf = [
+ "protobuf<7",
+ "tensorflow>=2.12,<2.16; python_version<'3.12'",
+ "tensorflow>=2.16.1,<2.18; python_version>='3.12'",
+ "tensorflow-io-gcs-filesystem==0.31; platform_system=='Windows' and python_version<'3.12'",
+ "tensorflow-metal==1.2; platform_system=='Darwin' and python_version<'3.12'",
+ "tensorflow-metal>=1.2; platform_system=='Darwin' and python_version>='3.12'",
+ "tensorpack>=0.11",
+ "tf-keras<2.15; python_version<'3.12'",
+ "tf-keras>=2.15,<2.18; python_version>='3.12'",
+ "tf-slim>=1.1",
+]
+tf-cu11 = [
+ "protobuf<7",
+ "tensorflow==2.14",
+ "tensorflow-io-gcs-filesystem==0.31; platform_system=='Windows'",
+ "tensorflow-metal==1.2; platform_system=='Darwin'",
+ "tensorpack==0.11",
+ "tf-keras==2.14.1",
+ "tf-slim==1.1",
+ "torch<2.1",
+ "torchvision<0.16",
+]
+tf-cu12 = [
+ "protobuf<7",
+ "tensorflow==2.18",
+ "tensorflow-metal==1.2; platform_system=='Darwin'",
+ "tensorpack==0.11",
+ "tf-keras==2.18",
+ "tf-slim==1.1",
+ "torch<2.11",
+ "torchvision<0.26",
+]
+tf-latest = [
+ "protobuf<7",
+ "tensorflow>=2.18",
+ "tensorflow-metal>=1.2; platform_system=='Darwin'",
+ "tensorpack>=0.11",
+ "tf-keras",
+ "tf-slim>=1.1",
+]
+# apple_mchips is kept for older systems, prefer [tf] in new projects.
+apple_mchips = [
+ "protobuf<7; platform_system=='Darwin'",
+ "tensorflow>=2.12,<2.15; platform_system=='Darwin' and python_version<'3.12'",
+ "tensorflow>=2.15,<2.18; platform_system=='Darwin' and python_version>='3.12'",
+ "tensorflow-metal==1.2; platform_system=='Darwin' and python_version<'3.12'",
+ "tensorflow-metal>=1.2; platform_system=='Darwin' and python_version>='3.12'",
+ "tensorpack>=0.11; platform_system=='Darwin'",
+ "tf-keras; platform_system=='Darwin'",
+ "tf-slim>=1.1; platform_system=='Darwin'",
+]
+modelzoo = [ "huggingface-hub" ]
+wandb = [ "wandb" ]
+[project.scripts]
+dlc = "deeplabcut.__main__:main"
+[project.urls]
+Homepage = "https://www.deeplabcut.org"
+Repository = "https://github.com/DeepLabCut/DeepLabCut"
+Documentation = "https://deeplabcut.github.io/DeepLabCut/README.html"
+
+[dependency-groups]
+dev = [
+ "coverage",
+ "nbformat>5",
+ "pre-commit",
+ "pytest",
+ "pytest-cov",
+ "ruff",
+]
+
+[tool.setuptools]
+include-package-data = false
+[tool.setuptools.package-data]
+"*" = [ "*.yaml", "*.yml", "*.json", "*.qss", "*.png", "*.md", "*.sh" ]
+[tool.setuptools.packages.find]
+include = [ "deeplabcut*" ]
+exclude = [ "tests*", "docs*", "examples*" ]
+
+[tool.uv]
+# One of tf / tf-cu12 / tf-latest. apple_mchips matches [tf] on macOS but conflicts with
+# [tf-cu12] and [tf-latest] (overlapping tensorflow pins cannot be unified with uv's lock).
+conflicts = [
+ [
+ { extra = "tf" },
+ { extra = "tf-cu11" },
+ { extra = "tf-cu12" },
+ { extra = "tf-latest" },
+ { extra = "apple_mchips" },
+ ],
+ [
+ { extra = "tf-cu11" },
+ { extra = "tf-cu12" },
+ { extra = "fmpose3d" },
+ ],
+]
+[[tool.uv.dependency-metadata]]
+name = "openvino-dev"
+version = "2022.1.0"
+requires-dist = []
+[tool.uv.pip]
+torch-backend = "auto"
+
+[tool.ruff]
+target-version = "py310"
+line-length = 120
+fix = true
+[tool.ruff.lint]
+select = [ "E", "F", "B", "I", "UP" ]
+ignore = [ "E741", "B007" ]
+[tool.ruff.lint.per-file-ignores]
+"__init__.py" = [ "F401", "E402" ]
+"deeplabcut/**/__init__.py" = [ "F403" ]
+"deeplabcut/gui/window.py" = [ "F403" ]
+"deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py" = [ "F403" ]
+"deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py" = [ "F403" ]
+"deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py" = [ "F403" ]
+"*.ipynb" = [ "E402" ]
+[tool.ruff.lint.pydocstyle]
+convention = "google"
+
+[tool.pyproject-fmt]
+max_supported_python = "3.12"
+generate_python_version_classifiers = true
+# Avoid collapsing tables to field.key = value format (less readable)
+table_format = "long"
+
+[tool.pytest.ini_options]
+markers = [
+ "require_models: mark test as requiring models to run",
+ "fmpose3d: tests for fmpose3d integration",
+ "unittest: fast unit-level tests",
+ "functional: functional/integration-style tests",
+ "deprecated: tests for deprecated APIs kept for backward-compatibility",
+]
diff --git a/reinstall.sh b/reinstall.sh
index 3c73d45b83..ec60dc6a8e 100755
--- a/reinstall.sh
+++ b/reinstall.sh
@@ -1,3 +1,4 @@
pip uninstall deeplabcut
+rm -rf dist/ build/ *.egg-info
python3 setup.py sdist bdist_wheel
-pip install dist/deeplabcut-2.3.9-py3-none-any.whl
+pip install dist/deeplabcut-3.0.0-py3-none-any.whl
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 5a0ef62d49..0000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,24 +0,0 @@
-dlclibrary
-ipython
-filterpy
-ruamel.yaml>=0.15.0
-intel-openmp
-imageio-ffmpeg
-imgaug==0.4.0
-numba>=0.54.0
-matplotlib<=3.5.2
-networkx>=2.6
-numpy>=1.18.5
-pandas>=1.0.1,!=1.5.0
-pyyaml
-scikit-image>=0.17
-scikit-learn>=1.0
-scipy>=1.9
-statsmodels>=0.11
-tensorflow>=2.0,<2.13.0
-tables==3.8.0
-tensorpack==0.11
-tf_slim==1.1.0
-torch==1.12
-tqdm
-Pillow>=7.1
diff --git a/setup.py b/setup.py
index 9915904ac5..c3e301e85e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,102 +1,12 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-DeepLabCut2.0-2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+"""DeepLabCut2.0-3.0 Toolbox (deeplabcut.org) © A.
+
+& M. Mathis Labs https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for
+contributors.
+https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
Licensed under GNU Lesser General Public License v3.0
"""
-import setuptools
-
-with open("README.md", encoding="utf-8", errors="replace") as fh:
- long_description = fh.read()
-
-
-setuptools.setup(
- name="deeplabcut",
- version="2.3.9",
- author="A. & M. Mathis Labs",
- author_email="alexander@deeplabcut.org",
- description="Markerless pose-estimation of user-defined features with deep learning",
- long_description=long_description,
- long_description_content_type="text/markdown",
- url="https://github.com/DeepLabCut/DeepLabCut",
- install_requires=[
- "dlclibrary>=0.0.6",
- "filterpy>=1.4.4",
- "ruamel.yaml>=0.15.0",
- "imgaug>=0.4.0",
- "imageio-ffmpeg",
- "numba>=0.54",
- "matplotlib>=3.3,!=3.7.0,!=3.7.1",
- "networkx>=2.6",
- "numpy>=1.18.5",
- "pandas>=1.0.1,!=1.5.0",
- "scikit-image>=0.17",
- "scikit-learn>=1.0",
- "scipy>=1.9",
- "statsmodels>=0.11",
- "tables>=3.7.0",
- "torch<=1.12",
- "tensorpack>=0.11",
- "tf_slim>=1.1.0",
- "tqdm",
- "pyyaml",
- "Pillow>=7.1",
- ],
- extras_require={
- "gui": [
- "pyside6<6.3.2",
- "qdarkstyle==3.1",
- "napari-deeplabcut>=0.2.1.2",
- ],
- "openvino": ["openvino-dev==2022.1.0"],
- "docs": ["numpydoc"],
- "tf": [
- "tensorflow>=2.0,<=2.10"
- ], # Last supported TF version on Windows Native is 2.10
- "apple_mchips": ["tensorflow-macos<2.13.0", "tensorflow-metal"],
- "modelzoo": ["huggingface_hub"],
- },
- scripts=["deeplabcut/pose_estimation_tensorflow/models/pretrained/download.sh"],
- packages=setuptools.find_packages(),
- data_files=[
- (
- "deeplabcut",
- [
- "deeplabcut/pose_cfg.yaml",
- "deeplabcut/inference_cfg.yaml",
- "deeplabcut/reid_cfg.yaml",
- "deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml",
- "deeplabcut/pose_estimation_tensorflow/superanimal_configs/superquadruped.yaml",
- "deeplabcut/pose_estimation_tensorflow/superanimal_configs/supertopview.yaml",
- "deeplabcut/gui/style.qss",
- "deeplabcut/gui/media/logo.png",
- "deeplabcut/gui/media/dlc_1-01.png",
- "deeplabcut/gui/assets/logo.png",
- "deeplabcut/gui/assets/logo_transparent.png",
- "deeplabcut/gui/assets/welcome.png",
- "deeplabcut/gui/assets/icons/help.png",
- "deeplabcut/gui/assets/icons/help2.png",
- "deeplabcut/gui/assets/icons/new_project.png",
- "deeplabcut/gui/assets/icons/new_project2.png",
- "deeplabcut/gui/assets/icons/open.png",
- "deeplabcut/gui/assets/icons/open2.png",
- "deeplabcut/modelzoo/models.json",
- ],
- )
- ],
- include_package_data=True,
- classifiers=[
- "Programming Language :: Python :: 3",
- "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
- "Operating System :: OS Independent",
- ],
- entry_points="""[console_scripts]
- dlc=dlc:main""",
-)
+from setuptools import setup
-# https://www.python.org/dev/peps/pep-0440/#compatible-release
+# All configuration is now in pyproject.toml. This file is kept for backward compatibility
+setup()
diff --git a/tests/conftest.py b/tests/conftest.py
index 1e67d0ca9f..30bd45364d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,23 +8,34 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
+
import os
import pickle
-import pytest
-import shutil
import urllib.request
import zipfile
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils
from io import BytesIO
+
+import numpy as np
+import pytest
from PIL import Image
from tqdm import tqdm
+from deeplabcut.core import inferenceutils
-TEST_DATA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")
+TESTS_DIR = os.path.dirname(os.path.realpath(__file__))
+TEST_DATA_DIR = os.path.join(TESTS_DIR, "data")
+REQUIRED_TEST_FILES = [
+ os.path.join(TEST_DATA_DIR, "dets.pickle"),
+ os.path.join(TEST_DATA_DIR, "outputs.pickle"),
+ os.path.join(TEST_DATA_DIR, "image.png"),
+ os.path.join(TEST_DATA_DIR, "trimouse_assemblies.pickle"),
+ os.path.join(TEST_DATA_DIR, "montblanc_tracks.h5"),
+ os.path.join(TEST_DATA_DIR, "trimouse_calib.h5"),
+]
-def unzip_from_url(url, dest_folder):
+
+def unzip_from_url(url: str, dest_folder: str) -> None:
"""Directly extract files without writing the archive to disk."""
os.makedirs(dest_folder, exist_ok=True)
resp = urllib.request.urlopen(url)
@@ -36,16 +47,23 @@ def unzip_from_url(url, dest_folder):
pass
-def pytest_sessionstart(session):
- unzip_from_url(
- "https://github.com/DeepLabCut/UnitTestData/raw/main/data.zip",
- os.path.split(TEST_DATA_DIR)[0],
- )
- session.__DATA_FOLDER = TEST_DATA_DIR
+def _test_data_ready() -> bool:
+ return all(os.path.exists(path) for path in REQUIRED_TEST_FILES)
+
+@pytest.fixture(scope="session", autouse=True)
+def ensure_test_data():
+ """Ensure shared test data exists once per pytest session.
-def pytest_sessionfinish(session, exitstatus):
- shutil.rmtree(session.__DATA_FOLDER)
+ This is autouse so tests that directly open files under tests/data/
+ keep working without being rewritten.
+ """
+ if not _test_data_ready():
+ unzip_from_url(
+ "https://github.com/DeepLabCut/UnitTestData/raw/main/data.zip",
+ TESTS_DIR,
+ )
+ yield
@pytest.fixture(scope="function")
diff --git a/tests/core/debug/test_debug_logger.py b/tests/core/debug/test_debug_logger.py
new file mode 100644
index 0000000000..c00a4566b6
--- /dev/null
+++ b/tests/core/debug/test_debug_logger.py
@@ -0,0 +1,483 @@
+from __future__ import annotations
+
+import logging
+from uuid import uuid4
+
+import pytest
+
+import deeplabcut.core.debug.debug_logger as debug_mod
+from deeplabcut.core.debug import (
+ DebugSection,
+ ExecutableSpec,
+ InMemoryDebugRecorder,
+ LibrarySpec,
+ build_debug_report,
+ collect_executable_summary,
+ collect_version_summary,
+ format_debug_report,
+ get_debug_recorder,
+ install_debug_recorder,
+ log_timing,
+)
+
+
+@pytest.fixture
+def logger_name() -> str:
+ return f"deeplabcut.tests.debug.{uuid4()}"
+
+
+@pytest.fixture
+def clean_logger(logger_name: str):
+ """Create an isolated logger namespace and fully clean it afterwards."""
+ logger = logging.getLogger(logger_name)
+ old_level = logger.level
+ old_propagate = logger.propagate
+ old_handlers = list(logger.handlers)
+
+ logger.setLevel(logging.DEBUG)
+ logger.propagate = False
+
+ yield logger
+
+ for handler in list(logger.handlers):
+ logger.removeHandler(handler)
+ try:
+ handler.close()
+ except Exception:
+ pass
+
+ for handler in old_handlers:
+ logger.addHandler(handler)
+
+ logger.setLevel(old_level)
+ logger.propagate = old_propagate
+
+ # Remove recorder marker installed by install_debug_recorder().
+ logger.__dict__.pop("_dlc_debug_recorder", None)
+
+
+def test_install_debug_recorder_is_idempotent(logger_name: str, clean_logger):
+ recorder1 = install_debug_recorder(logger_name=logger_name, capacity=10)
+ recorder2 = install_debug_recorder(logger_name=logger_name, capacity=99)
+
+ assert recorder1 is recorder2
+ assert isinstance(recorder1, InMemoryDebugRecorder)
+ assert get_debug_recorder(logger_name=logger_name) is recorder1
+
+
+def test_recorder_captures_messages_and_exceptions(logger_name: str, clean_logger):
+ logger = clean_logger
+ recorder = install_debug_recorder(logger_name=logger_name, capacity=10, handler_level=logging.DEBUG)
+
+ logger.info("hello %s", "dlc")
+ try:
+ raise ValueError("boom")
+ except ValueError:
+ logger.exception("something failed")
+
+ records = recorder.snapshot()
+
+ assert len(records) == 2
+ assert records[0].message == "hello dlc"
+ assert records[0].level == "INFO"
+ assert records[1].message == "something failed"
+ assert records[1].level == "ERROR"
+ assert records[1].exc_text is not None
+ assert "ValueError: boom" in records[1].exc_text
+
+
+def test_recorder_is_bounded(logger_name: str, clean_logger):
+ logger = clean_logger
+ recorder = install_debug_recorder(logger_name=logger_name, capacity=2, handler_level=logging.DEBUG)
+
+ logger.debug("first")
+ logger.debug("second")
+ logger.debug("third")
+
+ messages = [rec.message for rec in recorder.snapshot()]
+ assert messages == ["second", "third"]
+
+
+def test_render_text_contains_recent_messages(logger_name: str, clean_logger):
+ logger = clean_logger
+ recorder = install_debug_recorder(logger_name=logger_name, capacity=5)
+
+ logger.warning("alpha")
+ logger.error("beta")
+
+ text = recorder.render_text(limit=10)
+
+ assert "WARNING" in text
+ assert "ERROR" in text
+ assert "alpha" in text
+ assert "beta" in text
+ assert logger_name in text
+
+
+def test_clear_resets_records_and_drop_count(logger_name: str, clean_logger):
+ logger = clean_logger
+ recorder = install_debug_recorder(logger_name=logger_name, capacity=5)
+
+ logger.info("before clear")
+ assert recorder.snapshot()
+
+ recorder.clear()
+
+ assert recorder.snapshot() == []
+ assert recorder.dropped_count == 0
+ assert recorder.render_text() == ""
+
+
+def test_log_timing_emits_when_enabled(
+ monkeypatch: pytest.MonkeyPatch,
+ logger_name: str,
+ clean_logger,
+):
+ logger = clean_logger
+ calls: list[tuple[int, str, tuple[object, ...]]] = []
+
+ wrapped = log_timing.__wrapped__
+
+ monkeypatch.setitem(wrapped.__globals__, "DLC_LOG_TIMING", True)
+
+ ticks = iter([1_000_000_000, 1_005_000_000]) # 5.000 ms
+ monkeypatch.setitem(wrapped.__globals__, "perf_counter_ns", lambda: next(ticks))
+
+ monkeypatch.setattr(logger, "isEnabledFor", lambda level: True)
+
+ def fake_log(level, msg, *args):
+ calls.append((level, msg, args))
+
+ monkeypatch.setattr(logger, "log", fake_log)
+
+ with log_timing(logger, "tiny-step", threshold_ms=0.0):
+ pass
+
+ assert calls == [
+ (logging.DEBUG, "%s took %.3f ms", ("tiny-step", 5.0)),
+ ]
+
+
+def test_log_timing_is_silent_when_disabled(
+ monkeypatch: pytest.MonkeyPatch,
+ logger_name: str,
+ clean_logger,
+):
+ logger = clean_logger
+ calls: list[tuple[int, str, tuple[object, ...]]] = []
+
+ wrapped = log_timing.__wrapped__
+
+ monkeypatch.setitem(wrapped.__globals__, "DLC_LOG_TIMING", False)
+ monkeypatch.setattr(logger, "isEnabledFor", lambda level: True)
+
+ def fake_log(level, msg, *args):
+ calls.append((level, msg, args))
+
+ monkeypatch.setattr(logger, "log", fake_log)
+
+ with log_timing(logger, "should-not-appear", threshold_ms=0.0):
+ pass
+
+ assert calls == []
+
+
+def test_log_timing_respects_threshold(
+ monkeypatch: pytest.MonkeyPatch,
+ logger_name: str,
+ clean_logger,
+):
+ logger = clean_logger
+ calls: list[tuple[int, str, tuple[object, ...]]] = []
+
+ wrapped = log_timing.__wrapped__
+
+ monkeypatch.setitem(wrapped.__globals__, "DLC_LOG_TIMING", True)
+
+ ticks = iter([1_000_000_000, 1_001_000_000]) # 1.000 ms
+ monkeypatch.setitem(wrapped.__globals__, "perf_counter_ns", lambda: next(ticks))
+
+ monkeypatch.setattr(logger, "isEnabledFor", lambda level: True)
+
+ def fake_log(level, msg, *args):
+ calls.append((level, msg, args))
+
+ monkeypatch.setattr(logger, "log", fake_log)
+
+ with log_timing(logger, "tiny-step", threshold_ms=2.0):
+ pass
+
+ assert calls == []
+
+
+# ----------- Report building tests -----------
+def test_build_debug_report_includes_runtime_libraries_tools_and_recent_logs(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ recorder = InMemoryDebugRecorder(capacity=10, level=logging.DEBUG)
+
+ record = logging.LogRecord(
+ name="deeplabcut.tests.debug",
+ level=logging.INFO,
+ pathname=__file__,
+ lineno=123,
+ msg="hello %s",
+ args=("report",),
+ exc_info=None,
+ )
+ recorder.handle(record)
+
+ monkeypatch.setattr(
+ debug_mod,
+ "collect_runtime_summary",
+ lambda: {
+ "python": "3.11.9",
+ "platform": "TestOS-1.0",
+ "executable": "bin/python",
+ },
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_version",
+ lambda dist_name: {
+ "alpha": "1.2.3",
+ "opencv-python": "9.9.9-dist",
+ }.get(dist_name, "not-installed"),
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_module_version",
+ lambda module_name: {
+ "cv2": "4.10.0",
+ }.get(module_name, "not-installed"),
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_module_path",
+ lambda module_name: {
+ "alpha": "/tmp/site-packages/alpha/__init__.py",
+ "cv2": "/tmp/site-packages/cv2/__init__.py",
+ }.get(module_name, "unknown"),
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_command_version",
+ lambda command, version_args: {
+ "ffmpeg": "ffmpeg 6.1",
+ }.get(command, "unavailable"),
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_which",
+ lambda command: {
+ "ffmpeg": "/usr/bin/ffmpeg",
+ }.get(command, "not-found"),
+ )
+
+ report = build_debug_report(
+ recorder=recorder,
+ libraries=(
+ LibrarySpec("alpha"),
+ LibrarySpec(
+ "opencv-python",
+ dist_name="opencv-python",
+ module_name="cv2",
+ prefer_module_version=True,
+ ),
+ ),
+ executables=(ExecutableSpec("ffmpeg"),),
+ include_module_paths=True,
+ include_executable_paths=True,
+ log_limit=20,
+ )
+
+ assert "## Runtime" in report
+ assert "- python: 3.11.9" in report
+ assert "- platform: TestOS-1.0" in report
+ assert "- executable: bin/python" in report
+
+ assert "## Libraries" in report
+ assert "- alpha: 1.2.3" in report
+ assert "- opencv-python: 4.10.0" in report
+ assert "- alpha_module_path: alpha/__init__.py" in report
+ assert "- opencv-python_module_path: cv2/__init__.py" in report
+
+ assert "## External tools" in report
+ assert "- ffmpeg: ffmpeg 6.1" in report
+ assert "- ffmpeg_path: bin/ffmpeg" in report
+
+ assert "## Recent logs" in report
+ assert "deeplabcut.tests.debug" in report
+ assert "INFO" in report
+ assert "hello report" in report
+ assert "```text" in report
+
+
+def test_build_debug_report_default_grouped_sections_and_skips_unavailable_tf(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(
+ debug_mod,
+ "collect_runtime_summary",
+ lambda: {
+ "python": "3.12.0",
+ "platform": "GroupedTestOS",
+ "executable": "python",
+ },
+ )
+
+ def fake_collect_version_summary(*, libraries=None, include_module_paths=False):
+ if libraries == debug_mod.DLC_CORE_LIBS:
+ return {"deeplabcut": "1.0.0", "numpy": "2.0.0"}
+ if libraries == debug_mod.DLC_GUI_LIBS:
+ return {"PySide6": "6.8.0"}
+ if libraries == debug_mod.DLC_TF_LIBS:
+ return {
+ "tensorflow": "not-installed",
+ "tf_keras": "not-installed",
+ "tensorpack": "unknown",
+ "tf_slim": "not-installed",
+ }
+ raise AssertionError("unexpected libraries input")
+
+ monkeypatch.setattr(debug_mod, "collect_version_summary", fake_collect_version_summary)
+
+ monkeypatch.setattr(
+ debug_mod,
+ "collect_executable_summary",
+ lambda *, executables=None, include_paths=True: {
+ "ffmpeg": "unavailable",
+ "ffmpeg_path": "not-found",
+ },
+ )
+
+ report = build_debug_report(
+ recorder=None,
+ libraries=None,
+ executables=None,
+ )
+
+ assert "## Runtime" in report
+ assert "## DeepLabCut core libraries" in report
+ assert "- deeplabcut: 1.0.0" in report
+ assert "## GUI libraries" in report
+ assert "- PySide6: 6.8.0" in report
+
+ # All TF values are unavailable/unknown, so the section should be omitted.
+ assert "## TensorFlow libraries" not in report
+
+ # External tools should still be shown even when unavailable.
+ assert "## External tools" in report
+ assert "- ffmpeg: unavailable" in report
+ assert "- ffmpeg_path: not-found" in report
+
+ assert "## Recent logs" in report
+ assert "" in report
+
+
+def test_collect_version_summary_prefers_module_version_and_falls_back_to_distribution(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(
+ debug_mod,
+ "_module_version",
+ lambda module_name: {
+ "cv2": "not-installed",
+ }.get(module_name, "unknown"),
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "_version",
+ lambda dist_name: {
+ "opencv-python": "4.9.0.80",
+ "missing-lib": "not-installed",
+ }.get(dist_name, "not-installed"),
+ )
+
+ summary = collect_version_summary(
+ libraries=(
+ LibrarySpec(
+ "opencv-python",
+ dist_name="opencv-python",
+ module_name="cv2",
+ prefer_module_version=True,
+ ),
+ LibrarySpec("missing-lib"),
+ )
+ )
+
+ assert summary["opencv-python"] == "4.9.0.80"
+ assert summary["missing-lib"] == "not-installed"
+
+
+def test_collect_executable_summary_reports_unavailable_tool_and_path(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.setattr(debug_mod, "_command_version", lambda command, version_args: "unavailable")
+ monkeypatch.setattr(debug_mod, "_which", lambda command: "not-found")
+
+ summary = collect_executable_summary(
+ executables=(ExecutableSpec("ghosttool"),),
+ include_paths=True,
+ )
+
+ assert summary == {
+ "ghosttool": "unavailable",
+ "ghosttool_path": "not-found",
+ }
+
+
+def test_build_debug_report_uses_no_captured_logs_placeholder_for_empty_recorder(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ recorder = InMemoryDebugRecorder(capacity=5, level=logging.DEBUG)
+
+ monkeypatch.setattr(
+ debug_mod,
+ "collect_runtime_summary",
+ lambda: {
+ "python": "3.11.0",
+ "platform": "EmptyLogsOS",
+ "executable": "python",
+ },
+ )
+
+ monkeypatch.setattr(
+ debug_mod,
+ "collect_executable_summary",
+ lambda *, executables=None, include_paths=True: {},
+ )
+
+ report = build_debug_report(
+ recorder=recorder,
+ libraries=(),
+ executables=(),
+ )
+
+ assert "## Runtime" in report
+ assert "## Libraries" in report
+ assert "- " in report
+ assert "## Recent logs" in report
+ assert "" in report
+
+
+def test_format_debug_report_renders_empty_section_and_logs_block():
+ text = format_debug_report(
+ sections=[
+ DebugSection(title="Example", items={}),
+ ],
+ logs_text="line one\nline two",
+ )
+
+ assert "## Example" in text
+ assert "- " in text
+ assert "## Recent logs" in text
+ assert "```text" in text
+ assert "line one" in text
+ assert "line two" in text
diff --git a/tests/core/inferenceutils/test_map_computation.py b/tests/core/inferenceutils/test_map_computation.py
new file mode 100644
index 0000000000..c2cd4fffe9
--- /dev/null
+++ b/tests/core/inferenceutils/test_map_computation.py
@@ -0,0 +1,418 @@
+"""Tests mAP computation from inferenceutils."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from deeplabcut.core import inferenceutils
+from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints
+
+
+@pytest.mark.parametrize(
+ "ground_truth",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 2],
+ [150.0, 15.0, 2],
+ [202.0, 20.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ ],
+ },
+ ],
+)
+@pytest.mark.parametrize(
+ "predictions",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 0.9],
+ [150.0, 15.0, 0.7],
+ [202.0, 20.0, 0.8],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 0.9],
+ [140.0, 17.0, 0.7],
+ [192.0, 22.0, 0.8],
+ ],
+ [
+ [97.0, 11.0, 0.5],
+ [148.0, 14.0, 0.2],
+ [202.0, 21.0, 0.3],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 0.9],
+ [np.nan, np.nan, 0.0],
+ [192.0, 22.0, 0.8],
+ ],
+ [
+ [97.0, 11.0, 0.5],
+ [148.0, 14.0, 0.2],
+ [202.0, 21.0, 0.3],
+ ],
+ ],
+ },
+ ],
+)
+def test_map_single_image_simple(ground_truth: dict, predictions: dict):
+ gt = {k: np.array(v) for k, v in ground_truth.items()}
+ pred = {k: np.array(v) for k, v in predictions.items()}
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize(
+ "ground_truth",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 2],
+ [150.0, 15.0, 2],
+ [202.0, 20.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [326.0, 236.0, 2],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [0.0, 0.0, 0],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [0, 0, 0],
+ [457.0, 832.0, 2],
+ ],
+ [
+ [452.0, 321.0, 2],
+ [213.0, 387.0, 2],
+ [213.0, 832.0, 2],
+ ],
+ [
+ [253.0, 238.0, 2],
+ [213.0, 238.0, 2],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ ],
+)
+def test_map_single_image_random_errors(ground_truth: dict):
+ rng = np.random.default_rng(seed=0)
+
+ gt = {k: np.array(v) for k, v in ground_truth.items()}
+ pred = {}
+ for k, gt_kpts in gt.items():
+ num_idv, num_bpt = gt_kpts.shape[:2]
+
+ error = rng.integers(low=-30, high=30, size=(num_idv, num_bpt, 2))
+ scores = rng.random(size=(num_idv, num_bpt))
+
+ pred[k] = np.zeros(shape=(num_idv, num_bpt, 3))
+ pred[k][..., :2] = np.clip(gt_kpts[..., :2] + error, 0, 1024)
+ pred[k][..., 2] = scores
+
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize("num_images", [1, 2, 5, 10])
+@pytest.mark.parametrize("num_joints", [2, 5, 8, 20])
+@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40])
+def test_random_map_computation(num_images, num_joints, max_error):
+ rng = np.random.default_rng(seed=0)
+
+ num_individuals = rng.integers(low=0, high=20, size=(num_images, 2))
+ max_idv = num_individuals.max(initial=0)
+
+ gt = {}
+ pred = {}
+ for i, (gt_idv, pred_idv) in enumerate(num_individuals):
+ # padding needed as we then stack
+ gt_kpts = np.zeros((max_idv, num_joints, 3))
+ pred_kpts = -np.ones((max_idv, num_joints, 3))
+
+ gt_kpts[:gt_idv] = 2 * np.ones((gt_idv, num_joints, 3))
+ gt_kpts[:gt_idv, :, :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2))
+ gt[f"img_{i}"] = gt_kpts
+
+ # set scores
+ pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints))
+
+ # predictions that are ground truth + error
+ matched = min(gt_idv, pred_idv)
+ if matched > 0:
+ error = rng.integers(low=-max_error, high=max_error, size=(matched, num_joints, 2))
+ matched_pred = gt_kpts[:matched, :, :2] + error
+ pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024)
+
+ # random predictions
+ unmatched = pred_idv - matched
+ if unmatched > 0:
+ pred_kpts[matched:pred_idv, :, :2] = rng.integers(low=0, high=1024, size=(unmatched, num_joints, 2))
+
+ pred[f"img_{i}"] = pred_kpts
+
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize("num_images", [1, 2, 5, 10])
+@pytest.mark.parametrize("num_joints", [2, 5, 8, 20])
+@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40])
+def test_random_map_computation_with_missing_kpts(num_images, num_joints, max_error):
+ rng = np.random.default_rng(seed=0)
+
+ num_individuals = rng.integers(low=0, high=20, size=(num_images, 2))
+ max_idv = num_individuals.max(initial=0)
+
+ gt = {}
+ pred = {}
+ for i, (gt_idv, pred_idv) in enumerate(num_individuals):
+ # padding needed as we then stack
+ gt_kpts = np.zeros((max_idv, num_joints, 3))
+ pred_kpts = -np.ones((max_idv, num_joints, 3))
+
+ gt_kpts[:gt_idv] = 2 * np.ones((gt_idv, num_joints, 3))
+ gt_kpts[:gt_idv, :, :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2))
+ gt[f"img_{i}"] = gt_kpts
+
+ # drop some ground truth keypoints
+ gt_vis_mask = rng.random(size=(max_idv, num_joints)) < 0.2
+ gt_kpts[gt_vis_mask, 2] = 0
+
+ # set scores
+ pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints))
+
+ # predictions that are ground truth + error
+ matched = min(gt_idv, pred_idv)
+ if matched > 0:
+ error = rng.integers(low=-max_error, high=max_error, size=(matched, num_joints, 2))
+ matched_pred = gt_kpts[:matched, :, :2] + error
+ pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024)
+
+ # random predictions
+ unmatched = pred_idv - matched
+ if unmatched > 0:
+ pred_kpts[matched:pred_idv, :, :2] = rng.integers(low=0, high=1024, size=(unmatched, num_joints, 2))
+
+ pred[f"img_{i}"] = pred_kpts
+
+ _evaluate(gt, pred)
+
+
+def _evaluate(gt: dict[str, np.ndarray], pred: dict[str, np.ndarray]):
+ for k, v in gt.items():
+ print(20 * "-")
+ print(k)
+ print("GT")
+ print(v)
+ print("PR")
+ print(pred[k])
+
+ gt_assemblies = _to_assemblies(gt, ground_truth=True)
+ pred_assemblies = _to_assemblies(pred, ground_truth=False)
+ oks = inferenceutils.evaluate_assembly_greedy(
+ assemblies_gt=gt_assemblies,
+ assemblies_pred=pred_assemblies,
+ oks_sigma=0.1,
+ oks_thresholds=np.linspace(0.5, 0.95, 10),
+ margin=0.0,
+ symmetric_kpts=None,
+ )
+
+ num_joints = gt[list(gt.keys())[0]].shape[1]
+ coco_gt = _to_coco_ground_truth(gt, num_joints, bbox_margin=0)
+ coco_pred = _to_coco_predictions(coco_gt, pred, bbox_margin=0)
+ coco_oks = eval_coco(coco_gt, coco_pred, num_joints)
+ print(20 * "-")
+ print("dlc mAP:")
+ for k, v in oks.items():
+ print(k)
+ print(v)
+ print()
+ print(20 * "-")
+ print(f"pycocotools mAP: {coco_oks}")
+ print()
+ assert oks["mAP"] == coco_oks
+
+
+def _to_assemblies(
+ data: dict[str, np.ndarray],
+ ground_truth: bool,
+) -> dict[str, list[inferenceutils.Assembly]]:
+ images = list(data.keys())
+ raw_data = np.stack([data[i] for i in images], axis=0)
+
+ # mask not visible entries
+ mask = raw_data[..., 2] <= 0
+ raw_data[mask] = np.nan
+
+ # set the "score" to 1 for ground truth
+ if ground_truth:
+ raw_data[~mask, 2] = 1
+
+ return {images[i]: assembly for i, assembly in inferenceutils._parse_ground_truth_data(raw_data).items()}
+
+
+def _to_coco_ground_truth(
+ data: dict[str, np.ndarray],
+ num_joints: int,
+ bbox_margin: int = 0,
+ image_size: tuple[int, int] = (1024, 1024),
+) -> dict[str, list[dict]]:
+ w, h = image_size
+ anns, images = [], []
+ for path, image_keypoints in data.items():
+ id_ = len(images) + 1
+ images.append(dict(id=id_, file_name=path, width=w, height=h))
+
+ assert image_keypoints.shape[1] == num_joints
+ for _idv_id, kpts in enumerate(image_keypoints):
+ visible = kpts[:, 2] > 0
+ num_keypoints = visible.sum()
+
+ if num_keypoints > 1:
+ bbox = bbox_from_keypoints(
+ keypoints=kpts,
+ image_h=h,
+ image_w=w,
+ margin=bbox_margin,
+ )
+ area = bbox[2].item() * bbox[3].item()
+ anns.append(
+ {
+ "id": len(anns) + 1,
+ "image_id": id_,
+ "category_id": 1,
+ "area": area,
+ "bbox": bbox.tolist(),
+ "keypoints": kpts.reshape(-1).tolist(),
+ "iscrowd": 0,
+ "num_keypoints": num_keypoints,
+ }
+ )
+
+ keypoints = [f"bpt{i}" for i in range(num_joints)]
+ category = dict(id=1, name="animal", supercategory="animal", keypoints=keypoints)
+ return {"annotations": anns, "categories": [category], "images": images}
+
+
+def _to_coco_predictions(
+ ground_truth: dict,
+ predictions: dict[str, np.ndarray],
+ bbox_margin: int = 0,
+ image_size: tuple[int, int] = (1024, 1024),
+) -> list[dict]:
+ w, h = image_size
+ num_joints = len(ground_truth["categories"][0]["keypoints"])
+ path_to_id = {img["file_name"]: img["id"] for img in ground_truth["images"]}
+
+ coco_predictions = []
+ for path, image_keypoints in predictions.items():
+ assert image_keypoints.shape[1] == num_joints
+
+ img_id = path_to_id[path]
+ valid_predictions = [kpt for kpt in image_keypoints if np.any(np.all(~np.isnan(kpt), axis=-1))]
+ for kpts in valid_predictions:
+ score = float(np.nanmean(kpts[:, 2]).item())
+ kpts = kpts.copy()
+ kpts[:, 2] = 2
+
+ # NaN predictions to infinity
+ kpts[np.isnan(kpts)] = np.inf
+
+ bbox = bbox_from_keypoints(
+ keypoints=kpts,
+ image_h=h,
+ image_w=w,
+ margin=bbox_margin,
+ )
+ area = bbox[2].item() * bbox[3].item()
+ coco_predictions.append(
+ {
+ "image_id": img_id,
+ "category_id": 1,
+ "keypoints": kpts.reshape(-1).tolist(),
+ "bbox": bbox.tolist(),
+ "area": area,
+ "score": score,
+ }
+ )
+
+ return coco_predictions
+
+
+def eval_coco(
+ ground_truth: dict,
+ predictions: list[dict],
+ num_joints: int,
+) -> float | None:
+ try:
+ from pycocotools.coco import COCO
+ from pycocotools.cocoeval import COCOeval
+
+ coco = COCO()
+ coco.dataset["annotations"] = ground_truth["annotations"]
+ coco.dataset["categories"] = ground_truth["categories"]
+ coco.dataset["images"] = ground_truth["images"]
+ coco.dataset["info"] = {"description": "Generated by DeepLabCut"}
+ coco.createIndex()
+
+ coco_det = coco.loadRes(predictions)
+ coco_eval = COCOeval(coco, coco_det, iouType="keypoints")
+ coco_eval.params.kpt_oks_sigmas = np.array(num_joints * [0.1])
+ coco_eval.evaluate()
+ coco_eval.accumulate()
+ coco_eval.summarize()
+ return float(coco_eval.stats[0])
+
+ except ModuleNotFoundError:
+ print("pycocotools is not installed")
diff --git a/tests/core/metrics/test_metrics_api.py b/tests/core/metrics/test_metrics_api.py
new file mode 100644
index 0000000000..a38516ab58
--- /dev/null
+++ b/tests/core/metrics/test_metrics_api.py
@@ -0,0 +1,110 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""General tests for the metrics API."""
+
+import numpy as np
+import pytest
+from numpy.testing import assert_almost_equal
+
+import deeplabcut.core.metrics as metrics
+
+
+def _get_gt_and_pred_with_constant_err(num_idv: int, num_bpt: int, error: float) -> tuple[np.ndarray, np.ndarray]:
+ gt = np.arange(num_idv * num_bpt * 3).astype(float).reshape((num_idv, num_bpt, 3))
+ gt[..., 2] = 2
+ predictions = gt.copy()
+ predictions[..., 2] = 0.9
+ predictions[..., :2] += error
+ return gt, predictions
+
+
+def test_computing_metrics_with_no_predictions():
+ gt = np.arange(5 * 6 * 3).astype(float).reshape((5, 6, 3))
+ gt[..., 2] = 2
+ metrics.compute_metrics(
+ ground_truth={"image": gt},
+ predictions={"image": np.zeros((0, 12, 3))},
+ unique_bodypart_gt=None,
+ unique_bodypart_poses=None,
+ )
+
+
+@pytest.mark.parametrize("error", [0.5, 1, 2])
+def test_computing_metrics_with_constant_error(error):
+ # only works for small errors: otherwise another matching can be found
+ gt, predictions = _get_gt_and_pred_with_constant_err(5, 6, error)
+ results = metrics.compute_metrics(
+ ground_truth={"image": gt},
+ predictions={"image": predictions},
+ unique_bodypart_gt=None,
+ unique_bodypart_poses=None,
+ )
+ assert_almost_equal(results["rmse"], np.sqrt(2) * error)
+ assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error)
+
+
+@pytest.mark.parametrize("error", [0.5, 1, 2])
+def test_metrics_with_unique_with_constant_error(error):
+ # only works for small errors: otherwise another matching can be found
+ gt, predictions = _get_gt_and_pred_with_constant_err(5, 6, error)
+ gt_unique, pred_unique = _get_gt_and_pred_with_constant_err(1, 8, error)
+ results = metrics.compute_metrics(
+ ground_truth={"image": gt},
+ predictions={"image": predictions},
+ unique_bodypart_gt={"image": gt_unique},
+ unique_bodypart_poses={"image": pred_unique},
+ )
+ assert_almost_equal(results["rmse"], np.sqrt(2) * error)
+ assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error)
+
+
+@pytest.mark.parametrize("error", [0.5, 1, 2])
+def test_metrics_per_bpt_with_unique_with_constant_error(error):
+ # only works for small errors: otherwise another matching can be found
+ gt, predictions = _get_gt_and_pred_with_constant_err(5, 6, error)
+ gt_unique, pred_unique = _get_gt_and_pred_with_constant_err(1, 8, error)
+ results = metrics.compute_metrics(
+ ground_truth={"image": gt},
+ predictions={"image": predictions},
+ unique_bodypart_gt={"image": gt_unique},
+ unique_bodypart_poses={"image": pred_unique},
+ per_keypoint_rmse=True,
+ )
+ assert_almost_equal(results["rmse"], np.sqrt(2) * error)
+ assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error)
+
+ for bpt_idx in range(gt.shape[1]):
+ key = f"rmse_keypoint_{bpt_idx}"
+ assert key in results
+ assert_almost_equal(results[key], np.sqrt(2) * error)
+ for bpt_idx in range(gt_unique.shape[1]):
+ key = f"rmse_unique_keypoint_{bpt_idx}"
+ assert key in results
+ assert_almost_equal(results[key], np.sqrt(2) * error)
+
+
+@pytest.mark.parametrize("error", [0.5, 1, 2])
+def test_computing_metrics_single_animal(error):
+ # only works for small errors: otherwise another matching can be found
+ gt = np.arange(6 * 3).astype(float).reshape((1, 6, 3))
+ gt[..., 2] = 2
+ predictions = gt.copy()
+ predictions[..., 2] = 0.9
+ predictions[..., :2] += error
+ results = metrics.compute_metrics(
+ ground_truth={"image": gt},
+ predictions={"image": predictions},
+ single_animal=True,
+ unique_bodypart_gt=None,
+ unique_bodypart_poses=None,
+ )
+ assert_almost_equal(results["rmse"], np.sqrt(2) * error)
+ assert_almost_equal(results["rmse_pcutoff"], np.sqrt(2) * error)
diff --git a/tests/core/metrics/test_metrics_identity_accuracy.py b/tests/core/metrics/test_metrics_identity_accuracy.py
new file mode 100644
index 0000000000..017653d59e
--- /dev/null
+++ b/tests/core/metrics/test_metrics_identity_accuracy.py
@@ -0,0 +1,219 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for the scoring methods."""
+
+import numpy as np
+import pytest
+
+import deeplabcut.core.metrics.identity
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "individuals": ["i1", "i2"],
+ "bodyparts": ["arm"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[2.0, 2.0, 0.8]],
+ [[1.0, 1.0, 0.7]], # x, y, score
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.8, 0.5]],
+ [[0.51, 0.49]],
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[1.0, 1.0, 2]],
+ [[0, 0, 0]], # x, y, visibility
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 1.0,
+ },
+ },
+ {
+ "individuals": ["i1", "i2"],
+ "bodyparts": ["arm"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7]],
+ [[2.0, 2.0, 0.7]], # x, y, score
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.4, 0.6]],
+ [[0.6, 0.4]],
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2]],
+ [[1.0, 1.0, 2]], # x, y, visibility
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 1.0,
+ },
+ },
+ {
+ "individuals": ["i1", "i2"],
+ "bodyparts": ["arm"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7]],
+ [[2.0, 2.0, 0.7]], # x, y, score
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.6, 0.4]],
+ [[0.6, 0.4]], # both assemblies assigned to idv 1
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2]],
+ [[1.0, 1.0, 2]], # x, y, visibility
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 0.5,
+ },
+ },
+ {
+ "individuals": ["i1", "i2"],
+ "bodyparts": ["arm"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7]],
+ [[2.0, 2.0, 0.7]], # x, y, score
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.6, 0.4]],
+ [[0.4, 0.6]], # both assigned to wrong ID
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2]], # x, y, visibility
+ [[1.0, 1.0, 2]],
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 0.0,
+ },
+ },
+ {
+ "individuals": ["i1", "i2"],
+ "bodyparts": ["arm", "leg"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]],
+ [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]],
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.7, 0.3], [0.6, 0.2]],
+ [[0.6, 0.3], [0.6, 0.2]], # should not matter, not assigned to GT
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility
+ [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 1.0,
+ "leg_accuracy": 1.0,
+ },
+ },
+ {
+ "individuals": ["i1", "i2", "i3"],
+ "bodyparts": ["arm", "leg"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]],
+ [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]],
+ [[110.0, 110.0, 0.9], [98.0, 91.9, 0.8]],
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.7, 0.3, 0.0], [0.6, 0.2, 0.2]], # assigned to correct ID
+ [[0.6, 0.3, 0.1], [0.6, 0.2, 0.2]], # should not matter, not assigned to GT
+ [[0.6, 0.3, 0.1], [0.6, 0.2, 0.2]], # should not matter, not assigned to GT
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility
+ [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible
+ [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 1.0,
+ "leg_accuracy": 1.0,
+ },
+ },
+ {
+ "individuals": ["i1", "i2", "i3"],
+ "bodyparts": ["arm", "leg"],
+ "predictions": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, 3)
+ [[1.0, 1.0, 0.7], [10.0, 10.0, 0.9]],
+ [[100.0, 100.0, 0.9], [90.0, 90.9, 0.8]],
+ [[110.0, 110.0, 0.9], [98.0, 91.9, 0.8]],
+ ],
+ },
+ "identity_scores": {
+ "img0.png": [ # (num_assemblies, num_bodyparts, num_individuals)
+ [[0.7, 0.3, 0.1], [0.6, 0.2, 0.1]], # assigned to correct ID
+ [[0.1, 0.2, 0.7], [0.4, 0.3, 0.2]], # 1st correct, 2nd wrong
+ [
+ [0.6, 0.3, 0.5],
+ [0.6, 0.2, 0.4],
+ ], # should not matter, not assigned to GT
+ ],
+ },
+ "ground_truth": {
+ "img0.png": [ # (num_individuals, num_bodyparts, 3)
+ [[2.0, 2.0, 2], [8.0, 8.0, 2]], # x, y, visibility
+ [[-1, -1, 0.0], [-1, -1, 0.0]], # not visible
+ [[90.0, 90, 2], [80, 80, 2.0]], # x, y, visibility
+ ]
+ },
+ "accuracy": {
+ "arm_accuracy": 1.0,
+ "leg_accuracy": 0.5,
+ },
+ },
+ ],
+)
+def test_id_accuracy(data) -> None:
+ scores = deeplabcut.core.metrics.identity.compute_identity_scores(
+ individuals=data["individuals"],
+ bodyparts=data["bodyparts"],
+ predictions={k: np.array(v) for k, v in data["predictions"].items()},
+ identity_scores={k: np.array(v) for k, v in data["identity_scores"].items()},
+ ground_truth={k: np.array(v) for k, v in data["ground_truth"].items()},
+ )
+ assert scores == data["accuracy"]
diff --git a/tests/core/metrics/test_metrics_map_computation.py b/tests/core/metrics/test_metrics_map_computation.py
new file mode 100644
index 0000000000..5ef6a64f75
--- /dev/null
+++ b/tests/core/metrics/test_metrics_map_computation.py
@@ -0,0 +1,399 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests that mAP computation is correct."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from numpy.testing import assert_almost_equal
+
+from deeplabcut.core.metrics.api import prepare_evaluation_data
+from deeplabcut.core.metrics.distance_metrics import compute_oks
+from deeplabcut.pose_estimation_pytorch.data.utils import bbox_from_keypoints
+
+
+@pytest.mark.parametrize(
+ "ground_truth",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 2],
+ [150.0, 15.0, 2],
+ [202.0, 20.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ ],
+ },
+ ],
+)
+@pytest.mark.parametrize(
+ "predictions",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 0.9],
+ [150.0, 15.0, 0.7],
+ [202.0, 20.0, 0.8],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 0.9],
+ [140.0, 17.0, 0.7],
+ [192.0, 22.0, 0.8],
+ ],
+ [
+ [97.0, 11.0, 0.5],
+ [148.0, 14.0, 0.2],
+ [202.0, 21.0, 0.3],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 0.9],
+ [np.nan, np.nan, 0.0],
+ [192.0, 22.0, 0.8],
+ ],
+ [
+ [97.0, 11.0, 0.5],
+ [148.0, 14.0, 0.2],
+ [202.0, 21.0, 0.3],
+ ],
+ ],
+ },
+ ],
+)
+def test_map_single_image_simple(ground_truth: dict, predictions: dict):
+ gt = {k: np.array(v) for k, v in ground_truth.items()}
+ pred = {k: np.array(v) for k, v in predictions.items()}
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize(
+ "ground_truth",
+ [
+ {
+ "img0": [
+ [
+ [100.0, 10.0, 2],
+ [150.0, 15.0, 2],
+ [202.0, 20.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [326.0, 236.0, 2],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [0.0, 0.0, 0],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ {
+ "img0": [
+ [
+ [90.0, 12.0, 2],
+ [140.0, 17.0, 2],
+ [192.0, 22.0, 2],
+ ],
+ [
+ [726.0, 325.0, 2],
+ [0, 0, 0],
+ [457.0, 832.0, 2],
+ ],
+ [
+ [452.0, 321.0, 2],
+ [213.0, 387.0, 2],
+ [213.0, 832.0, 2],
+ ],
+ [
+ [253.0, 238.0, 2],
+ [213.0, 238.0, 2],
+ [457.0, 832.0, 2],
+ ],
+ ],
+ },
+ ],
+)
+def test_map_single_image_random_errors(ground_truth: dict):
+ rng = np.random.default_rng(seed=0)
+
+ gt = {k: np.array(v) for k, v in ground_truth.items()}
+ pred = {}
+ for k, gt_kpts in gt.items():
+ num_idv, num_bpt = gt_kpts.shape[:2]
+
+ error = rng.integers(low=-30, high=30, size=(num_idv, num_bpt, 2))
+ scores = rng.random(size=(num_idv, num_bpt))
+
+ pred[k] = np.zeros(shape=(num_idv, num_bpt, 3))
+ pred[k][..., :2] = np.clip(gt_kpts[..., :2] + error, 0, 1024)
+ pred[k][..., 2] = scores
+
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize("num_images", [1, 2, 5, 10])
+@pytest.mark.parametrize("num_joints", [2, 5, 8, 20])
+@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40])
+def test_random_map_computation(num_images, num_joints, max_error):
+ rng = np.random.default_rng(seed=0)
+
+ num_individuals = rng.integers(low=0, high=20, size=(num_images, 2))
+
+ gt, pred = {}, {}
+ for i, (gt_idv, pred_idv) in enumerate(num_individuals):
+ gt_kpts = 2 * np.ones((gt_idv, num_joints, 3))
+ gt_kpts[..., :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2))
+ gt[f"img_{i}"] = gt_kpts
+
+ # create predictions array
+ pred_kpts = np.zeros((pred_idv, num_joints, 3))
+ # set scores
+ pred_kpts[..., 2] = rng.random(size=(pred_idv, num_joints))
+
+ # predictions that are ground truth + error
+ matched = min(gt_idv, pred_idv)
+ if matched > 0:
+ error = rng.integers(low=-max_error, high=max_error, size=(matched, num_joints, 2))
+ matched_pred = gt_kpts[:matched, :, :2] + error
+ pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024)
+
+ # random predictions
+ unmatched = pred_idv - matched
+ if unmatched > 0:
+ pred_kpts[matched:, :, :2] = rng.integers(low=0, high=1024, size=(unmatched, num_joints, 2))
+
+ pred[f"img_{i}"] = pred_kpts
+
+ _evaluate(gt, pred)
+
+
+@pytest.mark.parametrize("num_images", [1, 2, 5, 10])
+@pytest.mark.parametrize("num_joints", [2, 5, 8, 20])
+@pytest.mark.parametrize("max_error", [1, 2, 5, 20, 40])
+def test_random_map_computation_with_missing_kpts(num_images, num_joints, max_error):
+ rng = np.random.default_rng(seed=0)
+ num_individuals = rng.integers(low=0, high=20, size=(num_images, 2))
+
+ gt, pred = {}, {}
+ for i, (gt_idv, pred_idv) in enumerate(num_individuals):
+ gt_kpts = 2 * np.ones((gt_idv, num_joints, 3))
+ gt_kpts[..., :2] = rng.integers(low=0, high=1024, size=(gt_idv, num_joints, 2))
+ gt[f"img_{i}"] = gt_kpts
+
+ # drop some ground truth keypoints
+ gt_vis_mask = rng.random(size=(gt_idv, num_joints)) < 0.2
+ gt_kpts[gt_vis_mask, 2] = 0
+
+ # generate predicted keypoints
+ pred_kpts = np.zeros((pred_idv, num_joints, 3))
+ pred_kpts[:pred_idv, :, 2] = rng.random(size=(pred_idv, num_joints))
+
+ # predictions that are ground truth + error
+ matched = min(gt_idv, pred_idv)
+ if matched > 0:
+ error = rng.integers(low=-max_error, high=max_error, size=(matched, num_joints, 2))
+ matched_pred = gt_kpts[:matched, :, :2] + error
+ pred_kpts[:matched, :, :2] = np.clip(matched_pred, 0, 1024)
+
+ # random predictions
+ unmatched = pred_idv - matched
+ if unmatched > 0:
+ pred_kpts[matched:, :, :2] = rng.integers(low=0, high=1024, size=(unmatched, num_joints, 2))
+
+ pred[f"img_{i}"] = pred_kpts
+
+ _evaluate(gt, pred)
+
+
+def _evaluate(gt: dict[str, np.ndarray], pred: dict[str, np.ndarray]):
+ for k, v in gt.items():
+ print(20 * "-")
+ print(k)
+ print("GT")
+ print(v)
+ print("PR")
+ print(pred[k])
+
+ data = prepare_evaluation_data(gt, pred)
+ oks = compute_oks(data, oks_bbox_margin=0)
+
+ num_joints = gt[list(gt.keys())[0]].shape[1]
+ coco_gt = _to_coco_ground_truth(gt, num_joints, bbox_margin=0)
+ coco_pred = _to_coco_predictions(coco_gt, pred, bbox_margin=0)
+ coco_oks = eval_coco(coco_gt, coco_pred, num_joints)
+ print(20 * "-")
+ print("dlc mAP:")
+ for k, v in oks.items():
+ print(k)
+ print(v)
+ print(20 * "-")
+ print(f"pycocotools mAP: {coco_oks}")
+ print()
+ dlc_map = oks["mAP"] / 100
+ assert_almost_equal(dlc_map, coco_oks)
+
+
+def _to_coco_ground_truth(
+ data: dict[str, np.ndarray],
+ num_joints: int,
+ bbox_margin: int = 0,
+ image_size: tuple[int, int] = (1024, 1024),
+) -> dict[str, list[dict]]:
+ w, h = image_size
+ anns, images = [], []
+ for path, image_keypoints in data.items():
+ id_ = len(images) + 1
+ images.append(dict(id=id_, file_name=path, width=w, height=h))
+
+ assert image_keypoints.shape[1] == num_joints
+ for _idv_id, kpts in enumerate(image_keypoints):
+ visible = kpts[:, 2] > 0
+ num_keypoints = visible.sum()
+
+ if num_keypoints > 1:
+ bbox = bbox_from_keypoints(
+ keypoints=kpts,
+ image_h=h,
+ image_w=w,
+ margin=bbox_margin,
+ )
+ area = bbox[2].item() * bbox[3].item()
+ anns.append(
+ {
+ "id": len(anns) + 1,
+ "image_id": id_,
+ "category_id": 1,
+ "area": area,
+ "bbox": bbox.tolist(),
+ "keypoints": kpts.reshape(-1).tolist(),
+ "iscrowd": 0,
+ "num_keypoints": num_keypoints,
+ }
+ )
+
+ keypoints = [f"bpt{i}" for i in range(num_joints)]
+ category = dict(id=1, name="animal", supercategory="animal", keypoints=keypoints)
+ return {
+ "info": {"description": "Generated COCO ground truth dataset"}, # Add this
+ "annotations": anns,
+ "categories": [category],
+ "images": images,
+ }
+
+
+def _to_coco_predictions(
+ ground_truth: dict,
+ predictions: dict[str, np.ndarray],
+ bbox_margin: int = 0,
+ image_size: tuple[int, int] = (1024, 1024),
+) -> list[dict]:
+ w, h = image_size
+ num_joints = len(ground_truth["categories"][0]["keypoints"])
+ path_to_id = {img["file_name"]: img["id"] for img in ground_truth["images"]}
+
+ coco_predictions = []
+ for path, image_keypoints in predictions.items():
+ assert image_keypoints.shape[1] == num_joints
+
+ img_id = path_to_id[path]
+ valid_predictions = [kpt for kpt in image_keypoints if np.any(np.all(~np.isnan(kpt), axis=-1))]
+ for kpts in valid_predictions:
+ score = float(np.nanmean(kpts[:, 2]).item())
+ kpts = kpts.copy()
+ kpts[:, 2] = 2
+
+ # NaN predictions to infinity
+ kpts[np.isnan(kpts)] = np.inf
+
+ bbox = bbox_from_keypoints(
+ keypoints=kpts,
+ image_h=h,
+ image_w=w,
+ margin=bbox_margin,
+ )
+ area = bbox[2].item() * bbox[3].item()
+ coco_predictions.append(
+ {
+ "image_id": img_id,
+ "category_id": 1,
+ "keypoints": kpts.reshape(-1).tolist(),
+ "bbox": bbox.tolist(),
+ "area": area,
+ "score": score,
+ }
+ )
+
+ return coco_predictions
+
+
+def eval_coco(
+ ground_truth: dict,
+ predictions: list[dict],
+ num_joints: int,
+) -> float | None:
+ try:
+ from pycocotools.coco import COCO
+ from pycocotools.cocoeval import COCOeval
+
+ coco = COCO()
+ coco.dataset["annotations"] = ground_truth["annotations"]
+ coco.dataset["categories"] = ground_truth["categories"]
+ coco.dataset["images"] = ground_truth["images"]
+ coco.dataset["info"] = {"description": "Generated by DeepLabCut"}
+ coco.createIndex()
+
+ coco_det = coco.loadRes(predictions)
+ coco_eval = COCOeval(coco, coco_det, iouType="keypoints")
+ coco_eval.params.kpt_oks_sigmas = np.array(num_joints * [0.1])
+ coco_eval.evaluate()
+ coco_eval.accumulate()
+ coco_eval.summarize()
+ return float(coco_eval.stats[0])
+
+ except ModuleNotFoundError:
+ print("pycocotools is not installed")
diff --git a/tests/core/metrics/test_metrics_rmse_computation.py b/tests/core/metrics/test_metrics_rmse_computation.py
new file mode 100644
index 0000000000..187279df31
--- /dev/null
+++ b/tests/core/metrics/test_metrics_rmse_computation.py
@@ -0,0 +1,374 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests RMSE computation."""
+
+import numpy as np
+import pytest
+from numpy.testing import assert_almost_equal
+
+from deeplabcut.core.metrics.distance_metrics import (
+ compute_detection_rmse,
+ compute_rmse,
+)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, result",
+ [
+ (
+ [ # ground truth pose
+ [[100.0, 10.0, 2], [150.0, 15.0, 2], [200.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[100.0, 10.0, 0.9], [150.0, 15.0, 0.8], [200.0, 20.0, 0.8]],
+ ],
+ (0, 0),
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[12.0, 10.0, 0.9], [12.0, 10.0, 0.9], [12.0, 10.0, 0.9]],
+ [[22.0, 20.0, 0.9], [22.0, 20.0, 0.9], [22.0, 20.0, 0.9]],
+ ],
+ (2, 2),
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[10.0, 12.0, 0.9], [10.0, 12.0, 0.9], [10.0, 12.0, 0.9]],
+ [[20.0, 22.0, 0.9], [20.0, 22.0, 0.9], [20.0, 22.0, 0.9]],
+ ],
+ (2, 2),
+ ),
+ ],
+)
+def test_rmse_single_image(gt: list, pred: list, result: tuple[float, float]):
+ data = [(np.asarray(gt), np.asarray(pred))]
+ computed_results = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0)
+ rmse, rmse_cutoff = computed_results["rmse"], computed_results["rmse_pcutoff"]
+ expected_rmse, expected_rmse_cutoff = result
+ assert_almost_equal(rmse, expected_rmse)
+ assert_almost_equal(rmse_cutoff, expected_rmse_cutoff)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, result",
+ [
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ [[20.0, 22.0, 0.2], [20.0, 22.0, 0.2], [20.0, 22.0, 0.2]],
+ ],
+ (1, 0), # 2 pixel error on half of keypoints, 0 on the other half
+ ),
+ ],
+)
+def test_rmse_pcutoff(gt: list, pred: list, result: tuple[float, float]):
+ data = [(np.asarray(gt), np.asarray(pred))]
+ expected_rmse, expected_rmse_cutoff = result
+
+ computed_results = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0)
+ rmse, rmse_cutoff = computed_results["rmse"], computed_results["rmse_pcutoff"]
+ assert_almost_equal(rmse, expected_rmse)
+ assert_almost_equal(rmse_cutoff, expected_rmse_cutoff)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, result",
+ [
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [float("nan"), float("nan"), 0], [10.0, 10.0, 2]],
+ ],
+ [ # predicted pose
+ [[12.0, 10.0, 0.9], [10.0, 10.0, 0.4], [10.0, 10.0, 0.9]],
+ ],
+ (1, 1), # only 2 valid ground truth bodyparts
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [float("nan"), float("nan"), 0]],
+ [[float("nan"), float("nan"), 0], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose, swapped prediction order
+ [[20.0, 20.0, 0.9], [21.0, 20.0, 0.9], [21.0, 20.0, 0.9]],
+ [[15.0, 10.0, 0.4], [15.0, 10.0, 0.4], [10.0, 10.0, 0.9]],
+ ],
+ (3, 1), # only 2 valid GT bodyparts
+ ),
+ ],
+)
+def test_rmse_with_nans(gt: list, pred: list, result: tuple[float, float]):
+ data = [(np.asarray(gt), np.asarray(pred))]
+ expected_rmse, expected_rmse_cutoff = result
+
+ results = compute_rmse(data, False, pcutoff=0.6, oks_bbox_margin=10.0)
+ rmse, rmse_cutoff = results["rmse"], results["rmse_pcutoff"]
+ assert_almost_equal(rmse, expected_rmse)
+ assert_almost_equal(rmse_cutoff, expected_rmse_cutoff)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, data_unique, result",
+ [
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [np.nan, np.nan, 0], [10.0, 10.0, 2]],
+ ],
+ [ # predicted pose
+ [[12.0, 10.0, 0.9], [10.0, 10.0, 0.4], [10.0, 10.0, 0.9]],
+ ],
+ None, # unique data
+ (1, 1), # error 2 on one, 0 on the other; only 2 valid GT
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ [ # predicted pose, perfect detections but misassembled
+ [[10.0, 10.0, 0.9], [50.0, 50.0, 0.9], [30.0, 30.0, 0.9]],
+ [[40.0, 40.0, 0.9], [20.0, 20.0, 0.4], [60.0, 60.0, 0.9]],
+ ],
+ None, # unique data
+ (0, 0), # all pose perfect
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ [ # predicted pose, small error in pose and misassembled
+ [[12.0, 10.0, 0.9], [52.0, 50.0, 0.9], [32.0, 30.0, 0.9]],
+ [[42.0, 40.0, 0.9], [18.0, 20.0, 0.4], [62.0, 60.0, 0.9]],
+ ],
+ None, # unique data
+ (2, 2), # pixel error of 2 on x-axis for all predictions
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ [ # predicted pose, small error in low-conf pose and misassembled
+ [[12.0, 10.0, 0.4], [50.0, 50.0, 0.9], [30.0, 30.0, 0.9]],
+ [[40.0, 40.0, 0.9], [22.0, 20.0, 0.4], [62.0, 60.0, 0.4]],
+ ],
+ None, # unique data
+ (1, 0), # error of 2 on half, 0 on the other half (with good conf)
+ ),
+ ( # more ground truth than detections
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]],
+ ],
+ [ # predicted pose, no error
+ [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ None, # unique data
+ (0, 0),
+ ),
+ ( # more detections than GT
+ [ # ground truth pose
+ [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ [ # predicted pose, no error
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ [[70.0, 70.0, 2], [80.0, 80.0, 2], [90.0, 90.0, 2]],
+ ],
+ None, # unique data
+ (0, 0),
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [np.nan, np.nan, 0], [10.0, 10.0, 2]],
+ ],
+ [ # predicted pose
+ [[12.0, 10.0, 0.9], [10.0, 10.0, 0.4], [10.0, 10.0, 0.9]],
+ ],
+ ( # unique data
+ [[[20, 20, 2], [22, 23, 2]]],
+ [[[20, 20, 0.8], [22, 23, 0.7]]],
+ ),
+ (0.5, 0.5), # error 2 on one, 0 on the other; only 2 valid GT
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [20.0, 20.0, 2], [30.0, 30.0, 2]],
+ [[40.0, 40.0, 2], [50.0, 50.0, 2], [60.0, 60.0, 2]],
+ ],
+ [ # predicted pose, perfect detections but misassembled
+ [[10.0, 10.0, 0.9], [50.0, 50.0, 0.9], [30.0, 30.0, 0.9]],
+ [[40.0, 40.0, 0.9], [20.0, 20.0, 0.4], [60.0, 60.0, 0.9]],
+ ],
+ ( # unique data
+ [], # missing ground truth for unique bodyparts
+ [[[20, 20, 0.8], [22, 23, 0.7]]],
+ ),
+ (0, 0), # all pose perfect
+ ),
+ ],
+)
+def test_detection_rmse(gt: list, pred: list, data_unique: tuple[list, list] | None, result: tuple[float, float]):
+ data = [(np.asarray(gt), np.asarray(pred))]
+ data_unique = [(np.asarray(data_unique[0]), np.asarray(data_unique[1]))] if data_unique else None
+ expected_rmse, expected_rmse_cutoff = result
+ rmse, rmse_cutoff = compute_detection_rmse(data, pcutoff=0.6, data_unique=data_unique)
+ assert_almost_equal(rmse, expected_rmse)
+ assert_almost_equal(rmse_cutoff, expected_rmse_cutoff)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, unique_gt, unique_pred, result",
+ [
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ [[20.0, 24.0, 0.2], [20.0, 24.0, 0.2], [20.0, 20.0, 0.2]],
+ ],
+ [ # Unique GT
+ [[10.0, 10.0, 2], [10.0, 10.0, 2]],
+ ],
+ [ # Unique Pred
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ ],
+ # 4 pixel error on 2 keypoints, 0 error on 5 keypoints
+ (1.0, 0.0),
+ ),
+ (
+ [np.zeros((0, 3, 2))], # no GT pose
+ [ # predicted pose
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ ],
+ [ # Unique GT
+ [[10.0, 10.0, 2], [10.0, 10.0, 2]],
+ ],
+ [ # Unique Pred
+ [[15.0, 10.0, 0.5], [11.0, 10.0, 0.9]],
+ ],
+ # 5 pixel error on 1 keypoint, 1 pixel error on the other
+ (3.0, 1.0),
+ ),
+ ],
+)
+def test_rmse_with_unique(
+ gt: list, pred: list, unique_gt: list, unique_pred: list, result: tuple[float, float]
+) -> None:
+ data = [(np.asarray(gt), np.asarray(pred))]
+ data_unique = [(np.asarray(unique_gt), np.asarray(unique_pred))]
+ expected_rmse, expected_rmse_cutoff = result
+
+ results = compute_rmse(
+ data,
+ False,
+ pcutoff=0.6,
+ data_unique=data_unique,
+ oks_bbox_margin=10.0,
+ )
+ rmse, rmse_cutoff = results["rmse"], results["rmse_pcutoff"]
+ assert_almost_equal(rmse, expected_rmse)
+ assert_almost_equal(rmse_cutoff, expected_rmse_cutoff)
+
+
+@pytest.mark.parametrize(
+ "gt, pred, unique_gt, unique_pred, result",
+ [
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ [[20.0, 24.0, 0.2], [20.0, 24.0, 0.2], [20.0, 20.0, 0.2]],
+ ],
+ [ # Unique GT
+ [[10.0, 10.0, 2], [10.0, 10.0, 2]],
+ ],
+ [ # Unique Pred
+ [[10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ ],
+ # 4 pixel error on 2 keypoints, 0 error on 5 keypoints
+ [(1.0, 0.0), [2.0, 2.0, 0.0], [0.0, 0.0]],
+ ),
+ (
+ [ # ground truth pose
+ [[10.0, 10.0, 2], [10.0, 10.0, 2], [10.0, 10.0, 2]],
+ [[20.0, 20.0, 2], [20.0, 20.0, 2], [20.0, 20.0, 2]],
+ ],
+ [ # predicted pose
+ [[10.0, 12.0, 0.9], [10.0, 10.0, 0.9], [10.0, 10.0, 0.9]],
+ [[20.0, 24.0, 0.7], [20.0, 24.0, 0.6], [20.0, 20.0, 0.8]],
+ ],
+ [ # Unique GT
+ [[10.0, 10.0, 2], [10.0, 10.0, 2]],
+ ],
+ [ # Unique Pred
+ [[12.0, 10.0, 0.9], [11.0, 10.0, 0.9]],
+ ],
+ [ # errors: 3 with 0px, 1 with 1px, 2 with 2px, 2 with 4px => 13/8
+ (1.625, 1.625),
+ [3.0, 2.0, 0.0],
+ [2.0, 1.0],
+ ],
+ ),
+ ],
+)
+def test_rmse_per_bodypart_with_unique(
+ gt: list,
+ pred: list,
+ unique_gt: list,
+ unique_pred: list,
+ result: tuple[tuple[float, float], list[float], list[float]],
+) -> None:
+ data = [(np.asarray(gt), np.asarray(pred))]
+ data_unique = [(np.asarray(unique_gt), np.asarray(unique_pred))]
+ expected_rmse, expected_rmse_cutoff = result[0]
+ bodypart_rmse = result[1]
+ unique_rmse = result[2]
+
+ results = compute_rmse(
+ data,
+ single_animal=False,
+ pcutoff=0.6,
+ data_unique=data_unique,
+ per_keypoint_results=True,
+ oks_bbox_margin=10.0,
+ )
+ assert_almost_equal(results["rmse"], expected_rmse)
+ assert_almost_equal(results["rmse_pcutoff"], expected_rmse_cutoff)
+ for bpt_index, bpt_rmse in enumerate(bodypart_rmse):
+ key = f"rmse_keypoint_{bpt_index}"
+ assert key in results
+ assert_almost_equal(results[key], bpt_rmse)
+
+ for bpt_index, bpt_rmse in enumerate(unique_rmse):
+ key = f"rmse_unique_keypoint_{bpt_index}"
+ assert key in results
+ assert_almost_equal(results[key], bpt_rmse)
diff --git a/tests/create_project/test_video_set_configuration.py b/tests/create_project/test_video_set_configuration.py
new file mode 100644
index 0000000000..86e50eecc9
--- /dev/null
+++ b/tests/create_project/test_video_set_configuration.py
@@ -0,0 +1,264 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Unit tests for deeplabcut.create_project.new module."""
+
+import logging
+import warnings
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+
+import deeplabcut.create_project.new as new_module
+from deeplabcut.utils.auxfun_videos import VideoReader
+
+
+@pytest.fixture
+def project_directory(tmpdir_factory) -> Path:
+ proj_dir = Path(tmpdir_factory.mktemp("test-project"))
+ return proj_dir
+
+
+@pytest.fixture
+def mock_video_file(tmpdir_factory) -> Path:
+ """Create a mock video file for testing."""
+ fake_folder = tmpdir_factory.mktemp("some_video")
+ video_path = Path(fake_folder) / "test_video.avi"
+ video_path.write_bytes(b"fake video content")
+ return video_path
+
+
+@pytest.fixture
+def mock_video_reader() -> VideoReader:
+ """Create a mock VideoReader."""
+ mock_reader = Mock(spec=VideoReader)
+ mock_reader.get_bbox.return_value = (0, 640, 277, 624)
+ return mock_reader
+
+
+@pytest.fixture
+def video_directory(tmpdir_factory) -> Path:
+ """Create a directory with multiple video files."""
+ video_dir = Path(tmpdir_factory.mktemp("some_videos"))
+ video_dir.mkdir(exist_ok=True)
+
+ # Create multiple video files with different extensions
+ (video_dir / "video1.avi").write_bytes(b"fake video 1")
+ (video_dir / "video2.mp4").write_bytes(b"fake video 2")
+ (video_dir / "video3.mov").write_bytes(b"fake video 3")
+ (video_dir / "not_a_video.txt").write_text("text file")
+
+ return video_dir
+
+
+def test_project_directory_creation_basic(
+ tmpdir: Path,
+ mock_video_file: Path,
+ mock_video_reader: VideoReader,
+):
+ """Test that project directories are created correctly."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test-project",
+ experimenter="test-user",
+ videos=[str(mock_video_file)],
+ working_directory=str(tmpdir),
+ copy_videos=False,
+ )
+
+ project_path = Path(config_path).parent
+ assert project_path.exists()
+ assert (project_path / "videos").exists()
+ assert (project_path / "labeled-data").exists()
+ assert (project_path / "training-datasets").exists()
+ assert (project_path / "dlc-models").exists()
+
+
+@pytest.mark.parametrize("copy_videos", [True, False])
+def test_single_video_file(
+ tmpdir: Path,
+ mock_video_file: Path,
+ mock_video_reader: VideoReader,
+ copy_videos: bool,
+):
+ """Test adding a single video file."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(mock_video_file)],
+ working_directory=str(tmpdir),
+ copy_videos=copy_videos,
+ )
+
+ project_path = Path(config_path).parent
+ video_path = project_path / "videos" / "test_video.avi"
+ assert video_path.exists() or video_path.is_symlink()
+
+ # Content should match
+ if copy_videos:
+ assert mock_video_file.read_bytes() == video_path.read_bytes()
+
+
+@pytest.mark.parametrize("copy_videos", [True, False])
+def test_video_directory(
+ tmpdir: Path,
+ video_directory: Path,
+ mock_video_reader: VideoReader,
+ copy_videos: bool,
+):
+ """Test adding videos from a directory."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(video_directory)],
+ working_directory=str(tmpdir),
+ video_extensions=".avi",
+ copy_videos=copy_videos,
+ )
+
+ project_path = Path(config_path).parent
+ assert (project_path / "videos" / "video1.avi").exists() or (project_path / "videos" / "video1.avi").is_symlink()
+
+ # Content should match
+ if copy_videos:
+ assert (project_path / "videos" / "video1.avi").read_bytes() == (video_directory / "video1.avi").read_bytes()
+
+
+@pytest.mark.parametrize("copy_videos", [True, False])
+def test_mixed_video_files_and_directories(
+ tmpdir,
+ mock_video_file: Path,
+ video_directory: Path,
+ mock_video_reader: VideoReader,
+ copy_videos: bool,
+):
+ """Test adding both video files and directories."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(mock_video_file), str(video_directory)],
+ working_directory=str(tmpdir),
+ video_extensions=".avi",
+ copy_videos=copy_videos,
+ )
+
+ project_path = Path(config_path).parent
+ videos_dir = project_path / "videos"
+ # Should have both the single file and files from directory
+ assert (videos_dir / mock_video_file.name).exists() or (videos_dir / mock_video_file.name).is_symlink()
+ assert (videos_dir / "video1.avi").exists() or (videos_dir / "video1.avi").is_symlink()
+
+
+def test_empty_video_directory(
+ tmpdir: Path,
+ mock_video_reader: VideoReader,
+):
+ """Test handling of empty video directory."""
+ empty_dir = tmpdir / "empty_videos"
+ empty_dir.mkdir()
+
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ with warnings.catch_warnings(record=True) as w:
+ result = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(empty_dir)],
+ working_directory=str(tmpdir),
+ video_extensions=".avi",
+ copy_videos=False,
+ )
+ # Should return "nothingcreated" when no valid videos found
+ assert result == "nothingcreated" or len(w) > 0
+
+
+def test_valid_video_included_in_config(
+ tmpdir: Path,
+ mock_video_file: Path,
+ mock_video_reader: VideoReader,
+):
+ """Test that valid videos are included in the config file."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(mock_video_file)],
+ working_directory=str(tmpdir),
+ copy_videos=False,
+ )
+
+ from deeplabcut.utils import auxiliaryfunctions
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+ logging.debug(f"Config content: {cfg}")
+ logging.debug(f"Video sets in config: {cfg.get('video_sets', {})}")
+ logging.debug(f"Video sets keys: {list(cfg.get('video_sets', {}).keys())}")
+
+ assert "video_sets" in cfg
+ assert len(cfg["video_sets"]) > 0
+ # Check that video path is in video_sets
+ video_keys = [Path(k) for k in cfg["video_sets"].keys()]
+ project_video = Path(config_path).parent / "videos" / mock_video_file.name
+
+ assert any(k.resolve() == project_video.resolve() for k in video_keys)
+
+
+def test_invalid_video_removed_from_project(
+ tmpdir: Path,
+ mock_video_file: Path,
+):
+ """Test that invalid videos are removed from the project."""
+ # Mock VideoReader to raise IOError
+ mock_reader = Mock(side_effect=OSError("Cannot open video"))
+
+ with patch("deeplabcut.create_project.new.VideoReader", mock_reader):
+ with warnings.catch_warnings(record=True):
+ result = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(mock_video_file)],
+ working_directory=str(tmpdir),
+ copy_videos=False,
+ )
+
+ # Should return "nothingcreated" when no valid videos
+ assert result == "nothingcreated"
+
+
+def test_config_file_video_sets_format(
+ tmpdir: Path,
+ mock_video_file: Path,
+ mock_video_reader: VideoReader,
+):
+ """Test that video_sets in config has correct format."""
+ with patch("deeplabcut.create_project.new.VideoReader", return_value=mock_video_reader):
+ config_path = new_module.create_new_project(
+ project="test",
+ experimenter="user",
+ videos=[str(mock_video_file)],
+ working_directory=str(tmpdir),
+ copy_videos=False,
+ )
+
+ from deeplabcut.utils import auxiliaryfunctions
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ assert "video_sets" in cfg
+ assert isinstance(cfg["video_sets"], dict)
+
+ # Check format of video_sets entries
+ for _video_path, video_info in cfg["video_sets"].items():
+ assert isinstance(video_info, dict)
+ assert "crop" in video_info
+ assert isinstance(video_info["crop"], str)
diff --git a/tests/generate_training_dataset/test_trainingset_manipulation.py b/tests/generate_training_dataset/test_trainingset_manipulation.py
new file mode 100644
index 0000000000..cf78ba3996
--- /dev/null
+++ b/tests/generate_training_dataset/test_trainingset_manipulation.py
@@ -0,0 +1,37 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for deeplabcut/generate_training_dataset/metadata.py."""
+
+from __future__ import annotations
+
+import pytest
+
+import deeplabcut.generate_training_dataset.trainingsetmanipulation as trainingsetmanipulation
+
+
+@pytest.mark.parametrize("train_fraction", [1, 2, 5, 17, 24, 29, 34, 47, 50, 53, 61, 68, 75, 90, 95, 97, 99])
+@pytest.mark.parametrize("n_train", [1, 2, 3, 5, 7, 11, 37, 62, 153])
+@pytest.mark.parametrize("n_test", [1, 2, 3, 5, 7, 13, 19, 85, 112])
+def test_compute_padding(train_fraction: int, n_train: int, n_test: int) -> None:
+ """
+ More complete tests can be run with:
+ "train_fraction": list(range(1, 100))
+ "n_train": list(range(1, 200))
+ "n_test": list(range(1, 200))
+
+ This was done locally, but as it's many many tests to run a subset was selected here
+ """
+ train_frac = train_fraction / 100
+ train_pad, test_pad = trainingsetmanipulation._compute_padding(train_frac, n_train, n_test)
+ print()
+ print(train_fraction, n_train, n_test, train_pad, test_pad)
+ frac = round((n_train + train_pad) / (n_train + n_test + train_pad + test_pad), 2)
+ assert train_frac == frac
diff --git a/tests/generate_training_dataset/test_trainset_metadata.py b/tests/generate_training_dataset/test_trainset_metadata.py
new file mode 100644
index 0000000000..dacd9978cf
--- /dev/null
+++ b/tests/generate_training_dataset/test_trainset_metadata.py
@@ -0,0 +1,580 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for deeplabcut/generate_training_dataset/metadata.py."""
+
+from __future__ import annotations
+
+import logging
+import pickle
+from unittest.mock import MagicMock, patch
+
+import pytest
+from ruamel.yaml import YAML
+
+import deeplabcut.generate_training_dataset.metadata as metadata
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils import auxiliaryfunctions
+
+SHUFFLE_DATA = [
+ {"name": "pJun17-t50s1", "index": 1, "train_fraction": 0.5, "split": 1, "engine": "torch"},
+ {"name": "pJun17-t50s2", "index": 2, "train_fraction": 0.5, "split": 1, "engine": "tf"},
+ {"name": "pJun17-t60s1", "index": 1, "train_fraction": 0.6, "split": 2, "engine": "torch"},
+ {"name": "pJun17-t60s2", "index": 2, "train_fraction": 0.6, "split": 3, "engine": "torch"},
+]
+SPLITS_DATA = {
+ 1: {"train": [0, 1], "test": [2, 3]},
+ 2: {"train": [0, 1, 2], "test": [3, 4]},
+ 3: {"train": [4, 3, 2], "test": [1, 0]},
+}
+
+BASE_SPLIT = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4))
+# Splits that should be equal to the base
+EQ_SPLIT = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4))
+# Splits that should not be equal to the base
+ADD_SPLIT = metadata.DataSplit(train_indices=(1, 2, 5), test_indices=(3, 4))
+ADD_SPLIT2 = metadata.DataSplit(train_indices=(1, 2), test_indices=(3, 4, 5))
+SUBS_SPLIT = metadata.DataSplit(train_indices=(1, 3), test_indices=(2, 4))
+DEL_SPLIT = metadata.DataSplit(train_indices=(1,), test_indices=(3, 4))
+DEL_SPLIT2 = metadata.DataSplit(train_indices=(1, 2), test_indices=(3,))
+
+SHUFFLES = {
+ 1: metadata.ShuffleMetadata("pJun17-t50s1", 0.5, 1, Engine.PYTORCH, BASE_SPLIT),
+ 2: metadata.ShuffleMetadata("pJun17-t50s2", 0.5, 2, Engine.PYTORCH, ADD_SPLIT),
+ 3: metadata.ShuffleMetadata("pJun17-t50s3", 0.5, 3, Engine.TF, BASE_SPLIT),
+ 4: metadata.ShuffleMetadata("pJun17-t50s4", 0.5, 4, Engine.PYTORCH, DEL_SPLIT),
+}
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "shuffles": {SHUFFLE_DATA[idx]["name"]: SHUFFLE_DATA[idx] for idx in [0, 1, 2]},
+ "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]},
+ },
+ {
+ "shuffles": {SHUFFLE_DATA[idx]["name"]: SHUFFLE_DATA[idx] for idx in [0]},
+ "splits": {idx: SPLITS_DATA[idx] for idx in [1, 2]},
+ },
+ ],
+)
+@pytest.mark.parametrize("load_splits", [True, False])
+def test_load_metadata(tmpdir, data: dict, load_splits: bool):
+ """Tests that loading the metadata from files doesn't fail."""
+ # write data to tmp file
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ with open(meta_path, "w") as f:
+ YAML().dump(data, f)
+
+ print(cfg_path)
+ print(meta_path)
+ print(data["shuffles"])
+ print(data["splits"])
+ print()
+
+ for _name, s in data["shuffles"].items():
+ split = data["splits"][s["split"]]
+ train, test = split["train"], split["test"]
+ _create_doc_data(cfg, trainset_dir, s["train_fraction"], s["index"], train, test)
+
+ trainset_meta = metadata.TrainingDatasetMetadata.load(str(cfg_path), load_splits=load_splits)
+ for s in trainset_meta.shuffles:
+ print(s)
+
+ assert len(data["shuffles"]) == len(trainset_meta.shuffles)
+
+ for s in trainset_meta.shuffles:
+ shuffle_in = data["shuffles"][s.name]
+ split_idx = data["splits"][shuffle_in["split"]]
+ assert s.train_fraction == shuffle_in["train_fraction"]
+ assert s.engine == Engine(shuffle_in["engine"])
+ if load_splits:
+ assert s.split is not None
+ assert s.split.train_indices == tuple(split_idx["train"])
+ assert s.split.test_indices == tuple(split_idx["test"])
+ else:
+ assert s.split is None
+ s_with_split = s.load_split(cfg, trainset_dir)
+ assert s_with_split.split.train_indices == tuple(split_idx["train"])
+ assert s_with_split.split.test_indices == tuple(split_idx["test"])
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "task": "ch",
+ "date": "Aug1",
+ "shuffles": (SHUFFLES[1],),
+ "expected": {
+ "shuffles": {SHUFFLES[1].name: {"index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch"}},
+ },
+ },
+ {
+ "task": "t",
+ "date": "Jan1",
+ "shuffles": (SHUFFLES[1], SHUFFLES[3]),
+ "expected": {
+ "shuffles": {
+ SHUFFLES[1].name: {"index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch"},
+ SHUFFLES[3].name: {
+ "index": 3,
+ "train_fraction": 0.5,
+ "split": 1,
+ "engine": "tensorflow",
+ },
+ },
+ },
+ },
+ {
+ "task": "t",
+ "date": "Jan1",
+ "shuffles": (SHUFFLES[1], SHUFFLES[2]),
+ "expected": {
+ "shuffles": {
+ SHUFFLES[1].name: {"index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch"},
+ SHUFFLES[2].name: {"index": 2, "train_fraction": 0.5, "split": 2, "engine": "pytorch"},
+ },
+ },
+ },
+ {
+ "shuffles": (SHUFFLES[1], SHUFFLES[2], SHUFFLES[3]),
+ "expected": {
+ "shuffles": {
+ SHUFFLES[1].name: {"index": 1, "train_fraction": 0.5, "split": 1, "engine": "pytorch"},
+ SHUFFLES[2].name: {"index": 2, "train_fraction": 0.5, "split": 2, "engine": "pytorch"},
+ SHUFFLES[3].name: {
+ "index": 3,
+ "train_fraction": 0.5,
+ "split": 1,
+ "engine": "tensorflow",
+ },
+ },
+ },
+ },
+ ],
+)
+def test_save_metadata_simple(tmpdir, data):
+ """Tests that saving the metadata creates the expected file."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, data["shuffles"])
+ print(trainset_meta)
+
+ trainset_meta.save()
+ with open(meta_path) as f:
+ meta = YAML().load(f)
+ print(data)
+ print(meta)
+ assert data["expected"] == meta
+
+
+@pytest.mark.parametrize(
+ "shuffles",
+ [[SHUFFLES[i] for i in indices] for indices in [[1], [1, 2], [1, 2, 3], [1, 2, 4], [1, 3, 4], [1, 2, 3, 4]]],
+)
+def test_save_metadata(tmpdir, shuffles):
+ """Tests that saving the metadata and reloading it leads to the same instance."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ for s in shuffles:
+ train, test = (
+ s.split.train_indices,
+ s.split.test_indices,
+ )
+ _create_doc_data(cfg, trainset_dir, s.train_fraction, s.index, train, test)
+
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, tuple(shuffles))
+ print(trainset_meta)
+ trainset_meta.save()
+ reloaded = metadata.TrainingDatasetMetadata.load(cfg)
+ print(reloaded)
+ print()
+
+ for s in trainset_meta.shuffles:
+ print(s)
+ print()
+ for s in reloaded.shuffles:
+ print(s)
+ print()
+ reloaded_with_splits = [s.load_split(cfg, trainset_dir) for s in reloaded.shuffles]
+ assert len(reloaded.shuffles) == len(trainset_meta.shuffles)
+ assert len(reloaded_with_splits) == len(trainset_meta.shuffles)
+ assert tuple(reloaded_with_splits) == trainset_meta.shuffles
+
+
+def test_add_shuffle(tmpdir):
+ """Tests that a shuffle can be added correctlt."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+ trainset_meta_added = trainset_meta.add(SHUFFLES[2])
+ assert len(trainset_meta.shuffles) == 1
+ assert len(trainset_meta_added.shuffles) == 2
+ assert trainset_meta_added.shuffles == (SHUFFLES[1], SHUFFLES[2])
+
+
+def test_add_shuffle_twice(tmpdir):
+ """Tests that a shuffle can be added correctlt."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+ trainset_meta_added = trainset_meta.add(SHUFFLES[2])
+ trainset_meta_added_2 = trainset_meta.add(SHUFFLES[2])
+ assert len(trainset_meta.shuffles) == 1
+ assert trainset_meta.shuffles == (SHUFFLES[1],)
+ assert len(trainset_meta_added.shuffles) == len(trainset_meta_added_2.shuffles)
+ assert trainset_meta_added.shuffles == trainset_meta_added_2.shuffles
+
+
+def test_add_shuffle_sorts_to_correct_order(tmpdir):
+ """Tests that a shuffle can be added correctlt."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1], SHUFFLES[3]))
+ trainset_meta_added = trainset_meta.add(SHUFFLES[2])
+ assert len(trainset_meta.shuffles) == 2
+ assert len(trainset_meta_added.shuffles) == 3
+ assert trainset_meta_added.shuffles == (SHUFFLES[1], SHUFFLES[2], SHUFFLES[3])
+
+
+@pytest.mark.parametrize(
+ "shuffles", [indices for indices in [[1], [1, 2], [1, 2, 3], [1, 2, 4], [1, 3, 4], [1, 2, 3, 4]]]
+)
+@pytest.mark.parametrize("shuffle_to_add", [1, 2, 3, 4])
+def test_add_shuffle_indices(tmpdir, shuffles, shuffle_to_add):
+ """Tests."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, tuple([SHUFFLES[i] for i in shuffles]))
+ if shuffle_to_add in shuffles:
+ with pytest.raises(RuntimeError):
+ trainset_meta_added = trainset_meta.add(SHUFFLES[shuffle_to_add], overwrite=False)
+
+ trainset_meta_added = trainset_meta.add(SHUFFLES[shuffle_to_add], overwrite=True)
+ assert len(trainset_meta_added.shuffles) == len(shuffles)
+ assert [s.index for s in trainset_meta_added.shuffles] == shuffles
+ else:
+ trainset_meta_added = trainset_meta.add(SHUFFLES[shuffle_to_add], overwrite=False)
+ indices = [s.index for s in trainset_meta_added.shuffles]
+ assert len(trainset_meta_added.shuffles) == len(shuffles) + 1
+ assert indices == list(sorted(shuffles + [shuffle_to_add]))
+
+
+@pytest.mark.parametrize(
+ "split1, split2, equal",
+ [
+ (BASE_SPLIT, EQ_SPLIT, True),
+ (BASE_SPLIT, ADD_SPLIT, False),
+ (BASE_SPLIT, ADD_SPLIT2, False),
+ (BASE_SPLIT, SUBS_SPLIT, False),
+ (BASE_SPLIT, DEL_SPLIT, False),
+ (BASE_SPLIT, DEL_SPLIT2, False),
+ ],
+)
+def test_data_split_equality(split1, split2, equal):
+ """Tests that equality functions as expected for DataSplits."""
+ print(split1)
+ print(split2)
+ print(equal)
+ assert (split1 == split2) == equal
+
+
+@pytest.mark.parametrize("split_idx", [1, 4, 20, 1000])
+@pytest.mark.parametrize("indices", [(2, 1), (10, 1), (1, 21, 20), (1, 2, 4, 3)])
+@pytest.mark.parametrize("sorted_indices", [(1, 2), (10, 12), (3, 4), (1, 1000, 1200)])
+def test_data_split_requires_sorted(split_idx: int, indices: tuple[int], sorted_indices: tuple[int]):
+ """Tests that equality functions as expected for DataSplits."""
+ with pytest.raises(RuntimeError):
+ metadata.DataSplit(train_indices=tuple(indices), test_indices=tuple(sorted_indices))
+
+ with pytest.raises(RuntimeError):
+ metadata.DataSplit(train_indices=tuple(sorted_indices), test_indices=tuple(indices))
+
+ with pytest.raises(RuntimeError):
+ metadata.DataSplit(train_indices=tuple(indices), test_indices=tuple(indices))
+
+ metadata.DataSplit(train_indices=tuple(sorted_indices), test_indices=tuple(sorted_indices))
+
+
+@pytest.mark.parametrize(
+ "shuffles",
+ [
+ ({"idx": 3, "train": [1], "test": [2], "train_fraction": 0.5},),
+ (
+ {"idx": 1, "train": [1], "test": [2], "train_fraction": 0.5},
+ {"idx": 5, "train": [1, 2, 3], "test": [4, 5], "train_fraction": 0.6},
+ {"idx": 4, "train": [1, 3], "test": [2], "train_fraction": 0.66},
+ ),
+ ],
+)
+def test_create_metadata_from_shuffles(tmpdir, shuffles):
+ """Tests that equality functions as expected for DataSplits."""
+ cfg, cfg_path, trainset_dir, meta_path = _create_project_with_config(tmpdir)
+ print(trainset_dir)
+ for s in shuffles:
+ doc = f"Documentation_data-ex_{s['train_fraction']}shuffle{s['idx']}.pickle"
+ doc_path = trainset_dir.join(doc)
+ with open(doc_path, "wb") as f:
+ pickle.dump([[], s["train"], s["test"], s["train_fraction"]], f, pickle.HIGHEST_PROTOCOL)
+
+ trainset_metadata = metadata.TrainingDatasetMetadata.create(cfg)
+ print()
+ print(trainset_metadata)
+ assert len(trainset_metadata.shuffles) == len(shuffles)
+
+ for shuffle_data, shuffle in zip(shuffles, trainset_metadata.shuffles, strict=False):
+ print(shuffle.index)
+ assert shuffle_data["idx"] == shuffle.index
+ assert shuffle_data["train_fraction"] == shuffle.train_fraction
+ assert tuple(shuffle_data["train"]) == shuffle.split.train_indices
+ assert tuple(shuffle_data["test"]) == shuffle.split.test_indices
+ print()
+
+
+def test_get_shuffle_engine_warns_when_metadata_get_fails_then_uses_model_folder(caplog):
+ """ValueError from metadata lookup is logged; engine is inferred from model folders."""
+ caplog.set_level(logging.WARNING)
+
+ cfg = {
+ "project_path": "/tmp/dlc-nonexistent-project-path",
+ "TrainingFraction": [0.95],
+ "Task": "t",
+ "date": "d",
+ "scorer": "s",
+ "iteration": 0,
+ }
+
+ meta_path_mock = MagicMock()
+ meta_path_mock.exists.return_value = True
+
+ training_meta_mock = MagicMock()
+ training_meta_mock.get.side_effect = ValueError("no shuffle for this index")
+
+ with (
+ patch.object(metadata.TrainingDatasetMetadata, "path", return_value=meta_path_mock),
+ patch.object(metadata.TrainingDatasetMetadata, "load", return_value=training_meta_mock),
+ patch.object(metadata, "find_engines_from_model_folders", return_value={Engine.PYTORCH}),
+ ):
+ engine = metadata.get_shuffle_engine(cfg, trainingsetindex=0, shuffle=1)
+
+ assert engine == Engine.PYTORCH
+ assert "no shuffle for this index" in caplog.text
+ assert "Falling back to detecting the engine from model folders" in caplog.text
+
+
+# ---------------------------------------------------------------------------
+# TrainingDatasetMetadata.__post_init__
+# ---------------------------------------------------------------------------
+
+
+def test_training_dataset_metadata_requires_sorted_shuffles(tmpdir):
+ """Constructor raises RuntimeError when shuffles are not sorted."""
+ cfg, *_ = _create_project_with_config(tmpdir)
+ with pytest.raises(RuntimeError):
+ metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[2], SHUFFLES[1]))
+
+
+# ---------------------------------------------------------------------------
+# TrainingDatasetMetadata.get
+# ---------------------------------------------------------------------------
+
+
+def test_get_returns_matching_shuffle(tmpdir):
+ """get() returns the correct ShuffleMetadata."""
+ cfg, *_ = _create_project_with_config(tmpdir)
+ cfg["TrainingFraction"] = [0.5]
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+
+ result = trainset_meta.get(trainset_index=0, index=1)
+ assert result == SHUFFLES[1]
+
+
+def test_get_raises_when_trainset_index_out_of_bounds(tmpdir):
+ """get() raises ValueError when trainset_index >= len(TrainingFraction)."""
+ cfg, *_ = _create_project_with_config(tmpdir)
+ cfg["TrainingFraction"] = [0.5]
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+
+ with pytest.raises(ValueError, match="out of bounds"):
+ trainset_meta.get(trainset_index=1, index=1)
+
+
+def test_get_raises_when_shuffle_not_found(tmpdir):
+ """get() raises ValueError when no shuffle matches the given index."""
+ cfg, *_ = _create_project_with_config(tmpdir)
+ cfg["TrainingFraction"] = [0.5]
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+
+ with pytest.raises(ValueError, match="Could not find"):
+ trainset_meta.get(trainset_index=0, index=99)
+
+
+# ---------------------------------------------------------------------------
+# TrainingDatasetMetadata.save — lazy load_split branch
+# ---------------------------------------------------------------------------
+
+
+def test_save_loads_split_when_shuffle_has_no_split(tmpdir):
+ """save() calls load_split for shuffles where split is None."""
+ cfg, _cfg_path, trainset_dir, _meta_path = _create_project_with_config(tmpdir)
+ _create_doc_data(cfg, trainset_dir, 0.5, 1, [0, 1], [2, 3])
+
+ # Build a shuffle with split=None — mimics a load(load_splits=False) result
+ shuffle_no_split = metadata.ShuffleMetadata(
+ name=SHUFFLES[1].name,
+ train_fraction=0.5,
+ index=1,
+ engine=Engine.PYTORCH,
+ split=None,
+ )
+ trainset_meta = metadata.TrainingDatasetMetadata(cfg, (shuffle_no_split,))
+ # Should not raise; save() must call load_split internally
+ trainset_meta.save()
+
+ reloaded = metadata.TrainingDatasetMetadata.load(cfg, load_splits=True)
+ assert len(reloaded.shuffles) == 1
+ assert reloaded.shuffles[0].split is not None
+
+
+# ---------------------------------------------------------------------------
+# TrainingDatasetMetadata.load
+# ---------------------------------------------------------------------------
+
+
+def test_load_raises_when_metadata_file_missing(tmpdir):
+ """load() raises FileNotFoundError when metadata.yaml does not exist."""
+ cfg, *_ = _create_project_with_config(tmpdir)
+ with pytest.raises(FileNotFoundError):
+ metadata.TrainingDatasetMetadata.load(cfg)
+
+
+# ---------------------------------------------------------------------------
+# TrainingDatasetMetadata.create — empty trainset_path branch
+# ---------------------------------------------------------------------------
+
+
+def test_create_returns_empty_when_trainset_dir_missing(tmp_path):
+ """create() returns metadata with no shuffles when the trainset dir is absent."""
+ cfg = {
+ "Task": "t",
+ "date": "d",
+ "scorer": "s",
+ "iteration": 0,
+ "project_path": str(tmp_path),
+ }
+ trainset_meta = metadata.TrainingDatasetMetadata.create(cfg)
+ assert len(trainset_meta.shuffles) == 0
+
+
+# ---------------------------------------------------------------------------
+# update_metadata
+# ---------------------------------------------------------------------------
+
+
+def test_update_metadata_adds_shuffle(tmpdir):
+ """update_metadata adds a new shuffle and persists it."""
+ cfg, _cfg_path, trainset_dir, _meta_path = _create_project_with_config(tmpdir)
+ # Seed an existing metadata file with one shuffle
+ _create_doc_data(cfg, trainset_dir, 0.5, 1, [0, 1], [2, 3])
+ seed = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+ seed.save()
+
+ metadata.update_metadata(
+ cfg,
+ train_fraction=0.5,
+ shuffle=2,
+ engine=Engine.PYTORCH,
+ train_indices=[0, 1],
+ test_indices=[2, 3],
+ )
+
+ reloaded = metadata.TrainingDatasetMetadata.load(cfg)
+ indices = [s.index for s in reloaded.shuffles]
+ assert 2 in indices
+
+
+def test_update_metadata_overwrite(tmpdir):
+ """update_metadata with overwrite=True replaces an existing shuffle."""
+ cfg, _cfg_path, trainset_dir, _meta_path = _create_project_with_config(tmpdir)
+ _create_doc_data(cfg, trainset_dir, 0.5, 1, [0, 1], [2, 3])
+ seed = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+ seed.save()
+
+ metadata.update_metadata(
+ cfg,
+ train_fraction=0.5,
+ shuffle=1,
+ engine=Engine.TF,
+ train_indices=[0, 1],
+ test_indices=[2, 3],
+ overwrite=True,
+ )
+
+ reloaded = metadata.TrainingDatasetMetadata.load(cfg)
+ assert len(reloaded.shuffles) == 1
+ assert reloaded.shuffles[0].engine == Engine.TF
+
+
+def test_update_metadata_raises_without_overwrite(tmpdir):
+ """update_metadata raises RuntimeError when shuffle exists and overwrite=False."""
+ cfg, _cfg_path, trainset_dir, _meta_path = _create_project_with_config(tmpdir)
+ _create_doc_data(cfg, trainset_dir, 0.5, 1, [0, 1], [2, 3])
+ seed = metadata.TrainingDatasetMetadata(cfg, (SHUFFLES[1],))
+ seed.save()
+
+ with pytest.raises(RuntimeError):
+ metadata.update_metadata(
+ cfg,
+ train_fraction=0.5,
+ shuffle=1,
+ engine=Engine.PYTORCH,
+ train_indices=[0, 1],
+ test_indices=[2, 3],
+ overwrite=False,
+ )
+
+
+def _create_project_with_config(
+ tmp,
+ task: str = "example",
+ date: str = "Feb21",
+ scorer: str = "wayneRooney",
+ iteration: int = 0,
+ engine: str | None = None,
+):
+ project_dir = tmp.mkdir("ex-ample-2024-02-21")
+ cfg = {
+ "Task": task,
+ "date": date,
+ "scorer": scorer,
+ "iteration": iteration,
+ "project_path": str(project_dir),
+ }
+ if engine is not None:
+ cfg["engine"] = engine
+
+ cfg_path = project_dir.join("config.yaml")
+ with open(cfg_path, "w") as file:
+ YAML().dump(cfg, file)
+
+ it = f"iteration-{iteration}"
+ dir_name = "UnaugmentedDataSet_" + task + date
+ trainset_dir = project_dir.mkdir("training-datasets").mkdir(it).mkdir(dir_name)
+
+ meta_path = trainset_dir.join("metadata.yaml")
+ return cfg, cfg_path, trainset_dir, meta_path
+
+
+def _create_doc_data(
+ cfg,
+ trainset_dir,
+ train_frac,
+ shuffle,
+ train_indices,
+ test_indices,
+) -> None:
+ _, doc_path = auxiliaryfunctions.get_data_and_metadata_filenames(trainset_dir, train_frac, shuffle, cfg)
+ auxiliaryfunctions.save_metadata(doc_path, {}, list(train_indices), list(test_indices), train_frac)
diff --git a/tests/pose_estimation_pytorch/apis/test_apis_evaluate.py b/tests/pose_estimation_pytorch/apis/test_apis_evaluate.py
new file mode 100644
index 0000000000..253841df54
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_apis_evaluate.py
@@ -0,0 +1,470 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from dataclasses import dataclass
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.apis as apis
+import deeplabcut.pose_estimation_pytorch.data as data
+
+PREDICT = Mock()
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.evaluation.predict", PREDICT)
+@pytest.mark.parametrize("num_individuals", [1, 2, 5])
+@pytest.mark.parametrize(
+ "bodyparts, error",
+ [
+ (["nose", "left_ear"], [5, 10]),
+ (["nose", "left_ear", "right_ear"], [2, 3, 4]),
+ ],
+)
+def test_evaluate_basic(
+ num_individuals: int,
+ bodyparts: list[str],
+ error: list[float],
+) -> None:
+ print()
+ gt, pred = generate_data(1, num_individuals, len(bodyparts), error)
+
+ pose_runner = Mock()
+
+ PREDICT.return_value = {img: {"bodyparts": pose} for img, pose in pred.items()}
+ loader = build_mock_loader(gt, num_individuals, bodyparts)
+ results, preds = apis.evaluate(pose_runner, loader, mode="test")
+ print("results", results)
+ np.testing.assert_almost_equal(results["rmse"], np.mean(error))
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.evaluation.predict", PREDICT)
+@pytest.mark.parametrize("num_individuals", [1, 2, 5])
+@pytest.mark.parametrize(
+ "bodyparts, error",
+ [
+ (["nose", "left_ear"], [5, 10]),
+ (["nose", "left_ear", "right_ear"], [2, 3, 4]),
+ ],
+)
+@pytest.mark.parametrize(
+ "unique_bodyparts, unique_error",
+ [
+ (["top_left"], [2]),
+ (["top_left", "bottom_right"], [2, 3]),
+ ],
+)
+def test_evaluate_with_unique_bodyparts(
+ num_individuals: int,
+ bodyparts: list[str],
+ error: list[float],
+ unique_bodyparts: list[str],
+ unique_error: list[float],
+) -> None:
+ print()
+ num_images = 5
+ gt, pred = generate_data(num_images, num_individuals, len(bodyparts), error)
+ gt_unique, pred_unique = generate_data(num_images, 1, len(unique_bodyparts), unique_error)
+
+ pose_runner = Mock()
+ PREDICT.return_value = {
+ img: {"bodyparts": pose, "unique_bodyparts": pred_unique[img]} for img, pose in pred.items()
+ }
+ loader = build_mock_loader(gt, num_individuals, bodyparts, gt_unique=gt_unique, unique=unique_bodyparts)
+ results, preds = apis.evaluate(pose_runner, loader, mode="test")
+ idv_errors = np.tile(error, (num_individuals, 1)).reshape(-1)
+ expected_rmse = np.mean(np.concatenate([idv_errors, unique_error]))
+ print(num_individuals)
+ print(error)
+ print(idv_errors)
+ print(unique_error)
+ print(np.concatenate([idv_errors, unique_error]))
+ print(expected_rmse)
+ print("results", results)
+ np.testing.assert_almost_equal(results["rmse"], expected_rmse)
+
+
+@dataclass
+class CompTestConfig:
+ num_individuals: int = 1
+ bodyparts: tuple[str, ...] = ("nose", "left_ear")
+ error: tuple[float, ...] = (5, 10)
+ unique_bodyparts: tuple[str, ...] = ("top_left",)
+ unique_error: tuple[float, ...] = (2,)
+ comparison_bodyparts: str | list[str] | None = None
+ expected_error: float = (2 + 5 + 10) / 3
+
+ def num_bpt(self) -> int:
+ return len(self.bodyparts)
+
+ def num_unique(self) -> int:
+ return len(self.unique_bodyparts)
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.evaluation.predict", PREDICT)
+@pytest.mark.parametrize(
+ "cfg",
+ [
+ CompTestConfig(comparison_bodyparts=None),
+ CompTestConfig(comparison_bodyparts="all"),
+ CompTestConfig(comparison_bodyparts=["nose", "left_ear", "top_left"]),
+ CompTestConfig(num_individuals=2, expected_error=(2 + 5 + 5 + 10 + 10) / 5),
+ CompTestConfig(comparison_bodyparts="nose", expected_error=5),
+ CompTestConfig(comparison_bodyparts=["nose"], expected_error=5),
+ CompTestConfig(comparison_bodyparts=["left_ear"], expected_error=10),
+ CompTestConfig(comparison_bodyparts=["nose", "left_ear"], expected_error=7.5),
+ CompTestConfig(comparison_bodyparts="top_left", expected_error=2),
+ CompTestConfig(comparison_bodyparts=["top_left"], expected_error=2),
+ CompTestConfig(
+ unique_bodyparts=("a", "b", "c"),
+ unique_error=(3.0, 4.0, 5.0),
+ comparison_bodyparts=["a", "b", "c"],
+ expected_error=4,
+ ),
+ CompTestConfig(
+ num_individuals=1,
+ unique_bodyparts=("a", "b", "c"),
+ unique_error=(3.0, 4.0, 5.0),
+ comparison_bodyparts=["nose", "a", "b", "c"],
+ expected_error=(5.0 + 3.0 + 4.0 + 5.0) / 4,
+ ),
+ CompTestConfig(
+ num_individuals=7,
+ unique_bodyparts=("a", "b", "c"),
+ unique_error=(3.0, 4.0, 5.0),
+ comparison_bodyparts=["nose", "left_ear", "a", "b"],
+ expected_error=((7 * 5) + (7 * 10) + 3.0 + 4.0) / (7 + 7 + 2),
+ ),
+ ],
+)
+def test_evaluate_with_comparison_bodyparts(cfg: CompTestConfig) -> None:
+ print()
+ num_images = 5
+ gt, pred = generate_data(num_images, cfg.num_individuals, cfg.num_bpt(), cfg.error)
+ gt_unique, pred_unique = generate_data(num_images, 1, cfg.num_unique(), cfg.unique_error)
+
+ pose_runner = Mock()
+ PREDICT.return_value = {
+ img: {"bodyparts": pose, "unique_bodyparts": pred_unique[img]} for img, pose in pred.items()
+ }
+ loader = build_mock_loader(
+ gt,
+ cfg.num_individuals,
+ cfg.bodyparts,
+ gt_unique=gt_unique,
+ unique=cfg.unique_bodyparts,
+ )
+ results, preds = apis.evaluate(
+ pose_runner,
+ loader,
+ mode="test",
+ comparison_bodyparts=cfg.comparison_bodyparts,
+ )
+ print(cfg)
+ print("results", results)
+ np.testing.assert_almost_equal(results["rmse"], cfg.expected_error)
+
+
+@dataclass
+class KeypointData:
+ img: int
+ idv: int
+ bodypart: str
+ gt: tuple[float, float]
+ pred: tuple[float, float]
+ score: float
+
+ def image(self) -> str:
+ return f"image_{self.img:04d}.png"
+
+ def error(self) -> float:
+ return np.linalg.norm(np.asarray(self.gt, dtype=float) - np.asarray(self.pred, dtype=float)).item()
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.evaluation.predict", PREDICT)
+@pytest.mark.parametrize(
+ "pcutoff",
+ [0.4, 0.6, 0.8, [0.3, 0.5, 0.7]],
+)
+@pytest.mark.parametrize(
+ "keypoints",
+ [
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(10, 10), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 20), pred=(21, 20), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="c", gt=(20, 20), pred=(20, 22), score=0.5),
+ ],
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(10, 10), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 20), pred=(21, 20), score=0.5),
+ KeypointData(img=0, idv=0, bodypart="c", gt=(30, 30), pred=(30, 32), score=0.2),
+ KeypointData(img=0, idv=1, bodypart="a", gt=(40, 10), pred=(41, 10), score=0.7),
+ KeypointData(img=0, idv=1, bodypart="b", gt=(50, 20), pred=(49, 20), score=0.5),
+ KeypointData(img=0, idv=1, bodypart="c", gt=(60, 20), pred=(58, 20), score=0.2),
+ ],
+ ],
+)
+def test_evaluate_with_pcutoff(
+ pcutoff: float | list[float],
+ keypoints: list[KeypointData],
+) -> None:
+ print()
+
+ images = {d.image() for d in keypoints}
+ individuals = list({d.idv for d in keypoints if d.idv != -1})
+ bodyparts = list({d.bodypart for d in keypoints if d.idv != -1})
+ unique_bodyparts = list({d.bodypart for d in keypoints if d.idv == -1})
+
+ num_idv = len(individuals)
+ num_bodyparts = len(bodyparts)
+ len(unique_bodyparts)
+
+ gt, pred = {}, {}
+ for img in images:
+ gt[img] = np.zeros((num_idv, num_bodyparts, 3))
+ pred[img] = np.zeros((num_idv, num_bodyparts, 3))
+
+ errors = []
+ errors_cutoff = []
+ for kpt in keypoints:
+ img = kpt.image()
+ bpt = bodyparts.index(kpt.bodypart)
+
+ gt[img][kpt.idv, bpt, :2] = kpt.gt
+ gt[img][kpt.idv, bpt, 2] = 2
+ pred[img][kpt.idv, bpt, :2] = kpt.pred
+ pred[img][kpt.idv, bpt, 2] = kpt.score
+
+ if isinstance(pcutoff, list):
+ bpt_cutoff = pcutoff[bpt]
+ else:
+ bpt_cutoff = pcutoff
+
+ errors.append(kpt.error())
+ if kpt.score >= bpt_cutoff:
+ errors_cutoff.append(kpt.error())
+
+ print(errors)
+ print(errors_cutoff)
+
+ pose_runner = Mock()
+ PREDICT.return_value = {img: {"bodyparts": pose} for img, pose in pred.items()}
+ loader = build_mock_loader(gt, num_idv, bodyparts)
+ results, preds = apis.evaluate(pose_runner, loader, mode="test", pcutoff=pcutoff)
+ print("results", results)
+ np.testing.assert_almost_equal(results["rmse"], np.mean(errors))
+ np.testing.assert_almost_equal(results["rmse_pcutoff"], np.mean(errors_cutoff))
+ if "rmse_detections" in results:
+ np.testing.assert_almost_equal(results["rmse_detections"], np.mean(errors))
+ np.testing.assert_almost_equal(results["rmse_detections_pcutoff"], np.mean(errors_cutoff))
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.evaluation.predict", PREDICT)
+@pytest.mark.parametrize(
+ "pcutoff",
+ [
+ 0.4,
+ 0.6,
+ 0.8,
+ [0.3, 0.5, 0.7, 0.4, 0.6],
+ [0.25, 0.43, 0.61, 0.46, 0.92],
+ [0.12, 0.15, 0.92, 0.97, 0.85],
+ [0.92, 0.97, 0.85, 0.12, 0.15],
+ ],
+)
+@pytest.mark.parametrize(
+ "keypoints",
+ [
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(10, 10), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 20), pred=(21, 20), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="c", gt=(20, 20), pred=(20, 22), score=0.5),
+ KeypointData(img=0, idv=-1, bodypart="u1", gt=(20, 20), pred=(20, 22), score=0.5),
+ KeypointData(img=0, idv=-1, bodypart="u2", gt=(20, 20), pred=(20, 22), score=0.3),
+ ],
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(10, 10), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 20), pred=(21, 20), score=0.5),
+ KeypointData(img=0, idv=0, bodypart="c", gt=(30, 30), pred=(30, 32), score=0.2),
+ KeypointData(img=0, idv=1, bodypart="a", gt=(40, 10), pred=(41, 10), score=0.7),
+ KeypointData(img=0, idv=1, bodypart="b", gt=(50, 20), pred=(49, 20), score=0.5),
+ KeypointData(img=0, idv=1, bodypart="c", gt=(60, 20), pred=(58, 20), score=0.2),
+ KeypointData(img=0, idv=-1, bodypart="u1", gt=(2, 3), pred=(3, 3), score=0.7),
+ KeypointData(img=0, idv=-1, bodypart="u2", gt=(20, 20), pred=(20, 22), score=0.9),
+ ],
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(8, 13), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 27), pred=(21, 20), score=0.5),
+ KeypointData(img=0, idv=0, bodypart="c", gt=(30, 36), pred=(30, 32), score=0.2),
+ KeypointData(img=0, idv=-1, bodypart="u1", gt=(2, 3), pred=(3, 3), score=0.7),
+ KeypointData(img=0, idv=-1, bodypart="u2", gt=(20, 20), pred=(20, 22), score=0.9),
+ KeypointData(img=1, idv=0, bodypart="a", gt=(15, 20), pred=(41, 10), score=0.7),
+ KeypointData(img=1, idv=0, bodypart="b", gt=(20, 12), pred=(49, 20), score=0.5),
+ KeypointData(img=1, idv=0, bodypart="c", gt=(17, 32), pred=(58, 20), score=0.2),
+ KeypointData(img=1, idv=-1, bodypart="u1", gt=(37, 4), pred=(3, 3), score=0.7),
+ KeypointData(img=1, idv=-1, bodypart="u2", gt=(12, 6), pred=(20, 22), score=0.9),
+ ],
+ [
+ KeypointData(img=0, idv=0, bodypart="a", gt=(8, 13), pred=(11, 10), score=0.7),
+ KeypointData(img=0, idv=0, bodypart="b", gt=(20, 27), pred=(21, 20), score=0.5),
+ KeypointData(img=0, idv=-1, bodypart="u1", gt=(30, 36), pred=(30, 32), score=0.2),
+ KeypointData(img=0, idv=-1, bodypart="u2", gt=(2, 3), pred=(3, 3), score=0.7),
+ KeypointData(img=0, idv=-1, bodypart="u3", gt=(20, 20), pred=(20, 22), score=0.9),
+ KeypointData(img=1, idv=0, bodypart="a", gt=(15, 20), pred=(41, 10), score=0.7),
+ KeypointData(img=1, idv=0, bodypart="b", gt=(20, 12), pred=(49, 20), score=0.5),
+ KeypointData(img=1, idv=-1, bodypart="u1", gt=(17, 32), pred=(58, 20), score=0.2),
+ KeypointData(img=1, idv=-1, bodypart="u2", gt=(37, 4), pred=(3, 3), score=0.7),
+ KeypointData(img=1, idv=-1, bodypart="u3", gt=(12, 6), pred=(20, 22), score=0.9),
+ ],
+ ],
+)
+def test_evaluate_with_pcutoff_and_unique_bodyparts(
+ pcutoff: float | list[float],
+ keypoints: list[KeypointData],
+) -> None:
+ print()
+
+ images = {d.image() for d in keypoints}
+ individuals = list({d.idv for d in keypoints if d.idv != -1})
+ bodyparts = list({d.bodypart for d in keypoints if d.idv != -1})
+ unique_bodyparts = list({d.bodypart for d in keypoints if d.idv == -1})
+
+ num_idv = len(individuals)
+ num_bodyparts = len(bodyparts)
+ num_unique = len(unique_bodyparts)
+
+ gt, pred, gt_unique, pred_unique = {}, {}, {}, {}
+ for img in images:
+ gt[img] = np.zeros((num_idv, num_bodyparts, 3))
+ pred[img] = np.zeros((num_idv, num_bodyparts, 3))
+ gt_unique[img] = np.zeros((1, num_unique, 3))
+ pred_unique[img] = np.zeros((1, num_unique, 3))
+
+ errors, errors_cutoff = [], []
+ for kpt in keypoints:
+ img = kpt.image()
+ if kpt.idv == -1:
+ idv, bpt = 0, unique_bodyparts.index(kpt.bodypart)
+ pcutoff_idx = bpt + len(bodyparts) # offset by number of bodyparts
+ gt_data, pred_data = gt_unique[img], pred_unique[img]
+ else:
+ idv, bpt = kpt.idv, bodyparts.index(kpt.bodypart)
+ pcutoff_idx = bpt
+ gt_data, pred_data = gt[img], pred[img]
+
+ gt_data[idv, bpt, :2] = kpt.gt
+ gt_data[idv, bpt, 2] = 2
+ pred_data[idv, bpt, :2] = kpt.pred
+ pred_data[idv, bpt, 2] = kpt.score
+
+ if isinstance(pcutoff, list):
+ bpt_cutoff = pcutoff[pcutoff_idx]
+ else:
+ bpt_cutoff = pcutoff
+
+ errors.append(kpt.error())
+ if kpt.score >= bpt_cutoff:
+ errors_cutoff.append(kpt.error())
+
+ print(errors)
+ print(errors_cutoff)
+
+ pose_runner = Mock()
+ PREDICT.return_value = {
+ img: {"bodyparts": pose, "unique_bodyparts": pred_unique[img]} for img, pose in pred.items()
+ }
+ loader = build_mock_loader(gt, num_idv, bodyparts, gt_unique, unique_bodyparts)
+ results, preds = apis.evaluate(pose_runner, loader, mode="test", pcutoff=pcutoff)
+
+ print("results", results)
+ np.testing.assert_almost_equal(results["rmse"], np.mean(errors))
+ np.testing.assert_almost_equal(results["rmse_pcutoff"], np.mean(errors_cutoff))
+ if "rmse_detections" in results:
+ np.testing.assert_almost_equal(results["rmse_detections"], np.mean(errors))
+ np.testing.assert_almost_equal(results["rmse_detections_pcutoff"], np.mean(errors_cutoff))
+
+
+def generate_data(
+ num_images: int,
+ num_individuals: int,
+ num_bodyparts: int,
+ error: list[float] | tuple[float, ...] | np.ndarray,
+ cutoffs: list[float] | tuple[float, ...] | np.ndarray | None = None,
+ error_cutoff: list[float] | tuple[float, ...] | np.ndarray | None = None,
+) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
+ num_elems = num_individuals * num_bodyparts
+ shape = num_individuals, num_bodyparts, 3
+ error = np.asarray(error)
+ coord_error = (np.sqrt(2) / 2) * error
+
+ gt, pred = {}, {}
+ for img in range(num_images):
+ gt_pose = 100 * np.arange(3 * num_elems, dtype=float).reshape(shape)
+ gt_pose[..., 2] = 2
+ gt[f"img_{img:04d}.png"] = gt_pose
+
+ pred_pose = np.ones(shape, dtype=float)
+ pred_pose[..., :2] = gt_pose[..., :2]
+ pred_pose[:, :, 0] += coord_error
+ pred_pose[:, :, 1] += coord_error
+ pred[f"img_{img:04d}.png"] = pred_pose
+
+ if error_cutoff is not None and cutoffs is not None:
+ for img in range(num_images):
+ gt_pose = 100 * np.arange(3 * num_elems, dtype=float).reshape(shape)
+ gt_pose[..., 2] = 2
+ gt[f"img_{num_images + img:04d}.png"] = gt_pose
+
+ pred_pose = np.ones(shape, dtype=float)
+ pred_pose[..., :2] = gt_pose[..., :2]
+ pred_pose[..., 2] = cutoffs
+ pred_pose[:, :, 0] += coord_error
+ pred_pose[:, :, 1] += coord_error
+ pred[f"img_{num_images + img:04d}.png"] = pred_pose
+
+ return gt, pred
+
+
+def build_mock_loader(
+ gt: dict[str, np.ndarray],
+ num_individuals: int,
+ bodyparts: list[str] | tuple[str, ...],
+ gt_unique: dict[str, np.ndarray] | None = None,
+ unique: list[str] | tuple[str, ...] | None = None,
+) -> Mock:
+ if unique is None:
+ unique = []
+
+ def _gt(mode: str, unique_bodypart: bool = False) -> dict[str, np.ndarray]:
+ if unique_bodypart:
+ print("LOADING UNIQUE GT")
+ return gt_unique
+ print("LOADING GT")
+ return gt
+
+ individuals = [f"animal_{i:03d}" for i in range(num_individuals)]
+ loader = Mock()
+ loader.get_dataset_parameters.return_value = data.PoseDatasetParameters(
+ bodyparts=bodyparts,
+ unique_bpts=unique,
+ individuals=individuals,
+ )
+ loader.ground_truth_keypoints = _gt
+ loader.model_cfg = {
+ "metadata": {
+ "bodyparts": bodyparts,
+ "unique_bodyparts": unique,
+ "individuals": individuals,
+ "with_identity": False,
+ },
+ "train_settings": {},
+ }
+ return loader
diff --git a/tests/pose_estimation_pytorch/apis/test_apis_export.py b/tests/pose_estimation_pytorch/apis/test_apis_export.py
new file mode 100644
index 0000000000..021a994f96
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_apis_export.py
@@ -0,0 +1,308 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests exporting models."""
+
+import copy
+import shutil
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+import torch
+from ruamel.yaml.scalarstring import SingleQuotedScalarString as SQS
+
+import deeplabcut.pose_estimation_pytorch.apis.export as export
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.pose_estimation_pytorch import Task
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot
+
+
+@pytest.fixture()
+def project_dir(tmp_path_factory) -> Path:
+ project_dir = tmp_path_factory.mktemp("tmp-project")
+ print("\nTemporary project directory:")
+ print(str(project_dir))
+ print("---")
+ yield project_dir
+ shutil.rmtree(str(project_dir))
+
+
+def _mock_multianimal_project(project_dir: Path):
+ video_dir = project_dir / "videos"
+ video_dir.mkdir(exist_ok=True)
+
+ cfg_file, yaml_file = af.create_config_template(multianimal=True)
+ yaml_file.width = 10_000
+
+ cfg_file["Task"] = "mock"
+ cfg_file["scorer"] = "mock"
+ cfg_file["video_sets"] = {SQS((video_dir / "vid.mp4").as_posix()): {"crop": "0, 640, 0, 480"}}
+ cfg_file["project_path"] = project_dir.as_posix()
+ cfg_file["individuals"] = ["a", "b"]
+ cfg_file["uniquebodyparts"] = []
+ cfg_file["multianimalbodyparts"] = ["k1", "k2", "k3"]
+ cfg_file["bodyparts"] = "MULTI!"
+
+ with open(project_dir / "config.yaml", "w", encoding="utf-8") as f:
+ yaml_file.dump(cfg_file, f)
+
+
+def _make_mock_loader(
+ project_path: Path,
+ project_task: str,
+ project_iteration: int,
+ model_folder: Path,
+ net_type: str,
+ pose_task: Task,
+ default_snapshot_index: int | str,
+ default_detector_snapshot_index: int | str,
+) -> Mock:
+ loader = Mock()
+ loader.project_path = project_path
+ loader.model_folder = model_folder
+ loader.pose_task = pose_task
+ loader.shuffle = 0
+
+ loader.project_cfg = dict(
+ project_path=str(project_path),
+ Task=project_task,
+ date="Jan12",
+ TrainingFraction=[0.95],
+ snapshotindex=default_snapshot_index,
+ detector_snapshotindex=default_detector_snapshot_index,
+ iteration=project_iteration,
+ )
+ loader.model_cfg = dict(
+ net_type=net_type,
+ metadata=dict(
+ project_path=str(project_path),
+ pose_config_path=str(loader.model_folder / "pytorch_config.yaml"),
+ ),
+ weight_init=None,
+ resume_training_from=None,
+ )
+ if pose_task == Task.TOP_DOWN:
+ loader.model_cfg["detector"] = dict(resume_training_from=None)
+
+ return loader
+
+
+def _get_export_model_data(
+ project_dir: Path,
+ num_snapshots: int,
+ task: Task,
+ project_iteration: int = 0,
+):
+ _mock_multianimal_project(project_dir)
+
+ model_dir = Path(project_dir) / f"iteration-{project_iteration}" / "fake-shuffle-0"
+ model_dir.mkdir(exist_ok=True, parents=True)
+ snapshots = []
+ snapshot_data = []
+ for i in range(num_snapshots):
+ snapshot = dict(model=dict(idx=i))
+ snapshot_path = model_dir / f"snapshot-{i:03}.pt"
+ torch.save(snapshot, snapshot_path)
+ snapshots.append(Snapshot(best=False, epochs=i, path=snapshot_path))
+ snapshot_data.append(snapshot)
+
+ detector_snapshots = []
+ detector_data = []
+ if task == Task.TOP_DOWN:
+ for i in range(num_snapshots):
+ snapshot = dict(model=dict(idx=i))
+ snapshot_path = model_dir / f"snapshot-detector-{i:03}.pt"
+ torch.save(snapshot, snapshot_path)
+ detector_data.append(snapshot)
+ detector_snapshots.append(Snapshot(best=False, epochs=i, path=snapshot_path))
+
+ mock_loader = _make_mock_loader(
+ project_path=project_dir,
+ project_task="mock",
+ project_iteration=project_iteration,
+ model_folder=model_dir,
+ net_type="fake-net",
+ pose_task=task,
+ default_snapshot_index=-1,
+ default_detector_snapshot_index=-1,
+ )
+ return mock_loader, snapshots, snapshot_data, detector_snapshots, detector_data
+
+
+@pytest.mark.parametrize(
+ "task, num_snapshots, idx, detector_idx",
+ [
+ (Task.BOTTOM_UP, 10, 0, None),
+ (Task.BOTTOM_UP, 10, 5, None),
+ (Task.BOTTOM_UP, 10, -1, None),
+ (Task.TOP_DOWN, 10, 0, 0),
+ (Task.TOP_DOWN, 10, -1, 0),
+ (Task.TOP_DOWN, 10, -1, 5),
+ (Task.TOP_DOWN, 10, -1, -1),
+ ],
+)
+def test_export_model(
+ project_dir,
+ task: Task,
+ num_snapshots: int,
+ idx: int,
+ detector_idx: int | None,
+):
+ test_data = _get_export_model_data(project_dir, num_snapshots, task)
+ mock_loader, snapshots, snapshot_data, detector_snapshots, detector_data = test_data
+
+ def get_mock_loader(*args, **kwargs):
+ return mock_loader
+
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.dlc3_data.DLCLoader",
+ get_mock_loader,
+ ):
+ # export the model
+ export.export_model(
+ project_dir / "config.yaml",
+ snapshotindex=idx,
+ detector_snapshot_index=detector_idx,
+ )
+
+ # check that the correct snapshot was exported
+ snapshot = snapshots[idx]
+ detector = None
+ if task == Task.TOP_DOWN:
+ detector = detector_snapshots[detector_idx]
+
+ dir_name = export.get_export_folder_name(mock_loader)
+ filename = export.get_export_filename(mock_loader, snapshot, detector)
+ expected_export = project_dir / "exported-models-pytorch" / dir_name / filename
+ assert expected_export.exists()
+
+ # check that content of the exports are correct
+ exported_data = torch.load(expected_export, weights_only=True)
+ assert isinstance(exported_data, dict)
+ assert "config" in exported_data
+ assert exported_data["config"] == mock_loader.model_cfg
+
+ assert "pose" in exported_data
+ assert exported_data["pose"] == snapshot_data[idx]["model"]
+
+ if task == Task.TOP_DOWN:
+ assert "detector" in exported_data
+ assert exported_data["detector"] == detector_data[detector_idx]["model"]
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.export.wipe_paths_from_model_config")
+@pytest.mark.parametrize("task", [Task.BOTTOM_UP, Task.TOP_DOWN])
+def test_export_model_clear_paths(mock_wipe: Mock, project_dir, task: Task):
+ test_data = _get_export_model_data(project_dir, 1, task)
+ mock_loader, snapshots, snapshot_data, detector_snapshots, detector_data = test_data
+
+ def get_mock_loader(*args, **kwargs):
+ return mock_loader
+
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.dlc3_data.DLCLoader",
+ get_mock_loader,
+ ):
+ export.export_model(project_dir / "config.yaml", wipe_paths=True)
+
+ # check that wipe_paths_from_model_config was called
+ assert mock_wipe.call_count == 1
+
+
+@pytest.mark.parametrize("task", [Task.BOTTOM_UP, Task.TOP_DOWN])
+@pytest.mark.parametrize("overwrite", [True, False])
+def test_export_overwrite(project_dir, task: Task, overwrite: bool):
+ test_data = _get_export_model_data(project_dir, 1, task)
+ mock_loader, snapshots, snapshot_data, detector_snapshots, detector_data = test_data
+ snapshot = snapshots[0]
+ detector = None if task == Task.BOTTOM_UP else detector_snapshots[0]
+
+ def get_mock_loader(*args, **kwargs):
+ return mock_loader
+
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.dlc3_data.DLCLoader",
+ get_mock_loader,
+ ):
+ dir_name = export.get_export_folder_name(mock_loader)
+ filename = export.get_export_filename(mock_loader, snapshot, detector)
+ expected_export = project_dir / "exported-models-pytorch" / dir_name / filename
+ expected_export.parent.mkdir(exist_ok=False, parents=True)
+
+ # add existing data
+ assert not expected_export.exists()
+ existing_data = dict()
+ torch.save(existing_data, expected_export)
+
+ # export data
+ export.export_model(project_dir / "config.yaml", overwrite=overwrite)
+
+ exported_data = torch.load(expected_export, weights_only=True)
+
+ if overwrite:
+ assert existing_data != exported_data
+ else:
+ assert existing_data == exported_data
+
+
+@pytest.mark.parametrize("task", [Task.BOTTOM_UP, Task.TOP_DOWN])
+@pytest.mark.parametrize("iteration", [5, 12])
+def test_export_change_iteration(project_dir, task: Task, iteration: int):
+ test_data = _get_export_model_data(
+ project_dir,
+ 1,
+ task,
+ project_iteration=0,
+ )
+ mock_loader, snapshots, snapshot_data, detector_snapshots, detector_data = test_data
+ snapshot = snapshots[0]
+ detector = None if task == Task.BOTTOM_UP else detector_snapshots[0]
+
+ loader_diff_iter = _get_export_model_data(project_dir, 1, task, project_iteration=iteration)[0]
+
+ def get_mock_loader(config, *args, **kwargs):
+ _loader = copy.deepcopy(mock_loader)
+ if isinstance(config, dict):
+ _loader = copy.deepcopy(mock_loader)
+ _loader.project_cfg = config
+ return _loader
+
+ def read_mock_config(*args, **kwargs):
+ return copy.deepcopy(mock_loader.project_cfg)
+
+ # patch the DLCLoader but also read_config
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.dlc3_data.DLCLoader",
+ get_mock_loader,
+ ):
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.af.read_config",
+ read_mock_config,
+ ):
+ # check no exports exist yet
+ for loader in [mock_loader, loader_diff_iter]:
+ dir_name = export.get_export_folder_name(loader)
+ filename = export.get_export_filename(loader, snapshot, detector)
+ assert not (project_dir / "exported-models-pytorch" / dir_name / filename).exists()
+
+ # export data
+ export.export_model(project_dir / "config.yaml", iteration=iteration)
+
+ # check the export exists for the correct iteration
+ for loader, file_should_exist in [
+ (mock_loader, False),
+ (loader_diff_iter, True),
+ ]:
+ dir_name = export.get_export_folder_name(loader)
+ filename = export.get_export_filename(loader, snapshot, detector)
+ expected = project_dir / "exported-models-pytorch" / dir_name / filename
+ expected_exists = expected.exists()
+ assert expected_exists == file_should_exist
diff --git a/tests/pose_estimation_pytorch/apis/test_create_tracking_dataset.py b/tests/pose_estimation_pytorch/apis/test_create_tracking_dataset.py
new file mode 100644
index 0000000000..e9ba4996d3
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_create_tracking_dataset.py
@@ -0,0 +1,74 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests method to create the tracking dataset in PyTorch."""
+
+from pathlib import Path
+
+import torch
+
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+import deeplabcut.pose_estimation_pytorch.apis.tracking_dataset as tracking_dataset
+import deeplabcut.pose_estimation_pytorch.models as models
+
+
+class MockLoader(dlc_torch.Loader):
+ """Mock loader for data."""
+
+ def __init__(self, tmp_folder: Path, bodyparts: list[str] | None = None):
+ if bodyparts is None:
+ bodyparts = ["nose", "left_eye", "right_eye", "tail_base"]
+ self.bodyparts = bodyparts
+
+ model_config_path = tmp_folder / "pytorch_config.yaml"
+ dlc_torch.config.make_pytorch_pose_config(
+ project_config=dlc_torch.config.make_basic_project_config(
+ dataset_path=str(tmp_folder),
+ bodyparts=self.bodyparts,
+ max_individuals=3,
+ ),
+ pose_config_path=tmp_folder / "pytorch_config.yaml",
+ net_type="resnet_50",
+ save=True,
+ )
+ super().__init__(
+ str(tmp_folder),
+ str(tmp_folder / "labeled-data"),
+ model_config_path,
+ )
+
+ def load_data(self, mode: str = "train") -> dict[str, list[dict]]:
+ return {
+ "annotations": [],
+ "categories": [],
+ "images": [],
+ }
+
+ def get_dataset_parameters(self) -> dlc_torch.PoseDatasetParameters:
+ return dlc_torch.PoseDatasetParameters(
+ bodyparts=self.bodyparts,
+ unique_bpts=[],
+ individuals=self.model_cfg["metadata"]["individuals"],
+ )
+
+
+def test_build_feature_extraction_runner(tmp_path_factory):
+ tmp_folder = Path(tmp_path_factory.mktemp("tmp-project"))
+
+ loader = MockLoader(tmp_folder=tmp_folder)
+ model = models.PoseModel.build(loader.model_cfg["model"])
+ snapshot_path = loader.model_folder / "snapshot.pt"
+ torch.save(dict(model=model.state_dict()), snapshot_path)
+ _ = tracking_dataset.build_feature_extraction_runner(
+ loader=loader,
+ snapshot_path=snapshot_path,
+ device="cpu",
+ batch_size=1,
+ )
diff --git a/tests/pose_estimation_pytorch/apis/test_tracklets.py b/tests/pose_estimation_pytorch/apis/test_tracklets.py
new file mode 100644
index 0000000000..8e06b4a55e
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_tracklets.py
@@ -0,0 +1,100 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.apis.tracklets import build_tracklets
+
+
+@pytest.mark.parametrize(
+ "assemblies_data, inference_cfg, joints, scorer, num_frames, unique_bodyparts",
+ [
+ (
+ # assemblies_data
+ {
+ "single": {
+ 0: np.array([[1, 2, 0.9]]),
+ 1: np.array([[1, 3, 0.7]]),
+ 2: np.array([[0, 1, 0.9]]),
+ },
+ 0: [
+ np.array([[10, 20, 0.9, -1], [30, 40, 0.8, -1]]),
+ np.array([[13, 23, 0.9, -1], [33, 43, 0.8, -1]]),
+ ],
+ 1: [
+ np.array([[9, 19, 0.9, -1], [29, 41, 0.8, -1]]),
+ np.array([[15, 21, 0.9, -1], [35, 45, 0.8, -1]]),
+ ],
+ 2: [
+ np.array([[13, 23, 0.9, -1], [33, 43, 0.8, -1]]),
+ np.array([[10, 20, 0.9, -1], [30, 40, 0.8, -1]]),
+ ],
+ },
+ # inference_cfg
+ {"max_age": 3, "min_hits": 1, "topktoretain": 1, "pcutoff": 0.5},
+ # joints
+ ["nose", "ear"],
+ # scorer
+ "DLC",
+ # num_frames
+ 3,
+ # unique_bodyparts
+ ["led"],
+ ),
+ (
+ # assemblies_data
+ {
+ 0: [
+ np.array([[10, 20, 0.9, -1], [30, 40, 0.8, -1]]),
+ np.array([[13, 23, 0.9, -1], [33, 43, 0.8, -1]]),
+ ],
+ 1: [
+ np.array([[9, 19, 0.9, -1], [29, 41, 0.8, -1]]),
+ np.array([[15, 21, 0.9, -1], [35, 45, 0.8, -1]]),
+ ],
+ 2: [
+ np.array([[13, 23, 0.9, -1], [33, 43, 0.8, -1]]),
+ np.array([[10, 20, 0.9, -1], [30, 40, 0.8, -1]]),
+ ],
+ },
+ # inference_cfg
+ {"max_age": 3, "min_hits": 1, "topktoretain": 1, "pcutoff": 0.5},
+ # joints
+ ["nose", "ear"],
+ # scorer
+ "DLC",
+ # num_frames
+ 3,
+ # unique_bodyparts
+ None,
+ ),
+ ],
+)
+def test_build_tracklets(
+ assemblies_data: dict,
+ inference_cfg: dict,
+ joints: list,
+ scorer: str,
+ num_frames: int,
+ unique_bodyparts: list,
+):
+ # Run the function
+ tracklets = build_tracklets(
+ assemblies_data=assemblies_data,
+ track_method="box",
+ inference_cfg=inference_cfg,
+ joints=joints,
+ scorer=scorer,
+ num_frames=num_frames,
+ unique_bodyparts=unique_bodyparts,
+ identity_only=False,
+ )
+
+ # # Assertions
+ assert "header" in tracklets
+ assert isinstance(tracklets["header"], pd.MultiIndex)
+ if unique_bodyparts:
+ assert "single" in tracklets
+ else:
+ assert "single" not in tracklets
+
+ assert isinstance(tracklets, dict)
diff --git a/tests/pose_estimation_pytorch/config/test_config_utils.py b/tests/pose_estimation_pytorch/config/test_config_utils.py
new file mode 100644
index 0000000000..4a28ef567a
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/test_config_utils.py
@@ -0,0 +1,67 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Test util functions for config creation."""
+
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.config.utils as utils
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ dict(
+ config={},
+ num_bodyparts=None,
+ num_individuals=None,
+ backbone_output_channels=None,
+ output_config={},
+ ),
+ dict(
+ config={
+ "a": "num_bodyparts",
+ "b": ["num_bodyparts // 2", "num_bodyparts // 3"],
+ "c": "num_bodyparts x 2",
+ "d": "num_bodyparts + 2",
+ },
+ num_bodyparts=10,
+ num_individuals=None,
+ backbone_output_channels=None,
+ output_config={
+ "a": 10,
+ "b": [5, 3],
+ "c": 20,
+ "d": 12,
+ },
+ ),
+ dict(
+ config={
+ "a": [{"b": "num_individuals x 3"}],
+ "b": [[{"b": "num_bodyparts x 3"}]],
+ },
+ num_bodyparts=10,
+ num_individuals=1,
+ backbone_output_channels=None,
+ output_config={
+ "a": [{"b": 3}],
+ "b": [[{"b": 30}]],
+ },
+ ),
+ ],
+)
+def test_replace_default_values_no_extras(data: dict):
+ output_config = utils.replace_default_values(
+ config=data["config"],
+ num_bodyparts=data["num_bodyparts"],
+ num_individuals=data["num_individuals"],
+ backbone_output_channels=data["backbone_output_channels"],
+ )
+ assert output_config == data["output_config"]
diff --git a/tests/pose_estimation_pytorch/config/test_make_pose_config.py b/tests/pose_estimation_pytorch/config/test_make_pose_config.py
new file mode 100644
index 0000000000..a8fbc7b6ff
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/test_make_pose_config.py
@@ -0,0 +1,484 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the pre-processors."""
+
+import pytest
+
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.core.config import pretty_print
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ make_basic_project_config,
+ make_pytorch_pose_config,
+)
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ update_config,
+ update_config_by_dotpath,
+)
+
+
+@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"])
+def test_make_single_animal_config(bodyparts: list[str], net_type: str):
+ # Single animal projects can't have unique bodyparts
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=False,
+ identity=False,
+ individuals=[],
+ bodyparts=bodyparts,
+ unique_bodyparts=[],
+ )
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ pretty_print(pytorch_pose_config)
+
+ # check heads are there
+ assert "bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ # check that the bodypart head has locref and heatmaps and the correct output shapes
+ bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"]
+
+ outputs = [("heatmap_config", len(bodyparts))]
+ if bodypart_head["predictor"]["location_refinement"]:
+ outputs += [("locref_config", 2 * len(bodyparts))]
+
+ for name, output_channels in outputs:
+ head = bodypart_head[name]
+ if "final_conv" in head:
+ actual_output_channels = head["final_conv"]["out_channels"]
+ else:
+ actual_output_channels = head["channels"][-1]
+ assert name in bodypart_head
+ assert actual_output_channels == output_channels
+
+
+@pytest.mark.parametrize("multianimal", [True])
+@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]])
+@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("identity", [False, True])
+@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]])
+@pytest.mark.parametrize("net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"])
+def test_backbone_plus_paf_config(
+ multianimal: bool,
+ individuals: list[str],
+ bodyparts: list[str],
+ identity: bool,
+ unique_bodyparts: list[str],
+ net_type: str,
+):
+ # Single animal projects can't have unique bodyparts
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=multianimal,
+ identity=identity,
+ individuals=individuals,
+ bodyparts=bodyparts,
+ unique_bodyparts=unique_bodyparts,
+ )
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ pretty_print(pytorch_pose_config)
+
+ graph = [[i, j] for i in range(len(bodyparts)) for j in range(i + 1, len(bodyparts))]
+ num_limbs = len(graph) * 2
+
+ # check heads are there
+ assert "bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"]
+
+ # check PAF head
+ assert bodypart_head["type"] == "DLCRNetHead"
+ assert bodypart_head["predictor"]["type"] == "PartAffinityFieldPredictor"
+
+ for name, output_channels in [
+ ("heatmap_config", len(bodyparts)),
+ ("locref_config", len(bodyparts) * 2),
+ ("paf_config", num_limbs),
+ ]:
+ print(name, bodypart_head[name]["channels"])
+ assert name in bodypart_head
+ assert bodypart_head[name]["channels"][-1] == output_channels
+
+ if len(unique_bodyparts) > 0:
+ assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"]
+ for name, output_channels in [
+ ("heatmap_config", len(unique_bodyparts)),
+ ("locref_config", 2 * len(unique_bodyparts)),
+ ]:
+ assert name in unique_bodypart_head
+ assert unique_bodypart_head[name]["channels"][-1] == output_channels
+ assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT"
+
+ if identity:
+ assert "identity" in pytorch_pose_config["model"]["heads"].keys()
+ id_head = pytorch_pose_config["model"]["heads"]["identity"]
+ assert "heatmap_config" in id_head
+ assert id_head["heatmap_config"]["channels"][-1] == len(individuals)
+ assert "locref_config" not in id_head
+ assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL"
+
+
+@pytest.mark.parametrize(
+ "detector",
+ [
+ (None, "SSDLite"),
+ ("ssdlite", "SSDLite"),
+ ("fasterrcnn_mobilenet_v3_large_fpn", "FasterRCNN"),
+ ("fasterrcnn_resnet50_fpn_v2", "FasterRCNN"),
+ ],
+)
+@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]])
+@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("net_type", ["resnet_50", "resnet_101", "hrnet_w18", "hrnet_w32", "hrnet_w48"])
+def test_top_down_config(
+ detector: tuple[str, str],
+ individuals: list[str],
+ bodyparts: list[str],
+ net_type: str,
+):
+ # Single animal projects can't have unique bodyparts
+ detector_type, expected_detector_type = detector
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=True,
+ identity=False,
+ individuals=individuals,
+ bodyparts=bodyparts,
+ unique_bodyparts=[],
+ )
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ top_down=True,
+ detector_type=detector_type,
+ )
+ pretty_print(pytorch_pose_config)
+
+ # check no collate function
+ collate = pytorch_pose_config["data"]["train"].get("collate")
+ print(f"Collate: {collate}")
+ assert not collate
+
+ # check heads are there
+ assert "bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"]
+
+ # check detector is there
+ assert "detector" in pytorch_pose_config.keys()
+ assert pytorch_pose_config["detector"]["model"]["type"] == expected_detector_type
+
+ for name, output_channels in [
+ ("heatmap_config", len(bodyparts)),
+ ]:
+ print(name, bodypart_head[name]["channels"])
+ assert name in bodypart_head
+ assert bodypart_head[name]["final_conv"]["out_channels"] == output_channels
+
+
+@pytest.mark.parametrize("multianimal", [True])
+@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]])
+@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("identity", [False, True])
+@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]])
+@pytest.mark.parametrize("net_type", ["dekr_w18", "dekr_w32", "dekr_w48"])
+def test_make_dekr_config(
+ multianimal: bool,
+ individuals: list[str],
+ bodyparts: list[str],
+ identity: bool,
+ unique_bodyparts: list[str],
+ net_type: str,
+):
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=multianimal,
+ identity=identity,
+ individuals=individuals,
+ bodyparts=bodyparts,
+ unique_bodyparts=unique_bodyparts,
+ )
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ pretty_print(pytorch_pose_config)
+
+ # check heads are there
+ assert "bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"]
+ for name, output_channels in [
+ ("heatmap_config", len(bodyparts) + 1),
+ ("offset_config", len(bodyparts)),
+ ]:
+ print(name, bodypart_head[name]["channels"])
+ assert name in bodypart_head
+ assert bodypart_head[name]["channels"][-1] == output_channels
+
+ if len(unique_bodyparts) > 0:
+ assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"]
+ for name, output_channels in [
+ ("heatmap_config", len(unique_bodyparts)),
+ ("locref_config", 2 * len(unique_bodyparts)),
+ ]:
+ assert name in unique_bodypart_head
+ assert unique_bodypart_head[name]["channels"][-1] == output_channels
+ assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT"
+
+ if identity:
+ assert "identity" in pytorch_pose_config["model"]["heads"].keys()
+ id_head = pytorch_pose_config["model"]["heads"]["identity"]
+ assert "heatmap_config" in id_head
+ assert id_head["heatmap_config"]["channels"][-1] == len(individuals)
+ assert "locref_config" not in id_head
+ assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL"
+
+
+@pytest.mark.parametrize("multianimal", [True])
+@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]])
+@pytest.mark.parametrize("bodyparts", [["nose", "ears"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("identity", [False, True])
+@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]])
+@pytest.mark.parametrize("net_type", ["dlcrnet_stride16_ms5", "dlcrnet_stride32_ms5"])
+def test_make_dlcrnet_config(
+ multianimal: bool,
+ individuals: list[str],
+ bodyparts: list[str],
+ identity: bool,
+ unique_bodyparts: list[str],
+ net_type: str,
+):
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=multianimal,
+ identity=identity,
+ individuals=individuals,
+ bodyparts=bodyparts,
+ unique_bodyparts=unique_bodyparts,
+ )
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ pretty_print(pytorch_pose_config)
+ paf_graph = [[i, j] for i in range(len(bodyparts)) for j in range(i + 1, len(bodyparts))]
+ num_limbs = len(paf_graph)
+
+ # check heads are there
+ assert "bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ bodypart_head = pytorch_pose_config["model"]["heads"]["bodypart"]
+ for name, output_channels in [
+ ("heatmap_config", len(bodyparts)),
+ ("locref_config", 2 * len(bodyparts)),
+ ("paf_config", 2 * num_limbs),
+ ]:
+ print(name, bodypart_head[name]["channels"])
+ assert name in bodypart_head
+ assert bodypart_head[name]["channels"][-1] == output_channels
+
+ if len(unique_bodyparts) > 0:
+ assert "unique_bodypart" in pytorch_pose_config["model"]["heads"].keys()
+ unique_bodypart_head = pytorch_pose_config["model"]["heads"]["unique_bodypart"]
+ for name, output_channels in [
+ ("heatmap_config", len(unique_bodyparts)),
+ ("locref_config", 2 * len(unique_bodyparts)),
+ ]:
+ assert name in unique_bodypart_head
+ assert unique_bodypart_head[name]["channels"][-1] == output_channels
+ assert unique_bodypart_head["target_generator"]["heatmap_mode"] == "KEYPOINT"
+
+ if identity:
+ assert "identity" in pytorch_pose_config["model"]["heads"].keys()
+ id_head = pytorch_pose_config["model"]["heads"]["identity"]
+ assert "heatmap_config" in id_head
+ assert id_head["heatmap_config"]["channels"][-1] == len(individuals)
+ assert "locref_config" not in id_head
+ assert id_head["target_generator"]["heatmap_mode"] == "INDIVIDUAL"
+
+
+@pytest.mark.parametrize("individuals", [["single"], ["bugs", "daffy"]])
+@pytest.mark.parametrize("bodyparts", [["nose", "eyes"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("identity", [False, True])
+@pytest.mark.parametrize("unique_bodyparts", [[], ["tail"]])
+@pytest.mark.parametrize("net_type", ["animaltokenpose_base"])
+def test_make_tokenpose_config(
+ individuals: list[str],
+ bodyparts: list[str],
+ identity: bool,
+ unique_bodyparts: list[str],
+ net_type: str,
+):
+ project_config = _make_project_config(
+ project_path="my/little/project",
+ multianimal=True,
+ identity=identity,
+ individuals=individuals,
+ bodyparts=bodyparts,
+ unique_bodyparts=unique_bodyparts,
+ )
+
+ if identity or len(unique_bodyparts) > 0:
+ with pytest.raises(ValueError) as _:
+ # Not yet implemented!
+ _ = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ else:
+ pytorch_pose_config = make_pytorch_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type=net_type,
+ )
+ pretty_print(pytorch_pose_config)
+
+ # check no collate function
+ collate = pytorch_pose_config["data"]["train"].get("collate")
+ print(f"Collate: {collate}")
+ assert not collate
+
+ # check detector is there
+ assert "detector" in pytorch_pose_config
+ assert "data" in pytorch_pose_config["detector"]
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "config": {"a": 0, "b": 0},
+ "updates": {"b": 1},
+ "expected_result": {"a": 0, "b": 1},
+ },
+ {
+ "config": {"a": 0, "b": {"i0": 1, "i1": 2}},
+ "updates": {"b": 1},
+ "expected_result": {"a": 0, "b": 1},
+ },
+ {
+ "config": {"a": 0, "b": {"i0": 1, "i1": 2}},
+ "updates": {"b": {"i0": [1, 2, 3]}},
+ "expected_result": {"a": 0, "b": {"i0": [1, 2, 3], "i1": 2}},
+ },
+ {
+ "config": {"detector": {"batch_size": 1, "epochs": 10, "save_epochs": 5}},
+ "updates": {
+ "batch_size": 1,
+ "detector": {"batch_size": 8, "save_epochs": 1},
+ },
+ "expected_result": {
+ "batch_size": 1,
+ "detector": {"batch_size": 8, "epochs": 10, "save_epochs": 1},
+ },
+ },
+ ],
+)
+def test_update_config(data: dict):
+ result = update_config(config=data["config"], updates=data["updates"])
+ print("\nResult")
+ pretty_print(result)
+ assert result == data["expected_result"]
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "config": {"a": 0, "b": 0},
+ "updates": {"b": 1},
+ "expected_result": {"a": 0, "b": 1},
+ },
+ {
+ "config": {"a": 0, "b": {"i0": 1, "i1": 2}},
+ "updates": {"b": 1},
+ "expected_result": {"a": 0, "b": 1},
+ },
+ {
+ "config": {"a": 0, "b": {"i0": 1, "i1": 2}},
+ "updates": {"b.i0": [1, 2, 3]},
+ "expected_result": {"a": 0, "b": {"i0": [1, 2, 3], "i1": 2}},
+ },
+ {
+ "config": {"detector": {"batch_size": 1, "epochs": 10, "save_epochs": 5}},
+ "updates": {
+ "batch_size": 1,
+ "detector.batch_size": 8,
+ "detector.save_epochs": 1,
+ },
+ "expected_result": {
+ "batch_size": 1,
+ "detector": {"batch_size": 8, "epochs": 10, "save_epochs": 1},
+ },
+ },
+ ],
+)
+def test_update_config_by_dotpath(data: dict):
+ result = update_config_by_dotpath(config=data["config"], updates=data["updates"])
+ print("\nResult")
+ pretty_print(result)
+ assert result == data["expected_result"]
+
+
+def _make_project_config(
+ project_path: str,
+ multianimal: bool,
+ identity: bool,
+ individuals: list[str],
+ bodyparts: list[str],
+ unique_bodyparts: list[str],
+) -> dict:
+ project_config = {
+ "project_path": project_path,
+ "multianimalproject": multianimal,
+ "identity": identity,
+ "uniquebodyparts": unique_bodyparts,
+ }
+
+ if multianimal:
+ project_config["multianimalbodyparts"] = bodyparts
+ project_config["bodyparts"] = "MULTI!"
+ project_config["individuals"] = individuals
+ else:
+ project_config["bodyparts"] = bodyparts
+
+ return project_config
+
+
+@pytest.mark.parametrize("bodyparts", [["nose"], ["nose", "ear", "eye"]])
+@pytest.mark.parametrize("max_idv", [1, 12, 20])
+@pytest.mark.parametrize("multi", [True, False])
+def test_make_basic_project_config(bodyparts: list[str], max_idv: int, multi: bool):
+ if not multi and max_idv > 1:
+ return
+
+ project_config = make_basic_project_config(
+ dataset_path="path/dataset",
+ bodyparts=bodyparts,
+ max_individuals=max_idv,
+ multi_animal=multi,
+ )
+
+ bpts = af.get_bodyparts(project_config)
+ assert bodyparts == bpts
+
+ individuals = project_config["individuals"]
+ assert len(individuals) == max_idv
+ assert len(set(individuals)) == max_idv
diff --git a/tests/pose_estimation_pytorch/data/test_data_ctd.py b/tests/pose_estimation_pytorch/data/test_data_ctd.py
new file mode 100644
index 0000000000..37bd834d03
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_data_ctd.py
@@ -0,0 +1,184 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import json
+import platform
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.data.ctd import CondFromFile
+
+CONDITIONS = [
+ np.zeros((4, 3, 3)).tolist(),
+ np.ones((4, 3, 3)).tolist(),
+ 2 * np.ones((4, 3, 3)).tolist(),
+ 3 * np.ones((4, 3, 3)).tolist(),
+]
+
+
+@pytest.mark.parametrize("path_prefix", ["/a/b"])
+@pytest.mark.parametrize(
+ "data",
+ [
+ [("/a/b/c/d.png", "/a/b/c/d.png", CONDITIONS[1])],
+ [("/a/b/c/d.png", "c/d.png", CONDITIONS[1])],
+ [
+ ("/a/b/c.png", "c.png", CONDITIONS[1]),
+ ("/a/b/c/d.png", "c/d.png", CONDITIONS[2]),
+ ("/a/b/c/e.png", "/a/b/c/e.png", CONDITIONS[3]),
+ ],
+ ],
+)
+def test_ctd_load_json_containing_rel_paths(
+ tmp_path_factory,
+ path_prefix: str | Path,
+ data: tuple[list[str], list[str], list],
+) -> None:
+ print("Starting test")
+ # convert the image paths to Windows format
+ if platform.system() == "Windows":
+ print("Converting to windows filesystem")
+
+ print("Path Prefix:", path_prefix)
+ if isinstance(path_prefix, Path):
+ print(f" As string: {str(path_prefix)}")
+ path_prefix = Path(_to_windows_path(str(path_prefix)))
+ else:
+ path_prefix = _to_windows_path(path_prefix)
+ print(f" Converted {path_prefix}")
+
+ data = [(_to_windows_path(img), _to_windows_path(key), cond) for img, key, cond in data]
+ print(f"Images: {[d[0] for d in data]}")
+ print(f"Condition keys: {[d[1] for d in data]}")
+ print("---")
+
+ images = [img for img, _, _ in data]
+ conditions = {key: cond for _, key, cond in data}
+
+ tmp_folder = Path(tmp_path_factory.mktemp("tmp-project"))
+ conditions_filepath = tmp_folder / "conditions.json"
+ with open(conditions_filepath, "w") as f:
+ json.dump(conditions, f)
+
+ conditions = CondFromFile.load_conditions_json(
+ conditions_filepath,
+ images,
+ path_prefix=path_prefix,
+ )
+ for img_path, _, condition in data:
+ assert img_path in conditions
+ np.testing.assert_allclose(condition, conditions[img_path])
+
+
+@pytest.mark.parametrize("path_prefix", ["/p"])
+@pytest.mark.parametrize("num_conditions", [1, 2, 3, 5, 10])
+@pytest.mark.parametrize("num_bodyparts", [1, 2, 3, 5, 10])
+@pytest.mark.parametrize(
+ "data",
+ [
+ [("/p/data/video0/img0.png", ("data", "video0", "img0.png"))],
+ [("/p/data/video0/img0.png", "data/video0/img0.png")],
+ [
+ ("/p/b/c/d0.png", ("b", "c", "d0.png")),
+ ("/p/b/c/d1.png", ("b", "c", "d1.png")),
+ ("/p/b/c/d2.png", ("b", "c", "d2.png")),
+ ],
+ [
+ ("/p/b/c/d0.png", "b/c/d0.png"),
+ ("/p/b/c/d1.png", "b/c/d1.png"),
+ ("/p/b/c/d2.png", "b/c/d2.png"),
+ ],
+ ],
+)
+def test_ctd_load_hdf_containing_rel_paths(
+ tmp_path_factory,
+ path_prefix: str | Path,
+ num_conditions: int,
+ num_bodyparts: int,
+ data: tuple[list[str], list[str]],
+) -> None:
+ print("\nStarting test")
+
+ # convert the image paths to Windows format
+ if platform.system() == "Windows":
+ print("Converting to windows filesystem")
+
+ print("Path Prefix:", path_prefix)
+ if isinstance(path_prefix, Path):
+ print(f" As string: {str(path_prefix)}")
+ path_prefix = Path(_to_windows_path(str(path_prefix)))
+ else:
+ path_prefix = _to_windows_path(path_prefix)
+ print(f" Converted {path_prefix}")
+
+ data = [(_to_windows_path(img), idx) for img, idx in data]
+ print(f"Images: {[d[0] for d in data]}")
+ print("---")
+
+ num_images = len(data)
+ images = [img for img, _ in data]
+ index = [idx for _, idx in data]
+ if isinstance(index[0], tuple):
+ index = pd.MultiIndex.from_tuples(index)
+
+ # generate random pose data
+ size = (num_images, num_conditions, num_bodyparts, 3)
+ rng = np.random.default_rng(0)
+ pose = rng.integers(low=0, high=1024, size=size).astype(float)
+ pose[:, :, :, 2] = rng.random(size=(num_images, num_conditions, num_bodyparts))
+
+ # set some missing data
+ is_nans = rng.random(size=size) > 0.8
+ pose[is_nans] = np.nan
+
+ # create what the output data will look like
+ keypoint_mask = np.any(is_nans, axis=3)
+ output_pose = pose.copy()
+ output_pose[keypoint_mask] = 0.0
+ idv_mask = ~np.all(keypoint_mask, axis=2)
+
+ output_pose = [
+ p[p_mask] if np.any(p_mask) else np.zeros((0, num_bodyparts, 3))
+ for p, p_mask in zip(output_pose, idv_mask, strict=False)
+ ]
+
+ # generate columns for the dataframe
+ columns = pd.MultiIndex.from_product(
+ [
+ ["scorer"],
+ [f"idv{i}" for i in range(num_conditions)],
+ [f"bpt{i}" for i in range(num_bodyparts)],
+ ["x", "y", "likelihood"],
+ ],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ df = pd.DataFrame(data=pose.reshape(num_images, -1), index=index, columns=columns)
+
+ print(df.head())
+
+ tmp_folder = Path(tmp_path_factory.mktemp("tmp-project"))
+ conditions_filepath = tmp_folder / "conditions.h5"
+ df.to_hdf(conditions_filepath, key="df_with_missing")
+
+ conditions = CondFromFile.load_conditions_h5(conditions_filepath, images, path_prefix=path_prefix)
+ for idx, (img_path, _img_index) in enumerate(data):
+ assert img_path in conditions
+ np.testing.assert_allclose(output_pose[idx], conditions[img_path])
+
+
+def _to_windows_path(s: str) -> str:
+ # Convert absolute paths to paths on C:
+ if s.startswith("/"):
+ return str(Path("C:\\", *s[1:].split("/")))
+
+ return s
diff --git a/tests/pose_estimation_pytorch/data/test_dlc_dataloader.py b/tests/pose_estimation_pytorch/data/test_dlc_dataloader.py
new file mode 100644
index 0000000000..76fe04ca61
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_dlc_dataloader.py
@@ -0,0 +1,68 @@
+from types import SimpleNamespace
+
+import numpy as np
+import pandas as pd
+
+import deeplabcut.pose_estimation_pytorch.data.dlcloader as dlcloader_mod
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+
+
+def test_to_coco_ignores_likelihood_columns(monkeypatch, tmp_path):
+ fake_shape = (3, 480, 640)
+ monkeypatch.setattr(
+ dlcloader_mod,
+ "read_image_shape_fast",
+ lambda _: fake_shape,
+ )
+
+ scorer = "testscorer"
+ bodyparts = ["nose", "tail"]
+
+ index = pd.MultiIndex.from_tuples(
+ [("labeled-data", "video1", "img0001.png")],
+ names=["set", "video", "image"],
+ )
+
+ # Baseline dataframe: x/y only
+ columns_xy = pd.MultiIndex.from_product(
+ [[scorer], bodyparts, ["x", "y"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+ df_xy = pd.DataFrame(
+ [[10.0, 20.0, 30.0, 40.0]],
+ index=index,
+ columns=columns_xy,
+ )
+
+ # Same data, but with likelihood columns added
+ columns_xyl = pd.MultiIndex.from_product(
+ [[scorer], bodyparts, ["x", "y", "likelihood"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+ df_xyl = pd.DataFrame(
+ [[10.0, 20.0, 0.9, 30.0, 40.0, 0.8]],
+ index=index,
+ columns=columns_xyl,
+ )
+
+ # to_coco only needs these attributes from parameters
+ params = SimpleNamespace(
+ bodyparts=bodyparts,
+ unique_bpts=[],
+ individuals=["animal"],
+ )
+
+ baseline = DLCLoader.to_coco(tmp_path, df_xy, params)
+ got = DLCLoader.to_coco(tmp_path, df_xyl, params)
+
+ assert len(got["images"]) == len(baseline["images"]) == 1
+ assert len(got["annotations"]) == len(baseline["annotations"]) == 1
+
+ got_ann = got["annotations"][0]
+ expected_ann = baseline["annotations"][0]
+
+ assert got_ann["image_id"] == expected_ann["image_id"]
+ assert got_ann["category_id"] == expected_ann["category_id"]
+ assert got_ann["num_keypoints"] == expected_ann["num_keypoints"] == 2
+ assert np.array_equal(got_ann["keypoints"], expected_ann["keypoints"])
+ assert np.allclose(got_ann["bbox"], expected_ann["bbox"])
diff --git a/tests/pose_estimation_pytorch/data/test_postprocessor.py b/tests/pose_estimation_pytorch/data/test_postprocessor.py
new file mode 100644
index 0000000000..7148fdf1c2
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py
@@ -0,0 +1,381 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the pre-processors."""
+
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.data.postprocessor import (
+ PredictKeypointIdentities,
+ PrepareBackboneFeatures,
+ RemoveLowConfidenceBoxes,
+ RescaleAndOffset,
+ TrimOutputs,
+)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "predictions": [[[0, 0, 0.95], [20, 30, 0.5]]],
+ "offsets": [(0, 0)],
+ "scales": [(1, 1)],
+ "rescaled": [[[0, 0, 0.95], [20, 30, 0.5]]],
+ },
+ {
+ "predictions": [
+ [[0, 0, 0.12], [1000, 0, 0.5]], # individual 1
+ [[18, 2, 0.24], [0, 1000, 0.6]], # individual 2
+ ],
+ "offsets": [(0, 0), (0, 0)],
+ "scales": [(1, 1), (0.5, 1.0)],
+ "rescaled": [
+ [[0, 0, 0.12], [1000, 0, 0.5]], # individual 1
+ [[9, 2, 0.24], [0, 1000, 0.6]], # individual 2
+ ],
+ },
+ {
+ "predictions": [
+ [[0, 0, 0.95], [20, 30, 0.5]], # individual 1
+ [[110, 5, 0.95], [60, 1200, 0.5]], # individual 2
+ ],
+ "offsets": [(12, 5), (27, 10)],
+ "scales": [(0.5, 0.5), (0.2, 0.2)],
+ "rescaled": [
+ [[12, 5, 0.95], [22, 20, 0.5]], # individual 1
+ [[49, 11, 0.95], [39, 250, 0.5]], # individual 2
+ ],
+ },
+ ],
+)
+def test_rescale_topdown(data):
+ """Expects x_processed = x * scale + offset."""
+ postprocessor = RescaleAndOffset(
+ keys_to_rescale=["bodyparts"],
+ mode=RescaleAndOffset.Mode.KEYPOINT_TD,
+ )
+ context = {"scales": data["scales"], "offsets": data["offsets"]}
+ predictions = {"bodyparts": np.array(data["predictions"])}
+ predictions, context = postprocessor(predictions, context=context)
+ print(predictions["bodyparts"].tolist())
+ print(data["rescaled"])
+ np.testing.assert_array_equal(predictions["bodyparts"], np.array(data["rescaled"]))
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "bboxes": [[0, 0, 0, 0], [1, 1, 1, 1]],
+ "bbox_scores": [0, 0],
+ "max_individuals": {"bboxes": 1, "bbox_scores": 1},
+ },
+ {
+ "bboxes": [[0, 0, 0, 0], [1, 1, 1, 1]],
+ "bbox_scores": [0, 0],
+ "max_individuals": {"bboxes": 2, "bbox_scores": 2},
+ },
+ ],
+)
+def test_trim_outputs(data):
+ """Expects x_processed = x * scale + offset."""
+ postprocessor = TrimOutputs(max_individuals=data["max_individuals"])
+ context = {}
+ predictions = {"bboxes": np.array(data["bboxes"]), "bbox_scores": np.array(data["bbox_scores"])}
+ predictions, context = postprocessor(predictions, context=context)
+ print(predictions["bboxes"].tolist())
+ print(predictions["bbox_scores"].tolist())
+ assert len(predictions["bboxes"]) == data["max_individuals"]["bboxes"]
+ assert len(predictions["bbox_scores"]) == data["max_individuals"]["bbox_scores"]
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "predictions": [[[0, 0, 0.95], [20, 30, 0.5]]],
+ "offsets": (0, 0),
+ "scales": (1, 1),
+ "rescaled": [[[0, 0, 0.95], [20, 30, 0.5]]],
+ },
+ {
+ "predictions": [
+ [[0, 0, 0.12], [10, 0, 0.5]], # individual 1
+ [[1000, 500, 0.24], [50, 250, 0.6]], # individual 2
+ ],
+ "offsets": (5, 7),
+ "scales": (0.2, 0.5),
+ "rescaled": [
+ [[5, 7, 0.12], [7, 7, 0.5]], # individual 1
+ [[205, 257, 0.24], [15, 132, 0.6]], # individual 2
+ ],
+ },
+ ],
+)
+def test_rescale_bottom_up(data):
+ """Expects x_processed = x * scale + offset."""
+ postprocessor = RescaleAndOffset(
+ keys_to_rescale=["bodyparts"],
+ mode=RescaleAndOffset.Mode.KEYPOINT,
+ )
+ context = {"scales": data["scales"], "offsets": data["offsets"]}
+ predictions = {"bodyparts": np.array(data["predictions"])}
+ predictions, context = postprocessor(predictions, context=context)
+ print(predictions["bodyparts"].tolist())
+ print(data["rescaled"])
+ np.testing.assert_array_equal(predictions["bodyparts"], np.array(data["rescaled"]))
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "bboxes": [[222.0, 562.0, 721.0, 637.0]],
+ "offsets": (0, 0),
+ "scales": (1, 1),
+ "rescaled": [[222.0, 562.0, 721.0, 637.0]],
+ },
+ {
+ "bboxes": [[386.71875, 219.53125, 281.640625, 248.828125]],
+ "offsets": (-768, 0),
+ "scales": (2.56, 2.56),
+ "rescaled": [[222.0, 562.0, 721.0, 637.0]],
+ },
+ {
+ "bboxes": [
+ [0, 0, 100, 100],
+ [5, 10, 100, 100],
+ [5, 10, 10, 20],
+ ],
+ "offsets": (3, 7),
+ "scales": (2, 0.5),
+ "rescaled": [
+ [3, 7, 200, 50],
+ [13, 12, 200, 50],
+ [13, 12, 20, 10],
+ ],
+ },
+ ],
+)
+def test_rescale_detector(data):
+ """Expects x_processed = x * scale + offset."""
+ postprocessor = RescaleAndOffset(
+ keys_to_rescale=["bboxes"],
+ mode=RescaleAndOffset.Mode.BBOX_XYWH,
+ )
+ context = {"scales": data["scales"], "offsets": data["offsets"]}
+ predictions = {"bboxes": np.array(data["bboxes"])}
+ predictions, context = postprocessor(predictions, context=context)
+ print(predictions["bboxes"].tolist())
+ print(data["rescaled"])
+ np.testing.assert_array_equal(predictions["bboxes"], np.array(data["rescaled"]))
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "bodyparts": [
+ [[3.1, 1, 0.8], [1, 0, 0.9]], # assembly 1 (x, y, score)
+ [[2.2, 1.6, 0.5], [3, 3, 0.4]], # assembly 2 (x, y, score)
+ ],
+ "id_heatmap": [ # id1, id2 score for each pixel
+ [[0.1, 0.1], [0.2, 0.1], [0.3, 0.1], [0.4, 0.1]],
+ [[0.1, 0.2], [0.2, 0.2], [0.3, 0.2], [0.4, 0.2]],
+ [[0.1, 0.3], [0.2, 0.3], [0.3, 0.3], [0.4, 0.3]],
+ [[0.1, 0.4], [0.2, 0.4], [0.3, 0.4], [0.4, 0.4]],
+ ],
+ "id_scores": [ # id1, id2 score for each bodypart
+ [[0.4, 0.2], [0.2, 0.1]], # assembly 1 (id_1 proba, id_2 proba)
+ [[0.3, 0.3], [0.4, 0.4]], # assembly 2 (id_1 proba, id_2 proba)
+ ],
+ },
+ ],
+)
+def test_assign_id_scores(data):
+ p = PredictKeypointIdentities(
+ identity_key="keypoint_identity",
+ identity_map_key="identity_map",
+ pose_key="bodyparts",
+ keep_id_maps=True,
+ )
+ bodyparts = np.array(data["bodyparts"])
+ id_heatmap = np.array(data["id_heatmap"])
+ expected_ids = np.array(data["id_scores"])
+ print()
+ print(bodyparts.shape)
+ print(id_heatmap.shape)
+ print(expected_ids.shape)
+ predictions_in = {"bodyparts": bodyparts, "identity_map": id_heatmap}
+ predictions, _ = p(predictions_in, {})
+ np.testing.assert_array_equal(
+ predictions["keypoint_identity"],
+ expected_ids,
+ )
+
+
+def test_prepare_backbone_features():
+ p = PrepareBackboneFeatures(top_down=False)
+
+ img_w, img_h = 256, 128
+ features = np.zeros((1, img_h, img_w))
+
+ features[0, 15, 10] = 1
+ features[0, 25, 20] = 2
+ features[0, 35, 30] = 3
+
+ pose = np.array(
+ [
+ [
+ [10.1, 15.1, 0.95],
+ [20.1, 25.1, 0.95],
+ [29.9, 34.9, 0.95],
+ ],
+ ]
+ )
+
+ predictions = [dict(backbone=dict(features=features), bodypart=dict(poses=pose))]
+ context = dict(image_size=(img_w, img_h))
+ predictions_out, context_out = p(predictions, context)
+
+ assert len(predictions_out) == 1
+ assert len(context_out) == 1
+ preds = predictions_out[0]
+
+ assert "backbone" in preds
+ assert "bodypart_features" in preds["backbone"]
+ bodypart_features = preds["backbone"]["bodypart_features"]
+ print(f"Bodypart features: {bodypart_features.shape}")
+ print(bodypart_features)
+ assert bodypart_features.shape == (1, 3, 1)
+ assert bodypart_features.reshape(-1).tolist() == [1, 2, 3]
+
+
+def test_prepare_top_down_backbone_features():
+ p = PrepareBackboneFeatures(top_down=True)
+
+ img_w, img_h = 256, 256
+
+ features = np.zeros((2, 1, img_h, img_w))
+ features[0, 0, 15, 10] = 1
+ features[0, 0, 25, 20] = 2
+ features[0, 0, 35, 30] = 3
+ features[1, 0, 95, 10] = 11
+ features[1, 0, 85, 20] = 12
+ features[1, 0, 75, 30] = 13
+
+ pose_idv0 = np.array(
+ [
+ [
+ [10.1, 15.1, 0.95],
+ [20.1, 25.1, 0.95],
+ [29.9, 34.9, 0.95],
+ ],
+ ]
+ )
+ pose_idv1 = np.array(
+ [
+ [
+ [10.1, 95.1, 0.95],
+ [20.1, 85.1, 0.95],
+ [29.9, 74.9, 0.95],
+ ],
+ ]
+ )
+
+ predictions = [
+ dict(backbone=dict(features=features[0]), bodypart=dict(poses=pose_idv0)),
+ dict(backbone=dict(features=features[1]), bodypart=dict(poses=pose_idv1)),
+ ]
+ context = dict(top_down_crop_size=(img_w, img_h))
+ predictions_out, context_out = p(predictions, context)
+
+ assert len(predictions_out) == 2
+ assert len(context_out) == 1
+ for preds, expected in zip(predictions_out, [[1, 2, 3], [11, 12, 13]], strict=True):
+ assert "backbone" in preds
+ assert "bodypart_features" in preds["backbone"]
+ bodypart_features = preds["backbone"]["bodypart_features"]
+ print(f"Bodypart features: {bodypart_features.shape}")
+ print(bodypart_features)
+ assert bodypart_features.shape == (1, 3, 1)
+ assert bodypart_features.reshape(-1).tolist() == expected
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "bboxes": [[0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50]],
+ "bbox_scores": [0.1, 0.5, 0.9],
+ "threshold": 0.3,
+ "expected_bboxes": [[20, 20, 30, 30], [40, 40, 50, 50]],
+ "expected_scores": [0.5, 0.9],
+ },
+ {
+ "bboxes": [[0, 0, 10, 10], [20, 20, 30, 30], [40, 40, 50, 50]],
+ "bbox_scores": [0.1, 0.2, 0.3],
+ "threshold": 0.5,
+ "expected_bboxes": [],
+ "expected_scores": [],
+ },
+ {
+ "bboxes": [[0, 0, 10, 10], [20, 20, 30, 30]],
+ "bbox_scores": [0.3, 0.7],
+ "threshold": 0.3,
+ "expected_bboxes": [[0, 0, 10, 10], [20, 20, 30, 30]],
+ "expected_scores": [0.3, 0.7],
+ },
+ {
+ "bboxes": [],
+ "bbox_scores": [],
+ "threshold": 0.5,
+ "expected_bboxes": [],
+ "expected_scores": [],
+ },
+ ],
+)
+def test_remove_low_confidence_boxes(data):
+ """Tests that RemoveLowConfidenceBoxes filters boxes below threshold."""
+ postprocessor = RemoveLowConfidenceBoxes(bbox_score_thresh=data["threshold"])
+ context = {}
+
+ # Handle empty input arrays with proper shape
+ if len(data["bboxes"]) == 0:
+ bboxes = np.empty((0, 4))
+ else:
+ bboxes = np.array(data["bboxes"])
+
+ if len(data["bbox_scores"]) == 0:
+ bbox_scores = np.empty((0,))
+ else:
+ bbox_scores = np.array(data["bbox_scores"])
+
+ predictions = {
+ "bboxes": bboxes,
+ "bbox_scores": bbox_scores,
+ }
+ predictions, context = postprocessor(predictions, context=context)
+
+ # Handle empty expected arrays with proper shape
+ if len(data["expected_bboxes"]) == 0:
+ expected_bboxes = np.empty((0, 4))
+ else:
+ expected_bboxes = np.array(data["expected_bboxes"])
+
+ if len(data["expected_scores"]) == 0:
+ expected_scores = np.empty((0,))
+ else:
+ expected_scores = np.array(data["expected_scores"])
+
+ np.testing.assert_array_equal(predictions["bboxes"], expected_bboxes)
+ np.testing.assert_array_equal(predictions["bbox_scores"], expected_scores)
diff --git a/tests/pose_estimation_pytorch/data/test_preprocessor.py b/tests/pose_estimation_pytorch/data/test_preprocessor.py
new file mode 100644
index 0000000000..9a68d76fe7
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_preprocessor.py
@@ -0,0 +1,158 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the pre-processors."""
+
+import albumentations as A
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.data.preprocessor import (
+ AugmentImage,
+ build_conditional_top_down_preprocessor,
+)
+from deeplabcut.pose_estimation_pytorch.data.transforms import build_resize_transforms
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "image_shape": (2, 4, 4),
+ "resize_transform": {"height": 5, "width": 4, "keep_ratio": True},
+ "output_shape": (2, 4, 4),
+ "padded_shape": (5, 4, 4), # single offset as not a batch
+ "output_context": {"offsets": (0, 0), "scales": (1, 1)},
+ },
+ {
+ "image_shape": (1, 2, 4, 4), # as batch
+ "resize_transform": {"height": 10, "width": 4, "keep_ratio": True},
+ "output_shape": (1, 2, 4, 4),
+ "padded_shape": (1, 10, 4, 4),
+ "output_context": {"offsets": [(0, 0)], "scales": [(1, 1)]},
+ },
+ {
+ "image_shape": (2, 4, 3),
+ "resize_transform": {"height": 10, "width": 8, "keep_ratio": True},
+ "output_shape": (4, 8, 3),
+ "padded_shape": (10, 8, 3),
+ "output_context": {"offsets": (0, 0), "scales": (0.5, 0.5)},
+ },
+ ],
+)
+def test_augment_image_rescaling(data):
+ resize_transform = build_resize_transforms(data["resize_transform"])
+ transform = A.Compose(
+ resize_transform,
+ keypoint_params=A.KeypointParams("xy", remove_invisible=False),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ preprocessor = AugmentImage(transform)
+ img = np.ones(data["image_shape"])
+ transformed_image, context = preprocessor(img, context={})
+ print()
+ print(transformed_image[:, :, 0]) # first channel
+ print(context)
+ assert np.sum(transformed_image) == np.sum(np.ones(data["output_shape"]))
+ assert context == data["output_context"]
+ assert transformed_image.shape == data["padded_shape"]
+
+
+ctd_preprocessor = build_conditional_top_down_preprocessor(
+ color_mode="RGB",
+ transform=A.Compose(
+ build_resize_transforms({"height": 100, "width": 100, "keep_ratio": True}),
+ keypoint_params=A.KeypointParams("xy", remove_invisible=False),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ ),
+ bbox_margin=0,
+ top_down_crop_size=(256, 256),
+)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ # two well-defined individuals
+ {
+ "image_shape": (100, 100, 3),
+ "context": {"cond_kpts": np.array([[[10, 10, 0.8], [20, 20, 0.8]], [[60, 60, 0.8], [70, 70, 0.8]]])},
+ "output_context": {
+ "cond_kpts": np.array([[[10, 10, 0.8], [20, 20, 0.8]], [[60, 60, 0.8], [70, 70, 0.8]]]),
+ "bboxes": [np.array([10, 10, 10, 10]), np.array([60, 60, 10, 10])],
+ "offsets": [(10, 10), (60, 60)],
+ "scales": [(0.1, 0.1), (0.1, 0.1)],
+ },
+ },
+ # one individual has 0 keypoints
+ {
+ "image_shape": (100, 100, 3),
+ "context": {"cond_kpts": np.array([[[10, 10, 0.8], [20, 20, 0.8]], [[60, 60, 0.0], [70, 70, 0.0]]])},
+ "output_context": {
+ "cond_kpts": np.array(
+ [
+ [[10, 10, 0.8], [20, 20, 0.8]],
+ ]
+ ),
+ "bboxes": [np.array([10, 10, 10, 10])],
+ "offsets": [(10, 10)],
+ "scales": [(0.1, 0.1)],
+ },
+ },
+ # one individual has only 1 keypoints
+ {
+ "image_shape": (100, 100, 3),
+ "context": {"cond_kpts": np.array([[[10, 10, 0.8], [20, 20, 0.8]], [[60, 60, 0.0], [70, 70, 0.9]]])},
+ "output_context": {
+ "cond_kpts": np.array(
+ [
+ [[10, 10, 0.8], [20, 20, 0.8]],
+ ]
+ ),
+ "bboxes": [np.array([10, 10, 10, 10])],
+ "offsets": [(10, 10)],
+ "scales": [(0.1, 0.1)],
+ },
+ },
+ # two individuals but one is low confidence
+ {
+ "image_shape": (100, 100, 3),
+ "context": {"cond_kpts": np.array([[[10, 10, 0.8], [20, 20, 0.8]], [[60, 60, 0.01], [70, 70, 0.01]]])},
+ "output_context": {
+ "cond_kpts": np.array(
+ [
+ [[10, 10, 0.8], [20, 20, 0.8]],
+ ]
+ ),
+ "bboxes": [np.array([10, 10, 10, 10])],
+ "offsets": [(10, 10)],
+ "scales": [(0.1, 0.1)],
+ },
+ },
+ ],
+)
+def test_conditional_top_down_preprocessor(data):
+ input_img = np.ones(data["image_shape"])
+
+ output_img, output_context = ctd_preprocessor(input_img, context=data["context"])
+
+ for context_key in ["cond_kpts", "bboxes", "offsets", "scales"]:
+ assert deep_equal(output_context[context_key], data["output_context"][context_key])
+
+
+def deep_equal(a, b):
+ if isinstance(a, np.ndarray) and isinstance(b, np.ndarray):
+ return np.array_equal(a, b)
+ elif isinstance(a, list) and isinstance(b, list):
+ if len(a) != len(b):
+ return False
+ return all(deep_equal(x, y) for x, y in zip(a, b, strict=False))
+ else:
+ return a == b
diff --git a/tests/pose_estimation_pytorch/data/test_transforms.py b/tests/pose_estimation_pytorch/data/test_transforms.py
new file mode 100644
index 0000000000..f85ce00ffe
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_transforms.py
@@ -0,0 +1,302 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the custom transforms."""
+
+import random
+
+import albumentations as A
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.data import transforms
+
+
+@pytest.mark.parametrize(
+ "height, width, image_shapes",
+ [
+ (200, 200, [(300, 300, 3), (1000, 1000, 3), (1024, 1024, 1)]),
+ (512, 512, [(1024, 1024, 3), (128, 128, 4), (300, 300, 1)]),
+ (1024, 512, [(600, 300, 3), (4096, 2048, 3), (50, 25, 1)]),
+ (800, 1300, [(80, 130, 3), (1600, 2600, 4), (1200, 1950, 1)]),
+ ],
+)
+def test_dlc_resize_pad_good_aspect_ratio(height, width, image_shapes):
+ aug = transforms.KeepAspectRatioResize(width=width, height=height, mode="pad")
+ for image_shape in image_shapes:
+ fake_image = np.zeros(image_shape)
+ transformed = aug(image=fake_image, keypoints=[])
+ assert transformed["image"].shape[:2] == (height, width)
+ assert transformed["image"].shape[2] == fake_image.shape[2]
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "height": 200,
+ "width": 200,
+ "in_shapes": [(100, 50, 3), (50, 400, 3)],
+ "out_shapes": [(200, 100, 3), (25, 200, 3)],
+ },
+ {
+ "height": 128,
+ "width": 256,
+ "in_shapes": [(100, 100, 3), (512, 256, 3)],
+ "out_shapes": [(128, 128, 3), (128, 64, 3)],
+ },
+ ],
+)
+def test_dlc_resize_pad_bad_aspect_ratio(data):
+ aug = transforms.KeepAspectRatioResize(width=data["width"], height=data["height"], mode="pad")
+ for in_shape, out_shape in zip(data["in_shapes"], data["out_shapes"], strict=False):
+ fake_image = np.zeros(in_shape)
+ transformed = aug(image=fake_image, keypoints=[])
+ assert transformed["image"].shape == out_shape
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "height": 200,
+ "width": 200,
+ "in_shape": (100, 50, 3),
+ "out_shape": (200, 100, 3),
+ "in_keypoints": [(50.0, 50.0), (25.0, 10.0)],
+ "out_keypoints": [(100.0, 100.0), (50.0, 20.0)],
+ },
+ {
+ "height": 512,
+ "width": 256,
+ "in_shape": (1024, 1024, 3),
+ "out_shape": (256, 256, 3),
+ "in_keypoints": [(512.0, 512.0), (100.0, 10.0)],
+ "out_keypoints": [(128.0, 128.0), (25.0, 2.5)],
+ },
+ ],
+)
+def test_dlc_resize_pad_bad_aspect_ratio_with_keypoints(data):
+ aug = transforms.KeepAspectRatioResize(width=data["width"], height=data["height"], mode="pad")
+ transform = A.Compose(
+ [aug],
+ keypoint_params=A.KeypointParams("xy", remove_invisible=False),
+ )
+ fake_image = np.zeros(data["in_shape"])
+ transformed = transform(image=fake_image, keypoints=data["in_keypoints"])
+ assert transformed["image"].shape == data["out_shape"]
+ assert transformed["keypoints"] == data["out_keypoints"]
+
+
+def test_coarse_dropout():
+ transforms.CoarseDropout(
+ max_holes=10,
+ max_height=0.05,
+ min_height=0.01,
+ max_width=0.05,
+ min_width=0.01,
+ p=0.5,
+ )
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=10.0,
+ shift_prob=0.0,
+ scale_factor=[0.1, 2.0],
+ scale_prob=0.0,
+ ),
+ },
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.0,
+ shift_prob=1.0,
+ scale_factor=[1.0, 1.0],
+ scale_prob=1.0,
+ sampling="uniform", # truncnorm throws an error if delta is 0
+ ),
+ },
+ ],
+)
+def test_random_bbox_transform_does_not_modify_with_base_config(data: dict) -> None:
+ _set_random_seed()
+ h, w, c = data["image_shape"]
+
+ # generate 100 bboxes
+ bboxes = _gen_random_bboxes(np.random.default_rng(seed=0), 100, w, h)
+
+ t = A.Compose(
+ [transforms.RandomBBoxTransform(**data["transform_config"])],
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ output = t(
+ image=np.zeros((h, w, c)),
+ bboxes=bboxes,
+ bbox_labels=np.zeros(len(bboxes)),
+ )
+ print("Output bounding boxes")
+ for out_bbox in output["bboxes"]:
+ print(out_bbox)
+ print()
+ bboxes_out = np.asarray(output["bboxes"])
+ print("bboxes")
+ print(bboxes_out)
+ print()
+ np.testing.assert_array_almost_equal(bboxes, bboxes_out)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.0,
+ shift_prob=0.0,
+ scale_factor=[0.25, 0.5],
+ scale_prob=1.0,
+ ),
+ },
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.0,
+ shift_prob=0.0,
+ scale_factor=[1.0, 1.5],
+ scale_prob=1.0,
+ ),
+ },
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.0,
+ shift_prob=0.0,
+ scale_factor=[0.5, 1.25],
+ scale_prob=1.0,
+ ),
+ },
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.0,
+ shift_prob=0.0,
+ scale_factor=[0.5, 1.5],
+ scale_prob=0.5,
+ ),
+ },
+ ],
+)
+def test_random_bbox_transform_scale(data: dict) -> None:
+ _set_random_seed()
+ h, w, c = data["image_shape"]
+
+ # generate 100 bboxes
+ bboxes = _gen_random_bboxes(np.random.default_rng(seed=0), 100, w, h)
+
+ t = A.Compose(
+ [transforms.RandomBBoxTransform(**data["transform_config"])],
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ output = t(
+ image=np.zeros((h, w, c)),
+ bboxes=bboxes,
+ bbox_labels=np.zeros(len(bboxes)),
+ )
+ print("Output bounding boxes")
+ for out_bbox in output["bboxes"]:
+ print(out_bbox)
+ print()
+
+ bboxes_out = np.asarray(output["bboxes"])
+ scale_low, scale_high = data["transform_config"]["scale_factor"]
+ for bbox_in_wh, bbox_out_wh in zip(bboxes[:, 2:], bboxes_out[:, 2:], strict=False):
+ print("bbox_in_wh", bbox_in_wh)
+ w, h = bbox_in_wh[0].item(), bbox_in_wh[1].item()
+ w_low, w_high = w * scale_low, w * scale_high
+ h_low, h_high = h * scale_low, h * scale_high
+ print("(w, w_low, w_high)", w, w_low, w_high)
+ print("(h, h_low, h_high)", h, h_low, h_high)
+ assert w_low <= bbox_out_wh[0].item() <= w_high
+ assert h_low <= bbox_out_wh[1].item() <= h_high
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "image_shape": [480, 640, 3],
+ "transform_config": dict(
+ shift_factor=0.1,
+ shift_prob=1.0,
+ scale_factor=[1.0, 1.0],
+ scale_prob=0.0,
+ ),
+ },
+ ],
+)
+def test_random_bbox_transform_shift(data: dict) -> None:
+ _set_random_seed()
+ h, w, c = data["image_shape"]
+
+ # generate 100 bboxes
+ bboxes = _gen_random_bboxes(np.random.default_rng(seed=0), 100, w, h)
+
+ t = A.Compose(
+ [transforms.RandomBBoxTransform(**data["transform_config"])],
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ output = t(
+ image=np.zeros((h, w, c)),
+ bboxes=bboxes,
+ bbox_labels=np.zeros(len(bboxes)),
+ )
+ print("Output bounding boxes")
+ for out_bbox in output["bboxes"]:
+ print(out_bbox)
+ print()
+
+ bboxes_out = np.asarray(output["bboxes"])
+ shift = data["transform_config"]["shift_factor"]
+ for bbox_in, bbox_out in zip(bboxes, bboxes_out, strict=False):
+ print("bbox_in", bbox_in)
+ x, y, w, h = bbox_in
+ x_out, y_out, w_out, h_out = bbox_out
+ max_shift_x, max_shift_y = w * shift, h * shift
+ assert x - max_shift_x <= x_out <= x + max_shift_x
+ assert y - max_shift_y <= y_out <= y + max_shift_y
+
+
+def _set_random_seed():
+ np.random.seed(0)
+ random.seed(0)
+
+
+def _gen_random_bboxes(
+ gen: np.random.Generator,
+ num_bboxes: int,
+ w: int,
+ h: int,
+) -> np.ndarray:
+ image_wh = np.array([w, h])
+ bboxes = np.zeros((num_bboxes, 4))
+ # sample x, y in the images
+ bboxes[:, :2] = image_wh * gen.random((num_bboxes, 2))
+ # sample w, h with the space remaining
+ bboxes[:, 2:] = (image_wh - bboxes[:, :2]) * gen.random((num_bboxes, 2))
+
+ print()
+ print("Input bounding boxes")
+ print(bboxes)
+ return bboxes
diff --git a/tests/pose_estimation_pytorch/data/test_utils.py b/tests/pose_estimation_pytorch/data/test_utils.py
new file mode 100644
index 0000000000..1494e01787
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_utils.py
@@ -0,0 +1,97 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests data utils."""
+
+import numpy as np
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.data.utils as utils
+
+
+@pytest.mark.parametrize(
+ "keypoints, expected_bboxes, params",
+ [
+ (
+ [[0, 0, 2], [10, 5, 2]],
+ [0, 0, 10, 5],
+ dict(image_w=1024, image_h=1024, margin=0),
+ ),
+ (
+ [[-1, -1, 2], [3, 4, 2]],
+ [0, 0, 3, 4],
+ dict(image_w=1024, image_h=1024, margin=0),
+ ),
+ (
+ [[0, 0, 2], [10, 5, 2]],
+ [0, 0, 5, 3],
+ dict(image_w=5, image_h=3, margin=0),
+ ),
+ (
+ [[0, 0, 2], [10, 5, 2]],
+ [0, 0, 5, 3],
+ dict(image_w=5, image_h=3, margin=10),
+ ),
+ (
+ [[[0, 0, 2], [10, 5, 2]]],
+ [[0, 0, 10, 5]],
+ dict(image_w=1024, image_h=1024, margin=0),
+ ),
+ (
+ [
+ [[4, 1, 2], [10, 5, 2], [3, 12, 0]],
+ [[7, 3, 2], [2, 0, -1], [1, 12, 2]],
+ ],
+ [
+ [4, 1, 6, 4],
+ [1, 3, 6, 9],
+ ],
+ dict(image_w=1024, image_h=1024, margin=0),
+ ),
+ (
+ [
+ [[4, 1, 2], [10, 5, 2], [3, 12, 0]],
+ [[7, 3, 2], [2, 0, -1], [1, 12, 2]],
+ ],
+ [
+ [2, 0, 10, 7],
+ [0, 1, 9, 13],
+ ],
+ dict(image_w=1024, image_h=1024, margin=2),
+ ),
+ (
+ [
+ [[4, 1, 2], [10, 5, 2], [3, 12, 0]],
+ [[7, 3, 2], [2, 0, -1], [1, 12, 2]],
+ ],
+ [
+ [2, 0, 8, 7],
+ [0, 1, 9, 9],
+ ],
+ dict(image_w=10, image_h=10, margin=2),
+ ),
+ (
+ [
+ [[4, 1, 2], [10, 5, 2], [3, 12, 0]],
+ [[7, 3, 0], [2, 0, -1], [1, 12, 0]],
+ ],
+ [
+ [2, 0, 8, 7],
+ [0, 0, 0, 0],
+ ],
+ dict(image_w=10, image_h=10, margin=2),
+ ),
+ ],
+)
+def test_bbox_from_keypoints(keypoints, expected_bboxes, params):
+ keypoints = np.asarray(keypoints, dtype=float)
+ bboxes = utils.bbox_from_keypoints(keypoints, **params)
+ expected_bboxes = np.asarray(expected_bboxes, dtype=float)
+ np.testing.assert_array_almost_equal(bboxes, expected_bboxes)
diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py
new file mode 100644
index 0000000000..7f418f4912
--- /dev/null
+++ b/tests/pose_estimation_pytorch/models/target_generators/test_heatmap_targets.py
@@ -0,0 +1,131 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the heatmap target generators (plateau and gaussian)"""
+
+import numpy as np
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import (
+ HeatmapGaussianGenerator,
+)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "dist_thresh": 3,
+ "num_heatmaps": 1,
+ "in_shape": (3, 3),
+ "out_shape": (3, 3),
+ "centers": [(1, 1)],
+ "expected_output": [
+ [0.7788, 0.8825, 0.7788],
+ [0.8825, 1.0000, 0.8825],
+ [0.7788, 0.8825, 0.7788],
+ ],
+ },
+ {
+ "dist_thresh": 3,
+ "num_heatmaps": 1,
+ "in_shape": (5, 5),
+ "out_shape": (5, 5),
+ "centers": [[1, 1], [2, 2]],
+ "expected_output": [
+ [0.7788, 0.8825, 0.7788, 0.5353, 0.3679],
+ [0.8825, 1.0000, 0.8825, 0.7788, 0.5353],
+ [0.7788, 0.8825, 1.0000, 0.8825, 0.6065],
+ [0.5353, 0.7788, 0.8825, 0.7788, 0.5353],
+ [0.3679, 0.5353, 0.6065, 0.5353, 0.3679],
+ ],
+ },
+ {
+ "dist_thresh": 1,
+ "num_heatmaps": 1,
+ "in_shape": (4, 4),
+ "out_shape": (4, 4),
+ "centers": [[1, 1]],
+ "expected_output": [
+ [0.1054, 0.3247, 0.1054, 0.0036],
+ [0.3247, 1.0, 0.3247, 0.0111],
+ [0.1054, 0.3247, 0.1054, 0.0036],
+ [0.0036, 0.0111, 0.0036, 0.0001],
+ ],
+ },
+ ],
+)
+def test_gaussian_heatmap_generation_single_keypoint(data):
+ dist_thresh = data["dist_thresh"]
+ generator = HeatmapGaussianGenerator(
+ num_heatmaps=data["num_heatmaps"],
+ pos_dist_thresh=dist_thresh,
+ heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT,
+ generate_locref=False,
+ )
+ stride = data["in_shape"][0] / data["out_shape"][0]
+ outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"]))
+ ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2)
+ annotations = {
+ "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y
+ }
+ targets = generator(stride, {"heatmap": outputs}, annotations)
+
+ print("Targets")
+ print(targets["heatmap"]["target"])
+ print()
+ np.testing.assert_almost_equal(
+ targets["heatmap"]["target"].cpu().numpy().reshape(data["out_shape"]),
+ np.array(data["expected_output"]),
+ decimal=3,
+ )
+
+
+@pytest.mark.parametrize(
+ "batch_size, num_keypoints, image_size",
+ [(2, 2, (64, 64)), (1, 5, (48, 64)), (15, 50, (64, 48))],
+)
+def test_random_gaussian_target_generation(batch_size: int, num_keypoints: int, image_size: tuple, num_animals=1):
+ # generate annotations
+ annotations = {
+ "keypoints": torch.randint(1, min(image_size), (batch_size, num_animals, num_keypoints, 2))
+ } # batch size, num animals, num keypoints, 2 for x,y
+
+ # model stride 1
+ stride = 1
+
+ # generate predictions
+ predicted_heatmaps = {"heatmap": torch.zeros((batch_size, num_keypoints, *image_size))}
+
+ # generate heatmap
+ generator = HeatmapGaussianGenerator(
+ num_heatmaps=num_keypoints,
+ pos_dist_thresh=17,
+ heatmap_mode=HeatmapGaussianGenerator.Mode.KEYPOINT,
+ generate_locref=False,
+ )
+ targets = generator(stride, predicted_heatmaps, annotations)
+ target_heatmap = targets["heatmap"]["target"].reshape(batch_size, num_keypoints, image_size[0] * image_size[1])
+
+ # get coords of max value of the heatmap
+ gaus_max = torch.argmax(target_heatmap, dim=2)
+
+ # get unraveled coords
+ x = gaus_max % image_size[1]
+ y = gaus_max // image_size[1]
+
+ # get heatmap center tensor
+ predict_kp = torch.stack((x, y), dim=-1)
+ # Remove num_animals dimension - only one animal is supported
+ annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1)
+
+ # compare heatmap center to annotation
+ assert torch.eq(annotations["keypoints"], predict_kp).all().item()
diff --git a/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py
new file mode 100644
index 0000000000..d335fc5524
--- /dev/null
+++ b/tests/pose_estimation_pytorch/models/target_generators/test_plateau_targets.py
@@ -0,0 +1,90 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the heatmap target generators (plateau and gaussian)"""
+
+import numpy as np
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators.heatmap_targets import (
+ HeatmapGenerator,
+ HeatmapPlateauGenerator,
+)
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {
+ "dist_thresh": 1,
+ "num_heatmaps": 1,
+ "in_shape": (3, 3),
+ "out_shape": (3, 3),
+ "centers": [(1, 1)],
+ "expected_output": [
+ [0.0, 1.0, 0.0],
+ [1.0, 1.0, 1.0],
+ [0.0, 1.0, 0.0],
+ ],
+ },
+ {
+ "dist_thresh": 2,
+ "num_heatmaps": 1,
+ "in_shape": (5, 5),
+ "out_shape": (5, 5),
+ "centers": [[1, 1], [2, 2]],
+ "expected_output": [
+ [1.0, 1.0, 1.0, 0.0, 0.0],
+ [1.0, 1.0, 1.0, 1.0, 0.0],
+ [1.0, 1.0, 1.0, 1.0, 1.0],
+ [0.0, 1.0, 1.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0, 0.0],
+ ],
+ },
+ {
+ "dist_thresh": 2,
+ "num_heatmaps": 1,
+ "in_shape": (4, 4),
+ "out_shape": (4, 4),
+ "centers": [[1, 1]],
+ "expected_output": [
+ [1.0, 1.0, 1.0, 0.0],
+ [1.0, 1.0, 1.0, 1.0],
+ [1.0, 1.0, 1.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ ],
+ },
+ ],
+)
+def test_plateau_heatmap_generation_single_keypoint(data):
+ dist_thresh = data["dist_thresh"]
+ generator = HeatmapPlateauGenerator(
+ num_heatmaps=data["num_heatmaps"],
+ pos_dist_thresh=dist_thresh,
+ heatmap_mode=HeatmapGenerator.Mode.KEYPOINT,
+ generate_locref=False,
+ )
+ stride = data["in_shape"][0] / data["out_shape"][0]
+ outputs = torch.zeros((1, data["num_heatmaps"], *data["out_shape"]))
+ ann_shape = (1, len(data["centers"]), data["num_heatmaps"], 2)
+ annotations = {
+ "keypoints": torch.tensor(data["centers"]).reshape(ann_shape) # x, y
+ }
+ targets = generator(stride, {"heatmap": outputs}, annotations)
+
+ print("Targets")
+ print(targets["heatmap"]["target"])
+ print()
+ np.testing.assert_almost_equal(
+ targets["heatmap"]["target"].cpu().numpy().reshape(data["out_shape"]),
+ np.array(data["expected_output"]),
+ decimal=3,
+ )
diff --git a/tests/tests_modelzoo.py b/tests/pose_estimation_pytorch/modelzoo/test_download.py
similarity index 91%
rename from tests/tests_modelzoo.py
rename to tests/pose_estimation_pytorch/modelzoo/test_download.py
index 555c590307..06cc9857e1 100644
--- a/tests/tests_modelzoo.py
+++ b/tests/pose_estimation_pytorch/modelzoo/test_download.py
@@ -4,12 +4,13 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
-import dlclibrary
import os
+
+import dlclibrary
import pytest
from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS
@@ -29,7 +30,7 @@ def test_download_huggingface_wrong_model():
dlclibrary.download_huggingface_model("wrong_model_name")
-@pytest.mark.skip
+@pytest.mark.skip(reason="slow")
@pytest.mark.parametrize("model", MODELOPTIONS)
def test_download_all_models(tmp_path_factory, model):
test_download_huggingface_model(tmp_path_factory, model)
diff --git a/tests/pose_estimation_pytorch/modelzoo/test_fmpose_integration.py b/tests/pose_estimation_pytorch/modelzoo/test_fmpose_integration.py
new file mode 100644
index 0000000000..e7608625fa
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_fmpose_integration.py
@@ -0,0 +1,198 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import pathlib
+import socket
+from types import SimpleNamespace
+
+import numpy as np
+import pandas as pd
+import pytest
+
+fmpose3d = pytest.importorskip("fmpose3d", reason="fmpose3d not installed")
+pytestmark = pytest.mark.fmpose3d
+
+# DLC fmpose_3d modules import fmpose3d; load only after importorskip above.
+from deeplabcut.pose_estimation_pytorch.modelzoo.fmpose_3d import inference as fmp_inf # noqa: E402
+from deeplabcut.pose_estimation_pytorch.modelzoo.fmpose_3d.fmpose3d import ( # noqa: E402
+ get_fmpose3d_inference_api,
+)
+
+
+def _has_network(host="huggingface.co", port=443, timeout=3) -> bool:
+ """Return True if we can reach *host* (used to download model weights)."""
+ try:
+ socket.create_connection((host, port), timeout=timeout).close()
+ return True
+ except OSError:
+ return False
+
+
+requires_network = pytest.mark.skipif(
+ not _has_network(),
+ reason="No network connection (needed to download model weights)",
+)
+
+_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
+_EXAMPLE_IMAGE = (
+ _REPO_ROOT / "examples" / "Reaching-Mackenzie-2018-08-30" / "labeled-data" / "reachingvideo1" / "img005.png"
+)
+
+
+# ---------------------------------------------------------------------------
+# Lightweight: verifies the API object is constructed correctly
+# ---------------------------------------------------------------------------
+@pytest.mark.parametrize("model_type", ["fmpose3d_humans", "fmpose3d_animals"])
+@pytest.mark.unittest
+def test_api_init(model_type):
+ api = get_fmpose3d_inference_api(model_type, device="cpu")
+ assert api is not None
+ assert hasattr(api, "prepare_2d")
+ assert hasattr(api, "pose_3d")
+ assert hasattr(api, "predict")
+
+
+# ---------------------------------------------------------------------------
+# Integration: downloads weights and runs inference (needs network)
+# ---------------------------------------------------------------------------
+@requires_network
+@pytest.mark.functional
+def test_prepare_2d_and_pose_3d():
+ """2D detection followed by 3D lifting on a real image."""
+ api = get_fmpose3d_inference_api("fmpose3d_animals", device="cpu")
+
+ result_2d = api.prepare_2d(source=str(_EXAMPLE_IMAGE))
+ assert isinstance(result_2d.keypoints, np.ndarray)
+ assert result_2d.keypoints.shape[-1] == 2
+
+ keypoints_3d = api.pose_3d(
+ keypoints_2d=result_2d.keypoints,
+ image_size=result_2d.image_size,
+ )
+ assert isinstance(keypoints_3d.poses_3d, np.ndarray)
+ assert keypoints_3d.poses_3d.shape[-1] == 3
+
+
+@requires_network
+@pytest.mark.functional
+def test_predict_end_to_end():
+ """Full pipeline (2D -> 3D) in a single call."""
+ api = get_fmpose3d_inference_api("fmpose3d_animals", device="cpu")
+ predictions_3d = api.predict(source=str(_EXAMPLE_IMAGE))
+
+ assert isinstance(predictions_3d.poses_3d, np.ndarray)
+ assert predictions_3d.poses_3d.shape[-1] == 3
+
+
+@pytest.mark.unittest
+def test_pose2d_to_dlc_predictions_shapes():
+ pose_2d = SimpleNamespace(
+ keypoints=np.random.rand(2, 3, 4, 2).astype(np.float32),
+ scores=np.random.rand(2, 3, 4).astype(np.float32),
+ )
+ preds = fmp_inf._pose2d_to_dlc_predictions(
+ pose_2d=pose_2d,
+ max_individuals=1,
+ num_bodyparts=4,
+ )
+
+ assert len(preds) == 3
+ assert preds[0]["bodyparts"].shape == (1, 4, 3)
+ np.testing.assert_allclose(preds[0]["bodyparts"][0, :, :2], pose_2d.keypoints[0, 0])
+
+
+@pytest.mark.unittest
+def test_poses3d_to_dataframe_layout():
+ scorer = "DLC_test"
+ bodyparts = ["bp1", "bp2", "bp3"]
+ columns_2d = pd.MultiIndex.from_product(
+ [[scorer], ["individual1"], bodyparts, ["x", "y", "likelihood"]],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ df_2d = pd.DataFrame(np.zeros((2, len(columns_2d))), columns=columns_2d)
+
+ poses_3d = [
+ np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]),
+ np.array([[10.0, 11.0, 12.0], [13.0, 14.0, 15.0], [16.0, 17.0, 18.0]]),
+ ]
+ df_3d = fmp_inf._poses3d_to_dataframe(poses_3d, df_2d, f"{scorer}_3d")
+
+ assert df_3d.columns.names == ["scorer", "bodyparts", "coords"]
+ assert set(df_3d.columns.get_level_values("coords")) == {"x", "y", "z"}
+ assert df_3d.loc[0, (f"{scorer}_3d", "bp1", "x")] == 1.0
+ assert df_3d.loc[1, (f"{scorer}_3d", "bp3", "z")] == 18.0
+
+
+@pytest.mark.functional
+def test_video_inference_fmpose3d_include_3d_return(tmp_path, monkeypatch):
+ frames = [np.zeros((8, 8, 3), dtype=np.uint8) for _ in range(2)]
+
+ class FakeVideoIterator:
+ def __init__(self, _path, cropping=None):
+ self.dimensions = (8, 8)
+ self.fps = 30
+ self._frames = frames
+
+ def __iter__(self):
+ return iter(self._frames)
+
+ class FakeAPI:
+ def prepare_2d(self, source):
+ n_frames = source.shape[0]
+ return SimpleNamespace(
+ keypoints=np.zeros((1, n_frames, 26, 2), dtype=np.float32),
+ scores=np.ones((1, n_frames, 26), dtype=np.float32),
+ image_size=(8, 8),
+ )
+
+ def pose_3d(self, keypoints_2d, image_size):
+ n_frames = keypoints_2d.shape[1]
+ return SimpleNamespace(
+ poses_3d=np.zeros((n_frames, 26, 3), dtype=np.float32),
+ )
+
+ def _fake_create_df_from_prediction(predictions, dlc_scorer, multi_animal, model_cfg, output_path, output_prefix):
+ bodyparts = model_cfg["metadata"]["bodyparts"]
+ individuals = model_cfg["metadata"]["individuals"]
+ columns = pd.MultiIndex.from_product(
+ [[dlc_scorer], individuals, bodyparts, ["x", "y", "likelihood"]],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ return pd.DataFrame(np.zeros((len(predictions), len(columns))), columns=columns)
+
+ monkeypatch.setattr(fmp_inf, "VideoIterator", FakeVideoIterator)
+ monkeypatch.setattr(
+ fmp_inf,
+ "get_fmpose3d_inference_api",
+ lambda model_type, device: FakeAPI(),
+ )
+ monkeypatch.setattr(fmp_inf, "create_df_from_prediction", _fake_create_df_from_prediction)
+ monkeypatch.setattr(
+ fmp_inf,
+ "get_superanimal_colormaps",
+ lambda: {
+ "superanimal_quadruped": "viridis",
+ "superanimal_humanbody": "viridis",
+ },
+ )
+
+ result = fmp_inf._video_inference_fmpose3d(
+ video_paths=[str(tmp_path / "dummy.mp4")],
+ model_name="fmpose3d_animals",
+ dest_folder=tmp_path,
+ create_labeled_video=False,
+ include_3d_in_return=True,
+ )
+
+ payload = result[str(tmp_path / "dummy.mp4")]
+ assert "df_2d" in payload
+ assert "df_3d" in payload
+ assert isinstance(payload["df_3d"], pd.DataFrame)
+ assert (tmp_path / "dummy_DLC_fmpose3d_animals_3d.h5").exists()
diff --git a/tests/pose_estimation_pytorch/modelzoo/test_inference_helpers.py b/tests/pose_estimation_pytorch/modelzoo/test_inference_helpers.py
new file mode 100644
index 0000000000..50e58e234d
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_inference_helpers.py
@@ -0,0 +1,172 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from types import SimpleNamespace
+
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.modelzoo.inference_helpers as helpers
+
+
+def _dummy_cfg(method: str = "TD") -> dict:
+ return {
+ "method": method,
+ "metadata": {"bodyparts": ["nose"], "unique_bodyparts": []},
+ }
+
+
+def test_create_superanimal_inference_runners_uses_custom_config_path(monkeypatch):
+ cfg = _dummy_cfg("TD")
+ read_calls = []
+
+ def fake_read_config_as_dict(path):
+ read_calls.append(path)
+ return cfg
+
+ monkeypatch.setattr(helpers, "read_config_as_dict", fake_read_config_as_dict)
+ monkeypatch.setattr(helpers, "update_config", lambda config, max_individuals, device: config)
+ monkeypatch.setattr(
+ helpers,
+ "get_inference_runners",
+ lambda **kwargs: ("pose_runner", "det_runner"),
+ )
+
+ import deeplabcut.modelzoo.weight_initialization as wi
+
+ monkeypatch.setattr(
+ wi,
+ "build_weight_init",
+ lambda **kwargs: SimpleNamespace(
+ snapshot_path="pose.pt",
+ detector_snapshot_path="det.pt",
+ ),
+ )
+
+ pose_runner, detector_runner, model_cfg = helpers.create_superanimal_inference_runners(
+ superanimal_name="superanimal_quadruped",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ customized_model_config="/tmp/custom_model_cfg.yaml",
+ )
+
+ assert read_calls == ["/tmp/custom_model_cfg.yaml"]
+ assert pose_runner == "pose_runner"
+ assert detector_runner == "det_runner"
+ assert model_cfg is cfg
+
+
+def test_create_superanimal_inference_runners_uses_deepcopy_for_custom_dict(monkeypatch):
+ custom_cfg = _dummy_cfg("TD")
+ monkeypatch.setattr(
+ helpers,
+ "read_config_as_dict",
+ lambda path: pytest.fail("read_config_as_dict should not be called for dict input"),
+ )
+
+ def fake_update_config(config, max_individuals, device):
+ # Mutate nested structure; caller-owned dict should stay unchanged.
+ config["metadata"]["bodyparts"].append("tail")
+ return config
+
+ monkeypatch.setattr(helpers, "update_config", fake_update_config)
+ monkeypatch.setattr(
+ helpers,
+ "get_inference_runners",
+ lambda **kwargs: ("pose_runner", None),
+ )
+
+ import deeplabcut.modelzoo.weight_initialization as wi
+
+ monkeypatch.setattr(
+ wi,
+ "build_weight_init",
+ lambda **kwargs: SimpleNamespace(
+ snapshot_path="pose.pt",
+ detector_snapshot_path=None,
+ ),
+ )
+
+ _, _, model_cfg = helpers.create_superanimal_inference_runners(
+ superanimal_name="superanimal_quadruped",
+ model_name="hrnet_w32",
+ detector_name=None,
+ customized_model_config=custom_cfg,
+ )
+
+ assert custom_cfg["metadata"]["bodyparts"] == ["nose"]
+ assert model_cfg["metadata"]["bodyparts"] == ["nose", "tail"]
+
+
+@pytest.mark.parametrize("input_device", ["auto", None])
+def test_create_superanimal_inference_runners_auto_device_selection(monkeypatch, input_device):
+ cfg = _dummy_cfg("TD")
+ captured = {}
+
+ monkeypatch.setattr(helpers, "read_config_as_dict", lambda path: cfg)
+
+ def fake_update_config(config, max_individuals, device):
+ captured["device"] = device
+ return config
+
+ monkeypatch.setattr(helpers, "update_config", fake_update_config)
+ monkeypatch.setattr(
+ helpers,
+ "get_inference_runners",
+ lambda **kwargs: ("pose_runner", "det_runner"),
+ )
+
+ import deeplabcut.modelzoo.weight_initialization as wi
+
+ monkeypatch.setattr(
+ wi,
+ "build_weight_init",
+ lambda **kwargs: SimpleNamespace(
+ snapshot_path="pose.pt",
+ detector_snapshot_path="det.pt",
+ ),
+ )
+
+ helpers.create_superanimal_inference_runners(
+ superanimal_name="superanimal_quadruped",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ customized_model_config="/tmp/custom_model_cfg.yaml",
+ device=input_device,
+ )
+ assert captured["device"] == "auto"
+
+
+def test_create_superanimal_inference_runners_raises_for_fmpose3d():
+ with pytest.raises(NotImplementedError, match="FMPose3D"):
+ helpers.create_superanimal_inference_runners(
+ superanimal_name="superanimal_quadruped",
+ model_name="FMPose3D_resnet",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ customized_model_config=_dummy_cfg("TD"),
+ )
+
+
+def test_create_superanimal_inference_runners_propagates_unsupported_dataset_error(
+ monkeypatch,
+):
+ monkeypatch.setattr(
+ helpers,
+ "load_super_animal_config",
+ lambda **kwargs: (_ for _ in ()).throw(ValueError("Unsupported dataset for model zoo config")),
+ )
+
+ with pytest.raises(ValueError, match="Unsupported dataset"):
+ helpers.create_superanimal_inference_runners(
+ superanimal_name="superanimal_unknown",
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ customized_model_config=None,
+ )
diff --git a/tests/pose_estimation_pytorch/modelzoo/test_load_superanimal_models.py b/tests/pose_estimation_pytorch/modelzoo/test_load_superanimal_models.py
new file mode 100644
index 0000000000..fab8e2fb3f
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_load_superanimal_models.py
@@ -0,0 +1,31 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import dlclibrary
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.modelzoo import get_super_animal_snapshot_path
+
+
+@pytest.mark.skip(reason="require-models")
+def test_load_superanimal_models_weights_only():
+ super_animal_names = dlclibrary.get_available_datasets()
+ for super_animal in super_animal_names:
+ print(f"\nTesting {super_animal}")
+ for detector in dlclibrary.get_available_detectors(super_animal):
+ print(super_animal, detector)
+ path = get_super_animal_snapshot_path(super_animal, detector)
+ _snapshot = torch.load(path, map_location="cpu", weights_only=True)
+
+ for pose_model in dlclibrary.get_available_models(super_animal):
+ print(super_animal, pose_model)
+ path = get_super_animal_snapshot_path(super_animal, pose_model)
+ _snapshot = torch.load(path, map_location="cpu", weights_only=True)
diff --git a/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py
new file mode 100644
index 0000000000..571dc9f5cd
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.py
@@ -0,0 +1,35 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+
+# TODO: make a proper test incl. human model, bird model and that skips the require... at least once per week.
+
+
+@pytest.mark.parametrize("super_animal", ["superanimal_quadruped", "superanimal_topviewmouse"])
+@pytest.mark.parametrize("model_name", ["hrnet_w32"])
+@pytest.mark.parametrize("detector_name", [None, "fasterrcnn_resnet50_fpn_v2"])
+def test_get_config_model_paths(super_animal, model_name, detector_name):
+ model_config = modelzoo.load_super_animal_config(
+ super_animal=super_animal,
+ model_name=model_name,
+ detector_name=detector_name,
+ )
+
+ assert isinstance(model_config, dict)
+ if detector_name is None:
+ assert model_config["method"].lower() == "bu"
+ assert "detector" not in model_config
+ else:
+ assert model_config["method"].lower() == "td"
+ assert "detector" in model_config
diff --git a/tests/pose_estimation_pytorch/modelzoo/test_webapp.py b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py
new file mode 100644
index 0000000000..34a210b462
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_webapp.py
@@ -0,0 +1,69 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import numpy as np
+import pytest
+
+from deeplabcut.modelzoo.webapp.inference import SuperanimalPyTorchInference
+from deeplabcut.utils import auxiliaryfunctions
+
+# TODO: make a proper test incl. human model, bird model and that skips the require... at least once per week.
+
+
+@pytest.mark.parametrize("max_individuals", [1, 3])
+@pytest.mark.parametrize("project_name", ["superanimal_quadruped", "superanimal_topviewmouse"])
+@pytest.mark.parametrize("pose_model_type", ["hrnet_w32"])
+def test_class_init(project_name, pose_model_type, max_individuals):
+ inference_pipeline = SuperanimalPyTorchInference(project_name, pose_model_type, max_individuals=max_individuals)
+
+ assert isinstance(inference_pipeline.config, dict)
+ assert inference_pipeline.config["metadata"]["bodyparts"]
+ assert len(inference_pipeline.config["metadata"]["bodyparts"]) > 0
+
+
+@pytest.mark.skip(reason="require-models")
+@pytest.mark.parametrize("project_name", ["superanimal_quadruped", "superanimal_topviewmouse"])
+@pytest.mark.parametrize("pose_model_type", ["hrnet_w32"])
+def test_runner_init(project_name, pose_model_type):
+ inference_pipeline = SuperanimalPyTorchInference(project_name, pose_model_type, max_individuals=1)
+ weight_folder = f"{auxiliaryfunctions.get_deeplabcut_path()}/modelzoo/checkpoints"
+ snapshot_path = f"{weight_folder}/{project_name}_{pose_model_type}.pth"
+ detector_path = f"{weight_folder}/{project_name}_fasterrcnn.pt"
+
+ inference_pipeline.initialize_models(snapshot_path, detector_path)
+
+ assert inference_pipeline.models.pose_runner
+ assert inference_pipeline.models.detector_runner
+
+
+@pytest.mark.skip(reason="require-models")
+@pytest.mark.parametrize("max_individuals", [10, 4, 1])
+@pytest.mark.parametrize("project_name", ["superanimal_quadruped", "superanimal_topviewmouse", "superanimal_humanbody"])
+@pytest.mark.parametrize("pose_model_type", ["hrnet_w32"])
+def test_predict(project_name, pose_model_type, max_individuals):
+ inference_pipeline = SuperanimalPyTorchInference(project_name, pose_model_type, max_individuals=max_individuals)
+ image_path = "img0001.png"
+ weight_folder = f"{auxiliaryfunctions.get_deeplabcut_path()}/modelzoo/checkpoints"
+ snapshot_path = f"{weight_folder}/{project_name}_{pose_model_type}.pth"
+ detector_path = f"{weight_folder}/{project_name}_fasterrcnn.pt"
+
+ inference_pipeline.initialize_models(snapshot_path, detector_path)
+ frame = {image_path: np.random.rand(100, 100, 3)}
+ response = inference_pipeline.predict(frame)
+
+ assert isinstance(response, dict)
+ assert response["joint_names"] == inference_pipeline.config["bodyparts"]
+ assert response["predictions"][0]["markers"].shape == (
+ max_individuals,
+ len(inference_pipeline.config["bodyparts"]),
+ 3,
+ )
+ assert response["predictions"][0]["image_path"] == image_path
diff --git a/tests/pose_estimation_pytorch/other/test_api_utils.py b/tests/pose_estimation_pytorch/other/test_api_utils.py
new file mode 100644
index 0000000000..0efbfbb52c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_api_utils.py
@@ -0,0 +1,94 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import random
+
+import numpy as np
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.data.transforms as transforms
+
+transform_dicts = [
+ {"auto_padding": {"pad_height_divisor": 64, "pad_width_divisor": 27}},
+ {"resize": {"height": 512, "width": 256, "keep_ration": True}},
+ {
+ "covering": True,
+ "gaussian_noise": 12.75,
+ "hist_eq": True,
+ "motion_blur": True,
+ "normalize_images": True,
+ "rotation": 30,
+ "scale_jitter": [0.5, 1.25],
+ "auto_padding": {"pad_width_divisor": 64, "pad_height_divisor": 27},
+ },
+ {
+ "covering": True,
+ "gaussian_noise": 100,
+ "hist_eq": True,
+ "motion_blur": True,
+ "normalize_images": True,
+ "rotation": 180,
+ "scale_jitter": [0.03, 20],
+ "auto_padding": {"pad_width_divisor": 64, "pad_height_divisor": 27},
+ },
+]
+
+
+def _get_random_params(transform_idx):
+ return (
+ transform_dicts[transform_idx],
+ (random.randint(100, 1000), random.randint(100, 1000)),
+ random.randint(1, 100),
+ random.randint(1, 100),
+ )
+
+
+@pytest.mark.parametrize(
+ "transform_dict, size_image, num_keypoints, num_animals",
+ [_get_random_params(i) for i in range(4)],
+)
+def test_build_transforms(transform_dict, size_image, num_keypoints, num_animals):
+ transform_bbox_aug = transforms.build_transforms(transform_dict)
+ w, h = size_image
+ for i in range(10):
+ test_image = np.random.randint(0, 255, (h, w, 3), dtype=np.uint8)
+ bboxes = np.random.randint(0, min(w - 1, h - 1), (num_animals, 4))
+ bboxes[:, 2] = w - bboxes[:, 0]
+ bboxes[:, 3] = h - bboxes[:, 1]
+ keypoints = np.random.randint(0, min(w, h), (num_keypoints, 2))
+
+ with pytest.raises(ValueError) as _err_info:
+ _ = transform_bbox_aug(image=test_image)
+ _ = transform_bbox_aug(image=test_image, bboxes=bboxes.copy())
+ _ = transform_bbox_aug(image=test_image, keypoints=keypoints.copy(), bboxes=bboxes.copy())
+
+ transformed_with_bbox = transform_bbox_aug(
+ image=test_image,
+ keypoints=keypoints.copy(),
+ bboxes=bboxes.copy(),
+ bbox_labels=np.arange(num_animals),
+ class_labels=[0 for _ in range(len(keypoints))],
+ )
+
+ if "resize" in transform_dict.keys():
+ assert transformed_with_bbox["image"].shape[:2] == (
+ transform_dict["resize"]["height"],
+ transform_dict["resize"]["width"],
+ )
+
+ if "auto_padding" in transform_dict.keys():
+ modh, modw = (
+ transform_dict["auto_padding"]["pad_height_divisor"],
+ transform_dict["auto_padding"]["pad_width_divisor"],
+ )
+ assert transformed_with_bbox["image"].shape[0] % modh == 0
+ assert transformed_with_bbox["image"].shape[1] % modw == 0
+
+ assert len(transformed_with_bbox["keypoints"]) == len(keypoints)
diff --git a/tests/pose_estimation_pytorch/other/test_configs/config.yaml b/tests/pose_estimation_pytorch/other/test_configs/config.yaml
new file mode 100644
index 0000000000..15ad6f4678
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_configs/config.yaml
@@ -0,0 +1,106 @@
+ # Project definitions (do not edit)
+Task: openfield
+scorer: Pranav
+date: Aug20
+multianimalproject: false
+identity:
+
+ # Project path (change when moving around)
+project_path: /home/quentin/datasets/Openfield_pytorch
+
+ # Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+ /Data/openfield-Pranav-2018-08-20/videos/m1s1.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m1s2.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m2s1.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m3s1.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m3s2.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m4s1.mp4:
+ crop: 0, 640, 0, 480
+ /Data/openfield-Pranav-2018-08-20/videos/m5s1.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m6s1.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m6s2.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m7s1.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m7s2.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m7s3.mp4:
+ crop: 0, 800, 0, 800
+ /Data/openfield-Pranav-2018-08-20/videos/m8s1.mp4:
+ crop: 0, 800, 0, 800
+
+ /Users/mwmathis/Downloads/ARCricket1.avi:
+ crop: 0, 720, 0, 540
+bodyparts:
+- snout
+- leftear
+- rightear
+- tailbase
+
+flipped_keypoints:
+- 0
+- 2
+- 1
+- 3
+
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+start: 0
+stop: 1
+numframes2pick: 20
+
+ # Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff: 0.4
+dotsize: 8
+alphavalue: 0.7
+colormap: jet
+
+ # Training,Evaluation and Analysis configuration
+TrainingFraction:
+- 0.95
+iteration: 1
+default_net_type: resnet_50
+default_augmenter: default
+snapshotindex: -1
+batch_size: 1
+
+ # Cropping Parameters (for analysis and outlier frame detection)
+cropping: false
+ #if cropping is true for analysis, then set the values here:
+x1: 0
+x2: 640
+y1: 277
+y2: 624
+
+ # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+- 50
+- 50
+move2corner: true
+croppedtraining:
diff --git a/tests/pose_estimation_pytorch/other/test_configs/pose_cfg.yaml b/tests/pose_estimation_pytorch/other/test_configs/pose_cfg.yaml
new file mode 100644
index 0000000000..ec41492bd4
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_configs/pose_cfg.yaml
@@ -0,0 +1,115 @@
+ # Project definitions (do not edit)
+Task:
+scorer:
+date:
+multianimalproject:
+identity:
+
+ # Project path (change when moving around)
+project_path: /home/quentin/datasets/Openfield_pytorch/dlc-models/iteration-1/openfieldAug20-trainset95shuffle1/train
+
+ # Annotation data set configuration (and individual video cropping parameters)
+video_sets:
+bodyparts:
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+start:
+stop:
+numframes2pick:
+
+ # Plotting configuration
+skeleton: []
+skeleton_color: black
+pcutoff:
+dotsize:
+alphavalue:
+colormap:
+
+ # Training,Evaluation and Analysis configuration
+TrainingFraction:
+iteration:
+default_net_type:
+default_augmenter:
+snapshotindex:
+batch_size: 1
+
+ # Cropping Parameters (for analysis and outlier frame detection)
+cropping:
+ #if cropping is true for analysis, then set the values here:
+x1:
+x2:
+y1:
+y2:
+
+ # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+corner2move2:
+move2corner:
+all_joints:
+- - 0
+- - 1
+- - 2
+- - 3
+all_joints_names:
+- snout
+- leftear
+- rightear
+- tailbase
+alpha_r: 0.02
+apply_prob: 0.5
+contrast:
+ clahe: true
+ claheratio: 0.1
+ histeq: true
+ histeqratio: 0.1
+convolution:
+ edge: false
+ emboss:
+ alpha:
+ - 0.0
+ - 1.0
+ strength:
+ - 0.5
+ - 1.5
+ embossratio: 0.1
+ sharpen: false
+ sharpenratio: 0.3
+cropratio: 0.4
+dataset: training-datasets/iteration-1/UnaugmentedDataSet_openfieldAug20/openfield_Pranav95shuffle1.mat
+dataset_type: default
+decay_steps: 30000
+display_iters: 1000
+global_scale: 0.8
+init_weights: /home/quentin/miniconda/envs/DEEPLABCUT/lib/python3.8/site-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt
+intermediate_supervision: false
+intermediate_supervision_layer: 12
+location_refinement: true
+locref_huber_loss: true
+locref_loss_weight: 0.05
+locref_stdev: 7.2801
+lr_init: 0.0005
+max_input_size: 1500
+metadataset: training-datasets/iteration-1/UnaugmentedDataSet_openfieldAug20/Documentation_data-openfield_95shuffle1.pickle
+min_input_size: 64
+mirror: false
+multi_stage: false
+multi_step:
+- - 0.005
+ - 10000
+- - 0.02
+ - 430000
+- - 0.002
+ - 730000
+- - 0.001
+ - 1030000
+net_type: resnet_50
+num_joints: 4
+pairwise_huber_loss: false
+pairwise_predict: false
+partaffinityfield_predict: false
+pos_dist_thresh: 17
+rotation: 25
+rotratio: 0.4
+save_iters: 50000
+scale_jitter_lo: 0.5
+scale_jitter_up: 1.25
+scmap_type: plateau
diff --git a/tests/pose_estimation_pytorch/other/test_configs/pytorch_config.yaml b/tests/pose_estimation_pytorch/other/test_configs/pytorch_config.yaml
new file mode 100644
index 0000000000..0be2ca0ed8
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_configs/pytorch_config.yaml
@@ -0,0 +1,45 @@
+project_root: /home/quentin/datasets/Openfield_pytorch
+pose_cfg_path: /home/quentin/datasets/Openfield_pytorch/dlc-models/iteration-1/openfieldAug20-trainset95shuffle1/train/pose_cfg.yaml
+cfg_path: /home/quentin/datasets/Openfield_pytorch/config.yaml
+
+seed: 42
+device: 'cuda:2' #needs to be updated dynamically; some users might have CPUs
+model:
+ backbone:
+ type: 'ResNet'
+ pretrained: 'https://download.pytorch.org/models/resnet50-19c8e357.pth'
+ heatmap_head:
+ type: 'SimpleHead'
+ channels: [ 2048, 1024, 4 ]
+ kernel_size: [ 2, 2 ]
+ strides: [ 2, 2 ]
+ locref_head:
+ type: 'SimpleHead'
+ channels: [ 2048, 1024, 8 ]
+ kernel_size: [ 2, 2 ]
+ strides: [ 2, 2 ]
+ pose_model:
+ stride: 8
+ heatmap_type: 'plateau'
+optimizer:
+ type: 'SGD'
+ params:
+ lr: 0.005
+scheduler:
+ type: "LRListScheduler"
+ params:
+ milestones : [10, 430]
+ lr_list : [[0.02], [0.002]]
+criterion:
+ type: 'PoseLoss'
+ loss_weight_locref: 0.1
+ locref_huber_loss: True
+#logger:
+# type: 'WandbLogger'
+# project_name: 'deeplabcut'
+# run_name: 'tmp'
+solver:
+ type: 'BottomUpSingleAnimalSolver'
+pos_dist_thresh : 17
+batch_size: 1
+epochs: 600
diff --git a/tests/pose_estimation_pytorch/other/test_custom_transforms.py b/tests/pose_estimation_pytorch/other/test_custom_transforms.py
new file mode 100644
index 0000000000..28ddb2ffc7
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_custom_transforms.py
@@ -0,0 +1,53 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.data import transforms
+
+
+@pytest.mark.parametrize("width, height", [(200, 200), (300, 300), (400, 400)])
+def test_keypoint_aware_cropping(width, height):
+ fake_image = np.empty((600, 600, 3))
+ fake_keypoints = [(i * 100, i * 100, 0, 0) for i in range(1, 6)]
+ aug = transforms.KeypointAwareCrop(width=width, height=height, crop_sampling="density")
+ transformed = aug(image=fake_image, keypoints=fake_keypoints)
+ assert transformed["image"].shape[:2] == (height, width)
+ # Ensure at least a keypoint is visible in each crop
+ assert len(transformed["keypoints"])
+
+
+def test_grayscale():
+ fake_image = np.ones((600, 600, 3))
+ fake_image *= np.random.uniform(0, 255, size=fake_image.shape)
+ fake_image = fake_image.astype(np.uint8)
+ gray = transforms.Grayscale(alpha=1, p=1)
+ aug_image = gray(image=fake_image)["image"]
+ assert aug_image.shape == fake_image.shape
+
+ gray = transforms.Grayscale(alpha=0, p=1)
+ aug_image = gray(image=fake_image)["image"]
+ assert np.allclose(fake_image, aug_image)
+
+ with pytest.warns(UserWarning, match="clipped"):
+ gray = transforms.Grayscale(alpha=1.5)
+ assert gray.alpha == 1
+
+
+def test_coarse_dropout():
+ fake_image = np.ones((300, 300, 3))
+ fake_image *= np.random.uniform(0, 255, size=fake_image.shape)
+ fake_image = fake_image.astype(np.uint8)
+ cd = transforms.CoarseDropout(max_height=0.9999, max_width=0.9999, p=1)
+ kpts = np.random.rand(10, 2) * 298 + 1
+ aug_kpts = cd(image=fake_image, keypoints=kpts)["keypoints"]
+ assert len(aug_kpts) == kpts.shape[0]
+ assert np.isnan([c for kpt in aug_kpts for c in kpt]).all()
diff --git a/tests/pose_estimation_pytorch/other/test_data_helper.py b/tests/pose_estimation_pytorch/other/test_data_helper.py
new file mode 100644
index 0000000000..73db76316d
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_data_helper.py
@@ -0,0 +1,94 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+import os
+from unittest.mock import Mock, patch
+from zipfile import Path
+
+import numpy as np
+import pytest
+
+from deeplabcut.generate_training_dataset import create_training_dataset
+from deeplabcut.pose_estimation_pytorch.data.dlcloader import DLCLoader
+from deeplabcut.pose_estimation_pytorch.data.utils import merge_list_of_dicts
+
+
+def mock_aux() -> Mock:
+ aux_functions = Mock()
+ aux_functions.read_plainconfig = Mock()
+ aux_functions.read_plainconfig.return_value = {}
+ return aux_functions
+
+
+@patch("deeplabcut.pose_estimation_pytorch.data.base.auxiliaryfunctions", mock_aux())
+def _get_loader(project_root):
+ if not (Path(project_root) / "training-datasets").exists():
+ create_training_dataset(config=str(Path(project_root) / "config.yaml"))
+ return DLCLoader(Path(project_root) / "config.yaml", shuffle=1)
+
+
+@pytest.mark.skip
+@pytest.mark.parametrize("repo_path", ["/home/anastasiia/DLCdev"])
+def test_propertymeta_project(repo_path):
+ project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30")
+ dlc_loader = _get_loader(project_root)
+
+ for prop in dlc_loader.properties:
+ print(prop, getattr(dlc_loader, prop))
+
+
+@pytest.mark.skip
+@pytest.mark.parametrize(
+ "repo_path, mode",
+ [("/home/anastasiia/DLCdev", "train"), ("/home/anastasiia/DLCdev", "test")],
+)
+def test_propertymeta_dataset(repo_path, mode):
+ repo_path = "/home/anastasiia/DLCdev"
+ mode = "train"
+ project_root = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30")
+ dlc_loader = _get_loader(project_root)
+ dataset = dlc_loader.create_dataset(transform=None, mode=mode)
+
+ for prop in dataset.properties:
+ print(prop, getattr(dataset, prop))
+
+
+@pytest.mark.parametrize(
+ "list_dicts, keys_to_include",
+ [
+ ([{"a": 1, "b": 2}, {"a": 3, "b": 4}], ["a"]),
+ (
+ [
+ *[
+ {
+ "keypoints": np.random.randn(27, 3),
+ "images": np.random.randn(256, 192),
+ }
+ ]
+ * 10
+ ],
+ [*["keypoints", "images"] * 10],
+ ),
+ ],
+)
+def test_merge_list_of_dicts(list_dicts, keys_to_include):
+ result_dict = merge_list_of_dicts(list_dicts, keys_to_include)
+ expected_result_dict = {}
+ for dictionary in list_dicts:
+ for key in dictionary:
+ if key not in keys_to_include:
+ continue
+ else:
+ if key not in expected_result_dict:
+ expected_result_dict[key] = []
+ expected_result_dict[key].append(dictionary[key])
+ assert result_dict == expected_result_dict
diff --git a/tests/pose_estimation_pytorch/other/test_dataset.py b/tests/pose_estimation_pytorch/other/test_dataset.py
new file mode 100644
index 0000000000..221ec64f19
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_dataset.py
@@ -0,0 +1,186 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+import random
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import albumentations as A
+import pytest
+from torch.utils.data import DataLoader
+
+import deeplabcut.pose_estimation_pytorch as dlc
+import deeplabcut.utils.auxiliaryfunctions as dlc_auxfun
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset import create_training_dataset
+
+
+def mock_config() -> Mock:
+ aux_functions = Mock()
+ aux_functions.read_config_as_dict = Mock()
+ aux_functions.read_config_as_dict.return_value = {
+ "data": {"train": {}, "inference": {}},
+ "metadata": {
+ "project_path": "",
+ "pose_config_path": "",
+ "bodyparts": ["snout", "leftear", "rightear", "tailbase"],
+ "unique_bodyparts": [],
+ "individuals": ["animal"],
+ "with_identity": False,
+ },
+ "method": "bu",
+ }
+ return aux_functions
+
+
+@patch("deeplabcut.pose_estimation_pytorch.data.base.config", mock_config())
+def _get_dataset(path, transform, mode="train"):
+ project_root = Path(path)
+ if not (project_root / "training-datasets").exists():
+ print(str(project_root / "config.yaml"))
+ create_training_dataset(
+ config=str(project_root / "config.yaml"),
+ net_type="resnet_50",
+ engine=Engine.PYTORCH,
+ )
+
+ loader = dlc.DLCLoader(Path(project_root) / "config.yaml", shuffle=1)
+ dataset = loader.create_dataset(transform=transform, mode=mode)
+ return dataset
+
+
+def _get_openfield_dataset(transform=None):
+ dlc_path = dlc_auxfun.get_deeplabcut_path()
+ repo_path = os.path.dirname(dlc_path)
+ openfield_path = os.path.join(repo_path, "examples", "openfield-Pranav-2018-10-30")
+
+ return _get_dataset(openfield_path, transform=transform)
+
+
+key_set = {
+ "offsets",
+ "path",
+ "scales",
+ "image",
+ "original_size",
+ "annotations",
+ "image_id",
+ "context",
+}
+anno_key_set = {
+ "keypoints",
+ "keypoints_unique",
+ "with_center_keypoints",
+ "area",
+ "boxes",
+ "is_crowd",
+ "labels",
+ "individual_ids",
+}
+
+
+@pytest.mark.parametrize("batch_size", [1, 2, random.randint(2, 20)])
+def test_iter_all_dataset_no_transform(batch_size):
+ if batch_size > 1: # if batched, all images need to be the same size
+ transform = A.Compose(
+ [A.Resize(512, 512)],
+ keypoint_params=A.KeypointParams(format="xy"),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ else:
+ transform = A.Compose(
+ [A.Normalize()],
+ keypoint_params=A.KeypointParams(format="xy"),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ dataset = _get_openfield_dataset(transform=transform)
+ dataloader = DataLoader(dataset, batch_size=batch_size)
+ max_num_animals = dataset.parameters.max_num_animals
+ num_keypoints = dataset.parameters.num_joints
+ for i, item in enumerate(dataloader):
+ is_last_batch = i == (len(dataloader) - 1)
+ assert set(item.keys()) == key_set, (
+ f"the key returned don't match the required ones: {item.keys()} != {key_set}"
+ )
+
+ anno = item["annotations"]
+ assert set(anno.keys()) == anno_key_set, "the annotation keys returned don't match the required ones"
+
+ assert (len(item["image"].shape) == 4) and ((item["image"].shape[:2] == (batch_size, 3)) or is_last_batch), (
+ "image shape is not (batch_size, 3, h, w)"
+ )
+
+ b, _, h, w = item["image"].shape
+ kpts, bboxes = anno["keypoints"], anno["boxes"]
+ assert kpts.shape == (batch_size, max_num_animals, num_keypoints, 3) or is_last_batch, (
+ "keypoints have the wrong shape"
+ )
+ assert bboxes.shape == (batch_size, max_num_animals, 4) or is_last_batch, "boxes have the wrong shape"
+ assert ((bboxes[:, :, 0] + bboxes[:, :, 2]) <= w).all() and ((bboxes[:, :, 1] + bboxes[:, :, 3]) <= h).all(), (
+ "boxes don't seem to be un the format (x, y, w, h)"
+ )
+
+
+def _generate_random_test_values_aug(min_exa):
+ batch_size = random.randint(1, 20)
+ x_size = random.randint(50, 600)
+ y_size = random.randint(50, 600)
+ exaggeration = random.randint(min_exa, 99)
+
+ return batch_size, x_size, y_size, exaggeration
+
+
+@pytest.mark.parametrize(
+ "batch_size, x_size, y_size, exaggeration",
+ [
+ (1, 512, 512, 1),
+ _generate_random_test_values_aug(1),
+ _generate_random_test_values_aug(50),
+ ],
+)
+def test_iter_all_augmented_dataset(batch_size, x_size, y_size, exaggeration):
+ transform = A.Compose(
+ [
+ A.Affine(
+ scale=(1 - exaggeration * 0.01, 1 + exaggeration),
+ rotate=(-exaggeration * 2, exaggeration * 2),
+ translate_px=(-exaggeration * 10, exaggeration * 10),
+ ),
+ A.Resize(y_size, x_size),
+ ],
+ keypoint_params=A.KeypointParams(format="xy", remove_invisible=False),
+ bbox_params=A.BboxParams(format="coco", label_fields=["bbox_labels"]),
+ )
+ dataset = _get_openfield_dataset(transform=transform)
+ dataloader = DataLoader(dataset, batch_size=batch_size)
+ max_num_animals = dataset.parameters.max_num_animals
+ num_keypoints = dataset.parameters.num_joints
+ for i, item in enumerate(dataloader):
+ is_last_batch = i == (len(dataloader) - 1)
+ assert set(item.keys()) == key_set, (
+ f"the key returned don't match the required ones: {item.keys()} != {key_set}"
+ )
+
+ anno = item["annotations"]
+ assert set(anno.keys()) == anno_key_set, "the annotation keys returned don't match the required ones"
+
+ assert (len(item["image"].shape) == 4) and ((item["image"].shape[:2] == (batch_size, 3)) or is_last_batch), (
+ "image shape is not (batch_size, 3, h, w)"
+ )
+
+ kpts, bboxes = anno["keypoints"], anno["boxes"]
+ b, _, h, w = item["image"].shape
+ assert (h == y_size) and (w == x_size)
+ assert kpts.shape == (batch_size, max_num_animals, num_keypoints, 3) or is_last_batch, (
+ "keypoints have the wrong shape"
+ )
+ assert bboxes.shape == (batch_size, max_num_animals, 4) or is_last_batch, "boxes have the wrong shape"
+ assert ((bboxes[:, :, 0] + bboxes[:, :, 2]) <= w).all() and ((bboxes[:, :, 1] + bboxes[:, :, 3]) <= h).all()
diff --git a/tests/pose_estimation_pytorch/other/test_gaussian_targets.py b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py
new file mode 100644
index 0000000000..1c202b1902
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_gaussian_targets.py
@@ -0,0 +1,56 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators import HeatmapGaussianGenerator
+
+
+@pytest.mark.parametrize(
+ "batch_size, num_keypoints, image_size",
+ [(2, 2, (64, 64)), (1, 5, (48, 64)), (15, 50, (64, 48))],
+)
+def test_gaussian_target_generation(batch_size: int, num_keypoints: int, image_size: tuple, num_animals=1):
+ # generate annotations
+ labels = {
+ "keypoints": torch.randint(1, min(image_size), (batch_size, num_animals, num_keypoints, 2))
+ } # batch size, num animals, num keypoints, 2 for x,y
+ # generate predictions
+ stride = 1
+ prediction = {
+ "heatmap": torch.rand((batch_size, num_keypoints, *image_size[:2])),
+ "locref": torch.rand((batch_size, 2 * num_keypoints, *image_size[:2])),
+ }
+
+ # generate heatmap
+ output = HeatmapGaussianGenerator(
+ num_heatmaps=num_keypoints,
+ pos_dist_thresh=17,
+ locref_std=5.0,
+ )
+ output = output(stride, prediction, labels)["heatmap"]["target"].reshape(
+ batch_size, num_keypoints, image_size[0] * image_size[1]
+ )
+
+ # get coords of max value of the heatmap
+ gaus_max = torch.argmax(output, dim=2)
+
+ # get unraveled coords
+ x = gaus_max % image_size[1]
+ y = gaus_max // image_size[1]
+
+ # get heatmap center tensor
+ predict_kp = torch.stack((x, y), dim=-1)
+ # Remove num_animals dimension - only one animal is supported
+ labels["keypoints"] = torch.squeeze(labels["keypoints"], dim=1)
+
+ # compare heatmap center to annotation
+ assert torch.eq(labels["keypoints"], predict_kp).all().item()
diff --git a/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py
new file mode 100644
index 0000000000..b44dc3f39f
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_heatmap_plateau_targets.py
@@ -0,0 +1,204 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators import HeatmapPlateauGenerator
+
+
+def get_target(
+ batch_size: int,
+ num_animals: int,
+ num_joints: int,
+ image_size: tuple[int, int],
+ locref_std: float,
+ pos_dist_thresh: int,
+):
+ """Summary Getting the target generator for certain annotations, predictions and
+ image size.
+
+ Args:
+ batch_size (int): number of images
+ num_animals (int): number of animals
+ num_joints (int): number of bodyparts
+ image_size (tuple): image size in pixels
+ locref_std (float): scaling factor
+ pos_dist_thresh (int): radius plateau on the heatmap
+
+ Returns:
+ target_output (dict): containing the heatmaps, locref_maps and locref_masks.
+ annotations (dict): containing input keypoint annotations.
+
+ Examples:
+ input:
+ batch_size = 1
+ num_animals = 1
+ num_joints = 6
+ image_size = (256,256)
+ locref_stdev = 7.2801
+ pos_dist_thresh = 17
+ output:
+ """
+ labels = {
+ "keypoints": torch.randint(1, min(image_size), (batch_size, num_animals, num_joints, 2))
+ } # 2 for x,y coords
+ stride = 1
+ prediction = {
+ "heatmap": torch.rand((batch_size, num_joints, image_size[0], image_size[1])),
+ "locref": torch.rand((batch_size, 2 * num_joints, image_size[0], image_size[1])),
+ }
+ generator = HeatmapPlateauGenerator(
+ num_heatmaps=num_joints,
+ pos_dist_thresh=pos_dist_thresh,
+ locref_std=locref_std,
+ generate_locref=True,
+ )
+
+ targets_output = generator(stride, prediction, labels)
+ return targets_output, labels
+
+
+data = [(1, 1, 10, (256, 256), 7.2801, 17)]
+
+
+@pytest.mark.parametrize(
+ "batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh",
+ data,
+)
+def test_expected_output(
+ batch_size: int,
+ num_animals: int,
+ num_joints: int,
+ image_size: tuple[int, int],
+ locref_stdev: float,
+ pos_dist_thresh: int,
+):
+ """Summary:
+ Testing if plateau targets return the expected output. We take a target generator from
+ get_target function. Given a sequence of random numbers for batch_size, num_animals etc., we assert if
+ it returns the expected heatmaps and locrefmaps, as well as checking if the output has the expected shape.
+
+ Args:
+ batch_size (int): number of images
+ num_animals (int): number of animals
+ num_joints (int): number of bodyparts
+ image_size (tuple): image size in pixels
+ locref_stdev (float): scaling factor
+ pos_dist_thresh (int): radius plateau on heatmap
+
+ Returns:
+ None
+
+ Examples:
+ input:
+ batch_size = 1
+ num_animals = 1
+ num_joints = 6
+ image_size = (256,256)
+ locref_stdev = 7.2801
+ pos_dist_thresh = 17
+ """
+ targets_output, annotations = get_target(
+ batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh
+ )
+
+ assert "heatmap" in targets_output
+ assert "locref" in targets_output
+ assert targets_output["heatmap"]["target"].shape == (
+ batch_size,
+ num_joints,
+ image_size[0],
+ image_size[1],
+ ) # heatmaps score output
+ assert targets_output["locref"]["weights"].shape == (
+ batch_size,
+ num_joints * 2,
+ image_size[0],
+ image_size[1],
+ )
+ assert targets_output["locref"]["target"].shape == (
+ batch_size,
+ num_joints * 2,
+ image_size[0],
+ image_size[1],
+ )
+
+
+data = [(1, 1, 10, (256, 256), 7.2801, 17)]
+
+
+@pytest.mark.parametrize(
+ "batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh",
+ data,
+)
+def test_single_animal(
+ batch_size: int,
+ num_animals: int,
+ num_joints: int,
+ image_size: tuple[int, int],
+ locref_stdev: float,
+ pos_dist_thresh: int,
+):
+ """Summary Testing, for single animals experiments (num_animals=1) if the distance
+ between the expected keypoints and the annotations keypoints is smaller than the
+ radius plateau.
+
+ 'argmax' function returns the indices of the max values of all elements in the input tensor.
+ If there are multiple maximal values, such as in our case because it's a plateau, then the
+ indices of the first maximal value are returned. From this tensor we exctact x,y coords
+ and then concatenate these new tensors along a new dimension. Then, we assert if the distance between
+ each x,y element in annotations and predicted keypoints is smaller or equal to the 'pos_dist_thresh',
+ which represents the radius of the plateau heatmap.
+
+ Args:
+ batch_size (int): number of images
+ num_animals (int): number of animals
+ num_joints (int): number of bodyparts
+ image_size (tuple): image size in pixels
+ locref_stdev (float): scaling factor
+ pos_dist_thresh (int): radius plateau on heatmap
+
+ Returns:
+ None
+
+ Examples:
+ input:
+ batch_size = 1
+ num_animals = 1
+ num_joints = 6
+ image_size = (256,256)
+ locref_stdev = 7.2801
+ pos_dist_thresh = 17
+ """
+ targets_output, annotations = get_target(
+ batch_size, num_animals, num_joints, image_size, locref_stdev, pos_dist_thresh
+ )
+
+ targets_output = torch.tensor(
+ targets_output["heatmap"]["target"].reshape(1, 10, image_size[0] * image_size[1])
+ ) # converting from dict to tensor. 'argmax' works on tensors.
+
+ plt_max = torch.argmax(targets_output, dim=2)
+ # get unraveled coords
+ x = plt_max % image_size[1]
+ y = plt_max // image_size[1]
+
+ predict_kp = torch.stack((x, y), dim=-1)
+
+ predict_kp = predict_kp.float()
+
+ annotations["keypoints"] = torch.squeeze(annotations["keypoints"], dim=1)
+ annotations["keypoints"] = annotations["keypoints"].float()
+
+ dist = torch.norm(annotations["keypoints"] - predict_kp, p=2, dim=-1)
+ assert (dist <= pos_dist_thresh).all()
diff --git a/tests/pose_estimation_pytorch/other/test_helper.py b/tests/pose_estimation_pytorch/other/test_helper.py
new file mode 100644
index 0000000000..1dfa250109
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_helper.py
@@ -0,0 +1,21 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import torch
+
+
+def test_train_valid_call():
+ tmp_model = torch.nn.Linear(3, 10)
+ to_train_mode = tmp_model.train
+ to_train_mode()
+ assert tmp_model.training
+ to_valid_mode = tmp_model.eval
+ to_valid_mode()
+ assert not tmp_model.training
diff --git a/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py
new file mode 100644
index 0000000000..1ee4073fc8
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_match_predictions_to_gt.py
@@ -0,0 +1,132 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.post_processing import (
+ match_predictions_to_gt as deeplabcut_torch_match_predictions_gt,
+)
+
+
+@pytest.fixture
+def animals_and_keypoints_invalid():
+ """Summary:
+ Fixture with invalid pred_kpts and gt_kpts shapes that will raise ValueErrors.
+
+ Returns:
+ tuple containing:
+ predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score)
+ ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y)
+ individual names (indv_names)
+ """
+ gt_kpts = 2 * np.ones((6, 6, 3)) # num animals, num keypoints, (x,y,vis)
+ gt_kpts[:, :, :2] = np.random.rand(6, 6, 2)
+ pred_kpts = np.random.rand(6, 8, 3) # num animals, num keypoints, (x,y,score)
+ indv_names = ["indv1", "indv2"]
+ return pred_kpts, gt_kpts, indv_names
+
+
+@pytest.fixture
+def animals_and_keypoints():
+ """Summary:
+ Fixture with pred_kpts, gt_kpts shapes and indv_names.
+
+ Returns:
+ tuple containing:
+ predicted keypoints(pred_kpts), of shape num_animals, num_keypoints, (x,y,score)
+ ground truth keypoints (gt_kpts), of shape num_animals, num_keypoints, (x,y)
+ individual names (indv_names)
+ """
+ gt_kpts = 2 * np.ones((6, 6, 3)) # num animals, num keypoints, (x,y,vis)
+ gt_kpts[:, :, :2] = np.random.rand(6, 6, 2)
+
+ # adding score value because the shape of pred_kpts should be (6,6,3)
+ score = np.full((gt_kpts.shape[0], gt_kpts.shape[1], 1), 0.5)
+ pred_kpts = np.concatenate((gt_kpts, score), axis=2)
+ np.random.shuffle(pred_kpts) # shuffle predicted keypoints
+
+ indv_names = ["indv1", "indv2"]
+ return pred_kpts, gt_kpts, indv_names
+
+
+def test_invalid_rmse(animals_and_keypoints_invalid: tuple) -> None:
+ """Summary:
+ Tets if an invalid output really returns a ValueError in the rmse function.
+
+ Args:
+ animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts),
+ ground truth keypoints (gt_kpts) and individual names (indv_names).
+ """
+ pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid
+
+ with pytest.raises(ValueError):
+ deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts, gt_kpts)
+
+
+def test_invalid_oks(animals_and_keypoints_invalid: tuple) -> None:
+ """Summary:
+ Test if an invalid output really returns a ValueError in the oks function.
+
+ Args:
+ animals_and_keypoints_invalid (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints
+ (gt_kpts)
+ and individual names (indv_names)
+ """
+ pred_kpts, gt_kpts, indv_names = animals_and_keypoints_invalid
+
+ with pytest.raises(ValueError):
+ deeplabcut_torch_match_predictions_gt.oks_match_prediction_to_gt(pred_kpts, gt_kpts, indv_names)
+
+
+def test_rmse_match_predictions_to_gt(animals_and_keypoints: tuple, num_animals: int = 6) -> None:
+ """Summary:
+ Test if rmse_match_prediction_to_gt function returns the expected shape output.
+
+ Args:
+ animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts)
+ and individual names (indv_names)
+ """
+ pred_kpts, gt_kpts, indv_names = animals_and_keypoints
+
+ col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts, gt_kpts)
+ assert isinstance(col_ind, np.ndarray)
+ assert col_ind.shape == (num_animals,)
+
+
+def test_oks_match_predictions_to_gt(animals_and_keypoints: tuple, num_animals: int = 6) -> None:
+ """Summary:
+ Test if oks_match_predictions_to_gt function returns the expected shape output.
+
+ Args:
+ animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts)
+ and individual names (indv_names)
+ """
+ pred_kpts, gt_kpts, indv_names = animals_and_keypoints
+
+ col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts, gt_kpts)
+ assert isinstance(col_ind, np.ndarray)
+ assert col_ind.shape == (num_animals,)
+
+
+def test_extend_col_ind(animals_and_keypoints: tuple, num_animals: int = 6) -> None:
+ """Summary:
+ Test if the column indices have the expected shape.
+
+ Args:
+ animals_and_keypoints (tuple): containing predicted keypoints (pred_kpts), ground truth keypoints (gt_kpts)
+ and individual names (indv_names)
+ """
+ pred_kpts, gt_kpts, indv_names = animals_and_keypoints
+
+ col_ind = deeplabcut_torch_match_predictions_gt.rmse_match_prediction_to_gt(pred_kpts, gt_kpts)
+ extended_array = deeplabcut_torch_match_predictions_gt.extend_col_ind(col_ind, num_animals)
+ assert extended_array.shape == (num_animals,)
diff --git a/tests/pose_estimation_pytorch/other/test_modelzoo.py b/tests/pose_estimation_pytorch/other/test_modelzoo.py
new file mode 100644
index 0000000000..18fddfd931
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_modelzoo.py
@@ -0,0 +1,50 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+
+import pytest
+
+from deeplabcut.modelzoo.video_inference import video_inference_superanimal
+from deeplabcut.utils import auxiliaryfunctions
+
+examples_folder = os.path.join(
+ auxiliaryfunctions.get_deeplabcut_path(),
+ "modelzoo",
+ "examples",
+)
+
+
+# requires videos to be in the examples folder
+@pytest.mark.skip
+@pytest.mark.parametrize(
+ "video_paths, superanimal_name",
+ [
+ (f"{examples_folder}/black_dog.mp4", "superanimal_quadruped"),
+ (f"{examples_folder}/black_dog.mp4", "superanimal_quadruped_hrnetw32"),
+ (f"{examples_folder}/swear_mouse_tiny.mp4", "superanimal_topviewmouse"),
+ (
+ f"{examples_folder}/swear_mouse_tiny.mp4",
+ "superanimal_topviewmouse_hrnetw32",
+ ),
+ ],
+)
+def test_video_inference_saves_file(video_paths, superanimal_name):
+ video_inference_superanimal(
+ video_paths,
+ superanimal_name=superanimal_name,
+ )
+ if isinstance(video_paths, str):
+ video_paths = [video_paths]
+ for video_path in video_paths:
+ output_path = video_path.replace(".mp4", "_labeled.mp4")
+ assert os.path.exists(output_path), "Output video file does not exist"
+
+ assert os.stat(output_path).st_size > 0, "Output video file is empty"
diff --git a/tests/pose_estimation_pytorch/other/test_paf_targets.py b/tests/pose_estimation_pytorch/other/test_paf_targets.py
new file mode 100644
index 0000000000..9865cf732c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_paf_targets.py
@@ -0,0 +1,37 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators import pafs_targets
+
+
+@pytest.mark.parametrize(
+ "batch_size, num_keypoints, image_size",
+ [(2, 2, (64, 64)), (1, 5, (48, 64)), (8, 50, (64, 48))],
+)
+def test_paf_target_generation(batch_size: int, num_keypoints: int, image_size: tuple, num_animals=2):
+ labels = {
+ "keypoints": torch.randint(1, min(image_size), (batch_size, num_animals, num_keypoints, 2))
+ } # 2 for x,y coords
+ graph = [(i, j) for i in range(num_keypoints) for j in range(i + 1, num_keypoints)]
+ prediction = {
+ "heatmap": torch.rand((batch_size, num_keypoints, image_size[0], image_size[1])),
+ "paf": torch.rand((batch_size, len(graph) * 2, image_size[0], image_size[1])),
+ }
+ generator = pafs_targets.PartAffinityFieldGenerator(graph=graph, width=20)
+ targets_output = generator(1, prediction, labels)
+ assert targets_output["paf"]["target"].shape == (
+ batch_size,
+ len(graph) * 2,
+ image_size[0],
+ image_size[1],
+ )
diff --git a/tests/pose_estimation_pytorch/other/test_pose_model.py b/tests/pose_estimation_pytorch/other/test_pose_model.py
new file mode 100644
index 0000000000..de36678c8c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_pose_model.py
@@ -0,0 +1,300 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import copy
+import random
+
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.models as dlc_models
+from deeplabcut.pose_estimation_pytorch.models import CRITERIONS, PREDICTORS, TARGET_GENERATORS
+from deeplabcut.pose_estimation_pytorch.models.criterions import LOSS_AGGREGATORS
+from deeplabcut.pose_estimation_pytorch.models.modules import AdaptBlock, BasicBlock
+
+backbones_dicts = [
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w32",
+ "output_channels": 480,
+ "stride": 4,
+ "interpolate_branches": True,
+ },
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w18",
+ "output_channels": 270,
+ "stride": 4,
+ "interpolate_branches": True,
+ },
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w48",
+ "output_channels": 720,
+ "stride": 4,
+ "interpolate_branches": True,
+ },
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w32",
+ "output_channels": 32,
+ "interpolate_branches": False,
+ "increased_channel_count": False,
+ "stride": 4,
+ },
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w18",
+ "output_channels": 18,
+ "interpolate_branches": False,
+ "increased_channel_count": False,
+ "stride": 4,
+ },
+ {
+ "type": "HRNet",
+ "model_name": "hrnet_w48",
+ "output_channels": 48,
+ "interpolate_branches": False,
+ "increased_channel_count": False,
+ "stride": 4,
+ },
+ {"type": "ResNet", "model_name": "resnet50_gn", "output_channels": 2048, "stride": 32},
+]
+
+heads_dicts = [
+ {
+ "type": "HeatmapHead",
+ "predictor": {
+ "type": "HeatmapPredictor",
+ "location_refinement": True,
+ "locref_std": 7.2801,
+ },
+ "target_generator": {
+ "type": "HeatmapPlateauGenerator",
+ "num_heatmaps": "num_bodyparts",
+ "pos_dist_thresh": 17,
+ "heatmap_mode": "KEYPOINT",
+ "generate_locref": True,
+ "locref_std": 7.2801,
+ },
+ "criterion": {
+ "heatmap": {
+ "type": "WeightedBCECriterion",
+ "weight": 1.0,
+ },
+ "locref": {
+ "type": "WeightedHuberCriterion",
+ "weight": 0.05,
+ },
+ },
+ "heatmap_config": {
+ "channels": [2048, 1024, -1],
+ "kernel_size": [2, 2],
+ "strides": [2, 2],
+ },
+ "locref_config": {
+ "channels": [2048, 1024, -1],
+ "kernel_size": [2, 2],
+ "strides": [2, 2],
+ },
+ "output_channels": -1,
+ "input_channels": 2048,
+ "total_stride": 4,
+ },
+ {
+ "type": "TransformerHead",
+ "predictor": {
+ "type": "HeatmapPredictor",
+ "location_refinement": False,
+ },
+ "target_generator": {
+ "type": "HeatmapPlateauGenerator",
+ "num_heatmaps": "num_bodyparts",
+ "pos_dist_thresh": 17,
+ "heatmap_mode": "KEYPOINT",
+ "generate_locref": False,
+ },
+ "criterion": {"type": "WeightedBCECriterion"},
+ "dim": 192,
+ "hidden_heatmap_dim": 384,
+ "heatmap_dim": -1,
+ "apply_multi": True,
+ "heatmap_size": [-1, -1],
+ "apply_init": True,
+ "total_stride": 1,
+ "input_channels": -1,
+ "output_channels": -1,
+ "head_stride": 1,
+ },
+ {
+ "type": "DEKRHead",
+ "predictor": {
+ "type": "DEKRPredictor",
+ "num_animals": 1,
+ "keypoint_score_type": "heatmap",
+ "max_absorb_distance": 75,
+ },
+ "target_generator": {
+ "type": "DEKRGenerator",
+ "num_joints": "num_bodyparts",
+ "pos_dist_thresh": 17,
+ "bg_weight": 0.1,
+ },
+ "criterion": {
+ "heatmap": {
+ "type": "WeightedBCECriterion",
+ "weight": 1.0,
+ },
+ "offset": {
+ "type": "WeightedHuberCriterion",
+ "weight": 0.03,
+ },
+ },
+ "heatmap_config": {
+ "channels": [480, 64, -1],
+ "num_blocks": 1,
+ "dilation_rate": 1,
+ "final_conv_kernel": 1,
+ "block": BasicBlock,
+ },
+ "offset_config": {
+ "channels": [480, -1, -1],
+ "num_offset_per_kpt": 15,
+ "num_blocks": 1,
+ "dilation_rate": 1,
+ "final_conv_kernel": 1,
+ "block": AdaptBlock,
+ },
+ "total_stride": 1,
+ "input_channels": 480,
+ "output_channels": -1,
+ },
+]
+
+
+def _generate_random_backbone_inputs(i):
+ # Returns sizes that are divisible by 64to be able to predict consistently output size
+ # (and be able to do the forward pass of HRNet)
+ x_size_tmp, y_size_tmp = random.randint(100, 1000), random.randint(100, 1000)
+ return (
+ backbones_dicts[i],
+ (x_size_tmp - x_size_tmp % 64, y_size_tmp - y_size_tmp % 64),
+ )
+
+
+@pytest.mark.parametrize(
+ "backbone_dict, input_size",
+ [_generate_random_backbone_inputs(i) for i in range(len(backbones_dicts))],
+)
+def test_backbone(backbone_dict, input_size):
+ input_tensor = torch.Tensor(1, 3, input_size[1], input_size[0])
+
+ stride = backbone_dict.pop("stride")
+ output_channels = backbone_dict.pop("output_channels")
+ backbone = dlc_models.BACKBONES.build(backbone_dict)
+
+ features = backbone(input_tensor)
+ _, c, h, w = features.shape
+ assert c == output_channels
+ assert h == input_size[1] // stride
+ assert w == input_size[0] // stride
+
+
+def _generate_random_head_inputs(i):
+ # Returns sizes that are divisible by 64to be able to predict consistently output size
+ # (and be able to do the forward pass of HRNet)
+ x_size_tmp, y_size_tmp = random.randint(8, 500), random.randint(8, 500)
+ num_kpts = random.randint(2, 50)
+ return (
+ heads_dicts[i],
+ (x_size_tmp - x_size_tmp % 4, y_size_tmp - y_size_tmp % 4),
+ num_kpts,
+ )
+
+
+@pytest.mark.parametrize(
+ "head_dict, input_shape, num_keypoints",
+ [_generate_random_head_inputs(i) for i in range(len(heads_dicts))],
+)
+def test_head(head_dict, input_shape, num_keypoints):
+ w, h = input_shape
+ head_dict = copy.deepcopy(head_dict)
+
+ head_type = head_dict["type"]
+ input_channels = head_dict.pop("input_channels")
+ output_channels = head_dict.pop("output_channels")
+ total_stride = head_dict.pop("total_stride")
+ if head_type == "HeatmapHead":
+ output_channels = num_keypoints
+ head_dict["heatmap_config"]["channels"][2] = output_channels
+ head_dict["locref_config"]["channels"][2] = 2 * output_channels
+ head_dict["target_generator"]["num_heatmaps"] = output_channels
+ input_tensor = torch.zeros((1, input_channels, h, w))
+
+ elif head_type == "TransformerHead":
+ output_channels = num_keypoints
+ input_channels = num_keypoints
+ head_dict["heatmap_dim"] = h * w
+ head_dict["heatmap_size"] = [h, w]
+ head_dict["target_generator"]["num_heatmaps"] = output_channels
+ input_tensor = torch.zeros((1, input_channels, head_dict["dim"] * 3))
+
+ elif head_type == "DEKRHead":
+ output_channels = num_keypoints + 1
+ head_dict["target_generator"]["num_joints"] = num_keypoints
+ head_dict["heatmap_config"]["channels"][2] = num_keypoints + 1
+ head_dict["offset_config"]["channels"][1] = num_keypoints * head_dict["offset_config"]["num_offset_per_kpt"]
+ head_dict["offset_config"]["channels"][2] = num_keypoints
+ input_tensor = torch.zeros((1, input_channels, h, w))
+
+ if "type" in head_dict["criterion"]:
+ head_dict["criterion"] = CRITERIONS.build(head_dict["criterion"])
+ else:
+ weights = {}
+ criterions = {}
+ for loss_name, criterion_cfg in head_dict["criterion"].items():
+ weights[loss_name] = criterion_cfg.get("weight", 1.0)
+ criterion_cfg = {k: v for k, v in criterion_cfg.items() if k != "weight"}
+ criterions[loss_name] = CRITERIONS.build(criterion_cfg)
+
+ aggregator_cfg = {"type": "WeightedLossAggregator", "weights": weights}
+ head_dict["aggregator"] = LOSS_AGGREGATORS.build(aggregator_cfg)
+ head_dict["criterion"] = criterions
+
+ head_dict["target_generator"] = TARGET_GENERATORS.build(head_dict["target_generator"])
+ head_dict["predictor"] = PREDICTORS.build(head_dict["predictor"])
+ head = dlc_models.HEADS.build(head_dict)
+
+ output = head(input_tensor)["heatmap"]
+ _, c_out, h_out, w_out = output.shape
+ assert (h_out == h * total_stride) and (w_out == w * total_stride)
+ assert c_out == output_channels
+
+
+def test_msa_hrnet():
+ # TODO: build microsoft asia hrnet and check dimension of output
+ # TODO: check if hyperparameters are loaded correctly (from the config file)
+ pass
+
+
+def test_msa_tokenpose():
+ # TODO: build microsoft asia hrnet and check dimension of output
+ # TODO: check if hyperparameters are loaded correctly (from the config file)
+ # cf https://github.com/amathislab/BUCTDdev/blob/main/lib/models/transpose_h.py#L1
+ pass
+
+
+def test_msa_hrnetCOAM():
+ # TODO: build BUCTD COAM hrnet and check dimension of output
+ # TODO: check if hyperparameters are loaded correctly (from the config file)
+ pass
+
+
+# TODO: add other model variants our pipeline can build ;)
diff --git a/tests/pose_estimation_pytorch/other/test_seq_targets.py b/tests/pose_estimation_pytorch/other/test_seq_targets.py
new file mode 100644
index 0000000000..c2816c8650
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_seq_targets.py
@@ -0,0 +1,51 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from itertools import combinations
+
+import torch
+
+from deeplabcut.pose_estimation_pytorch.models.target_generators import (
+ TARGET_GENERATORS,
+)
+
+
+def test_sequential_generator():
+ batch_size = 4
+ image_size = 256, 256
+ num_keypoints = 12
+ num_animals = 2
+ graph = [list(edge) for edge in combinations(range(num_keypoints), 2)]
+ num_limbs = len(graph)
+ cfg = {
+ "type": "SequentialGenerator",
+ "generators": [
+ {
+ "type": "HeatmapPlateauGenerator",
+ "num_heatmaps": num_keypoints,
+ "pos_dist_thresh": 17,
+ "generate_locref": True,
+ "locref_std": 7.2801,
+ },
+ {"type": "PartAffinityFieldGenerator", "graph": graph, "width": 20},
+ ],
+ }
+ gen = TARGET_GENERATORS.build(cfg)
+
+ annotations = {"keypoints": torch.randint(1, min(image_size), (batch_size, num_animals, num_keypoints, 2))}
+ head_outputs = {
+ "heatmap": torch.rand(batch_size, num_keypoints, 32, 32),
+ "locref": torch.rand(batch_size, num_keypoints * 2, 32, 32),
+ "paf": torch.rand(batch_size, num_limbs * 2, 32, 32),
+ }
+ out = gen(stride=1, outputs=head_outputs, labels=annotations)
+ assert all(s in out for s in list(head_outputs))
+ for k, v in head_outputs.items():
+ assert out[k]["target"].shape == v.shape
diff --git a/tests/pose_estimation_pytorch/post_processing/test_identity.py b/tests/pose_estimation_pytorch/post_processing/test_identity.py
new file mode 100644
index 0000000000..3f16eade5f
--- /dev/null
+++ b/tests/pose_estimation_pytorch/post_processing/test_identity.py
@@ -0,0 +1,59 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests identity matching."""
+
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.post_processing.identity import assign_identity
+
+
+@pytest.mark.parametrize(
+ "prediction, identity_scores, output_order",
+ [
+ (
+ [
+ [[0, 0, 1.0], [0, 0, 1.0]], # assembly 1
+ [[5, 5, 1.0], [5, 5, 1.0]], # assembly 2
+ [[9, 9, 1.0], [9, 9, 1.0]], # assembly 3
+ ],
+ [ # a0 -> idv1, a1 -> idv2, a2 -> idv0
+ [[0.1, 0.8, 0.3], [0.1, 0.7, 0.3]], # assembly 1 ID scores
+ [[0.2, 0.1, 0.6], [0.3, 0.1, 0.5]], # assembly 2 ID scores
+ [[0.7, 0.1, 0.1], [0.6, 0.2, 0.2]], # assembly 3 ID scores
+ ],
+ [2, 0, 1],
+ ),
+ (
+ [
+ [[0, 0, 1.0], [0, 0, 1.0]], # assembly 1
+ [[1, 1, 1.0], [5, 5, 1.0]], # assembly 2
+ [[0, 0, 1.0], [9, 9, 1.0]], # assembly 3
+ ],
+ [ # a0 -> idv0, a1 -> idv1, a2 -> idv2
+ [[0.4, 0.4, 0.3], [0.5, 0.3, 0.3]], # assembly 1 ID scores
+ [[0.4, 0.4, 0.3], [0.3, 0.5, 0.4]], # assembly 2 ID scores
+ [[0.2, 0.2, 0.4], [0.2, 0.2, 0.3]], # assembly 3 ID scores
+ ],
+ [0, 1, 2],
+ ),
+ ],
+)
+def test_single_identity_assignment(prediction, identity_scores, output_order):
+ predictions = np.array(prediction)
+ identity_scores = np.array(identity_scores)
+ new_order = assign_identity(predictions, identity_scores)
+ predictions_with_id = predictions[new_order]
+
+ print()
+ print(predictions.shape)
+ print(identity_scores.shape)
+ np.testing.assert_equal(predictions[output_order], predictions_with_id)
diff --git a/tests/pose_estimation_pytorch/post_processing/test_postprocessing_nms.py b/tests/pose_estimation_pytorch/post_processing/test_postprocessing_nms.py
new file mode 100644
index 0000000000..48f65fd5e1
--- /dev/null
+++ b/tests/pose_estimation_pytorch/post_processing/test_postprocessing_nms.py
@@ -0,0 +1,108 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests pose NMS."""
+
+import numpy as np
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.post_processing.nms as nms
+
+
+@pytest.mark.parametrize(
+ "poses, score_threshold, expected_kept",
+ [
+ (
+ [
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ ],
+ 0.1,
+ [True], # a single pose should be kept
+ ),
+ (
+ [
+ [[0.0, np.nan, 0], [0, 0, 0], [0, 0, 0]],
+ ],
+ 0.1,
+ [True], # a single pose should be kept
+ ),
+ (
+ [
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ ],
+ 0.1,
+ [False, False], # no valid poses
+ ),
+ (
+ [
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ ],
+ 0.1,
+ [False, True, False, False], # a single valid pose
+ ),
+ (
+ [
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ [[100.0, 100, 0.89], [110, 110, 0.89], [120, 120, 0.89]],
+ ],
+ 0.1,
+ [True, True], # two valid poses, far apart
+ ),
+ (
+ [
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ [[100.0, 100, 0.8], [110, 110, 0.8], [120, 120, 0.8]],
+ ],
+ 0.1,
+ [False, True, True], # two valid poses, far apart
+ ),
+ (
+ [
+ [[0.0, 0, 0], [0, 0, 0], [0, 0, 0]],
+ [[100.0, 100, 0.8], [110, 110, 0.8], [120, 120, 0.8]],
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ ],
+ 0.1,
+ [False, True, True], # two valid poses, far apart, sorted by score
+ ),
+ (
+ [
+ [[0.0, 0, 0.89], [10, 10, 0.89], [20, 20, 0.89]],
+ [[100.0, 100, 0.8], [110, 110, 0.8], [120, 120, 0.8]],
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ ],
+ 0.1,
+ [False, True, True], # two valid poses, far apart, sorted by score, one suppressed
+ ),
+ (
+ [
+ [[1.0, 0, 0.89], [11, 10, 0.89], [21, 20, 0.89]],
+ [[100.0, 100, 0.8], [110, 110, 0.8], [120, 120, 0.8]],
+ [[0.0, 0, 0.9], [10, 10, 0.9], [20, 20, 0.9]],
+ ],
+ 0.1,
+ [False, True, True], # two valid poses, far apart, sorted by score, one suppressed
+ ),
+ ],
+)
+def test_oks_nms_post_processing(poses, score_threshold, expected_kept):
+ """Tests pose NMS."""
+ kept = nms.nms_oks(
+ predictions=np.asarray(poses),
+ oks_threshold=0.9,
+ oks_sigmas=0.1,
+ score_threshold=0.1,
+ )
+ assert kept.tolist() == expected_kept
diff --git a/tests/pose_estimation_pytorch/runners/test_bottom_up.py b/tests/pose_estimation_pytorch/runners/test_bottom_up.py
new file mode 100644
index 0000000000..ae6de38298
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_bottom_up.py
@@ -0,0 +1,77 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for the bottom-up pytorch runner."""
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config
+from deeplabcut.pose_estimation_pytorch.models import PoseModel
+from deeplabcut.pose_estimation_pytorch.runners.train import build_training_runner
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+SINGLE_ANIMAL_NETS = ["resnet_50"]
+MULTI_ANIMAL_NETS = ["dekr_w18"]
+NETS = [(n, False) for n in SINGLE_ANIMAL_NETS] + [(n, True) for n in MULTI_ANIMAL_NETS]
+
+
+def print_dict(data: dict, indent: int = 0):
+ for k, v in data.items():
+ if isinstance(v, dict):
+ print_dict(v, indent=indent + 2)
+ else:
+ print(f"{indent * ' '}{k}: {v}")
+
+
+# @pytest.mark.skip(reason="This test is outdated and needs to be updated to reflect changes in the codebase.")
+
+
+@pytest.mark.parametrize("net_type, multianimal", NETS)
+def test_build_bottom_up_runner(
+ net_type: str,
+ multianimal: bool,
+ tmp_path: Path,
+) -> None:
+ project_cfg: dict[str, Any] = {
+ "multianimalproject": multianimal,
+ "project_path": str(tmp_path),
+ }
+ if multianimal:
+ project_cfg["bodyparts"] = "MULTI!"
+ project_cfg["multianimalbodyparts"] = ["head", "shoulder", "knee", "toe"]
+ project_cfg["uniquebodyparts"] = []
+ project_cfg["individuals"] = ["tom", "jerry"]
+ else:
+ project_cfg["bodyparts"] = ["head", "shoulder", "knee", "toe"]
+ project_cfg["uniquebodyparts"] = []
+ project_cfg["individuals"] = ["tom"]
+
+ root_path = Path(__file__).parent.parent
+ template_path = (root_path / "other/test_configs/pytorch_config.yaml").resolve()
+ assert template_path.is_file(), f"Template config not found at {template_path}"
+
+ pytorch_cfg = make_pytorch_pose_config(project_cfg, str(template_path), net_type)
+ pose_model = PoseModel.build(pytorch_cfg["model"])
+
+ # NOTE: @C-Achard 2026-03-18 This file was not named with test_* as a prefix,
+ # so it never ran in CI. A lot of imports are outdated and non-existent
+ # FIX: replace RUNNERS registry with build_training_runner and remove unused imports
+ runner = build_training_runner(
+ runner_config=pytorch_cfg["runner"],
+ model_folder=tmp_path,
+ task=Task.BOTTOM_UP,
+ model=pose_model,
+ device=pytorch_cfg["device"],
+ logger=None,
+ )
+ assert runner is not None
diff --git a/tests/pose_estimation_pytorch/runners/test_dynamic_cropper.py b/tests/pose_estimation_pytorch/runners/test_dynamic_cropper.py
new file mode 100644
index 0000000000..7c1ebb162f
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_dynamic_cropper.py
@@ -0,0 +1,194 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the dynamic cropper."""
+
+import numpy as np
+import pytest
+import torch
+
+from deeplabcut.pose_estimation_pytorch.runners.dynamic_cropping import (
+ DynamicCropper,
+ TopDownDynamicCropper,
+)
+
+
+@pytest.mark.parametrize("dynamic", [(False, 0.5, 10)])
+def test_build_dynamic_cropper(dynamic: tuple[bool, float, int]):
+ cropper = DynamicCropper.build(*dynamic)
+ should_be_built, threshold, margin = dynamic
+ if should_be_built:
+ assert isinstance(cropper, DynamicCropper)
+ assert cropper.threshold == threshold
+ assert cropper.margin == margin
+ else:
+ assert cropper is None
+
+
+@pytest.mark.parametrize("batch_size", [0, 2, 8])
+def test_dynamic_fails_with_image_batch(batch_size: int):
+ cropper = DynamicCropper(threshold=0.6, margin=10)
+ with pytest.raises(RuntimeError):
+ cropper.crop(torch.zeros(batch_size, 3, 128, 128))
+
+
+def test_dynamic_fails_with_variable_frame_size():
+ cropper = DynamicCropper(threshold=0.6, margin=10)
+ cropper.crop(torch.zeros(1, 3, 64, 64))
+ with pytest.raises(RuntimeError):
+ cropper.crop(torch.zeros(1, 3, 128, 128))
+
+
+def test_dynamic_fails_with_update_before_crop():
+ cropper = DynamicCropper(threshold=0.6, margin=10)
+ with pytest.raises(RuntimeError):
+ cropper.update(torch.ones(5, 17, 3))
+
+
+@pytest.mark.parametrize("threshold", [0.25, 0.5, 0.8])
+def test_dynamic_cropper_does_nothing_with_low_quality(threshold: float):
+ cropper = DynamicCropper(threshold=threshold, margin=10)
+ image_in = torch.ones((1, 3, 32, 32))
+ cropper.crop(image_in)
+ for i in range(10):
+ pose = _generate_random_pose(
+ (32, 64),
+ min_score=0.0,
+ max_score=threshold - 0.001,
+ seed=i,
+ )
+ cropper.update(pose)
+ image_out = cropper.crop(image_in)
+ assert torch.equal(image_in, image_out)
+
+
+@pytest.mark.parametrize(
+ "pose, threshold, margin, expected_crop",
+ [
+ ([[float("nan"), float("nan"), float("nan")]], 0.1, 10, [0, 0, 64, 64]),
+ ([[float("nan"), 30, 0.0]], 0.5, 10, [0, 0, 64, 64]),
+ ([[20, 30, 0.0]], 0.5, 10, [0, 0, 64, 64]),
+ ([[20, 30, 0.49]], 0.5, 10, [0, 0, 64, 64]),
+ ([[20, 30, 0.8]], 0.5, 10, [10, 20, 30, 40]),
+ ([[20, 30, 0.8], [float("nan"), float("nan"), 0.2]], 0.5, 15, [5, 15, 35, 45]),
+ ([[20, 30, 0.8], [5, 5, 0.2]], 0.5, 15, [0, 0, 35, 45]),
+ ([[20, 30, 0.8], [35, 30, 0.79]], 0.8, 5, [15, 25, 40, 35]),
+ ([[40, 10, 0.2], [35, 15, 0.79]], 0.3, 8, [27, 2, 48, 23]),
+ (
+ [
+ [[float("nan"), float("nan"), float("nan")]],
+ [[float("nan"), float("nan"), float("nan")]],
+ ],
+ 0.15,
+ 10,
+ [0, 0, 64, 64],
+ ),
+ (
+ [
+ [[20, 30, 0.8], [5, 12, 0.2]],
+ [[40, 10, 0.2], [35, 15, 0.79]],
+ ],
+ 0.15,
+ 5,
+ [0, 5, 45, 35],
+ ),
+ ],
+)
+def test_dynamic_cropper_basic_crop(
+ pose: list[list[float]], threshold: float, margin: int, expected_crop: tuple[int, int, int, int]
+) -> None:
+ x0, y0, x1, y1 = expected_crop
+ crop_w, crop_h = x1 - x0, y1 - y0
+
+ image_in = torch.zeros((1, 3, 64, 64))
+ image_in[:, :, y0:y1, x0:x1] = 1
+ expected_image_out = torch.ones((1, 3, crop_h, crop_w))
+
+ cropper = DynamicCropper(threshold=threshold, margin=margin)
+ image_out = cropper.crop(image_in)
+ assert torch.equal(image_out, image_in)
+
+ cropper.update(torch.tensor(pose))
+ image_out = cropper.crop(image_in)
+ assert image_out.shape == expected_image_out.shape
+ assert torch.equal(image_out, expected_image_out)
+
+ pose_out = torch.tensor(pose)
+ print("\nPose in")
+ print(pose_out.numpy())
+ pose_out[..., 0] -= x0
+ pose_out[..., 1] -= y0
+ print("Pose out before update")
+ print(pose_out.numpy())
+ cropper.update(pose_out)
+ print("Pose out after update")
+ print(pose_out.numpy())
+ np.testing.assert_allclose(pose_out.numpy(), np.array(pose))
+
+
+@pytest.mark.parametrize("size", [128, 256, 291, 320, 480, 500, 640, 800])
+@pytest.mark.parametrize("n", [1, 2, 3, 4, 5])
+@pytest.mark.parametrize("overlap", [0, 1, 5, 10, 100])
+def test_tddc_array_split(size: int, n: int, overlap: int) -> None:
+ print("\nTesting TopDownDynamicCropper array split")
+ print("Size:", size)
+ print("N:", n)
+ print("Overlap:", overlap)
+ sections = TopDownDynamicCropper.split_array(size, n, overlap)
+ print("Sections:")
+ for section in sections:
+ print(f" {section}")
+
+ # check that we have the desired number of sections
+ assert len(sections) == n
+
+ # check that the sections start at 0 and end at the array size
+ start, end = sections[0][0], sections[-1][1]
+ assert start == 0
+ assert end == size
+
+ # check all sections have size at least 1
+ for start, end in sections:
+ assert start < end
+
+ # check that all sections have the same size
+ sizes = [end - start for start, end in sections]
+ assert len(set(sizes)) == 1
+
+ # check the overlap is big enough for each section
+ for (_start_1, end_1), (start_2, _end_2) in zip(sections[:-1], sections[1:], strict=False):
+ assert end_1 >= start_2
+ assert end_1 - start_2 >= overlap
+
+ # check that the difference between overlaps is at most 1
+ # FIXME(niels) - auto-correct the overlap to spread it out more evenly
+ # if n > 1:
+ # overlaps = [
+ # end_1 - start_2
+ # for (start_1, end_1), (start_2, end_2) in zip(sections[:-1], sections[1:])
+ # ]
+ #
+ # assert max(overlaps) - min(overlaps) <= 1
+
+
+def _generate_random_pose(
+ image_shape: tuple[int, int],
+ min_score: float,
+ max_score: float,
+ num_animals: int = 3,
+ num_keypoints: int = 7,
+ seed: int = 0,
+) -> torch.Tensor:
+ gen = np.random.default_rng(seed)
+ pose = gen.random((num_animals, num_keypoints, 3))
+ pose[..., 0] *= image_shape[0]
+ pose[..., 1] *= image_shape[1]
+ pose[..., 2] = (pose[..., 2] * (max_score - min_score)) + min_score
+ return torch.from_numpy(pose)
diff --git a/tests/pose_estimation_pytorch/runners/test_filtered_detector_inference_runner.py b/tests/pose_estimation_pytorch/runners/test_filtered_detector_inference_runner.py
new file mode 100644
index 0000000000..4eaa6ad543
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_filtered_detector_inference_runner.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Test script for superanimal_humanbody with torchvision detector."""
+
+from deeplabcut.pose_estimation_pytorch.apis.utils import (
+ TORCHVISION_DETECTORS,
+ get_filtered_coco_detector_inference_runner,
+)
+from deeplabcut.pose_estimation_pytorch.models.detectors.filtered_detector import (
+ FilteredDetector,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo import load_super_animal_config
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import COCO_PERSON_CATEGORY_ID
+
+
+def test_torchvision_detector():
+ """Test that the torchvision detector works with superanimal_humanbody."""
+ for detector_name in TORCHVISION_DETECTORS:
+ # Load the superanimal_humanbody config
+ superanimal_config = load_super_animal_config(
+ super_animal="superanimal_humanbody",
+ model_name="rtmpose_x",
+ detector_name=detector_name,
+ )
+ print("Config loaded successfully!")
+
+ # Test loading the torchvision detector directly
+ print("\nTesting torchvision detector loading...")
+ entry = TORCHVISION_DETECTORS[detector_name]
+ weights = entry["weights"]
+ coco_detector = entry["fn"](weights=weights, box_score_thresh=0.6)
+ coco_detector.eval()
+ print("Torchvision detector loaded successfully!")
+
+ # Test loading the FilteredDetector
+ person_detector = FilteredDetector(coco_detector, class_id=COCO_PERSON_CATEGORY_ID)
+ person_detector.eval()
+ print("Filtered detector loaded successfully!")
+
+ _ = get_filtered_coco_detector_inference_runner(
+ model_name=detector_name,
+ category_id=COCO_PERSON_CATEGORY_ID,
+ batch_size=1,
+ model_config=superanimal_config,
+ )
+ print("Filtered detector runner created successfully!")
+
+ print("\n✅ All tests passed! The torchvision detector integration is working correctly.")
+ return True
+
+
+if __name__ == "__main__":
+ print("Testing superanimal_humanbody with torchvision detector...")
+ success = test_torchvision_detector()
+ if success:
+ print("\n✅ Test passed! The torchvision detector works with superanimal_humanbody")
+ else:
+ print("\n❌ Test failed! There's an issue with the torchvision detector integration")
diff --git a/tests/pose_estimation_pytorch/runners/test_inference_directml_no_grad.py b/tests/pose_estimation_pytorch/runners/test_inference_directml_no_grad.py
new file mode 100644
index 0000000000..318c909e3f
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_inference_directml_no_grad.py
@@ -0,0 +1,67 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests DLC_DIRECTML_NO_GRAD toggles inference_mode vs no_grad (AMD DirectML)."""
+
+from __future__ import annotations
+
+import importlib
+import os
+from unittest.mock import Mock
+
+import numpy as np
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.runners.inference as inference
+
+
+def _reload_with_env(env_value: str | None):
+ if env_value is None:
+ os.environ.pop("DLC_DIRECTML_NO_GRAD", None)
+ else:
+ os.environ["DLC_DIRECTML_NO_GRAD"] = env_value
+ importlib.reload(inference)
+
+
+@pytest.fixture(autouse=True)
+def _restore_env():
+ yield
+ _reload_with_env(None) # always restore defaults after each test
+
+
+@pytest.mark.parametrize(
+ ("env_value", "directml_no_grad"),
+ [(None, False), ("false", False), ("true", True)],
+)
+def test_directml_no_grad_env(env_value, directml_no_grad):
+ """env var sets _directml_no_grad and selects the correct torch grad context."""
+ _reload_with_env(env_value)
+ assert inference._directml_no_grad is directml_no_grad
+
+ class _SniffRunner(inference.InferenceRunner):
+ def __init__(self):
+ super().__init__(
+ model=Mock(),
+ batch_size=1,
+ inference_cfg=inference.InferenceConfig(
+ multithreading=inference.MultithreadingConfig(enabled=False),
+ ),
+ )
+ self.saw_inference_mode: bool | None = None
+
+ def predict(self, inputs: torch.Tensor, **kwargs):
+ self.saw_inference_mode = torch.is_inference_mode_enabled()
+ return [{"mock": {"poses": np.zeros((1,), dtype=np.float32)}}]
+
+ runner = _SniffRunner()
+ runner.inference([np.zeros((1, 3, 8, 8), dtype=np.float32)])
+
+ assert runner.saw_inference_mode is not directml_no_grad
diff --git a/tests/pose_estimation_pytorch/runners/test_logger.py b/tests/pose_estimation_pytorch/runners/test_logger.py
new file mode 100644
index 0000000000..9b27314302
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_logger.py
@@ -0,0 +1,102 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests loggers."""
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.runners.logger as logging
+
+
+class MockImageLogger(logging.ImageLoggerMixin):
+ """Mock image logger."""
+
+ def log_images(
+ self,
+ inputs: dict[str, Any],
+ outputs: dict[str, torch.Tensor],
+ targets: dict[str, dict[str, torch.Tensor]],
+ step: int,
+ ) -> None:
+ pass
+
+
+@pytest.mark.parametrize(
+ "keypoints",
+ [
+ [
+ [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]],
+ ],
+ [
+ [[float("nan"), float("nan")], [float("nan"), float("nan")]],
+ ],
+ [
+ [[0.0, 0.0], [1, 1], [2, 2]],
+ ],
+ [[[float("nan"), 0.0], [1, 1], [2, 2]]],
+ [[[-1.0, -1.0], [1, 1], [2, 2]]],
+ [
+ [[-1.0, -1.0], [-1.0, -1.0]],
+ ],
+ [
+ [[-1.0, -1.0], [-1.0, -1.0]],
+ [[1.0, 1.0], [1.0, 1.0]],
+ ],
+ ],
+)
+@pytest.mark.parametrize("denormalize", [True, False])
+def test_prepare_image(keypoints: list[list[float]], denormalize: bool) -> None:
+ image = torch.ones((3, 256, 256))
+ keypoints = torch.tensor(keypoints)
+
+ print()
+ print(f"IMAGE: {image.shape}")
+ print(f"KEYPOINTS: {keypoints.shape}")
+ for k in keypoints:
+ print(k)
+ print()
+ print()
+
+ logger = MockImageLogger()
+ logger._prepare_image(
+ image=image,
+ denormalize=denormalize,
+ keypoints=keypoints,
+ bboxes=None,
+ )
+
+
+def test_csv_logger_resume(tmp_path: Path) -> None:
+ """Test CSVLogger preserves data when resuming from snapshot."""
+ log_file = tmp_path / "learning_stats.csv"
+
+ # Initial training: log some metrics
+ logger1 = logging.CSVLogger(str(tmp_path), "learning_stats.csv")
+ logger1.log({"loss": 0.5, "accuracy": 0.8}, step=1)
+ logger1.log({"loss": 0.4, "accuracy": 0.9}, step=2)
+
+ assert log_file.exists()
+ assert len(logger1._steps) == 2
+
+ # Resume training: should load existing data
+ logger2 = logging.CSVLogger(str(tmp_path), "learning_stats.csv")
+ assert len(logger2._steps) == 2
+ assert logger2._steps == [1, 2]
+ assert logger2._metric_store[0]["loss"] == 0.5
+ assert logger2._metric_store[1]["accuracy"] == 0.9
+
+ # Log new data: should append, not overwrite
+ logger2.log({"loss": 0.3, "accuracy": 0.95}, step=3)
+ assert len(logger2._steps) == 3
+ assert logger2._steps == [1, 2, 3]
diff --git a/tests/pose_estimation_pytorch/runners/test_runners.py b/tests/pose_estimation_pytorch/runners/test_runners.py
new file mode 100644
index 0000000000..c3afa93db5
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_runners.py
@@ -0,0 +1,38 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import pickle
+from pathlib import Path
+from unittest.mock import Mock
+
+import numpy as np
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.runners as runners
+
+
+@pytest.mark.parametrize("value", [True, False])
+def test_set_load_weights_only(value: bool):
+ print(f"\nget_load_weights_only: {runners.get_load_weights_only()}")
+ print(f"setting value to {value}")
+ runners.set_load_weights_only(value)
+ print(f"get_load_weights_only: {runners.get_load_weights_only()}\n")
+ assert runners.get_load_weights_only() == value
+
+
+def test_load_snapshot_weights_only_error(tmpdir_factory):
+ snapshot_dir = Path(tmpdir_factory.mktemp("snapshot-dir"))
+ snapshot_path = snapshot_dir / "snapshot.pt"
+ torch.save(dict(content=np.zeros(10)), str(snapshot_path))
+
+ runners.set_load_weights_only(False)
+ with pytest.raises(pickle.UnpicklingError):
+ runners.Runner.load_snapshot(snapshot_path, device="cpu", model=Mock(), weights_only=True)
diff --git a/tests/pose_estimation_pytorch/runners/test_runners_inference.py b/tests/pose_estimation_pytorch/runners/test_runners_inference.py
new file mode 100644
index 0000000000..f59831ca4e
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_runners_inference.py
@@ -0,0 +1,195 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests inference runners."""
+
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.data.postprocessor as post
+import deeplabcut.pose_estimation_pytorch.data.preprocessor as prep
+import deeplabcut.pose_estimation_pytorch.runners.inference as inference
+from deeplabcut.pose_estimation_pytorch import get_load_weights_only
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.build_optimizer", Mock())
+@pytest.mark.parametrize("task", [Task.DETECT, Task.TOP_DOWN, Task.BOTTOM_UP])
+@pytest.mark.parametrize("weights_only", [None, True, False])
+def test_load_weights_only_with_build_training_runner(task: Task, weights_only: bool):
+ with patch("deeplabcut.pose_estimation_pytorch.runners.base.torch.load") as load:
+ snapshot = "snapshot.pt"
+ inference.build_inference_runner(
+ task=task,
+ model=Mock(),
+ device="cpu",
+ snapshot_path=snapshot,
+ load_weights_only=weights_only,
+ )
+ if weights_only is None:
+ weights_only = get_load_weights_only()
+ load.assert_called_once_with(snapshot, map_location="cpu", weights_only=weights_only)
+
+
+class MockInferenceRunner(inference.InferenceRunner):
+ """Mocks the predict function for an inference runner."""
+
+ def __init__(
+ self,
+ batch_size: int = 1,
+ preprocessor: prep.Preprocessor | None = None,
+ postprocessor: post.Postprocessor | None = None,
+ ) -> None:
+ super().__init__(
+ model=Mock(),
+ batch_size=batch_size,
+ preprocessor=preprocessor,
+ postprocessor=postprocessor,
+ )
+ self.batch_shapes = []
+
+ def predict(self, inputs: torch.Tensor) -> list[dict[str, dict[str, np.ndarray]]]:
+ self.batch_shapes.append(tuple(inputs.shape))
+ return [ # return first elem of input
+ {"mock": {"index": i[0, 0, 0].detach().numpy()}} for i in inputs
+ ]
+
+
+@pytest.mark.parametrize("batch_size", [1, 2, 4, 8])
+def test_mock_bottom_up(batch_size):
+ h, w = 640, 480
+ images = [i * np.ones((1, 3, h, w)) for i in range(10)]
+
+ runner = MockInferenceRunner(batch_size=batch_size)
+ predictions = runner.inference(images)
+
+ print()
+ print(f"Num images: {len(predictions)}")
+ print(f"Num predictions: {len(predictions)}")
+ print(f"Batch shapes: {runner.batch_shapes}")
+ print(80 * "-")
+ for i in images:
+ print(i[0, 0, 0, 0])
+ print("----")
+ print(80 * "-")
+ for p in predictions:
+ print(p)
+ print("----")
+
+ _check_batch_shapes(batch_size, h, w, runner.batch_shapes)
+ assert len(images) == len(predictions)
+ for i, p in zip(images, predictions, strict=True):
+ assert len(p) == 1 # only 1 output per image
+ assert i[0, 0, 0, 0] == p[0]["mock"]["index"]
+
+
+@pytest.mark.parametrize("batch_size", [1, 2, 4, 8])
+@pytest.mark.parametrize(
+ "detections_per_image",
+ [
+ [1, 1, 1, 1, 1],
+ [0, 1, 0, 1, 1], # some frames might not have predictions
+ [0, 0, 0, 5, 2],
+ [1, 2, 3, 4],
+ [3, 4, 2, 1, 4],
+ [4, 23, 5, 20, 64, 100],
+ ],
+)
+def test_mock_top_down(batch_size, detections_per_image):
+ h, w = 8, 8
+ images = []
+ for index, num_detections in enumerate(detections_per_image):
+ if num_detections == 0:
+ detections = np.zeros((0, 3, 1, 1)) # random shape when no detections
+ else:
+ detections = np.concatenate(
+ [(1_000_000 * (index + 1) + i) * np.ones((1, 3, h, w)) for i in range(num_detections)],
+ axis=0,
+ )
+
+ images.append(detections)
+
+ runner = MockInferenceRunner(batch_size=batch_size)
+ predictions = runner.inference(images)
+
+ print()
+ print(f"Num images: {len(predictions)}")
+ print(f"Num predictions: {len(predictions)}")
+ print(80 * "-")
+ for i in images:
+ for i_det in i:
+ print(i_det.shape)
+ print(i_det[0, 0, 0])
+ print("----")
+
+ print(80 * "-")
+ for p in predictions:
+ print(p)
+ print("----")
+
+ _check_batch_shapes(batch_size, h, w, runner.batch_shapes)
+
+ assert len(images) == len(predictions)
+ for i, p in zip(images, predictions, strict=True):
+ assert len(p) == len(i) # one prediction per input
+ for i_det, p_det in zip(i, p, strict=True):
+ print(i_det.shape)
+ print(p_det["mock"]["index"])
+ assert i_det[0, 0, 0] == p_det["mock"]["index"]
+
+
+def test_dynamic_pose_inference_calls_dynamic():
+ pose_batch = torch.zeros((1, 1, 1, 3))
+ pose_batch_updated = torch.ones((1, 1, 1, 3))
+
+ image_crop = Mock()
+ image_crop.__len__ = Mock(return_value=1)
+
+ model = Mock()
+ model.get_predictions = Mock()
+ model.get_predictions.return_value = dict(bodypart=dict(poses=pose_batch))
+
+ dynamic = Mock()
+ dynamic.crop = Mock()
+ dynamic.crop.return_value = image_crop
+ dynamic.update = Mock()
+ dynamic.update.return_value = pose_batch_updated
+
+ runner = inference.PoseInferenceRunner(
+ model=model,
+ dynamic=dynamic,
+ batch_size=1,
+ )
+ image = torch.zeros((1, 3, 64, 64))
+ updated_pose = runner.predict(image)
+ dynamic.crop.assert_called_once_with(image)
+ dynamic.update.assert_called_once_with(pose_batch)
+
+ assert len(updated_pose) == 1
+ np.testing.assert_allclose(
+ updated_pose[0]["bodypart"]["poses"],
+ pose_batch_updated[0].cpu().numpy(),
+ )
+
+
+def _check_batch_shapes(batch_size, h, w, batch_shapes) -> None:
+ for b in batch_shapes[:-1]:
+ assert b[0] == batch_size
+ assert b[1] == 3
+ assert b[2] == h
+ assert b[3] == w
+
+ assert batch_shapes[-1][0] <= batch_size
+ assert batch_shapes[-1][1] <= 3
+ assert batch_shapes[-1][2] <= h
+ assert batch_shapes[-1][3] <= w
diff --git a/tests/pose_estimation_pytorch/runners/test_runners_train.py b/tests/pose_estimation_pytorch/runners/test_runners_train.py
new file mode 100644
index 0000000000..3fa0994217
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_runners_train.py
@@ -0,0 +1,320 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from dataclasses import dataclass
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pytest
+import torch
+
+import deeplabcut.pose_estimation_pytorch.runners.schedulers as schedulers
+import deeplabcut.pose_estimation_pytorch.runners.train as train_runners
+from deeplabcut.pose_estimation_pytorch.models import PoseModel
+from deeplabcut.pose_estimation_pytorch.models.backbones import ResNet
+from deeplabcut.pose_estimation_pytorch.models.heads import HeatmapHead
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.build_optimizer", Mock())
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.CSVLogger", Mock())
+@pytest.mark.parametrize("task", [Task.DETECT, Task.TOP_DOWN, Task.BOTTOM_UP])
+@pytest.mark.parametrize("weights_only", [True, False])
+def test_load_weights_only_with_build_training_runner(task: Task, weights_only: bool):
+ runner_config = dict(
+ optimizer=dict(),
+ snapshots=dict(max_snapshots=1, save_epochs=5, save_optimizer_state=False),
+ load_weights_only=weights_only,
+ )
+ with patch("deeplabcut.pose_estimation_pytorch.runners.base.torch.load") as load:
+ train_runners.build_training_runner(
+ runner_config=runner_config,
+ model_folder=Mock(),
+ task=task,
+ model=Mock(),
+ device="cpu",
+ snapshot_path="snapshot.pt",
+ )
+ load.assert_called_once_with("snapshot.pt", map_location="cpu", weights_only=weights_only)
+
+
+@dataclass
+class SchedulerTestConfig:
+ cfg: dict
+ init_lr: float
+ expected_lrs: list[float]
+
+
+TEST_SCHEDULERS = [
+ SchedulerTestConfig(
+ cfg=dict(
+ type="LRListScheduler",
+ params=dict(milestones=[2, 5], lr_list=[[0.5], [0.1]]),
+ ),
+ init_lr=1.0,
+ expected_lrs=[1.0, 1.0, 0.5, 0.5, 0.5, 0.1, 0.1, 0.1],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="LRListScheduler", params=dict(milestones=[1], lr_list=[[0.1]])),
+ init_lr=0.1,
+ expected_lrs=[0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="LRListScheduler", params=dict(milestones=[1], lr_list=[[0.5]])),
+ init_lr=0.1,
+ expected_lrs=[0.1, 0.5, 0.5, 0.5],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="StepLR", params=dict(step_size=3, gamma=0.1)),
+ init_lr=1.0,
+ expected_lrs=[1.0, 1.0, 1.0, 0.1, 0.1, 0.1, 0.01, 0.01, 0.01, 0.001],
+ ),
+]
+
+
+@pytest.mark.parametrize("load_head_weights", [True, False])
+def test_load_head_weights(tmp_path_factory, load_head_weights):
+ model_folder = tmp_path_factory.mktemp("model_folder")
+ runner_config = dict(
+ optimizer=dict(type="SGD", params=dict(lr=1)),
+ snapshots=dict(max_snapshots=1, save_epochs=1, save_optimizer_state=False),
+ )
+
+ model = PoseModel(
+ cfg=dict(),
+ backbone=ResNet(),
+ heads=dict(
+ bodyparts=HeatmapHead(
+ predictor=Mock(),
+ target_generator=Mock(),
+ criterion=Mock(),
+ aggregator=None,
+ heatmap_config=dict(channels=[2048, 10], kernel_size=[3], strides=[2]),
+ ),
+ ),
+ )
+
+ original_state_dict = model.state_dict()
+ zero_state_dict = {k: torch.zeros_like(v) for k, v in original_state_dict.items()}
+
+ load = Mock()
+ load.return_value = dict(model=zero_state_dict)
+
+ with patch("deeplabcut.pose_estimation_pytorch.runners.train.torch.load", load):
+ r = train_runners.build_training_runner(
+ runner_config,
+ model_folder=model_folder,
+ task=Task.BOTTOM_UP,
+ model=model,
+ device="cpu",
+ snapshot_path=model_folder / "snapshot.pt",
+ load_head_weights=load_head_weights,
+ )
+ loaded_state_dict = r.model.state_dict()
+ for k, v in loaded_state_dict.items():
+ if load_head_weights or k.startswith("backbone."):
+ assert torch.equal(v, zero_state_dict[k])
+ else:
+ assert torch.equal(v, original_state_dict[k])
+
+
+@pytest.mark.parametrize("load_head_weights", [True, False])
+def test_mocked_load_head_weights(tmp_path_factory, load_head_weights):
+ model_folder = tmp_path_factory.mktemp("model_folder")
+ snapshot_manager = Mock()
+ snapshot_manager.model_folder = model_folder
+
+ model = Mock()
+ model.backbone = Mock()
+ state_dict = {"backbone.test": 0, "head.test": 1}
+ state_dict_backbone = {"test": 0}
+ load = Mock()
+ load.return_value = dict(model=state_dict)
+
+ with patch("deeplabcut.pose_estimation_pytorch.runners.train.torch.load", load):
+ _ = train_runners.PoseTrainingRunner(
+ model=model,
+ optimizer=Mock(),
+ snapshot_manager=snapshot_manager,
+ device="cpu",
+ snapshot_path="snapshot.pt",
+ load_head_weights=load_head_weights,
+ )
+ if load_head_weights:
+ model.load_state_dict.assert_called_once_with(state_dict)
+ else:
+ model.backbone.load_state_dict.assert_called_once_with(state_dict_backbone)
+
+
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.CSVLogger", Mock())
+@pytest.mark.parametrize(
+ "runner_cls",
+ [
+ train_runners.PoseTrainingRunner,
+ train_runners.DetectorTrainingRunner,
+ ],
+)
+@pytest.mark.parametrize("test_cfg", TEST_SCHEDULERS)
+def test_training_with_scheduler(runner_cls, test_cfg: SchedulerTestConfig) -> None:
+ runner = _fit_runner_and_check_lrs(
+ runner_cls,
+ test_cfg.init_lr,
+ test_cfg.cfg,
+ test_cfg.expected_lrs,
+ )
+ assert runner.current_epoch == len(test_cfg.expected_lrs)
+
+
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.CSVLogger", Mock())
+@pytest.mark.parametrize(
+ "runner_cls",
+ [
+ train_runners.PoseTrainingRunner,
+ train_runners.DetectorTrainingRunner,
+ ],
+)
+@pytest.mark.parametrize("test_cfg", TEST_SCHEDULERS)
+def test_resuming_training_scheduler_every_epoch(
+ runner_cls,
+ test_cfg: SchedulerTestConfig,
+):
+ snapshot_to_load = None
+ for epoch, expected_lr in enumerate(test_cfg.expected_lrs):
+ runner = _fit_runner_and_check_lrs(
+ runner_cls,
+ test_cfg.init_lr,
+ test_cfg.cfg,
+ [expected_lr], # trains for 1 epoch
+ snapshot_to_load=snapshot_to_load,
+ )
+ snapshot_to_load = dict(metadata=dict(epoch=epoch + 1), scheduler=runner.scheduler.state_dict())
+
+
+@patch("deeplabcut.pose_estimation_pytorch.runners.train.CSVLogger", Mock())
+@pytest.mark.parametrize(
+ "runner_cls",
+ [
+ train_runners.PoseTrainingRunner,
+ train_runners.DetectorTrainingRunner,
+ ],
+)
+@pytest.mark.parametrize(
+ "test_cfg, resume_epoch",
+ [
+ (
+ SchedulerTestConfig(
+ cfg=dict(
+ type="LRListScheduler",
+ params=dict(milestones=[2, 5], lr_list=[[0.5], [0.1]]),
+ ),
+ init_lr=1.0,
+ expected_lrs=[1.0, 1.0, 0.5, 1.0, 1.0, 0.1, 0.1, 0.1],
+ ),
+ 3, # cut after the 3rd epoch - restart at LR=1 until epoch 5
+ ),
+ (
+ SchedulerTestConfig(
+ cfg=dict(type="StepLR", params=dict(step_size=4, gamma=0.1)),
+ init_lr=1.0,
+ expected_lrs=(4 * [1.0]) + (4 * [0.1]) + (4 * [0.01]) + (4 * [0.001]),
+ ),
+ 3, # cut after the 3rd epoch - restart at LR=1 and update at 4 correctly
+ ),
+ (
+ SchedulerTestConfig(
+ cfg=dict(type="StepLR", params=dict(step_size=4, gamma=0.1)),
+ init_lr=1.0,
+ expected_lrs=(4 * [1.0]) + [0.1, 1, 1, 1] + (4 * [0.1]),
+ ),
+ 5, # cut after the 5th epoch - restart at LR=1 and update again at 8
+ ),
+ ],
+)
+def test_resuming_training_with_no_scheduler_state(runner_cls, test_cfg: SchedulerTestConfig, resume_epoch: int):
+ """Without a scheduler config, there is no way to set the initial LR.
+
+ All we can do is set the last_epoch value, and adjust correctly at milestones going
+ forward.
+ """
+ runner = _fit_runner_and_check_lrs(
+ runner_cls,
+ test_cfg.init_lr,
+ test_cfg.cfg,
+ test_cfg.expected_lrs[:resume_epoch],
+ )
+ assert runner.current_epoch == resume_epoch
+
+ runner = _fit_runner_and_check_lrs(
+ runner_cls,
+ test_cfg.init_lr,
+ test_cfg.cfg,
+ expected_lrs=test_cfg.expected_lrs[resume_epoch:],
+ snapshot_to_load=dict(metadata=dict(epoch=resume_epoch)),
+ )
+ assert runner.current_epoch == len(test_cfg.expected_lrs)
+
+
+def _fit_runner_and_check_lrs(
+ runner_cls,
+ init_lr: float,
+ scheduler_cfg: dict,
+ expected_lrs: list[float],
+ snapshot_to_load: dict | None = None,
+) -> train_runners.TrainingRunner:
+ runner_kwargs = dict(device="cpu", eval_interval=1_000_000)
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=init_lr)
+ scheduler = schedulers.build_scheduler(scheduler_cfg, optimizer)
+ num_epochs = len(expected_lrs)
+
+ base_path = "deeplabcut.pose_estimation_pytorch.runners"
+ with patch(f"{base_path}.base.Runner.load_snapshot") as base_mock_load:
+ with patch(f"{base_path}.train.PoseTrainingRunner.load_snapshot") as mock_load:
+ snapshot_path = None
+ base_mock_load.return_value = dict()
+ mock_load.return_value = dict()
+ if snapshot_to_load is not None:
+ snapshot_path = "fake_snapshot.pt"
+ base_mock_load.return_value = snapshot_to_load
+ mock_load.return_value = snapshot_to_load
+
+ print()
+ print(f"Scheduler: {scheduler}")
+ print(f"Starting training for {num_epochs} epochs")
+ runner = runner_cls(
+ model=Mock(),
+ optimizer=optimizer,
+ snapshot_manager=Mock(),
+ scheduler=scheduler,
+ snapshot_path=snapshot_path,
+ **runner_kwargs,
+ )
+
+ # Mock the step call; check that the learning rate is correct for the epoch
+ def step(*args, **kwargs):
+ # the current_epoch value is indexed at 1
+ total_epoch = runner.current_epoch - 1
+ epoch = total_epoch - runner.starting_epoch
+ _assert_learning_rates_match(total_epoch, optimizer, expected_lrs[epoch])
+ optimizer.step()
+ return dict(total_loss=0)
+
+ train_loader, val_loader = [Mock()], [Mock()]
+ runner.step = step
+ runner.fit(train_loader, val_loader, epochs=num_epochs, display_iters=1000)
+
+ return runner
+
+
+def _assert_learning_rates_match(e, optimizer, expected):
+ current_lrs = [g["lr"] for g in optimizer.param_groups]
+ print(f"Epoch {e}: LR={current_lrs}, expected={expected}")
+ for lr in current_lrs:
+ assert isinstance(lr, float)
+ np.testing.assert_almost_equal(lr, expected)
diff --git a/tests/pose_estimation_pytorch/runners/test_schedulers.py b/tests/pose_estimation_pytorch/runners/test_schedulers.py
new file mode 100644
index 0000000000..37e98ea6a1
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_schedulers.py
@@ -0,0 +1,271 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests building schedulers from config."""
+
+import random
+from dataclasses import dataclass
+
+import numpy as np
+import pytest
+import torch
+import torch.nn as nn
+
+import deeplabcut.pose_estimation_pytorch.runners.schedulers as schedulers
+
+
+def generate_random_lr_list(num_floats: int):
+ """Generate list of lists including random numbers.
+
+ Args:
+ num_floats: number of floats we want to include in our list
+
+ Returns:
+ ran_list: random list of sorted numbers, being first number bigger than the last
+ """
+ ran_list = []
+ for i in range(num_floats):
+ random_floats = [random.random()]
+ ran_list.append(random_floats)
+ return sorted(ran_list, reverse=True)
+
+
+@pytest.mark.parametrize(
+ "milestones, lr_list",
+ [([10, 430], [[0.05], [0.005]]), (list(sorted(random.sample(range(0, 999), 2))), generate_random_lr_list(2))],
+)
+def test_scheduler(milestones, lr_list):
+ """Testing schedulers.py.
+
+ Given a list of milestones and a list of learning rates, this function tests
+ if the length of each list is the same. Furthermore, it will assess if
+ the current learning rate (output from the function we are testing) is a float
+ and corresponds to the expected learning rate given the milestones.
+
+ Args:
+ milestones: list of epochs indices (number of epochs)
+ lr_list: learning rates list
+
+ Returns:
+ None
+
+ Examples:
+ input:
+ milestones = [10,25,50]
+ lr_list = [[0.00001],[0.000005],[0.000001]]
+ """
+
+ assert len(milestones) == len(lr_list)
+
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=0.01)
+ s = schedulers.LRListScheduler(optimizer, milestones=milestones, lr_list=lr_list)
+
+ index_rng = range(milestones[0], milestones[1])
+ for i in range((milestones[-1]) + 1):
+ if i < milestones[0]:
+ expected_lr = [0.01]
+ elif i in index_rng:
+ expected_lr = lr_list[0]
+ else:
+ expected_lr = lr_list[1]
+
+ current_lr = s.get_lr()[0]
+ assert s.get_lr() == expected_lr
+ assert isinstance(current_lr, float)
+ optimizer.step()
+ s.step()
+
+
+@dataclass
+class SchedulerTestConfig:
+ cfg: dict
+ init_lr: float
+ expected_lrs: list[float]
+
+
+TEST_SCHEDULERS = [
+ SchedulerTestConfig(
+ cfg=dict(type="LRListScheduler", params=dict(milestones=[2, 5], lr_list=[[0.5], [0.1]])),
+ init_lr=1.0,
+ expected_lrs=[1.0, 1.0, 0.5, 0.5, 0.5, 0.1, 0.1, 0.1],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="LRListScheduler", params=dict(milestones=[1], lr_list=[[0.1]])),
+ init_lr=0.1,
+ expected_lrs=[0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="LRListScheduler", params=dict(milestones=[1], lr_list=[[0.5]])),
+ init_lr=0.1,
+ expected_lrs=[0.1, 0.5, 0.5, 0.5],
+ ),
+ SchedulerTestConfig(
+ cfg=dict(type="StepLR", params=dict(step_size=3, gamma=0.1)),
+ init_lr=1.0,
+ expected_lrs=[1.0, 1.0, 1.0, 0.1, 0.1, 0.1, 0.01, 0.01, 0.01, 0.001],
+ ),
+]
+
+
+@pytest.mark.parametrize("test_cfg", TEST_SCHEDULERS)
+def test_build_scheduler(test_cfg: SchedulerTestConfig) -> None:
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=test_cfg.init_lr)
+ s = schedulers.build_scheduler(test_cfg.cfg, optimizer)
+ print()
+ print(f"Scheduler: {s}")
+ num_epochs = len(test_cfg.expected_lrs)
+ for e in range(num_epochs):
+ _assert_learning_rates_match(e, optimizer, test_cfg.expected_lrs[e])
+ optimizer.step()
+ s.step()
+
+
+@pytest.mark.parametrize("test_cfg", TEST_SCHEDULERS)
+def test_resume_scheduler_after_each_epoch(test_cfg: SchedulerTestConfig) -> None:
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=test_cfg.init_lr)
+ s = schedulers.build_scheduler(test_cfg.cfg, optimizer)
+ print()
+ print(f"Scheduler: {s}")
+ num_epochs = len(test_cfg.expected_lrs)
+ for e in range(num_epochs):
+ _assert_learning_rates_match(e, optimizer, test_cfg.expected_lrs[e])
+ optimizer.step()
+ s.step()
+
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=test_cfg.init_lr)
+ new_scheduler = schedulers.build_scheduler(test_cfg.cfg, optimizer)
+ schedulers.load_scheduler_state(new_scheduler, s.state_dict())
+ s = new_scheduler
+
+
+@pytest.mark.parametrize(
+ "test_cfg, middle_epoch",
+ [
+ (TEST_SCHEDULERS[0], 3),
+ (TEST_SCHEDULERS[1], 5),
+ (TEST_SCHEDULERS[2], 2),
+ (TEST_SCHEDULERS[3], 2),
+ (TEST_SCHEDULERS[3], 3),
+ (TEST_SCHEDULERS[3], 4),
+ ],
+)
+def test_two_stage_training(test_cfg: SchedulerTestConfig, middle_epoch: int) -> None:
+ num_epochs = len(test_cfg.expected_lrs)
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=test_cfg.init_lr)
+ s = schedulers.build_scheduler(test_cfg.cfg, optimizer)
+
+ print()
+ print(f"Scheduler: {s}")
+ for e in range(middle_epoch):
+ _assert_learning_rates_match(e, optimizer, test_cfg.expected_lrs[e])
+ optimizer.step()
+ s.step()
+
+ optimizer = torch.optim.SGD([torch.randn(2, 2)], lr=test_cfg.init_lr)
+ new_scheduler = schedulers.build_scheduler(test_cfg.cfg, optimizer)
+ schedulers.load_scheduler_state(new_scheduler, s.state_dict())
+ s = new_scheduler
+ for e in range(middle_epoch, num_epochs):
+ _assert_learning_rates_match(e, optimizer, test_cfg.expected_lrs[e])
+ s.step()
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ dict( # example with 3 warm-up epochs
+ config=dict(
+ dict(
+ type="ConstantLR",
+ params=dict(factor=0.1, total_iters=3),
+ ),
+ ),
+ start_lr=1.0,
+ expected_lrs=[[0.1], [0.1], [0.1], [1.0], [1.0]],
+ ),
+ dict( # example from torch.optim.lr_scheduler.SequentialLR
+ config=dict(
+ type="SequentialLR",
+ params=dict(
+ schedulers=[
+ dict(
+ type="ConstantLR",
+ params=dict(factor=0.1, total_iters=2),
+ ),
+ dict(type="ExponentialLR", params=dict(gamma=0.9)),
+ ],
+ milestones=[2],
+ ),
+ ),
+ start_lr=1.0,
+ expected_lrs=[[0.1], [0.1], [1.0], [0.9], [0.81], [0.729]],
+ ),
+ dict( # example from torch.optim.lr_scheduler.SequentialLR
+ config=dict(
+ type="SequentialLR",
+ params=dict(
+ schedulers=[
+ dict(
+ type="ConstantLR",
+ params=dict(factor=0.1, total_iters=2),
+ ),
+ dict(type="StepLR", params=dict(step_size=2, gamma=0.1)),
+ ],
+ milestones=[5],
+ ),
+ ),
+ start_lr=1.0,
+ expected_lrs=[
+ [0.1],
+ [0.1],
+ [1.0],
+ [1.0],
+ [1.0], # ConstantLR
+ [1.0],
+ [1.0],
+ [0.1],
+ [0.1],
+ [0.01], # StepLR
+ ],
+ ),
+ ],
+)
+def test_build_sequential_lr(data):
+ print("\nTESTING")
+ start_lr = data["start_lr"]
+ print(f"Start LR: {start_lr}")
+ model = nn.Linear(in_features=1, out_features=1)
+ optimizer = torch.optim.SGD(params=model.parameters(), lr=start_lr)
+
+ print("BUILDING")
+ scheduler = schedulers.build_scheduler(data["config"], optimizer)
+
+ print("RUNNING")
+ lrs = []
+ for epoch in range(len(data["expected_lrs"])):
+ lrs.append(scheduler.get_last_lr())
+ print(scheduler.get_last_lr())
+ scheduler.step()
+
+ print(f"Expected: {data['expected_lrs']}")
+ print(f"Actual: {lrs}")
+ np.testing.assert_allclose(
+ np.asarray(data["expected_lrs"]),
+ np.asarray(lrs),
+ atol=1e-10,
+ )
+
+
+def _assert_learning_rates_match(e, optimizer, expected):
+ current_lrs = [g["lr"] for g in optimizer.param_groups]
+ print(f"Epoch {e}: LR={current_lrs}, expected={expected}")
+ for lr in current_lrs:
+ assert isinstance(lr, float)
+ np.testing.assert_almost_equal(lr, expected)
diff --git a/tests/pose_estimation_pytorch/runners/test_shelving.py b/tests/pose_estimation_pytorch/runners/test_shelving.py
new file mode 100644
index 0000000000..741c3c53c4
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_shelving.py
@@ -0,0 +1,160 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for ShelfWriter / ShelfReader."""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.runners.shelving import (
+ ShelfReader,
+ ShelfWriter,
+)
+
+POSE_CFG = {
+ "all_joints": [[0], [1], [2]],
+ "all_joints_names": ["snout", "leftear", "rightear"],
+ "nmsradius": 5,
+ "minconfidence": 0.1,
+ "sigma": 1,
+}
+
+
+def _make_bodyparts(num_assemblies: int = 2, num_bpts: int = 3) -> np.ndarray:
+ """(num_assemblies, num_bpts, 3) — x, y, score."""
+ rng = np.random.default_rng(0)
+ return rng.random((num_assemblies, num_bpts, 3)).astype(np.float32)
+
+
+# -- lifecycle ----------------------------------------------------------------
+
+
+def test_write_before_open_raises(tmp_path):
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf")
+ with pytest.raises(ValueError, match="open"):
+ writer.add_prediction(_make_bodyparts())
+
+
+def test_open_close_roundtrip(tmp_path):
+ path = tmp_path / "shelf"
+ writer = ShelfWriter(POSE_CFG, path)
+ writer.open()
+ writer.add_prediction(_make_bodyparts())
+ writer.close()
+
+ reader = ShelfReader(path)
+ reader.open()
+ assert "metadata" in reader.keys()
+ assert "frame00000" in reader.keys()
+ reader.close()
+
+
+# -- key formatting -----------------------------------------------------------
+
+
+@pytest.mark.parametrize("num_frames,width", [(9, 1), (100, 2), (1000, 3)])
+def test_key_str_width(tmp_path, num_frames, width):
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf", num_frames=num_frames)
+ writer.open()
+ writer.add_prediction(_make_bodyparts())
+ writer.close()
+
+ reader = ShelfReader(tmp_path / "shelf")
+ reader.open()
+ expected_key = "frame" + "0".zfill(width)
+ assert expected_key in reader.keys()
+ reader.close()
+
+
+# -- data shape ---------------------------------------------------------------
+
+
+def test_add_prediction_stores_correct_shapes(tmp_path):
+ num_assemblies, num_bpts = 2, 3
+ bp = _make_bodyparts(num_assemblies, num_bpts)
+
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf", num_frames=10)
+ writer.open()
+ writer.add_prediction(bp)
+ writer.close()
+
+ reader = ShelfReader(tmp_path / "shelf")
+ reader.open()
+ data = reader["frame0"]
+
+ coords = data["coordinates"][0]
+ assert len(coords) == num_bpts
+ assert coords[0].shape == (num_assemblies, 2)
+
+ scores = data["confidence"]
+ assert len(scores) == num_bpts
+ assert scores[0].shape == (num_assemblies, 1)
+ reader.close()
+
+
+# -- metadata on close --------------------------------------------------------
+
+
+def test_metadata_nframes_updated_on_close(tmp_path):
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf", num_frames=100)
+ writer.open()
+ for _ in range(3):
+ writer.add_prediction(_make_bodyparts())
+ writer.close()
+
+ reader = ShelfReader(tmp_path / "shelf")
+ reader.open()
+ assert reader["metadata"]["nframes"] == 3
+ reader.close()
+
+
+# -- unique bodyparts ---------------------------------------------------------
+
+
+def test_unique_bodyparts_appended(tmp_path):
+ num_assemblies, num_bpts, num_unique = 2, 3, 1
+ bp = _make_bodyparts(num_assemblies, num_bpts)
+ ubp = np.random.default_rng(1).random((num_assemblies, num_unique, 3)).astype(np.float32)
+
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf", num_frames=5)
+ writer.open()
+ writer.add_prediction(bp, unique_bodyparts=ubp)
+ writer.close()
+
+ reader = ShelfReader(tmp_path / "shelf")
+ reader.open()
+ data = reader["frame0"]
+ assert len(data["coordinates"][0]) == num_bpts + num_unique
+ assert len(data["confidence"]) == num_bpts + num_unique
+ reader.close()
+
+
+# -- identity scores ----------------------------------------------------------
+
+
+def test_identity_scores_stored(tmp_path):
+ num_assemblies, num_bpts, num_individuals = 2, 3, 2
+ bp = _make_bodyparts(num_assemblies, num_bpts)
+ ids = np.random.default_rng(2).random((num_assemblies, num_bpts, num_individuals)).astype(np.float32)
+
+ writer = ShelfWriter(POSE_CFG, tmp_path / "shelf", num_frames=5)
+ writer.open()
+ writer.add_prediction(bp, identity_scores=ids)
+ writer.close()
+
+ reader = ShelfReader(tmp_path / "shelf")
+ reader.open()
+ data = reader["frame0"]
+ assert "identity" in data
+ assert len(data["identity"]) == num_bpts
+ assert data["identity"][0].shape == (num_assemblies, num_individuals)
+ reader.close()
diff --git a/tests/pose_estimation_pytorch/runners/test_task.py b/tests/pose_estimation_pytorch/runners/test_task.py
new file mode 100644
index 0000000000..2f821d0aa3
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_task.py
@@ -0,0 +1,28 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests the Task enum."""
+
+import pytest
+
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+@pytest.mark.parametrize(
+ "task, task_strings",
+ [
+ (Task.BOTTOM_UP, ["bu", "BU", "bU", "Bu"]),
+ (Task.TOP_DOWN, ["TD", "tD"]),
+ (Task.DETECT, ["dt", "DT"]),
+ ],
+)
+def test_build_task(task: Task, task_strings: list[str]):
+ for s in task_strings:
+ assert task == Task(s)
diff --git a/tests/test_auxfun_models.py b/tests/test_auxfun_models.py
index c7830b1b41..0684da7651 100644
--- a/tests/test_auxfun_models.py
+++ b/tests/test_auxfun_models.py
@@ -4,15 +4,15 @@
# https://github.com/DeepLabCut/DeepLabCut
#
# Please see AUTHORS for contributors.
-# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
#
# Licensed under GNU Lesser General Public License v3.0
#
+import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
-import unittest
from unittest.mock import patch
from deeplabcut.utils.auxfun_models import MODELTYPE_FILEPATH_MAP, check_for_weights
@@ -21,23 +21,15 @@
class CheckForWeightsTestCase(unittest.TestCase):
def test_filepaths_for_modeltypes(self):
with TemporaryDirectory() as tmpdir:
- with patch(
- "deeplabcut.utils.auxfun_models.download_weights"
- ) as mocked_download:
+ with patch("deeplabcut.utils.auxfun_models.download_weights") as mocked_download:
for modeltype, expected_path in MODELTYPE_FILEPATH_MAP.items():
actual_path = check_for_weights(modeltype, Path(tmpdir))
self.assertIn(str(expected_path), actual_path)
if "efficientnet" in modeltype:
- mocked_download.assert_called_with(
- modeltype, tmpdir / expected_path.parent
- )
+ mocked_download.assert_called_with(modeltype, tmpdir / expected_path.parent)
else:
- mocked_download.assert_called_with(
- modeltype, tmpdir / expected_path
- )
+ mocked_download.assert_called_with(modeltype, tmpdir / expected_path)
def test_bad_modeltype(self):
- actual_path = check_for_weights(
- "dummymodel", "nonexistentpath"
- )
+ actual_path = check_for_weights("dummymodel", "nonexistentpath")
self.assertEqual(actual_path, "nonexistentpath")
diff --git a/tests/test_auxfun_multianimal.py b/tests/test_auxfun_multianimal.py
index 1dbd67d2f6..8e8b7d28dc 100644
--- a/tests/test_auxfun_multianimal.py
+++ b/tests/test_auxfun_multianimal.py
@@ -8,12 +8,14 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
+from itertools import combinations
+
import networkx as nx
import numpy as np
import pandas as pd
import pytest
+
from deeplabcut.utils import auxfun_multianimal
-from itertools import combinations
def test_prune_paf_graph():
@@ -44,9 +46,7 @@ def test_reorder_individuals_in_df():
individuals = df.columns.get_level_values("individuals").unique().to_list()
# Generate a random permutation and reorder data. Ignore the unique bodypart
- permutation_indices = random.sample(
- range(len(individuals[:-1])), k=len(individuals[:-1])
- )
+ permutation_indices = random.sample(range(len(individuals[:-1])), k=len(individuals[:-1]))
permutation = [individuals[i] for i in permutation_indices]
permutation.append("single")
df_reordered = auxfun_multianimal.reorder_individuals_in_df(df, permutation)
@@ -56,9 +56,7 @@ def test_reorder_individuals_in_df():
inverse_permutation_indices = np.argsort(permutation_indices).tolist()
inverse_permutation = [individuals[i] for i in inverse_permutation_indices]
inverse_permutation.append("single")
- df_inverse_reordering = auxfun_multianimal.reorder_individuals_in_df(
- df_reordered, inverse_permutation
- )
+ df_inverse_reordering = auxfun_multianimal.reorder_individuals_in_df(df_reordered, inverse_permutation)
# Check
pd.testing.assert_frame_equal(df, df_inverse_reordering)
diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py
index 7397060897..ff16dc1c1e 100644
--- a/tests/test_auxiliaryfunctions.py
+++ b/tests/test_auxiliaryfunctions.py
@@ -9,7 +9,9 @@
# Licensed under GNU Lesser General Public License v3.0
#
from pathlib import Path
+
import pytest
+
from deeplabcut.utils import auxiliaryfunctions
from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
@@ -17,7 +19,7 @@
def test_find_analyzed_data(tmpdir_factory):
fake_folder = tmpdir_factory.mktemp("videos")
SUPPORTED_VIDEOS = ["avi"]
- n_ext = len(SUPPORTED_VIDEOS)
+ len(SUPPORTED_VIDEOS)
SCORER = "DLC_dlcrnetms5_multi_mouseApr11shuffle1_5"
WRONG_SCORER = "DLC_dlcrnetms5_multi_mouseApr11shuffle3_5"
@@ -36,22 +38,18 @@ def _create_fake_file(filename):
for ind, ext in enumerate(SUPPORTED_VIDEOS):
# test if existing models are found:
- assert auxiliaryfunctions.find_analyzed_data(
- fake_folder, "video" + str(ind), SCORER
- )
+ assert auxiliaryfunctions.find_analyzed_data(fake_folder, "video" + str(ind), SCORER)
# Test if nonexisting models are not found
with pytest.raises(FileNotFoundError):
- auxiliaryfunctions.find_analyzed_data(
- fake_folder, "video" + str(ind), WRONG_SCORER
- )
+ auxiliaryfunctions.find_analyzed_data(fake_folder, "video" + str(ind), WRONG_SCORER)
with pytest.raises(FileNotFoundError):
- auxiliaryfunctions.find_analyzed_data(
- fake_folder, "video" + str(ind), SCORER, filtered=True
- )
+ auxiliaryfunctions.find_analyzed_data(fake_folder, "video" + str(ind), SCORER, filtered=True)
+@pytest.mark.deprecated
+@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_get_list_of_videos(tmpdir_factory):
fake_folder = tmpdir_factory.mktemp("videos")
n_ext = len(SUPPORTED_VIDEOS)
@@ -121,7 +119,7 @@ def _create_fake_file(filename):
def test_write_config_has_skeleton(tmpdir_factory):
- """Required for backward compatibility"""
+ """Required for backward compatibility."""
fake_folder = tmpdir_factory.mktemp("fakeConfigs")
fake_config_file = fake_folder / Path("fakeConfig")
auxiliaryfunctions.write_config(fake_config_file, {})
@@ -165,21 +163,15 @@ def test_intersection_of_body_parts_and_ones_given_by_user(
else:
all_bodyparts = bodyparts
- filtered_bpts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg, comparisonbodyparts="all"
- )
- )
+ filtered_bpts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts="all")
print(all_bodyparts)
print(filtered_bpts)
assert len(all_bodyparts) == len(filtered_bpts)
assert all([bpt in all_bodyparts for bpt in filtered_bpts])
- filtered_bpts = (
- auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
- cfg,
- comparisonbodyparts=comparison_bpts,
- )
+ filtered_bpts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
+ cfg,
+ comparisonbodyparts=comparison_bpts,
)
print(filtered_bpts)
assert len(expected_bpts) == len(filtered_bpts)
@@ -230,3 +222,50 @@ def get_rglob_results(*args, **kwargs):
monkeypatch.setattr(Path, "rglob", get_rglob_results)
next_folder = auxiliaryfunctions.find_next_unlabeled_folder(fake_cfg)
assert str(next_folder) == str(Path(data_folder / next_folder_name))
+
+
+@pytest.fixture
+def mock_snapshot_folder(tmp_path):
+ """Mock folder with snapshots."""
+ folder = tmp_path / "train"
+ folder.mkdir()
+
+ # mock files
+ snapshot_files = [
+ "snapshot-4.index",
+ "snapshot-5.index",
+ "snapshot-6.index",
+ "snapshot-3.data-00000-of-00001",
+ "snapshot-3.index",
+ "snapshot-3.meta",
+ ]
+ for file_name in snapshot_files:
+ (folder / file_name).touch()
+
+ return folder
+
+
+@pytest.fixture
+def mock_no_snapshots_folder(tmp_path):
+ """Mock folder with no snapshots."""
+ folder = tmp_path / "train"
+ folder.mkdir()
+
+ # mock files
+ snapshot_files = ["log.txt", "pose_cfg.yaml"]
+ for file_name in snapshot_files:
+ (folder / file_name).touch()
+
+ return folder
+
+
+def test_get_snapshots_from_folder(mock_snapshot_folder):
+ """Test returns expected snapshots in order."""
+ snapshot_names = auxiliaryfunctions.get_snapshots_from_folder(mock_snapshot_folder)
+ assert snapshot_names == ["snapshot-3", "snapshot-4", "snapshot-5", "snapshot-6"]
+
+
+def test_get_snapshots_from_folder_none(mock_no_snapshots_folder):
+ """Test raises ValueError if no snapshots are found."""
+ with pytest.raises(FileNotFoundError):
+ auxiliaryfunctions.get_snapshots_from_folder(mock_no_snapshots_folder)
diff --git a/tests/test_conversioncode.py b/tests/test_conversioncode.py
index 3ec57e0197..ca287ba861 100644
--- a/tests/test_conversioncode.py
+++ b/tests/test_conversioncode.py
@@ -9,8 +9,10 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
+
import pandas as pd
from conftest import TEST_DATA_DIR
+
from deeplabcut.utils import conversioncode
diff --git a/tests/test_crossvalutils.py b/tests/test_crossvalutils.py
index b501a7f9a6..5c821bd03e 100644
--- a/tests/test_crossvalutils.py
+++ b/tests/test_crossvalutils.py
@@ -8,10 +8,11 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
import pickle
-from deeplabcut.pose_estimation_tensorflow.lib import crossvalutils
+import numpy as np
+
+from deeplabcut.core import crossvalutils
BEST_GRAPH = [14, 15, 16, 11, 22, 31, 61, 7, 59, 62, 64]
BEST_GRAPH_MONTBLANC = [1, 0, 2, 5, 4, 3]
@@ -21,9 +22,7 @@ def test_get_n_best_paf_graphs(evaluation_data_and_metadata):
data, metadata = evaluation_data_and_metadata
params = crossvalutils._set_up_evaluation(data)
n_graphs = 5
- paf_inds, dict_ = crossvalutils._get_n_best_paf_graphs(
- data, metadata, params["paf_graph"], n_graphs=n_graphs
- )
+ paf_inds, dict_ = crossvalutils._get_n_best_paf_graphs(data, metadata, params["paf_graph"], n_graphs=n_graphs)
assert len(paf_inds) == n_graphs
assert len(dict_) == len(params["paf_graph"])
assert len(paf_inds[0]) == 11
@@ -67,9 +66,7 @@ def test_benchmark_paf_graphs(evaluation_data_and_metadata):
],
}
inference_cfg = {"topktoretain": 3, "pcutoff": 0.1, "pafthreshold": 0.1}
- results = crossvalutils._benchmark_paf_graphs(
- cfg, inference_cfg, data, [BEST_GRAPH]
- )
+ results = crossvalutils._benchmark_paf_graphs(cfg, inference_cfg, data, [BEST_GRAPH])
all_scores = results[0]
assert len(all_scores) == 1
assert all_scores[0][1] == BEST_GRAPH
@@ -103,8 +100,8 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc):
np.testing.assert_equal(
results[1].loc["purity"].to_numpy().squeeze(),
[
- results_gt[0][6][('purity', 'mean')],
- results_gt[0][6][('purity', 'std')],
+ results_gt[0][6][("purity", "mean")],
+ results_gt[0][6][("purity", "std")],
],
)
vals = [
@@ -116,9 +113,9 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc):
np.testing.assert_equal(
vals,
[
- results_gt[0][6][('mAP_train', 'mean')],
- results_gt[0][6][('mAR_train', 'mean')],
- results_gt[0][6][('mAP_test', 'mean')],
- results_gt[0][6][('mAR_test', 'mean')],
+ results_gt[0][6][("mAP_train", "mean")],
+ results_gt[0][6][("mAR_train", "mean")],
+ results_gt[0][6][("mAP_test", "mean")],
+ results_gt[0][6][("mAR_test", "mean")],
],
)
diff --git a/tests/test_dataset_augmentation.py b/tests/test_dataset_augmentation.py
index a11dd148d0..9e935c7259 100644
--- a/tests/test_dataset_augmentation.py
+++ b/tests/test_dataset_augmentation.py
@@ -11,8 +11,14 @@
import imgaug.augmenters as iaa
import numpy as np
import pytest
+
from deeplabcut.pose_estimation_tensorflow.datasets import augmentation
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
+)
+
@pytest.mark.parametrize(
"width, height",
@@ -98,9 +104,10 @@ def test_keypoint_horizontal_flip(
keypoints=list(map(str, range(12))),
symmetric_pairs=pairs,
)
- keypoints_aug = aug(images=[sample_image], keypoints=[sample_keypoints],)[
- 1
- ][0]
+ keypoints_aug = aug(
+ images=[sample_image],
+ keypoints=[sample_keypoints],
+ )[1][0]
temp = keypoints_aug.reshape((3, 12, 2))
for pair in pairs:
temp[:, pair] = temp[:, pair[::-1]]
diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py
index e37e4822e0..69ea13d7c6 100644
--- a/tests/test_evaluate.py
+++ b/tests/test_evaluate.py
@@ -13,6 +13,15 @@
import pytest
import deeplabcut.pose_estimation_tensorflow as pet
+from deeplabcut.pose_estimation_tensorflow.core.evaluate import (
+ get_available_requested_snapshots,
+ get_snapshots_by_index,
+)
+
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
+)
def make_single_animal_rmse_df(
@@ -39,9 +48,7 @@ def make_multi_animal_rmse_df(
names=["scorer", "individuals", "bodyparts"],
)
if error_data is None:
- error_data = np.ones(
- (len(train_indices) + len(test_indices), len(individuals) * len(bodyparts))
- )
+ error_data = np.ones((len(train_indices) + len(test_indices), len(individuals) * len(bodyparts)))
return pd.DataFrame(error_data, columns=columns)
@@ -152,3 +159,75 @@ def test_evaluate_keypoint_error(inputs, expected_values):
mean_error = mean_errors[1]
assert keypoint_error.loc[error_name, bodypart] == mean_error
+
+
+def test_get_available_requested_snapshots_ok():
+ """Test that the correct snapshots are returned."""
+ available = ["snapshot-1", "snapshot-2"]
+ requested = ["snapshot-2", "snapshot-3"]
+
+ snapshots = get_available_requested_snapshots(
+ requested_snapshots=requested,
+ available_snapshots=available,
+ )
+ assert snapshots == ["snapshot-2"]
+
+
+def test_get_available_requested_snapshots_error():
+ """Test that a ValueError is raised when requested snapshots are not available."""
+ with pytest.raises(ValueError):
+ get_available_requested_snapshots(
+ requested_snapshots=["snapshot-2"],
+ available_snapshots=["snapshot-1", "snapshot-3"],
+ )
+
+
+def test_get_snapshots_by_index_int_ok():
+ """Test that the correct snapshots are returned."""
+ available = ["snapshot-1", "snapshot-2", "snapshot-3"]
+
+ # positive int
+ snapshots = get_snapshots_by_index(
+ idx=2,
+ available_snapshots=available,
+ )
+ assert snapshots == ["snapshot-3"]
+
+ # negative int
+ snapshots = get_snapshots_by_index(
+ idx=-2,
+ available_snapshots=available,
+ )
+ assert snapshots == ["snapshot-2"]
+
+ # all snapshots
+ snapshots = get_snapshots_by_index(
+ idx="all",
+ available_snapshots=available,
+ )
+ assert snapshots == ["snapshot-1", "snapshot-2", "snapshot-3"]
+
+
+def test_get_snapshots_by_index_error():
+ """Test that a ValueError is raised when the index is out of range or invalid
+ str."""
+ available = ["snapshot-1", "snapshot-2", "snapshot-3"]
+
+ # positive int
+ with pytest.raises(IndexError):
+ get_snapshots_by_index(
+ idx=5,
+ available_snapshots=available,
+ )
+ # negative int
+ with pytest.raises(IndexError):
+ get_snapshots_by_index(
+ idx=-4,
+ available_snapshots=available,
+ )
+ # invalid str
+ with pytest.raises(IndexError):
+ get_snapshots_by_index(
+ idx="1",
+ available_snapshots=available,
+ )
diff --git a/tests/test_frame_selection_tools.py b/tests/test_frame_selection_tools.py
index ddee2346c7..17b615ad61 100644
--- a/tests/test_frame_selection_tools.py
+++ b/tests/test_frame_selection_tools.py
@@ -8,10 +8,13 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-""" Tests for frame selection tools """
+"""Tests for frame selection tools."""
+
import math
from unittest.mock import Mock
+
import pytest
+
import deeplabcut.utils.frameselectiontools as fst
diff --git a/tests/test_inferenceutils.py b/tests/test_inferenceutils.py
index f44aad610f..cd52c8cd36 100644
--- a/tests/test_inferenceutils.py
+++ b/tests/test_inferenceutils.py
@@ -8,15 +8,17 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
import os
import pickle
+from copy import deepcopy
+
+import numpy as np
import pytest
from conftest import TEST_DATA_DIR
-from copy import deepcopy
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils
from scipy.spatial.distance import squareform
+from deeplabcut.core import inferenceutils
+
def test_conv_square_to_condensed_indices():
n = 5
@@ -25,7 +27,7 @@ def test_conv_square_to_condensed_indices():
mat[rows, cols] = mat[cols, rows] = np.arange(1, len(rows) + 1)
vec = squareform(mat)
vals = []
- for i, j in zip(rows, cols):
+ for i, j in zip(rows, cols, strict=False):
ind = inferenceutils._conv_square_to_condensed_indices(i, j, n)
vals.append(vec[ind])
np.testing.assert_equal(vec, vals)
@@ -36,9 +38,7 @@ def test_calc_object_keypoint_similarity(real_assemblies):
xy1 = real_assemblies[0][0].xy
xy2 = real_assemblies[0][1].xy
assert inferenceutils.calc_object_keypoint_similarity(xy1, xy1, sigma) == 1
- assert np.isclose(
- inferenceutils.calc_object_keypoint_similarity(xy1, xy2, sigma), 0
- )
+ assert np.isclose(inferenceutils.calc_object_keypoint_similarity(xy1, xy2, sigma), 0)
xy3 = xy1.copy()
xy3[: len(xy3) // 2] = np.nan
assert inferenceutils.calc_object_keypoint_similarity(xy3, xy1, sigma) == 0.5
@@ -51,36 +51,27 @@ def test_calc_object_keypoint_similarity(real_assemblies):
symmetric_pair = [0, 11]
xy4[symmetric_pair] = xy4[symmetric_pair[::-1]]
assert inferenceutils.calc_object_keypoint_similarity(xy1, xy4, sigma) != 1
- assert (
- inferenceutils.calc_object_keypoint_similarity(
- xy1, xy4, sigma, symmetric_kpts=[symmetric_pair]
- )
- == 1
- )
+ assert inferenceutils.calc_object_keypoint_similarity(xy1, xy4, sigma, symmetric_kpts=[symmetric_pair]) == 1
def test_match_assemblies(real_assemblies):
assemblies = real_assemblies[0]
- matched, unmatched = inferenceutils.match_assemblies(
- assemblies, assemblies[::-1], 0.01
- )
- assert not unmatched
- for ass1, ass2, oks in matched:
- assert ass1 is ass2
- assert oks == 1
+ num_gt, matches = inferenceutils.match_assemblies(assemblies, assemblies[::-1], 0.01)
+ assert len(assemblies) == len(matches)
+ for m in matches:
+ assert m.prediction is m.ground_truth
+ assert m.oks == 1
- matched, unmatched = inferenceutils.match_assemblies([], assemblies, 0.01)
- assert not matched
- assert all(ass1 is ass2 for ass1, ass2 in zip(unmatched, assemblies))
+ num_gt, matches = inferenceutils.match_assemblies([], assemblies, 0.01)
+ assert len(matches) == 0
+ assert num_gt == len(assemblies)
def test_evaluate_assemblies(real_assemblies):
assemblies = {i: real_assemblies[i] for i in range(3)}
n_thresholds = 5
thresholds = np.linspace(0.5, 0.95, n_thresholds)
- dict_ = inferenceutils.evaluate_assembly(
- assemblies, assemblies, oks_thresholds=thresholds
- )
+ dict_ = inferenceutils.evaluate_assembly(assemblies, assemblies, oks_thresholds=thresholds)
assert dict_["mAP"] == dict_["mAR"] == 1
assert len(dict_["precisions"]) == len(dict_["recalls"]) == n_thresholds
assert dict_["precisions"].shape[1] == 101
@@ -107,7 +98,7 @@ def test_link():
j1 = inferenceutils.Joint(pos1, conf, idx=idx1)
j2 = inferenceutils.Joint(pos2, conf, idx=idx2)
link = inferenceutils.Link(j1, j2)
- assert link.confidence == conf ** 2
+ assert link.confidence == conf**2
assert link.idx == (idx1, idx2)
assert link.to_vector() == [*pos1, *pos2]
@@ -172,13 +163,11 @@ def test_assembler(tmpdir_factory, real_assemblies):
ass.assemble()
assert not ass.unique
assert len(ass.assemblies) == len(real_assemblies)
- assert sum(1 for a in ass.assemblies.values() for _ in a) == sum(
- 1 for a in real_assemblies.values() for _ in a
- )
+ assert sum(1 for a in ass.assemblies.values() for _ in a) == sum(1 for a in real_assemblies.values() for _ in a)
- output_name = tmpdir_factory.mktemp("data").join("fake.h5")
- ass.to_h5(output_name)
- ass.to_pickle(str(output_name).replace("h5", "pickle"))
+ output_dir = tmpdir_factory.mktemp("data")
+ ass.to_h5(output_dir.join("fake.h5"))
+ ass.to_pickle(output_dir.join("fake.pickle"))
def test_assembler_with_single_bodypart(real_assemblies):
@@ -223,15 +212,9 @@ def test_assembler_with_unique_bodypart(real_assemblies_montblanc):
ass.assemble(chunk_size=0)
assert len(ass.assemblies) == len(real_assemblies_montblanc[0])
assert len(ass.unique) == len(real_assemblies_montblanc[1])
- assemblies = np.concatenate(
- [ass.xy for assemblies in ass.assemblies.values() for ass in assemblies]
- )
+ assemblies = np.concatenate([ass.xy for assemblies in ass.assemblies.values() for ass in assemblies])
assemblies_gt = np.concatenate(
- [
- ass.xy
- for assemblies in real_assemblies_montblanc[0].values()
- for ass in assemblies
- ]
+ [ass.xy for assemblies in real_assemblies_montblanc[0].values() for ass in assemblies]
)
np.testing.assert_equal(assemblies, assemblies_gt)
@@ -270,9 +253,7 @@ def test_assembler_with_identity(tmpdir_factory, real_assemblies):
ass.assemble()
assert not ass.unique
assert len(ass.assemblies) == len(real_assemblies)
- assert sum(1 for a in ass.assemblies.values() for _ in a) == sum(
- 1 for a in real_assemblies.values() for _ in a
- )
+ assert sum(1 for a in ass.assemblies.values() for _ in a) == sum(1 for a in real_assemblies.values() for _ in a)
assert all(np.all(_.data[:, -1] != -1) for a in ass.assemblies.values() for _ in a)
# Test now with identity only and ensure assemblies
@@ -288,9 +269,9 @@ def test_assembler_with_identity(tmpdir_factory, real_assemblies):
eq.append(np.all(ids == ids[0]))
assert all(eq)
- output_name = tmpdir_factory.mktemp("data").join("fake.h5")
- ass.to_h5(output_name)
- ass.to_pickle(str(output_name).replace("h5", "pickle"))
+ output_dir = tmpdir_factory.mktemp("data")
+ ass.to_h5(output_dir.join("fake.h5"))
+ ass.to_pickle(output_dir.join("fake.pickle"))
def test_assembler_calibration(real_assemblies):
diff --git a/tests/test_pose_multianimal_imgaug.py b/tests/test_pose_multianimal_imgaug.py
index c0c8a8c5b0..d0f1debab8 100644
--- a/tests/test_pose_multianimal_imgaug.py
+++ b/tests/test_pose_multianimal_imgaug.py
@@ -8,17 +8,24 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
import os
+
+import numpy as np
import pytest
from conftest import TEST_DATA_DIR
+
from deeplabcut.pose_estimation_tensorflow.datasets import (
Batch,
- pose_multianimal_imgaug,
PoseDatasetFactory,
+ pose_multianimal_imgaug,
)
from deeplabcut.utils import read_plainconfig
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
+)
+
def mock_imread(path, mode):
return (np.random.rand(400, 400, 3) * 255).astype(np.uint8)
@@ -67,19 +74,13 @@ def test_get_batch(ma_dataset):
for batch_size in 1, 4, 8, 16:
ma_dataset.batch_size = batch_size
batch_images, joint_ids, batch_joints, data_items = ma_dataset.get_batch()
- assert (
- len(batch_images)
- == len(joint_ids)
- == len(batch_joints)
- == len(data_items)
- == batch_size
- )
- for data_item, joint_id, batch_joint in zip(data_items, joint_ids, batch_joints):
+ assert len(batch_images) == len(joint_ids) == len(batch_joints) == len(data_items) == batch_size
+ for data_item, joint_id, batch_joint in zip(data_items, joint_ids, batch_joints, strict=False):
assert len(data_item.joints) == len(joint_id)
assert len(batch_joint) == len(np.concatenate(joint_id))
start = 0
mask = ~np.isnan(batch_joint).any(axis=1)
- for joints, id_ in zip(data_item.joints.values(), joint_id):
+ for joints, id_ in zip(data_item.joints.values(), joint_id, strict=False):
inds = id_ + start
mask_ = mask[inds]
np.testing.assert_equal(joints[:, 0], id_[mask_])
@@ -100,22 +101,14 @@ def test_get_targetmaps(ma_dataset, num_idchannel):
scale = np.mean(target_size / ma_dataset.default_size)
maps = ma_dataset.get_targetmaps_update(*batch, sm_size, scale)
assert all(len(map_) == ma_dataset.batch_size for map_ in maps.values())
- assert (
- maps[Batch.part_score_targets][0].shape
- == maps[Batch.part_score_weights][0].shape
- )
- assert (
- maps[Batch.part_score_targets][0].shape[2]
- == ma_dataset.cfg["num_joints"] + num_idchannel
- )
+ assert maps[Batch.part_score_targets][0].shape == maps[Batch.part_score_weights][0].shape
+ assert maps[Batch.part_score_targets][0].shape[2] == ma_dataset.cfg["num_joints"] + num_idchannel
assert maps[Batch.locref_targets][0].shape == maps[Batch.locref_mask][0].shape
assert maps[Batch.locref_targets][0].shape[2] == 2 * ma_dataset.cfg["num_joints"]
- assert (
- maps[Batch.pairwise_targets][0].shape == maps[Batch.pairwise_targets][0].shape
- )
+ assert maps[Batch.pairwise_targets][0].shape == maps[Batch.pairwise_targets][0].shape
assert maps[Batch.pairwise_targets][0].shape[2] == 2 * ma_dataset.cfg["num_limbs"]
def test_batching(ma_dataset):
for _ in range(10):
- batch = ma_dataset.next_batch()
+ ma_dataset.next_batch()
diff --git a/tests/test_predict_multianimal.py b/tests/test_predict_multianimal.py
index eb9bbd7d16..4646a6ca93 100644
--- a/tests/test_predict_multianimal.py
+++ b/tests/test_predict_multianimal.py
@@ -9,9 +9,14 @@
# Licensed under GNU Lesser General Public License v3.0
#
import numpy as np
-import tensorflow as tf
+import pytest
+
from deeplabcut.pose_estimation_tensorflow.core import predict_multianimal
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
+)
RADIUS = 5
THRESHOLD = 0.01
@@ -66,14 +71,10 @@ def test_association_costs(model_outputs, ground_truth_detections):
costs_pred = preds["costs"]
assert len(costs_pred) == len(costs_gt)
eq = [
- np.array_equal(np.argmax(v["m1"], axis=0), np.argmax(costs_gt[k]["m1"], axis=0))
- for k, v in costs_pred.items()
+ np.array_equal(np.argmax(v["m1"], axis=0), np.argmax(costs_gt[k]["m1"], axis=0)) for k, v in costs_pred.items()
]
assert sum(eq) == 60 # 6 arrays are unequal as cost computation was corrected
- assert all(
- np.allclose(v["distance"], costs_gt[k]["distance"], atol=1.5)
- for k, v in costs_pred.items()
- )
+ assert all(np.allclose(v["distance"], costs_gt[k]["distance"], atol=1.5) for k, v in costs_pred.items())
def test_compute_peaks_and_costs_no_graph(model_outputs):
diff --git a/tests/test_predict_supermodel.py b/tests/test_predict_supermodel.py
index 767e2739a8..1453984620 100644
--- a/tests/test_predict_supermodel.py
+++ b/tests/test_predict_supermodel.py
@@ -10,7 +10,8 @@
#
import numpy as np
import pytest
-from deeplabcut.modelzoo.api import superanimal_inference
+
+from deeplabcut.pose_estimation_tensorflow.modelzoo.api import superanimal_inference
def test_get_multi_scale_frames():
@@ -22,7 +23,7 @@ def test_get_multi_scale_frames():
heights,
)
assert len(frames) == len(shapes) == len(heights)
- assert all(shape[0] == h for shape, h in zip(shapes, heights))
+ assert all(shape[0] == h for shape, h in zip(shapes, heights, strict=False))
assert all(round(shape[0] * ar) == shape[1] for shape in shapes)
@@ -44,4 +45,4 @@ def test_project_pred_to_original_size(scale):
)
coords_orig = preds_orig["coordinates"][0]
assert len(coords_orig) == len(xs)
- assert all([round(x * scale) == round(xy[0]) for xy, x in zip(coords_orig, xs)])
+ assert all([round(x * scale) == round(xy[0]) for xy, x in zip(coords_orig, xs, strict=False)])
diff --git a/tests/test_refine_train_dataset/test_outlierframes.py b/tests/test_refine_train_dataset/test_outlierframes.py
new file mode 100644
index 0000000000..a0d5f229ba
--- /dev/null
+++ b/tests/test_refine_train_dataset/test_outlierframes.py
@@ -0,0 +1,255 @@
+from unittest.mock import MagicMock
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from deeplabcut.refine_training_dataset import outlier_frames
+
+# ----------------------------
+# Helpers / fixtures
+# ----------------------------
+
+STATS = [
+ "distance",
+ "sig",
+ "meanx",
+ "meany",
+ "lowerCIx",
+ "higherCIx",
+ "lowerCIy",
+ "higherCIy",
+]
+
+
+@pytest.fixture
+def patch_hdf_write(monkeypatch):
+ """
+ Avoid filesystem / pytables dependency when storeoutput='full' is used.
+ Also lets us assert that the write path is still exercised.
+ """
+ mock = MagicMock()
+ monkeypatch.setattr(pd.DataFrame, "to_hdf", mock)
+ return mock
+
+
+@pytest.fixture
+def patch_fit_sarimax(monkeypatch):
+ def fake_fit_sarimax_model(x, p, p_bound, alpha, ARdegree, MAdegree):
+ x = np.asarray(x, dtype=float)
+ mean = x.copy()
+ ci = np.c_[mean - 1.0, mean + 1.0]
+ return mean, ci
+
+ mock = MagicMock(side_effect=fake_fit_sarimax_model)
+ monkeypatch.setattr(outlier_frames, "FitSARIMAXModel", mock)
+ return mock
+
+
+@pytest.fixture
+def sparse_multianimal_df():
+ """
+ maDLC-like sparse layout:
+ - 2 individuals with shared bodyparts
+ - unique bodyparts present only under a special 'single' bucket
+ This breaks if reconstructed with the full Cartesian product of the non-'coords' levels,
+ e.g. multi-animal projects with unique bodyparts were previously
+ producing many extra columns for the non-existent combinations of individual x unique bodypart.
+ """
+ n_frames = 7
+ scorer = "DLC_scorer"
+ individuals = ["ind1", "ind2"]
+ shared_bodyparts = [f"shared_{i}" for i in range(18)]
+ unique_bodyparts = [f"unique_{i}" for i in range(4)]
+ coords = ["x", "y", "likelihood"]
+
+ tuples = []
+
+ # Shared bodyparts for each real individual
+ for ind in individuals:
+ for bp in shared_bodyparts:
+ for c in coords:
+ tuples.append((scorer, ind, bp, c))
+
+ # Unique bodyparts only under a special bucket
+ for bp in unique_bodyparts:
+ for c in coords:
+ tuples.append((scorer, "single", bp, c))
+
+ columns = pd.MultiIndex.from_tuples(tuples, names=["scorer", "individuals", "bodyparts", "coords"])
+
+ # 18 shared * 2 + 4 unique = 40 streams, each with x/y/likelihood
+ assert len(columns) == 40 * 3
+
+ rng = np.random.default_rng(42)
+ values = rng.normal(size=(n_frames, len(columns)))
+
+ # Keep likelihood valid / boring
+ likelihood_mask = columns.get_level_values("coords") == "likelihood"
+ values[:, likelihood_mask] = 0.9
+
+ df = pd.DataFrame(values, columns=columns)
+ return df
+
+
+@pytest.fixture
+def dense_multianimal_df():
+ """
+ Dense/full-combination layout:
+ every individual x bodypart combination exists.
+ For this topology, the old from_product(...) logic and the new "preserve
+ actual tuples" logic should produce the same output columns (assuming the
+ dataframe is created in canonical product order, which we do here).
+ """
+ n_frames = 5
+ scorer = "DLC_scorer"
+ individuals = ["ind1", "ind2"]
+ bodyparts = ["nose", "tail", "paw"]
+ coords = ["x", "y", "likelihood"]
+
+ tuples = [(scorer, ind, bp, c) for ind in individuals for bp in bodyparts for c in coords]
+
+ columns = pd.MultiIndex.from_tuples(tuples, names=["scorer", "individuals", "bodyparts", "coords"])
+
+ rng = np.random.default_rng(42)
+ values = rng.normal(size=(n_frames, len(columns)))
+ likelihood_mask = columns.get_level_values("coords") == "likelihood"
+ values[:, likelihood_mask] = 0.95
+
+ df = pd.DataFrame(values, columns=columns)
+ return df
+
+
+def _expected_output_columns_from_actual_streams(df):
+ """
+ Expected output columns preserve actual non-'coords' tuples and append the 8 derived stats.
+ """
+ base_cols = df.xs("x", axis=1, level="coords", drop_level=True).columns
+ return pd.MultiIndex.from_tuples(
+ [(tuple(col) if isinstance(col, tuple) else (col,)) + (stat,) for col in base_cols for stat in STATS],
+ names=df.columns.names,
+ )
+
+
+def _expected_output_columns_from_dense_product(df):
+ """
+ Expected output columns for the previous implementation:
+ full Cartesian product of all non-'coords' levels, then the 8 derived stats.
+ This is only correct / behavior-preserving for dense layouts.
+ """
+ columns = df.columns
+ prod = []
+ for i in range(columns.nlevels - 1):
+ prod.append(columns.get_level_values(i).unique())
+ prod.append(STATS)
+ return pd.MultiIndex.from_product(prod, names=columns.names)
+
+
+# ----------------------------
+# Tests
+# ----------------------------
+
+
+def test_compute_deviations_regression_sparse_unique_bodyparts(
+ sparse_multianimal_df,
+ patch_fit_sarimax,
+ patch_hdf_write,
+):
+ """
+ Regression test for the following maDLC unique-bodypart bug:
+ output columns must match the actual sparse stream layout rather than an
+ inflated Cartesian product of all non-'coords' level values.
+ """
+ df = sparse_multianimal_df
+ n_frames = len(df)
+
+ d, o, data = outlier_frames.compute_deviations(
+ df,
+ dataname="dummy.h5",
+ p_bound=0.01,
+ alpha=0.01,
+ ARdegree=3,
+ MAdegree=1,
+ storeoutput="full",
+ )
+
+ # There are 40 real streams in the sparse fixture
+ n_streams = 40
+
+ # Shape sanity checks
+ assert d.shape == (n_frames,)
+ assert o.shape == (n_frames,)
+ assert data.shape == (n_frames, n_streams * 8)
+
+ # Column layout must preserve only the actual streams
+ expected_columns = _expected_output_columns_from_actual_streams(df)
+ assert data.columns.equals(expected_columns)
+
+ # xs(...) on the last level should still work exactly as before
+ distance = data.xs("distance", axis=1, level=-1)
+ sig = data.xs("sig", axis=1, level=-1)
+ assert distance.shape == (n_frames, n_streams)
+ assert sig.shape == (n_frames, n_streams)
+
+ # With the fake fitter, predictions equal observations => zero distances and sig
+ np.testing.assert_allclose(d, 0.0)
+ np.testing.assert_allclose(o, 0.0)
+
+ # FitSARIMAXModel should be called twice per stream (x and y)
+ assert patch_fit_sarimax.call_count == 2 * n_streams
+
+ # "full" path should still try to persist the result
+ patch_hdf_write.assert_called_once()
+
+
+def test_compute_deviations_behavior_preserved_for_dense_layout(
+ dense_multianimal_df,
+ patch_fit_sarimax,
+ patch_hdf_write,
+):
+ """
+ Behavior-preserved check:
+ for a dense layout where every combination exists, the fixed implementation
+ should produce the same columns that the old from_product(...) logic would
+ have produced.
+ """
+ df = dense_multianimal_df
+ n_frames = len(df)
+ n_streams = len(df.xs("x", axis=1, level="coords", drop_level=True).columns)
+
+ d, o, data = outlier_frames.compute_deviations(
+ df,
+ dataname="dummy.h5",
+ p_bound=0.01,
+ alpha=0.01,
+ ARdegree=3,
+ MAdegree=1,
+ storeoutput="full",
+ )
+
+ # Basic shape / output checks
+ assert d.shape == (n_frames,)
+ assert o.shape == (n_frames,)
+ assert data.shape == (n_frames, n_streams * 8)
+
+ # For dense data, new behavior should match old dense-product behavior exactly
+ expected_old_dense_columns = _expected_output_columns_from_dense_product(df)
+ expected_new_columns = _expected_output_columns_from_actual_streams(df)
+
+ # Sanity check of the fixture assumption:
+ # in the dense case, these should indeed be identical.
+ assert expected_new_columns.equals(expected_old_dense_columns)
+
+ # Actual output should match that shared expected index
+ assert data.columns.equals(expected_old_dense_columns)
+
+ # Still selectable by derived-stat level
+ assert data.xs("distance", axis=1, level=-1).shape == (n_frames, n_streams)
+ assert data.xs("sig", axis=1, level=-1).shape == (n_frames, n_streams)
+
+ # Deterministic fake fitter
+ np.testing.assert_allclose(d, 0.0)
+ np.testing.assert_allclose(o, 0.0)
+
+ assert patch_fit_sarimax.call_count == 2 * n_streams
+ patch_hdf_write.assert_called_once()
diff --git a/tests/test_stitcher.py b/tests/test_stitcher.py
index 033552e54c..699ba96d7f 100644
--- a/tests/test_stitcher.py
+++ b/tests/test_stitcher.py
@@ -11,8 +11,8 @@
import numpy as np
import pandas as pd
import pytest
-from deeplabcut.refine_training_dataset.stitch import Tracklet, TrackletStitcher
+from deeplabcut.refine_training_dataset.stitch import Tracklet, TrackletStitcher
TRACKLET_LEN = 1000
TRACKLET_START = 50
@@ -100,7 +100,7 @@ def test_tracklet_data_access(tracklet):
@pytest.mark.parametrize(
"tracklet, where, norm",
- list(zip(make_fake_tracklets(), ("head", "tail"), (False, True))),
+ list(zip(make_fake_tracklets(), ("head", "tail"), (False, True), strict=False)),
)
def test_tracklet_calc_velocity(tracklet, where, norm):
_ = tracklet.calc_velocity(where, norm)
diff --git a/tests/test_tf_install_smoke.py b/tests/test_tf_install_smoke.py
new file mode 100644
index 0000000000..af19b8c8a3
--- /dev/null
+++ b/tests/test_tf_install_smoke.py
@@ -0,0 +1,53 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+"""Smoke test for TensorFlow when optional TF extras are installed."""
+
+import pytest
+
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
+)
+
+
+def test_tensorflow_imports_and_has_matmul() -> None:
+ assert tf.__version__
+ a = tf.constant([[1.0, 2.0]])
+ b = tf.constant([[3.0], [4.0]])
+ c = tf.matmul(a, b)
+
+ if tf.executing_eagerly():
+ result = c.numpy()
+ else:
+ with tf.compat.v1.Session() as sess:
+ result = sess.run(c)
+
+ assert (result == [[11.0]]).all()
+
+
+def test_tf_slim_imports_and_has_conv2d() -> None:
+ try:
+ import tf_slim as slim
+ except ImportError as e:
+ raise AssertionError("tf_slim is not installed or not importable") from e
+
+ assert slim.conv2d(tf.constant([[[[1.0]]]]), 1, kernel_size=[1, 1], stride=1).shape == (1, 1, 1, 1)
+
+
+def test_tf_keras_imports_and_has_regularizers() -> None:
+ try:
+ import tf_keras as keras
+ except ImportError as e:
+ raise AssertionError("tf_keras is not installed or not importable") from e
+ import numpy as np
+
+ assert keras.regularizers.l2(0.01).l2 == np.array(0.01, dtype="float32")
diff --git a/tests/test_trackingutils.py b/tests/test_trackingutils.py
index 1795db03ee..b3dac6d263 100644
--- a/tests/test_trackingutils.py
+++ b/tests/test_trackingutils.py
@@ -10,7 +10,8 @@
#
import numpy as np
import pytest
-from deeplabcut.pose_estimation_tensorflow.lib import trackingutils
+
+from deeplabcut.core import trackingutils
@pytest.fixture()
@@ -21,9 +22,7 @@ def ellipse():
def test_ellipse(ellipse):
assert ellipse.aspect_ratio == 2
- np.testing.assert_equal(
- ellipse.contains_points(np.asarray([[0, 0], [10, 10]])), [True, False]
- )
+ np.testing.assert_equal(ellipse.contains_points(np.asarray([[0, 0], [10, 10]])), [True, False])
def test_ellipse_similarity(ellipse):
@@ -73,12 +72,8 @@ def test_tracking_ellipse(real_assemblies, real_tracklets):
trackers = mot_tracker.track(animals[..., :2])
trackingutils.fill_tracklets(tracklets, trackers, animals, ind)
assert len(tracklets) == len(tracklets_ref)
- assert [len(tracklet) for tracklet in tracklets.values()] == [
- len(tracklet) for tracklet in tracklets_ref.values()
- ]
- assert all(
- t.shape[1] == 4 for tracklet in tracklets.values() for t in tracklet.values()
- )
+ assert [len(tracklet) for tracklet in tracklets.values()] == [len(tracklet) for tracklet in tracklets_ref.values()]
+ assert all(t.shape[1] == 4 for tracklet in tracklets.values() for t in tracklet.values())
def test_box_tracker():
@@ -105,12 +100,8 @@ def test_tracking_box(real_assemblies, real_tracklets):
trackers = mot_tracker.track(bboxes)
trackingutils.fill_tracklets(tracklets, trackers, animals, ind)
assert len(tracklets) == len(tracklets_ref)
- assert [len(tracklet) for tracklet in tracklets.values()] == [
- len(tracklet) for tracklet in tracklets_ref.values()
- ]
- assert all(
- t.shape[1] == 4 for tracklet in tracklets.values() for t in tracklet.values()
- )
+ assert [len(tracklet) for tracklet in tracklets.values()] == [len(tracklet) for tracklet in tracklets_ref.values()]
+ assert all(t.shape[1] == 4 for tracklet in tracklets.values() for t in tracklet.values())
def test_tracking_montblanc(
@@ -127,9 +118,7 @@ def test_tracking_montblanc(
trackers = mot_tracker.track(animals[..., :2])
trackingutils.fill_tracklets(tracklets, trackers, animals, ind)
assert len(tracklets) == len(tracklets_ref)
- assert [len(tracklet) for tracklet in tracklets.values()] == [
- len(tracklet) for tracklet in tracklets_ref.values()
- ]
+ assert [len(tracklet) for tracklet in tracklets.values()] == [len(tracklet) for tracklet in tracklets_ref.values()]
for k, assemblies in tracklets.items():
ref = tracklets_ref[k]
for ind, data in assemblies.items():
@@ -140,12 +129,8 @@ def test_tracking_montblanc(
def test_calc_bboxes_from_keypoints():
# Test bounding box from a single keypoint
xy = np.asarray([[[0, 0, 1]]])
- np.testing.assert_equal(
- trackingutils.calc_bboxes_from_keypoints(xy, 10), [[-10, -10, 10, 10, 1]]
- )
- np.testing.assert_equal(
- trackingutils.calc_bboxes_from_keypoints(xy, 20, 10), [[-10, -20, 30, 20, 1]]
- )
+ np.testing.assert_equal(trackingutils.calc_bboxes_from_keypoints(xy, 10), [[-10, -10, 10, 10, 1]])
+ np.testing.assert_equal(trackingutils.calc_bboxes_from_keypoints(xy, 20, 10), [[-10, -20, 30, 20, 1]])
width = 200
height = width * 2
@@ -160,9 +145,7 @@ def test_calc_bboxes_from_keypoints():
slack = 20
bboxes = trackingutils.calc_bboxes_from_keypoints(xyp, slack=slack)
- np.testing.assert_equal(
- bboxes, [[-slack, -slack, width + slack, height + slack, 0.5]]
- )
+ np.testing.assert_equal(bboxes, [[-slack, -slack, width + slack, height + slack, 0.5]])
offset = 50
bboxes = trackingutils.calc_bboxes_from_keypoints(xyp, offset=offset)
diff --git a/tests/test_trainingsetmanipulation.py b/tests/test_trainingsetmanipulation.py
index d49093dc9a..97749c52d1 100644
--- a/tests/test_trainingsetmanipulation.py
+++ b/tests/test_trainingsetmanipulation.py
@@ -8,22 +8,25 @@
#
# Licensed under GNU Lesser General Public License v3.0
#
-import numpy as np
import os
+
+import numpy as np
import pandas as pd
+import pytest
from conftest import TEST_DATA_DIR
+from skimage import color, io
+
from deeplabcut.generate_training_dataset import (
- read_image_shape_fast,
SplitTrials,
- format_training_data,
format_multianimal_training_data,
- trainingsetmanipulation,
+ format_training_data,
multiple_individuals_trainingsetmanipulation,
+ parse_video_filenames,
+ read_image_shape_fast,
+ trainingsetmanipulation,
)
-
from deeplabcut.utils.auxfun_videos import imread
from deeplabcut.utils.conversioncode import guarantee_multiindex_rows
-from skimage import color, io
def test_read_image_shape_fast(tmp_path):
@@ -58,23 +61,16 @@ def test_format_training_data(monkeypatch):
"read_image_shape_fast",
lambda _: fake_shape,
)
- df = pd.read_hdf(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5")).xs(
- "mus1", level="individuals", axis=1
- )
+ df = pd.read_hdf(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5")).xs("mus1", level="individuals", axis=1)
guarantee_multiindex_rows(df)
train_inds = list(range(10))
_, data = format_training_data(df, train_inds, 12, "")
assert len(data) == len(train_inds)
# Check data comprise path, shape, and xy coordinates
assert all(len(d) == 3 for d in data)
- assert all(
- (d[0].size == 3 and d[0].dtype.char == "U" and d[0][0, -1].endswith(".png"))
- for d in data
- )
+ assert all((d[0].size == 3 and d[0].dtype.char == "U" and d[0][0, -1].endswith(".png")) for d in data)
assert all(np.all(d[1] == np.array(fake_shape)[None]) for d in data)
- assert all(
- (d[2][0, 0].shape[1] == 3 and d[2][0, 0].dtype == np.int64) for d in data
- )
+ assert all((d[2][0, 0].shape[1] == 3 and d[2][0, 0].dtype == np.int64) for d in data)
def test_format_multianimal_training_data(monkeypatch):
@@ -93,8 +89,142 @@ def test_format_multianimal_training_data(monkeypatch):
assert all(isinstance(d, dict) for d in data)
assert all(len(d["image"]) == 3 for d in data)
assert all(np.all(d["size"] == np.array(fake_shape)) for d in data)
- assert all(
- (xy.shape[1] == 3 and np.isfinite(xy).all())
- for d in data
- for xy in d["joints"].values()
+ assert all((xy.shape[1] == 3 and np.isfinite(xy).all()) for d in data for xy in d["joints"].values())
+
+
+@pytest.mark.parametrize(
+ "videos, expected_filenames",
+ [
+ ([], []),
+ (["/data/my-video.mov"], ["my-video"]),
+ (["/data/my-video.mp4", "/data2/my-video.mov"], ["my-video"]),
+ (["/data/my-video.mov", "/data/video2.mov"], ["my-video", "video2"]),
+ (["/a/v1.mov", "/a/v2.mp4", "/b/v1.mov"], ["v1", "v2"]),
+ (["v1.mov", "v2.mov", "v1.mov"], ["v1", "v2"]),
+ (["/a/v1.mp4", "/a/v2.mov", "/b/v2.mov"], ["v1", "v2"]),
+ (["/a/v1.mp4", "/a/v2.mov", "/b/v2.mov", "/b/v3.mp4"], ["v1", "v2", "v3"]),
+ ],
+)
+def test_parse_video_filenames(videos: list[str], expected_filenames: list[str]):
+ filenames = parse_video_filenames(videos)
+ assert filenames == expected_filenames
+
+
+def test_format_training_data_ignores_likelihood_columns(monkeypatch):
+ fake_shape = 3, 480, 640
+ monkeypatch.setattr(
+ trainingsetmanipulation,
+ "read_image_shape_fast",
+ lambda _: fake_shape,
)
+
+ # Base single-animal dataframe (x/y only)
+ df = pd.read_hdf(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5")).xs(
+ "mus1",
+ level="individuals",
+ axis=1,
+ )
+ guarantee_multiindex_rows(df)
+
+ # Add a likelihood column so the layout becomes:
+ # x, y, likelihood, x, y, likelihood, ...
+ new_cols = []
+ new_arrays = []
+
+ coord_level = df.columns.names.index("coords")
+
+ for col in df.columns:
+ new_cols.append(col)
+ new_arrays.append(df[col].to_numpy())
+
+ if col[coord_level] == "y":
+ lik_col = list(col)
+ lik_col[coord_level] = "likelihood"
+ new_cols.append(tuple(lik_col))
+ new_arrays.append(np.ones(len(df), dtype=float))
+
+ df_with_likelihood = pd.DataFrame(
+ np.column_stack(new_arrays),
+ index=df.index,
+ columns=pd.MultiIndex.from_tuples(new_cols, names=df.columns.names),
+ )
+
+ train_inds = list(range(10))
+
+ baseline_train_data, baseline_matlab_data = format_training_data(df, train_inds, 12, "")
+ train_data, matlab_data = format_training_data(df_with_likelihood, train_inds, 12, "")
+
+ # The presence of likelihood columns should not change the formatted result
+ assert len(train_data) == len(baseline_train_data)
+ assert len(matlab_data) == len(baseline_matlab_data)
+
+ for got, expected in zip(train_data, baseline_train_data, strict=False):
+ assert got["image"] == expected["image"]
+ assert got["size"] == expected["size"]
+ assert np.array_equal(got["joints"], expected["joints"])
+
+ for got, expected in zip(matlab_data, baseline_matlab_data, strict=False):
+ assert np.array_equal(got["image"], expected["image"])
+ assert np.array_equal(got["size"], expected["size"])
+ assert np.array_equal(got["joints"][0, 0], expected["joints"][0, 0])
+
+
+def test_merge_annotateddatasets_drops_likelihood_columns(tmp_path):
+ scorer = "testscorer"
+ video_name = "video1"
+ bodyparts = ["nose", "tail"]
+
+ project_path = tmp_path
+ labeled_data_dir = project_path / "labeled-data" / video_name
+ labeled_data_dir.mkdir(parents=True)
+
+ trainingsetfolder_full = project_path / "training-datasets" / "iteration-0"
+ trainingsetfolder_full.mkdir(parents=True)
+
+ # Build a single-animal annotation dataframe with x/y/likelihood columns
+ columns = pd.MultiIndex.from_product(
+ [[scorer], bodyparts, ["x", "y", "likelihood"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+
+ index = pd.MultiIndex.from_tuples(
+ [("labeled-data", video_name, "img0001.png")],
+ )
+
+ data = np.array([[10.0, 20.0, 0.9, 30.0, 40.0, 0.8]])
+ df = pd.DataFrame(data, index=index, columns=columns)
+
+ input_h5 = labeled_data_dir / f"CollectedData_{scorer}.h5"
+ df.to_hdf(input_h5, key="df_with_missing", mode="w")
+
+ cfg = {
+ "project_path": str(project_path),
+ "video_sets": {str(project_path / "videos" / f"{video_name}.mp4"): {}},
+ "scorer": scorer,
+ "bodyparts": bodyparts,
+ "multianimalproject": False,
+ }
+
+ merged = trainingsetmanipulation.merge_annotateddatasets(
+ cfg,
+ trainingsetfolder_full,
+ )
+
+ # Returned dataframe should not contain likelihood anymore
+ coord_level = "coords" if "coords" in merged.columns.names else merged.columns.names[-1]
+ assert "likelihood" not in merged.columns.get_level_values(coord_level)
+
+ # Saved merged h5 should also not contain likelihood
+ output_h5 = trainingsetfolder_full / f"CollectedData_{scorer}.h5"
+ saved = pd.read_hdf(output_h5)
+
+ coord_level = "coords" if "coords" in saved.columns.names else saved.columns.names[-1]
+ assert "likelihood" not in saved.columns.get_level_values(coord_level)
+
+ # Sanity check: x/y are preserved
+ assert set(saved.columns.get_level_values(coord_level)) == {"x", "y"}
+ output_csv = trainingsetfolder_full / f"CollectedData_{scorer}.csv"
+ saved_csv = pd.read_csv(output_csv, header=[0, 1, 2], index_col=[0, 1, 2])
+
+ coord_level = "coords" if "coords" in saved_csv.columns.names else saved_csv.columns.names[-1]
+ assert "likelihood" not in saved_csv.columns.get_level_values(coord_level)
diff --git a/tests/test_triangulation.py b/tests/test_triangulation.py
index 0fcf2b058c..a1b2fe382c 100644
--- a/tests/test_triangulation.py
+++ b/tests/test_triangulation.py
@@ -11,6 +11,7 @@
import numpy as np
import pandas as pd
import pytest
+
from deeplabcut.pose_estimation_3d import triangulation
@@ -48,9 +49,7 @@ def test_undistort_views(n_view_pairs, is_multi, stereo_params):
df = df.xs("bird1", level="individuals", axis=1)
view_pairs = [(df, df) for _ in range(n_view_pairs)]
- cam_params = {
- f"camera-1-camera-{i}": stereo_params for i in range(2, n_view_pairs + 2)
- }
+ cam_params = {f"camera-1-camera-{i}": stereo_params for i in range(2, n_view_pairs + 2)}
dfs = triangulation._undistort_views(view_pairs, cam_params)
assert len(dfs) == n_view_pairs
assert all(len(pair) == 2 for pair in dfs)
diff --git a/tests/test_video.py b/tests/test_video.py
index 02b70e828f..915933cf6c 100644
--- a/tests/test_video.py
+++ b/tests/test_video.py
@@ -9,10 +9,11 @@
# Licensed under GNU Lesser General Public License v3.0
#
import os
+
import pytest
from conftest import TEST_DATA_DIR
-from deeplabcut.utils.auxfun_videos import VideoWriter
+from deeplabcut.utils.auxfun_videos import VideoWriter
POS_FRAMES = 1 # Equivalent to cv2.CAP_PROP_POS_FRAMES
@@ -57,9 +58,7 @@ def test_reader_wrong_fps(video_clip):
def test_reader_duration(video_clip):
- assert video_clip.calc_duration() == pytest.approx(
- video_clip.calc_duration(robust=False), abs=0.01
- )
+ assert video_clip.calc_duration() == pytest.approx(video_clip.calc_duration(robust=False), abs=0.01)
def test_reader_set_frame(video_clip):
@@ -93,9 +92,7 @@ def test_writer_bbox(video_clip):
assert video_clip.get_bbox(relative=True) == (0, 1, 0, 1)
-@pytest.mark.parametrize(
- "start, end", [(0, 10), ("0:0", "0:10"), ("00:00:00", "00:00:10")]
-)
+@pytest.mark.parametrize("start, end", [(0, 10), ("0:0", "0:10"), ("00:00:00", "00:00:10")])
def test_writer_shorten_invalid_timestamps(video_clip, start, end):
with pytest.raises(ValueError):
video_clip.shorten(start, end)
diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py
new file mode 100644
index 0000000000..ef9b94e067
--- /dev/null
+++ b/tests/tools/conftest.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import importlib
+import sys
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+
+def _repo_root() -> Path:
+ # tests/tools/conftest.py -> repo root is 2 levels up
+ return Path(__file__).resolve().parents[2]
+
+
+def load_selector_module() -> ModuleType:
+ root = _repo_root()
+ tools_dir = root / "tools"
+ init_file = tools_dir / "__init__.py"
+ selector_path = tools_dir / "test_selector.py"
+
+ if not selector_path.exists():
+ raise FileNotFoundError(f"Selector script not found: {selector_path}")
+
+ if not init_file.exists():
+ raise FileNotFoundError(f"tools package marker not found: {init_file}")
+
+ # Ensure repo root is importable so `tools.test_selector` resolves as a package import.
+ root_str = str(root)
+ if root_str not in sys.path:
+ sys.path.insert(0, root_str)
+
+ return importlib.import_module("tools.test_selector")
+
+
+@pytest.fixture(scope="session")
+def selector():
+ """Imported selector module (tools.test_selector)."""
+ return load_selector_module()
diff --git a/tests/tools/docs_and_notebooks_checks/test_check_contracts.py b/tests/tools/docs_and_notebooks_checks/test_check_contracts.py
new file mode 100644
index 0000000000..1abe6c47cb
--- /dev/null
+++ b/tests/tools/docs_and_notebooks_checks/test_check_contracts.py
@@ -0,0 +1,559 @@
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import subprocess
+from collections.abc import Callable
+from datetime import date, datetime, timezone
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def no_github_step_summary(monkeypatch):
+ monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
+
+
+# -----------------------------
+# Module loader (tools/ is not necessarily a package)
+# -----------------------------
+def load_tool_module() -> ModuleType:
+ repo_root = Path(__file__).resolve().parents[3]
+ tool_path = repo_root / "tools" / "docs_and_notebooks_check.py"
+ assert tool_path.exists(), f"Missing tool: {tool_path}"
+
+ spec = importlib.util.spec_from_file_location("docs_and_notebooks_check", tool_path)
+ assert spec and spec.loader
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod) # type: ignore[attr-defined]
+ return mod
+
+
+@pytest.fixture(scope="session")
+def tool() -> ModuleType:
+ return load_tool_module()
+
+
+def _write_default_cfg(repo: Path, include: list[str]) -> Path:
+ cfg_path = repo / "tools" / "docs_and_notebooks_report_config.yml"
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
+ cfg_path.write_text(
+ "version: 1\n"
+ "scan:\n"
+ " include:\n" + "".join(f" - {pat}\n" for pat in include) + " exclude: []\n"
+ "policy:\n"
+ " warn_if_content_older_than_days: 365\n"
+ " warn_if_verified_older_than_days: 365\n"
+ " missing_last_verified_is_warning: true\n"
+ " fail_on_scan_errors: false\n"
+ " require_metadata: []\n"
+ " require_recent_verification: []\n"
+ " require_notebook_normalized: []\n",
+ encoding="utf-8",
+ )
+ return cfg_path
+
+
+# -----------------------------
+# Git helpers for a temp repo
+# -----------------------------
+def _run(cmd: list[str], cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run(cmd, cwd=str(cwd), env=env, capture_output=True, text=True, check=True)
+
+
+def _git_init(repo: Path) -> None:
+ _run(["git", "init"], repo)
+ _run(["git", "config", "user.email", "ci@example.com"], repo)
+ _run(["git", "config", "user.name", "CI"], repo)
+
+
+def _git_commit(repo: Path, message: str, when_iso: str) -> None:
+ env = os.environ.copy()
+ env["GIT_AUTHOR_DATE"] = when_iso
+ env["GIT_COMMITTER_DATE"] = when_iso
+ _run(["git", "add", "-A"], repo, env=env)
+ _run(["git", "commit", "-m", message], repo, env=env)
+
+
+def _write(repo: Path, rel: str, content: str) -> None:
+ p = repo / rel
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content, encoding="utf-8")
+
+
+# -----------------------------
+# Shared fixtures
+# -----------------------------
+@pytest.fixture
+def repo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git_init(repo)
+ return repo
+
+
+@pytest.fixture
+def cfg(tool) -> Callable[..., object]:
+ def _make_cfg(
+ include: list[str],
+ exclude: list[str] | None = None,
+ **policy_overrides,
+ ):
+ policy = tool.PolicyConfig(**policy_overrides)
+ return tool.ToolConfig(
+ version=1,
+ scan=tool.ScanConfig(include=include, exclude=exclude or []),
+ policy=policy,
+ )
+
+ return _make_cfg
+
+
+# -----------------------------
+# Contract tests
+# -----------------------------
+def test_marker_constants_exist(tool):
+ assert hasattr(tool, "META_COMMIT_MARKER")
+ assert hasattr(tool, "SUGGESTED_TAGGED_COMMIT")
+ assert tool.META_COMMIT_MARKER in tool.SUGGESTED_TAGGED_COMMIT
+
+
+def test_schema_contract_fields(tool):
+ # DLCMeta must have new fields and must NOT have old last_git_updated
+ meta = tool.DLCMeta()
+ assert hasattr(meta, "last_content_updated")
+ assert hasattr(meta, "last_metadata_updated")
+ assert hasattr(meta, "last_verified")
+ assert hasattr(meta, "verified_for")
+ assert not hasattr(meta, "last_git_updated")
+
+
+def test_git_content_date_skips_meta_commits(tool, repo: Path):
+ """
+ Contract: last_content_updated is computed from git history excluding metadata commits.
+ """
+ rel = "docs/page.md"
+ _write(repo, rel, "# hello\n")
+ _git_commit(repo, "docs: initial content", "2020-01-01T12:00:00+00:00")
+
+ # meta-only rewrite (simulated) committed with marker
+ _write(
+ repo,
+ rel,
+ "---\ndeeplabcut:\n last_metadata_updated: 2026-03-01\n---\n# hello\n",
+ )
+ _git_commit(
+ repo,
+ f"chore(meta): update {tool.META_COMMIT_MARKER}",
+ "2026-03-01T12:00:00+00:00",
+ )
+
+ # raw touched date = 2026-03-01
+ touched = tool.git_last_touched(repo, rel)
+ assert touched == date(2026, 3, 1)
+
+ # content updated date should skip marker commit => 2020-01-01
+ content_date, used_fallback = tool.git_last_content_updated(repo, rel)
+ assert content_date == date(2020, 1, 1)
+ assert used_fallback is False
+
+
+def test_git_content_date_fallback_when_only_meta_commits(tool, repo: Path):
+ """
+ If all commits touching the file are meta-marker commits, we fall back to git_last_touched
+ and flag used_fallback=True.
+ """
+ rel = "docs/page.md"
+ _write(repo, rel, "---\ndeeplabcut:\n notes: hi\n---\n")
+ _git_commit(
+ repo,
+ f"chore(meta): init {tool.META_COMMIT_MARKER}",
+ "2026-03-01T12:00:00+00:00",
+ )
+
+ content_date, used_fallback = tool.git_last_content_updated(repo, rel)
+ assert content_date == date(2026, 3, 1)
+ assert used_fallback is True
+
+
+def test_scan_is_read_only(tool, repo: Path, cfg):
+ """
+ Contract: report/check (scan_files) must be read-only.
+ We validate by asserting file content does not change.
+ """
+ rel = "docs/page.md"
+ orig = "---\ndeeplabcut:\n last_verified: 2020-01-01\n---\n# hello\n"
+ _write(repo, rel, orig)
+ _git_commit(repo, "docs: add page", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ before = (repo / rel).read_text(encoding="utf-8")
+ records = tool.scan_files(repo, tool_cfg, targets=[r".\docs\page.md"])
+ after = (repo / rel).read_text(encoding="utf-8")
+
+ assert before == after
+ assert len(records) == 1
+ assert records[0].path == rel
+ assert records[0].kind == "md"
+
+
+def test_scan_targets_support_directory_and_glob(tool, repo: Path, cfg):
+ rel_a = "docs/gui/napari/basic_usage.md"
+ rel_b = "docs/gui/napari/advanced_usage.md"
+ rel_c = "docs/other/overview.md"
+
+ _write(repo, rel_a, "# a\n")
+ _write(repo, rel_b, "# b\n")
+ _write(repo, rel_c, "# c\n")
+ _git_commit(repo, "docs: add pages", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=["docs/**/*.md"])
+
+ # Directory selector
+ recs_dir = tool.scan_files(repo, tool_cfg, targets=["docs/gui/napari/"])
+ paths_dir = sorted(r.path for r in recs_dir)
+ assert set(paths_dir) == {rel_a, rel_b}
+
+ # Glob selector
+ recs_glob = tool.scan_files(repo, tool_cfg, targets=["docs/gui/napari/*.md"])
+ paths_glob = sorted(r.path for r in recs_glob)
+ assert set(paths_glob) == {rel_a, rel_b}
+
+ # Recursive glob selector
+ recs_recursive = tool.scan_files(repo, tool_cfg, targets=["docs/**/*.md"])
+ paths_recursive = sorted(r.path for r in recs_recursive)
+ assert set(paths_recursive) == {rel_a, rel_b, rel_c}
+
+
+def test_validate_requested_targets_treats_dot_slash_as_unmatched(tool, repo: Path, cfg):
+ _write(repo, "docs/page.md", "# hello\n")
+ tool_cfg = cfg(include=["docs/**/*.md"])
+
+ matched, unmatched = tool.validate_requested_targets(repo, tool_cfg, ["./"])
+ assert matched == []
+ assert unmatched == ["./"]
+
+
+def test_validate_requested_targets_treats_empty_like_unmatched(tool, repo: Path, cfg):
+ _write(repo, "docs/page.md", "# hello\n")
+ tool_cfg = cfg(include=["docs/**/*.md"])
+
+ matched, unmatched = tool.validate_requested_targets(repo, tool_cfg, ["", " "])
+ assert matched == []
+ assert unmatched == ["", " "]
+
+
+def test_validate_requested_targets_reports_mixed_valid_and_invalid_targets(tool, repo: Path, cfg):
+ rel = "docs/page.md"
+ _write(repo, rel, "# hello\n")
+ tool_cfg = cfg(include=["docs/**/*.md"])
+
+ matched, unmatched = tool.validate_requested_targets(repo, tool_cfg, [rel, "./"])
+ assert rel in matched
+ assert unmatched == ["./"]
+
+
+def test_scan_files_with_invalid_only_targets_matches_nothing(tool, repo: Path, cfg):
+ tool_cfg = cfg(include=["docs/**/*.md"])
+ records = tool.scan_files(repo, tool_cfg, targets=["./"])
+ assert records == []
+
+
+def test_validate_requested_targets_reports_unmatched(tool, repo: Path, cfg):
+ rel_a = "docs/gui/napari/basic_usage.md"
+ rel_b = "docs/gui/napari/advanced_usage.md"
+
+ _write(repo, rel_a, "# a\n")
+ _write(repo, rel_b, "# b\n")
+ _git_commit(repo, "docs: add pages", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=["docs/**/*.md"])
+
+ matched, unmatched = tool.validate_requested_targets(
+ repo,
+ tool_cfg,
+ targets=[
+ r".\docs\gui\napari\basic_usage.md",
+ "docs/gui/napari/",
+ "docs/**/*.md",
+ "docs/missing/",
+ "examples/**/*.ipynb",
+ ],
+ )
+
+ assert matched == sorted([rel_a, rel_b])
+ assert unmatched == ["docs/missing/", "examples/**/*.ipynb"]
+
+
+def test_update_requires_ack_when_write(tool, repo: Path, cfg):
+ """
+ Contract: write mode should refuse unless --ack-meta-commit-marker is provided.
+ """
+ rel = "docs/page.md"
+ _write(repo, rel, "# hello\n")
+ _git_commit(repo, "docs: initial content", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ # should refuse to write without ack
+ with pytest.raises(SystemExit):
+ tool.update_files(
+ repo_root=repo,
+ cfg=tool_cfg,
+ targets=[rel],
+ write=True,
+ set_content_date_from_git=True,
+ set_last_verified=None,
+ set_verified_for=None,
+ ack_meta_commit_marker=False,
+ )
+
+
+def test_update_set_content_date_from_git_only_changes_that_field(tool, repo: Path, cfg):
+ """
+ Contract: update --set-content-date-from-git only sets last_content_updated
+ (plus last_metadata_updated when writing),
+ does NOT override last_verified/verified_for unless explicitly provided.
+ """
+ rel = "docs/page.md"
+ initial = "---\ndeeplabcut:\n last_verified: 2020-02-02\n verified_for: 3.0.0rc1\n---\n# hello\n"
+ _write(repo, rel, initial)
+ _git_commit(repo, "docs: initial content", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ records = tool.update_files(
+ repo_root=repo,
+ cfg=tool_cfg,
+ targets=[rel],
+ write=True,
+ set_content_date_from_git=True,
+ set_last_verified=None,
+ set_verified_for=None,
+ ack_meta_commit_marker=True,
+ )
+ assert len(records) == 1
+
+ # Read back and confirm verified fields unchanged
+ text = (repo / rel).read_text(encoding="utf-8")
+ fm, _body, _ = tool.read_md_frontmatter(text)
+ assert isinstance(fm, dict) and tool.DLC_NAMESPACE in fm
+ meta = fm[tool.DLC_NAMESPACE]
+
+ assert meta["last_verified"] == "2020-02-02"
+ assert meta["verified_for"] == "3.0.0rc1"
+
+ # last_content_updated should reflect git content date (2020-01-01)
+ assert meta["last_content_updated"] == "2020-01-01"
+
+ # last_metadata_updated should exist because we wrote
+ assert "last_metadata_updated" in meta
+
+
+def test_update_set_verified_fields_only_changes_verified(tool, repo: Path, cfg):
+ """
+ Contract: update with --set-last-verified / --set-verified-for changes only those fields
+ (plus last_metadata_updated if writing), and does not set last_content_updated unless requested.
+ """
+ rel = "docs/page.md"
+ initial = "---\ndeeplabcut:\n last_content_updated: 2000-01-01\n---\n# hello\n"
+ _write(repo, rel, initial)
+ _git_commit(repo, "docs: initial content", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ records = tool.update_files(
+ repo_root=repo,
+ cfg=tool_cfg,
+ targets=[rel],
+ write=True,
+ set_content_date_from_git=False,
+ set_last_verified=date(2026, 3, 5),
+ set_verified_for="3.0.0rc13",
+ ack_meta_commit_marker=True,
+ )
+ assert len(records) == 1
+
+ text = (repo / rel).read_text(encoding="utf-8")
+ fm, _body, _ = tool.read_md_frontmatter(text)
+ meta = fm[tool.DLC_NAMESPACE]
+
+ # Verified fields updated
+ assert meta["last_verified"] == "2026-03-05"
+ assert meta["verified_for"] == "3.0.0rc13"
+
+ # last_content_updated remains whatever it was (not overwritten)
+ assert meta["last_content_updated"] == "2000-01-01"
+
+
+def test_normalize_is_explicit_and_marks_would_change(tool, repo: Path, cfg):
+ """
+ Contract: normalize is separate and explicit; in dry-run it should mark would_change
+ if notebook is not already in canonical nbformat output.
+ """
+ rel = "docs/nbs/nb.ipynb"
+ # Minimal notebook JSON but not in nbformat canonical formatting (indent/newline differences)
+ raw = '{\n "cells": [],\n "metadata": {},\n "nbformat": 4,\n "nbformat_minor": 5\n}\n'
+ _write(repo, rel, raw)
+ _git_commit(repo, "docs: add notebook", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ # Dry-run normalize: should set would_change True if not normalized
+ records = tool.normalize_notebooks(
+ repo_root=repo,
+ cfg=tool_cfg,
+ targets=[rel],
+ write=False,
+ ack_meta_commit_marker=True,
+ )
+ assert len(records) == 1
+ assert records[0].kind == "ipynb"
+ # may be True depending on canonical formatting differences
+ assert records[0].would_change
+
+
+def test_write_outputs_contract(tool, repo: Path, cfg, tmp_path: Path):
+ """
+ Contract: write_outputs creates both JSON and Markdown files and JSON is schema-valid.
+ """
+ rel = "docs/page.md"
+ _write(repo, rel, "# hello\n")
+ _git_commit(repo, "docs: initial content", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+ records = tool.scan_files(repo, tool_cfg, targets=[rel])
+
+ report = tool.Report(
+ generated_at=datetime.now(timezone.utc),
+ repo_root=str(repo),
+ config_path="in-memory",
+ totals=tool.summarize(records),
+ records=records,
+ )
+
+ out_dir = tmp_path / "out"
+ json_path, md_path = tool.write_outputs(report, tool_cfg, out_dir)
+
+ assert json_path.exists()
+ assert md_path.exists()
+
+ payload = json.loads(json_path.read_text(encoding="utf-8"))
+ assert payload["schema_version"] == tool.SCHEMA_VERSION
+ assert "records" in payload and isinstance(payload["records"], list)
+ assert md_path.read_text(encoding="utf-8").startswith("#")
+
+
+def test_notebook_missing_dlc_namespace_warns_missing_metadata(tool, repo: Path, cfg):
+ rel = "docs/nbs/nb.ipynb"
+ # Valid minimal notebook, but no "deeplabcut" namespace under metadata
+ nb = '{\n "cells": [],\n "metadata": {},\n "nbformat": 4,\n "nbformat_minor": 5\n}\n'
+ _write(repo, rel, nb)
+ _git_commit(repo, "docs: add notebook", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ records = tool.scan_files(repo, tool_cfg, targets=[rel])
+ assert len(records) == 1
+ r = records[0]
+ assert r.kind == "ipynb"
+ assert "missing_metadata" in r.warnings
+ assert r.meta is None
+
+
+def test_notebook_invalid_dlc_namespace_warns_invalid_metadata(tool, repo: Path, cfg):
+ rel = "docs/nbs/nb.ipynb"
+ # deeplabcut namespace exists but is invalid: last_verified must be a date
+ nb = (
+ "{\n"
+ ' "cells": [],\n'
+ ' "metadata": {\n'
+ ' "deeplabcut": {\n'
+ ' "last_verified": "not-a-date"\n'
+ " }\n"
+ " },\n"
+ ' "nbformat": 4,\n'
+ ' "nbformat_minor": 5\n'
+ "}\n"
+ )
+ _write(repo, rel, nb)
+ _git_commit(repo, "docs: add notebook with bad meta", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel])
+
+ records = tool.scan_files(repo, tool_cfg, targets=[rel])
+ assert len(records) == 1
+ r = records[0]
+ assert r.kind == "ipynb"
+ assert "invalid_metadata" in r.warnings
+ assert r.meta is None
+
+
+def test_main_prints_matched_files_and_fails_on_unmatched_targets(tool, repo: Path, monkeypatch, capsys):
+ rel = "docs/gui/napari/basic_usage.md"
+ _write(repo, rel, "# hello\n")
+ _git_commit(repo, "docs: add page", "2020-01-01T12:00:00+00:00")
+
+ cfg_path = _write_default_cfg(repo, include=["docs/**/*.md"])
+
+ monkeypatch.chdir(repo)
+
+ rc = tool.main(
+ [
+ "--config",
+ str(cfg_path),
+ "--no-step-summary",
+ "report",
+ "--targets",
+ r".\docs\gui\napari\basic_usage.md",
+ "docs/missing/",
+ ]
+ )
+
+ out = capsys.readouterr().out
+ assert rc == 2
+ assert "Matched 1 file(s) from --targets:" in out
+ assert f"- {rel}" in out
+ assert "Unmatched --targets:" in out
+ assert "- docs/missing/" in out
+
+
+def test_main_prints_matched_files_for_valid_targets(tool, repo: Path, monkeypatch, capsys):
+ rel = "docs/gui/napari/basic_usage.md"
+ _write(repo, rel, "# hello\n")
+ _git_commit(repo, "docs: add page", "2020-01-01T12:00:00+00:00")
+ cfg_path = _write_default_cfg(repo, include=["docs/**/*.md"])
+
+ monkeypatch.chdir(repo)
+
+ rc = tool.main(
+ [
+ "--config",
+ str(cfg_path),
+ "--no-step-summary",
+ "report",
+ "--targets",
+ "docs/gui/napari/",
+ ]
+ )
+
+ out = capsys.readouterr().out
+ assert rc == 0
+ assert "Matched 1 file(s) from --targets:" in out
+ assert f"- {rel}" in out
+ assert "Report generated:" in out
+
+
+def test_main_returns_2_for_invalid_target_selector(tool, repo: Path, monkeypatch):
+ _write(repo, "docs/page.md", "# hello\n")
+ _git_commit(repo, "docs: add page", "2020-01-01T12:00:00+00:00")
+ cfg_path = _write_default_cfg(repo, include=["docs/**/*.md"])
+
+ monkeypatch.chdir(repo)
+
+ rc = tool.main(["--config", str(cfg_path), "--no-step-summary", "report", "--targets", "./"])
+ assert rc == 2
diff --git a/tests/tools/test_selector/test_selector_decision.py b/tests/tools/test_selector/test_selector_decision.py
new file mode 100644
index 0000000000..c45ce63d7b
--- /dev/null
+++ b/tests/tools/test_selector/test_selector_decision.py
@@ -0,0 +1,341 @@
+# tests/tools/test_selector/test_selector_decision.py
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+
+from tools.test_selector_config import (
+ CATEGORY_RULES,
+ CategoryRule,
+ prefix,
+ validate_category_rules,
+)
+
+
+def assert_lanes(res, *, skip=False, docs=False, fast=False, full=False):
+ assert res.lanes.skip is skip
+ assert res.lanes.docs is docs
+ assert res.lanes.fast is fast
+ assert res.lanes.full is full
+
+
+def test_fail_safe_on_empty_changes(selector):
+ res = selector.decide([])
+
+ assert_lanes(res, full=True)
+ assert "no_changed_files_or_diff_unavailable" in res.reasons
+ assert res.lane_reasons["full"] == ["no_changed_files_or_diff_unavailable"]
+
+
+def test_docs_only(selector):
+ files = ["docs/index.md", "docs/guide/intro.md", "_config.yml"]
+ res = selector.decide(files)
+
+ assert_lanes(res, docs=True)
+ assert res.pytest_paths == []
+ assert res.functional_scripts == []
+ assert "category:docs" in res.reasons
+ assert res.lane_reasons["docs"] == ["category:docs"]
+
+
+def test_full_suite_trigger_pyproject_preserves_docs_lane(selector):
+ files = ["pyproject.toml", "docs/index.md"]
+ res = selector.decide(files)
+
+ assert_lanes(res, full=True, docs=True)
+ assert "full_suite_trigger" in res.reasons
+ assert "category:docs" in res.reasons
+ assert res.lane_reasons["full"] == [
+ "full_suite_trigger",
+ "full_suite_trigger_count:1",
+ ]
+
+
+def test_full_suite_trigger_tests_folder(selector):
+ files = ["tests/test_something.py"]
+ res = selector.decide(files)
+
+ assert_lanes(res, full=True)
+ assert "full_suite_trigger" in res.reasons
+ assert res.lane_reasons["full"] == [
+ "full_suite_trigger",
+ "full_suite_trigger_count:1",
+ ]
+
+
+def test_fast_core(selector):
+ files = ["deeplabcut/core/some_module.py"]
+ res = selector.decide(files)
+
+ assert_lanes(res, fast=True)
+
+ # core rule should include these paths (subset check)
+ assert "tests/core/" in res.pytest_paths
+ assert "tests/utils/" in res.pytest_paths
+ # assert res.functional_scripts == [] # not empty, but we don't need to specify exact scripts here
+
+ assert "category:core" in res.reasons
+ assert res.lane_reasons["fast"] == ["category:core"]
+
+ # Provenance should attribute selected pytest roots to the core category.
+ assert "tests/core/" in res.provenance.pytest
+ assert res.provenance.pytest["tests/core/"] == ["core"]
+
+
+def test_fast_multianimal_includes_functional(selector):
+ files = ["deeplabcut/pose_estimation_pytorch/multianimal/foo.py"]
+ res = selector.decide(files)
+
+ assert_lanes(res, fast=True)
+
+ assert "tests/test_predict_multianimal.py" in res.pytest_paths
+ assert "examples/testscript_tensorflow_multi_animal.py" in res.functional_scripts
+
+ assert "multianimal" in res.provenance.pytest["tests/test_predict_multianimal.py"]
+ assert "multianimal" in res.provenance.scripts["examples/testscript_tensorflow_multi_animal.py"]
+
+
+def test_fast_ci_workflows_uses_full_suite(selector):
+ files = [".github/workflows/ci.yml"]
+ res = selector.decide(files)
+
+ assert_lanes(res, full=True)
+
+
+def test_no_category_matched_is_full(selector):
+ files = ["some/unknown/place/file.xyz"]
+ res = selector.decide(files)
+
+ assert_lanes(res, full=True)
+ assert "no_category_matched" in res.reasons
+ assert res.lane_reasons["full"] == ["no_category_matched"]
+
+
+def test_docs_and_core_run_both_lanes(selector):
+ files = ["docs/index.md", "deeplabcut/core/a.py"]
+ res = selector.decide(files)
+
+ assert_lanes(res, docs=True, fast=True)
+ assert "category:docs" in res.reasons
+ assert "category:core" in res.reasons
+
+ assert res.lane_reasons["docs"] == ["category:docs"]
+ assert res.lane_reasons["fast"] == ["category:core"]
+
+ assert "tests/core/" in res.pytest_paths
+ assert "tests/utils/" in res.pytest_paths
+
+
+def test_dedup_and_sorted_outputs(selector):
+ # Force overlap: core includes tests/test_auxiliaryfunctions.py and
+ # ci_tools contributes tests/tools/. Outputs should stay deduped and sorted.
+ files = [
+ "deeplabcut/core/a.py",
+ "tools/whatever.py",
+ ]
+ res = selector.decide(files)
+
+ assert_lanes(res, fast=True)
+
+ # No duplicates
+ assert len(res.pytest_paths) == len(set(res.pytest_paths))
+ assert len(res.functional_scripts) == len(set(res.functional_scripts))
+
+ # Sorted
+ assert res.pytest_paths == sorted(res.pytest_paths)
+ assert res.functional_scripts == sorted(res.functional_scripts)
+
+
+# ----------------------------
+# Validation of category rules
+# ----------------------------
+def test_category_rule_rejects_empty_name():
+ with pytest.raises(ValidationError, match="Rule name must not be empty"):
+ CategoryRule(
+ name="",
+ match_any=[prefix("docs/")],
+ )
+
+
+def test_category_rule_rejects_invalid_name():
+ with pytest.raises(ValidationError, match=r"Rule name must match"):
+ CategoryRule(
+ name="docs-rule",
+ match_any=[prefix("docs/")],
+ )
+
+
+def test_category_rule_requires_non_empty_match_any():
+ with pytest.raises(ValidationError, match="at least 1 item|at least one predicate"):
+ CategoryRule(
+ name="docs",
+ match_any=[],
+ )
+
+
+def test_category_rule_rejects_non_callable_match_any():
+ with pytest.raises(ValidationError, match="callable"):
+ CategoryRule(
+ name="docs",
+ match_any=[123], # type: ignore[list-item]
+ )
+
+
+@pytest.mark.parametrize(
+ "field_name,bad_value",
+ [
+ ("pytest_paths", "/absolute/path.py"),
+ ("pytest_paths", "../escape.py"),
+ ("functional_scripts", "/absolute/script.py"),
+ ("functional_scripts", "../escape_script.py"),
+ ],
+)
+def test_category_rule_rejects_invalid_repo_relative_paths(field_name, bad_value):
+ kwargs = {
+ "name": "docs",
+ "match_any": [prefix("docs/")],
+ "pytest_paths": [],
+ "functional_scripts": [],
+ }
+ kwargs[field_name] = [bad_value]
+
+ with pytest.raises(ValidationError, match="repo-relative|path traversal|absolute path"):
+ CategoryRule(**kwargs)
+
+
+def test_validate_category_rules_rejects_duplicate_names():
+ rules = [
+ CategoryRule(name="docs", match_any=[prefix("docs/")]),
+ CategoryRule(name="docs", match_any=[prefix("more-docs/")]),
+ ]
+
+ with pytest.raises(ValueError, match="Duplicate CategoryRule name"):
+ validate_category_rules(rules)
+
+
+def test_lint_only_changes_select_skip_lane(selector):
+ files = [".pre-commit-config.yaml"]
+ res = selector.decide(files)
+
+ assert_lanes(res, skip=True)
+ assert res.pytest_paths == []
+ assert res.functional_scripts == []
+
+ assert "lint_only" in res.reasons
+ assert "skip" in res.lane_reasons
+ assert "lint_only" in res.lane_reasons["skip"]
+
+
+def test_validate_selected_paths_escalates_to_full_on_missing(selector, tmp_path: Path):
+ # Build a minimal repo dir with none of the selected paths present.
+ repo = tmp_path / "repo"
+ repo.mkdir()
+
+ res = selector.SelectorResult(
+ lanes=selector.LaneSelection(fast=True),
+ pytest_paths=["tests/does_not_exist.py"],
+ functional_scripts=["examples/missing_script.py"],
+ provenance=selector.SelectionProvenance(
+ pytest={"tests/does_not_exist.py": ["core"]},
+ scripts={"examples/missing_script.py": ["core"]},
+ ),
+ reasons=["category:core"],
+ changed_files=["deeplabcut/core/foo.py"],
+ lane_reasons={"fast": ["category:core"]},
+ )
+
+ out = selector.validate_selected_paths(res, repo)
+
+ assert out.lanes.fast is False
+ assert out.lanes.full is True
+
+ assert out.pytest_paths == []
+ assert out.functional_scripts == []
+ assert out.provenance.pytest == {}
+ assert out.provenance.scripts == {}
+
+ assert "missing_selected_paths" in out.reasons
+ assert any(r.startswith("pytest:tests/does_not_exist.py") for r in out.reasons)
+ assert any(r.startswith("script:examples/missing_script.py") for r in out.reasons)
+
+ assert "full" in out.lane_reasons
+
+
+def test_validate_selected_paths_keeps_fast_when_paths_exist(selector, tmp_path: Path):
+ repo = tmp_path / "repo"
+ (repo / "tests").mkdir(parents=True)
+ (repo / "examples").mkdir(parents=True)
+
+ (repo / "tests" / "test_ok.py").write_text("def test_ok(): pass\n")
+ (repo / "examples" / "script_ok.py").write_text("print('ok')\n")
+
+ res = selector.SelectorResult(
+ lanes=selector.LaneSelection(fast=True),
+ pytest_paths=["tests/test_ok.py"],
+ functional_scripts=["examples/script_ok.py"],
+ provenance=selector.SelectionProvenance(
+ pytest={"tests/test_ok.py": ["core"]},
+ scripts={"examples/script_ok.py": ["core"]},
+ ),
+ reasons=["category:core"],
+ changed_files=["deeplabcut/core/foo.py"],
+ lane_reasons={"fast": ["category:core"]},
+ )
+
+ out = selector.validate_selected_paths(res, repo)
+
+ assert out.lanes.fast is True
+ assert out.lanes.full is False
+ assert out.pytest_paths == ["tests/test_ok.py"]
+ assert out.functional_scripts == ["examples/script_ok.py"]
+
+
+# --------------------------------------
+# Current config validity & sanity checks
+# --------------------------------------
+
+
+def test_current_category_rules_are_typed_models():
+ assert CATEGORY_RULES
+ assert all(isinstance(rule, CategoryRule) for rule in CATEGORY_RULES)
+
+
+def test_current_category_rules_pass_cross_rule_validation():
+ validate_category_rules(CATEGORY_RULES)
+
+
+def test_current_category_rule_names_are_unique():
+ names = [rule.name for rule in CATEGORY_RULES]
+ assert len(names) == len(set(names))
+
+
+def test_current_category_rules_have_matchers():
+ assert all(rule.match_any for rule in CATEGORY_RULES)
+
+
+def test_required_category_rules_exist():
+ names = {rule.name for rule in CATEGORY_RULES}
+ assert "docs" in names
+ assert "core" in names
+
+
+def test_docs_rule_exists_once():
+ docs_rules = [rule for rule in CATEGORY_RULES if rule.name == "docs"]
+ assert len(docs_rules) == 1
+
+
+def test_current_selected_paths_exist():
+ repo_root = Path(__file__).resolve().parents[3]
+ missing = []
+
+ for rule in CATEGORY_RULES:
+ for path in rule.pytest_paths:
+ if not (repo_root / path).exists():
+ missing.append((rule.name, "pytest", path))
+ for path in rule.functional_scripts:
+ if not (repo_root / path).exists():
+ missing.append((rule.name, "script", path))
+
+ assert missing == []
diff --git a/tests/tools/test_selector/test_selector_validation.py b/tests/tools/test_selector/test_selector_validation.py
new file mode 100644
index 0000000000..41d2bfe912
--- /dev/null
+++ b/tests/tools/test_selector/test_selector_validation.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+# -----------------
+# Git helpers
+# -----------------
+def _git(repo: Path, *args: str) -> str:
+ proc = subprocess.run(
+ ["git", *args],
+ cwd=repo,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
+ return proc.stdout.strip()
+
+
+def _init_repo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init")
+ _git(repo, "config", "user.name", "Test User")
+ _git(repo, "config", "user.email", "test@example.com")
+ return repo
+
+
+def _commit_file(repo: Path, relpath: str, content: str, message: str) -> str:
+ path = repo / relpath
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ _git(repo, "add", relpath)
+ _git(repo, "commit", "-m", message)
+ return _git(repo, "rev-parse", "HEAD")
+
+
+def _write_event(tmp_path: Path, payload: dict) -> Path:
+ event_path = tmp_path / "event.json"
+ event_path.write_text(json.dumps(payload), encoding="utf-8")
+ return event_path
+
+
+# --------------
+# SHA validation & diff range parsing
+# --------------
+def test_validate_sha_accepts(selector):
+ assert selector._validate_sha("x", "abc1234") == "abc1234"
+ assert selector._validate_sha("x", "a" * 40) == "a" * 40
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "", # empty
+ "notasha", # non-hex
+ "123", # too short
+ "g" * 40, # non-hex
+ " " * 8, # whitespace
+ ],
+)
+def test_validate_sha_rejects(selector, bad):
+ with pytest.raises(ValueError):
+ selector._validate_sha("x", bad)
+
+
+def test_determine_diff_range_pr_uses_merge_base(selector, tmp_path, monkeypatch):
+ repo = _init_repo(tmp_path)
+
+ merge_base = _commit_file(repo, "shared.txt", "base", "base commit")
+ base_sha = _commit_file(repo, "main.txt", "main", "main branch commit")
+
+ _git(repo, "checkout", "-b", "feature", merge_base)
+ head_sha = _commit_file(repo, "feature.txt", "feature", "feature branch commit")
+
+ event_path = _write_event(
+ tmp_path,
+ {
+ "pull_request": {
+ "base": {"sha": base_sha},
+ "head": {"sha": head_sha},
+ }
+ },
+ )
+ monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
+ monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
+
+ base, head, mode = selector.determine_diff_range(repo, None, None)
+
+ assert base == merge_base
+ assert head == head_sha
+ assert mode == selector.DiffMode.PR
+
+
+def test_determine_diff_range_push_uses_before_after(selector, tmp_path, monkeypatch):
+ repo = _init_repo(tmp_path)
+
+ before = _commit_file(repo, "a.txt", "one", "first commit")
+ after = _commit_file(repo, "a.txt", "two", "second commit")
+
+ event_path = _write_event(tmp_path, {"before": before, "after": after})
+ monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
+ monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
+
+ base, head, mode = selector.determine_diff_range(repo, None, None)
+
+ assert base == before
+ assert head == after
+ assert mode == selector.DiffMode.PUSH
+
+
+def test_determine_diff_range_push_zero_sha_uses_empty_tree(selector, tmp_path, monkeypatch):
+ repo = _init_repo(tmp_path)
+
+ after = _commit_file(repo, "initial.txt", "hello", "initial commit")
+ zero_sha = "0" * 40
+ event_path = _write_event(tmp_path, {"before": zero_sha, "after": after})
+ monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
+ monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_path))
+
+ base, head, mode = selector.determine_diff_range(repo, None, None)
+
+ assert base == selector._empty_tree(repo)
+ assert head == after
+ assert mode == selector.DiffMode.INITIAL
+
+
+def test_determine_diff_range_fallback_uses_head_parent(selector, tmp_path, monkeypatch):
+ repo = _init_repo(tmp_path)
+
+ prev = _commit_file(repo, "a.txt", "one", "first commit")
+ head_sha = _commit_file(repo, "a.txt", "two", "second commit")
+
+ monkeypatch.delenv("GITHUB_EVENT_NAME", raising=False)
+ monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
+
+ base, head, mode = selector.determine_diff_range(repo, None, None)
+
+ assert base == prev
+ assert head == head_sha
+ assert mode == selector.DiffMode.FALLBACK
+
+
+# -----------------
+# Paths
+# -----------------
+def test_normalize_relpath_basic(selector):
+ assert selector._normalize_relpath("docs/index.md") == "docs/index.md"
+ assert selector._normalize_relpath("docs\\index.md") == "docs/index.md"
+
+
+@pytest.mark.parametrize(
+ "bad",
+ [
+ "", # empty
+ " ", # whitespace
+ "/etc/passwd", # absolute unix
+ "C:/Windows/x", # absolute windows
+ "../secret.txt", # traversal
+ "docs/../../x", # traversal inside
+ "a\x00b", # NUL
+ ],
+)
+def test_normalize_relpath_rejects_bad(selector, bad):
+ with pytest.raises(ValueError):
+ selector._normalize_relpath(bad)
diff --git a/tests/utils/test_collect_video_paths.py b/tests/utils/test_collect_video_paths.py
new file mode 100644
index 0000000000..251778c141
--- /dev/null
+++ b/tests/utils/test_collect_video_paths.py
@@ -0,0 +1,207 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for ``collect_video_paths``.
+
+These tests pin down the rule:
+
+* When ``video_type`` is not set, directory enumeration filters by
+ ``SUPPORTED_VIDEOS`` but explicitly-supplied files are trusted (returned
+ as-is, even if they have no suffix).
+* When ``video_type`` is set, it is honoured everywhere — both for files
+ pulled from directories and for files supplied by the caller.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS, collect_video_paths
+from deeplabcut.utils.deprecation import DLCDeprecationWarning
+
+
+def _touch(path: Path) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(b"")
+ return path
+
+
+def test_keeps_suffixless_files_when_explicitly_listed(tmp_path):
+ """Regression test: a caller-supplied file without an extension (e.g.
+ a content-addressed cache entry) must not be silently dropped."""
+ suffixed = _touch(tmp_path / "video.mp4")
+ hashed = _touch(tmp_path / "abcd1234")
+
+ result = collect_video_paths([suffixed, hashed], extensions=None)
+
+ assert {p.name for p in result} == {"video.mp4", "abcd1234"}
+
+
+def test_accepts_path_objects_and_strings(tmp_path):
+ suffixed = _touch(tmp_path / "video.mp4")
+ hashed = _touch(tmp_path / "abcd1234")
+
+ result = collect_video_paths([str(suffixed), hashed], extensions=None)
+
+ assert {p.name for p in result} == {"video.mp4", "abcd1234"}
+
+
+def test_accepts_single_path_argument(tmp_path):
+ """A single path (not wrapped in a list) is also valid input."""
+ hashed = _touch(tmp_path / "abcd1234")
+
+ result = collect_video_paths(hashed, extensions=None)
+
+ assert [p.name for p in result] == ["abcd1234"]
+
+
+def test_explicit_video_type_filters_listed_files(tmp_path):
+ """When ``extensions`` is set, it filters explicitly-supplied files too."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ avi = _touch(tmp_path / "video.avi")
+
+ result = collect_video_paths([mp4, avi], extensions="mp4")
+
+ assert {p.name for p in result} == {"video.mp4"}
+
+
+def test_explicit_video_type_accepts_leading_dot(tmp_path):
+ mp4 = _touch(tmp_path / "video.mp4")
+ avi = _touch(tmp_path / "video.avi")
+
+ result = collect_video_paths([mp4, avi], extensions=".mp4")
+
+ assert {p.name for p in result} == {"video.mp4"}
+
+
+def test_explicit_video_type_case_insensitive(tmp_path):
+ """Extension matching must be case-insensitive."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ avi = _touch(tmp_path / "video.avi")
+
+ result = collect_video_paths([mp4, avi], extensions="MP4")
+
+ assert {p.name for p in result} == {"video.mp4"}
+
+
+def test_multiple_extensions_filter_directory(tmp_path):
+ """A sequence of extensions filters directory contents to only matching files."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ avi = _touch(tmp_path / "video.avi")
+ _touch(tmp_path / "video.mkv")
+
+ result = collect_video_paths(tmp_path, extensions=["mp4", "avi"])
+
+ assert {p.name for p in result} == {mp4.name, avi.name}
+
+
+def test_directory_enumeration_filters_by_supported_videos(tmp_path):
+ """Directory scans must continue to discriminate videos from non-videos."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ _touch(tmp_path / "notes.txt")
+ _touch(tmp_path / "results.h5")
+ _touch(tmp_path / "abcd1234") # suffix-less file in a directory: not a video
+
+ result = collect_video_paths(tmp_path, extensions=None)
+
+ assert [p.name for p in result] == [mp4.name]
+
+
+def test_directory_enumeration_skips_dlc_artifacts(tmp_path):
+ """``*_labeled.*`` and ``*_full.*`` are DLC outputs, not inputs."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ _touch(tmp_path / "video_labeled.mp4")
+ _touch(tmp_path / "video_full.mp4")
+
+ result = collect_video_paths(tmp_path, extensions=None)
+
+ assert {p.name for p in result} == {mp4.name}
+
+
+def test_disable_exclude_patterns_includes_dlc_artifacts(tmp_path):
+ """Setting ``exclude_patterns=[]`` disables all pattern exclusion."""
+ mp4 = _touch(tmp_path / "video.mp4")
+ labeled = _touch(tmp_path / "video_labeled.mp4")
+ full = _touch(tmp_path / "video_full.mp4")
+
+ result = collect_video_paths(tmp_path, extensions=None, exclude_patterns=[])
+
+ assert {p.name for p in result} == {mp4.name, labeled.name, full.name}
+
+
+def test_mixed_files_and_directories(tmp_path):
+ """The function handles a mix of explicit files and directories."""
+ folder = tmp_path / "folder"
+ in_folder = _touch(folder / "from_dir.mp4")
+ _touch(folder / "ignored.txt")
+
+ explicit_mp4 = _touch(tmp_path / "explicit.mp4")
+ explicit_hashed = _touch(tmp_path / "abcd1234")
+
+ result = collect_video_paths(
+ [folder, explicit_mp4, explicit_hashed],
+ extensions=None,
+ )
+
+ assert {p.name for p in result} == {
+ in_folder.name,
+ explicit_mp4.name,
+ explicit_hashed.name,
+ }
+
+
+def test_duplicates_are_removed(tmp_path):
+ mp4 = _touch(tmp_path / "video.mp4")
+
+ result = collect_video_paths([mp4, mp4, str(mp4)], extensions=None)
+
+ assert len(result) == 1
+ assert result[0].name == "video.mp4"
+
+
+def test_missing_path_raises(tmp_path):
+ with pytest.raises(FileNotFoundError):
+ collect_video_paths([tmp_path / "does_not_exist.mp4"], extensions=None)
+
+
+@pytest.mark.parametrize("ext", SUPPORTED_VIDEOS)
+def test_each_supported_extension_picked_up_in_directory(tmp_path, ext):
+ expected = _touch(tmp_path / f"clip.{ext}")
+
+ result = collect_video_paths(tmp_path, extensions=None)
+
+ assert [p.name for p in result] == [expected.name]
+
+
+def test_sorted_by_default_when_not_shuffled(tmp_path):
+ a = _touch(tmp_path / "a.mp4")
+ b = _touch(tmp_path / "b.mp4")
+ c = _touch(tmp_path / "c.mp4")
+
+ result = collect_video_paths([c, a, b], extensions=None, shuffle=False)
+
+ assert [p.name for p in result] == ["a.mp4", "b.mp4", "c.mp4"]
+
+
+@pytest.mark.parametrize("deprecated_value", ["", [""], ("",), {""}])
+def test_deprecated_empty_extensions_warns(tmp_path, deprecated_value):
+ """Empty / blank extension values are deprecated and should emit a warning."""
+ _touch(tmp_path / "video.mp4")
+
+ with pytest.warns(DLCDeprecationWarning):
+ collect_video_paths(tmp_path, extensions=deprecated_value)
+
+
+def test_empty_sequence_raises(tmp_path):
+ """An empty sequence is not a valid filter; callers must pass None instead."""
+ with pytest.raises(ValueError):
+ collect_video_paths(tmp_path, extensions=[])
diff --git a/tests/utils/test_deprecation.py b/tests/utils/test_deprecation.py
new file mode 100644
index 0000000000..7f5a73fd5a
--- /dev/null
+++ b/tests/utils/test_deprecation.py
@@ -0,0 +1,291 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import warnings
+
+import pytest
+from packaging.version import Version
+
+from deeplabcut.utils.deprecation import (
+ DLCDeprecationWarning,
+ deprecated,
+ renamed_parameter,
+)
+
+# ---------------------------------------------------------------------------
+# @deprecated
+# ---------------------------------------------------------------------------
+
+
+def test_deprecated_emits_deprecation_warning():
+ @deprecated()
+ def old_fn():
+ return 42
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = old_fn()
+
+ assert result == 42
+
+
+def test_deprecated_warning_contains_function_name():
+ @deprecated()
+ def my_old_function():
+ pass
+
+ with pytest.warns(DLCDeprecationWarning, match="my_old_function"):
+ my_old_function()
+
+
+def test_deprecated_warning_contains_replacement():
+ @deprecated(replacement="new_module.new_fn")
+ def old_fn():
+ pass
+
+ with pytest.warns(DLCDeprecationWarning, match="new_module.new_fn"):
+ old_fn()
+
+
+def test_deprecated_warning_contains_since_and_removed_in():
+ @deprecated(since="3.1", removed_in="4.0")
+ def old_fn():
+ pass
+
+ with pytest.warns(DLCDeprecationWarning, match="3.1") as record:
+ old_fn()
+
+ assert "4.0" in str(record[0].message)
+
+
+def test_deprecated_preserves_return_value_and_args():
+ @deprecated()
+ def add(a, b):
+ return a + b
+
+ with pytest.warns(DLCDeprecationWarning):
+ assert add(2, 3) == 5
+
+
+def test_deprecated_preserves_name_and_docstring():
+ @deprecated(replacement="new_fn")
+ def documented_fn():
+ """Original docstring."""
+
+ assert documented_fn.__name__ == "documented_fn"
+ assert "Original docstring." in documented_fn.__doc__
+ assert "Deprecated." in documented_fn.__doc__
+ assert "new_fn" in documented_fn.__doc__
+
+
+def test_deprecated_attaches_metadata():
+ @deprecated(replacement="new_fn", since="3.1", removed_in="4.0")
+ def old_fn():
+ pass
+
+ info = old_fn.__deprecated_info__
+ assert info.kind == "callable"
+ assert info.target.endswith("old_fn")
+ assert info.replacement == "new_fn"
+ assert info.since == Version("3.1")
+ assert info.removed_in == Version("4.0")
+
+
+def test_deprecated_invalid_since_raises():
+ with pytest.raises(ValueError, match="Invalid version"):
+
+ @deprecated(since="not-a-version")
+ def old_fn():
+ pass
+
+
+def test_deprecated_invalid_removed_in_raises():
+ with pytest.raises(ValueError, match="Invalid version"):
+
+ @deprecated(removed_in="definitely-not-a-version")
+ def old_fn():
+ pass
+
+
+def test_deprecated_removed_in_must_be_greater_than_since():
+ with pytest.raises(ValueError, match="must be greater than"):
+
+ @deprecated(since="4.0", removed_in="4.0")
+ def old_fn():
+ pass
+
+
+# ---------------------------------------------------------------------------
+# @renamed_parameter
+# ---------------------------------------------------------------------------
+
+
+def test_renamed_parameter_old_name_emits_warning():
+ @renamed_parameter(old="in_random_order", new="shuffle")
+ def fn(shuffle=False):
+ return shuffle
+
+ with pytest.warns(DLCDeprecationWarning):
+ fn(in_random_order=True)
+
+
+def test_renamed_parameter_old_name_is_forwarded():
+ @renamed_parameter(old="in_random_order", new="shuffle")
+ def fn(shuffle=False):
+ return shuffle
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = fn(in_random_order=True)
+
+ assert result is True
+
+
+def test_renamed_parameter_new_name_no_warning():
+ @renamed_parameter(old="in_random_order", new="shuffle")
+ def fn(shuffle=False):
+ return shuffle
+
+ # No warning should be emitted when using the current name.
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DLCDeprecationWarning)
+ result = fn(shuffle=True)
+
+ assert result is True
+
+
+def test_renamed_parameter_warning_contains_names():
+ @renamed_parameter(old="videotype", new="video_extensions", since="3.2")
+ def fn(video_extensions=None):
+ return video_extensions
+
+ with pytest.warns(DLCDeprecationWarning, match="videotype") as record:
+ fn(videotype="mp4")
+
+ message = str(record[0].message)
+ assert "video_extensions" in message
+ assert "3.2" in message
+
+
+def test_renamed_parameter_preserves_name():
+ @renamed_parameter(old="foo", new="bar")
+ def my_fn(bar=None):
+ """Docstring."""
+
+ assert my_fn.__name__ == "my_fn"
+
+
+def test_renamed_parameter_old_and_new_together_raise():
+ @renamed_parameter(old="videotype", new="video_extensions")
+ def fn(video_extensions=None):
+ return video_extensions
+
+ with pytest.raises(TypeError, match="both 'videotype' and 'video_extensions'"):
+ fn(videotype="mp4", video_extensions="avi")
+
+
+def test_renamed_parameter_attaches_metadata():
+ @renamed_parameter(old="videotype", new="video_extensions", since="3.2")
+ def fn(video_extensions=None):
+ return video_extensions
+
+ params = fn.__deprecated_params__
+ assert len(params) == 1
+
+ info = params[0]
+ assert info.kind == "parameter"
+ assert info.target.endswith("fn")
+ assert info.old_parameter == "videotype"
+ assert info.new_parameter == "video_extensions"
+ assert info.since == Version("3.2")
+
+
+def test_renamed_parameter_invalid_since_raises():
+ with pytest.raises(ValueError, match="Invalid version"):
+
+ @renamed_parameter(old="videotype", new="video_extensions", since="invalid-version")
+ def fn(video_extensions=None):
+ return video_extensions
+
+
+def test_renamed_parameter_new_not_in_signature_raises():
+ with pytest.raises(ValueError, match="not a parameter"):
+
+ @renamed_parameter(old="foo", new="nonexistent")
+ def fn(bar=None):
+ return bar
+
+
+def test_new_not_in_signature_raises():
+ """Applying a rename whose 'new' is not in the signature raises an error."""
+ with pytest.raises(ValueError, match="not a parameter"):
+
+ @renamed_parameter(old="old_name", new="new_name")
+ def fn(not_new_name=None):
+ return not_new_name
+
+
+def test_old_still_in_signature_raises():
+ """Applying a rename when the old name is still in the signature raises an error."""
+ with pytest.raises(ValueError, match="still a parameter"):
+
+ @renamed_parameter(old="old_name", new="new_name")
+ def fn(old_name=None, new_name=None):
+ return new_name
+
+
+def test_renamed_parameter_chaining_raises():
+ """Chaining renames A→B→C raises an error."""
+ with pytest.raises(ValueError, match="chaining renames is not allowed"):
+
+ @renamed_parameter(old="A", new="B") # outer: A→B, but B is already deprecated to C
+ @renamed_parameter(old="B", new="C") # inner: B→C
+ def fn(C=None):
+ return C
+
+
+def test_renamed_parameter_multiple_independent_renames():
+ @renamed_parameter(old="batchsize", new="batch_size")
+ @renamed_parameter(old="videotype", new="video_extensions")
+ def fn(video_extensions=None, batch_size=None):
+ return video_extensions, batch_size
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = fn(videotype="mp4")
+ assert result == ("mp4", None)
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = fn(batchsize=4)
+ assert result == (None, 4)
+
+
+def test_renamed_parameter_positional_arg_unaffected():
+ @renamed_parameter(old="in_random_order", new="shuffle")
+ def fn(shuffle=False):
+ return shuffle
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DLCDeprecationWarning)
+ result = fn(True)
+
+ assert result is True
+
+
+def test_multiple_subsequent_renames_allowed():
+ @renamed_parameter(old="oldestname", new="newest", since="3.0.0")
+ @renamed_parameter(old="older_name", new="newest", since="4.0.0")
+ def fn(*, newest):
+ return newest
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = fn(oldestname=1)
+ assert result == 1
+
+ with pytest.warns(DLCDeprecationWarning):
+ result = fn(older_name=2)
+ assert result == 2
diff --git a/tests/utils/test_multiprocessing.py b/tests/utils/test_multiprocessing.py
new file mode 100644
index 0000000000..a5ab47dd5f
--- /dev/null
+++ b/tests/utils/test_multiprocessing.py
@@ -0,0 +1,40 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Please see AUTHORS for contributors.
+# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import time
+
+import pytest
+
+from deeplabcut.utils.multiprocessing import call_with_timeout
+
+
+def _succeeding_method(parameter):
+ return parameter
+
+
+def _failing_method():
+ raise ValueError("Raise value error on purpose")
+
+
+def _hanging_method():
+ while True:
+ time.sleep(5)
+
+
+@pytest.mark.skip(reason="Flaky on CI - imports that can exceed timeout on resource-constrained systems")
+def test_call_with_timeout():
+ parameter = (10, "Hello test")
+ assert call_with_timeout(_succeeding_method, 30, parameter) == parameter
+
+ with pytest.raises(ValueError):
+ call_with_timeout(_failing_method, timeout=30)
+
+ with pytest.raises(TimeoutError):
+ call_with_timeout(_hanging_method, timeout=1)
diff --git a/tests/utils/test_skeleton.py b/tests/utils/test_skeleton.py
new file mode 100644
index 0000000000..c83d5060d1
--- /dev/null
+++ b/tests/utils/test_skeleton.py
@@ -0,0 +1,374 @@
+import warnings
+from types import SimpleNamespace
+
+import matplotlib
+
+matplotlib.use("Agg", force=True)
+
+import numpy as np
+import pandas as pd
+import pytest
+from matplotlib.collections import LineCollection
+from matplotlib.figure import Figure
+from scipy.spatial import KDTree
+
+from deeplabcut.utils import skeleton as skeleton_mod
+from deeplabcut.utils.skeleton import SkeletonBuilder, write_config
+
+# ---------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------
+
+
+def make_config(project_path, scorer="TestScorer", skeleton=None):
+ return {
+ "project_path": str(project_path),
+ "scorer": scorer,
+ "skeleton": skeleton or [],
+ "skeleton_color": "red",
+ "dotsize": 4,
+ }
+
+
+def make_test_builder():
+ """
+ Construct a SkeletonBuilder instance without calling __init__,
+ so individual methods can be unit-tested in isolation.
+ """
+ builder = SkeletonBuilder.__new__(SkeletonBuilder)
+ return builder
+
+
+def attach_fake_canvas(builder):
+ builder.fig = Figure()
+ builder.fig.canvas.draw_idle = lambda: None
+
+
+# ---------------------------------------------------------------------
+# pick_labeled_frame
+# ---------------------------------------------------------------------
+
+
+def test_pick_labeled_frame_multi_animal_drops_single(monkeypatch):
+ builder = make_test_builder()
+
+ index = pd.MultiIndex.from_tuples(
+ [("labeled-data/session1", "img001.png")],
+ names=["folder", "image"],
+ )
+ columns = pd.MultiIndex.from_product(
+ [["TestScorer"], ["single", "mouseA"], ["nose", "tail"], ["x", "y"]],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+
+ # "single" is fully labeled too, but should be dropped before choosing.
+ row = [
+ 1.0,
+ 2.0,
+ 3.0,
+ 4.0, # single
+ 10.0,
+ 20.0,
+ 30.0,
+ 40.0, # mouseA
+ ]
+ builder.df = pd.DataFrame([row], index=index, columns=columns)
+
+ monkeypatch.setattr(np.random, "shuffle", lambda x: None)
+
+ picked_row, picked_col = builder.pick_labeled_frame()
+
+ assert picked_row == ("labeled-data/session1", "img001.png")
+ assert picked_col == "mouseA"
+
+
+def test_pick_labeled_frame_without_individuals(monkeypatch):
+ builder = make_test_builder()
+
+ index = pd.MultiIndex.from_tuples(
+ [("labeled-data/session1", "img001.png")],
+ names=["folder", "image"],
+ )
+ columns = pd.MultiIndex.from_product(
+ [["TestScorer"], ["nose", "tail"], ["x", "y"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+
+ builder.df = pd.DataFrame(
+ [[1.0, 2.0, 3.0, 4.0]],
+ index=index,
+ columns=columns,
+ )
+
+ monkeypatch.setattr(np.random, "shuffle", lambda x: None)
+
+ picked_row, picked_col = builder.pick_labeled_frame()
+
+ assert picked_row == ("labeled-data/session1", "img001.png")
+ # fallback path uses count(...).to_frame(), so the single column is usually 0
+ assert picked_col == 0
+
+
+# ---------------------------------------------------------------------
+# clear
+# ---------------------------------------------------------------------
+
+
+def test_clear_resets_indices_segments_and_linecollection():
+ builder = make_test_builder()
+ builder.inds = {(0, 1), (1, 2)}
+ builder.segs = {
+ ((0.0, 0.0), (10.0, 0.0)),
+ ((10.0, 0.0), (20.0, 0.0)),
+ }
+ builder.lines = LineCollection([np.array([[0.0, 0.0], [10.0, 0.0]]), np.array([[10.0, 0.0], [20.0, 0.0]])])
+ attach_fake_canvas(builder)
+
+ builder.clear()
+
+ assert builder.inds == set()
+ assert builder.segs == set()
+ assert list(builder.lines.get_segments()) == []
+
+
+# ---------------------------------------------------------------------
+# export
+# ---------------------------------------------------------------------
+
+
+def test_export_sorts_pairs_and_warns_for_unconnected(monkeypatch):
+ builder = make_test_builder()
+ builder.config_path = "dummy_config.yaml"
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [20.0, 0.0],
+ [30.0, 0.0], # intentionally left unconnected
+ ]
+ )
+ builder.bpts = pd.Index(["nose", "tail", "paw", "ear"], name="bodyparts")
+ builder.inds = {(1, 2), (0, 1)} # intentionally unordered
+ builder.cfg = {"skeleton": []}
+
+ captured = {}
+
+ def fake_write_config(path, cfg):
+ captured["path"] = path
+ captured["cfg"] = cfg.copy()
+
+ monkeypatch.setattr(skeleton_mod, "write_config", fake_write_config)
+
+ with pytest.warns(UserWarning, match="didn't connect all the bodyparts"):
+ builder.export()
+
+ assert captured["path"] == "dummy_config.yaml"
+ assert captured["cfg"]["skeleton"] == [
+ ("nose", "tail"),
+ ("tail", "paw"),
+ ]
+
+
+def test_export_without_warning_when_all_bodyparts_connected(monkeypatch):
+ builder = make_test_builder()
+ builder.config_path = "dummy_config.yaml"
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [20.0, 0.0],
+ ]
+ )
+ builder.bpts = pd.Index(["nose", "tail", "paw"], name="bodyparts")
+ builder.inds = {(0, 1), (1, 2)}
+ builder.cfg = {"skeleton": []}
+
+ monkeypatch.setattr(skeleton_mod, "write_config", lambda path, cfg: None)
+
+ with warnings.catch_warnings(record=True) as record:
+ warnings.simplefilter("always")
+ builder.export()
+
+ assert not any("didn't connect all the bodyparts" in str(w.message) for w in record)
+ assert builder.cfg["skeleton"] == [
+ ("nose", "tail"),
+ ("tail", "paw"),
+ ]
+
+
+# ---------------------------------------------------------------------
+# on_select
+# ---------------------------------------------------------------------
+
+
+def test_on_select_adds_pairs_segments_and_updates_canvas():
+ builder = make_test_builder()
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [20.0, 0.0],
+ ]
+ )
+ builder.tree = KDTree(builder.xy)
+ builder.inds = set()
+ builder.segs = set()
+ builder.lines = LineCollection([])
+ attach_fake_canvas(builder)
+
+ verts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)]
+ builder.on_select(verts)
+
+ assert builder.inds == {(0, 1), (1, 2)}
+ assert ((0.0, 0.0), (10.0, 0.0)) in builder.segs
+ assert ((10.0, 0.0), (20.0, 0.0)) in builder.segs
+ assert len(builder.lines.get_segments()) == 2
+
+
+def test_on_select_ignores_duplicate_hits():
+ builder = make_test_builder()
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [20.0, 0.0],
+ ]
+ )
+ builder.tree = KDTree(builder.xy)
+ builder.inds = set()
+ builder.segs = set()
+ builder.lines = LineCollection([])
+ attach_fake_canvas(builder)
+
+ # Repeated nearby vertices should not create duplicate pairs
+ verts = [(0.0, 0.0), (0.1, 0.0), (10.0, 0.0), (10.1, 0.0), (20.0, 0.0)]
+ builder.on_select(verts)
+
+ assert builder.inds == {(0, 1), (1, 2)}
+ assert len(builder.segs) == 2
+
+
+# ---------------------------------------------------------------------
+# on_pick
+# ---------------------------------------------------------------------
+
+
+def test_on_pick_right_click_removes_segment_and_pair():
+ builder = make_test_builder()
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ ]
+ )
+ builder.tree = KDTree(builder.xy)
+ builder.inds = {(0, 1)}
+ builder.segs = {((0.0, 0.0), (10.0, 0.0))}
+ builder.lines = LineCollection([np.array([[0.0, 0.0], [10.0, 0.0]])])
+ attach_fake_canvas(builder)
+
+ event = SimpleNamespace(
+ mouseevent=SimpleNamespace(button=3),
+ artist=builder.lines,
+ ind=[0],
+ )
+
+ builder.on_pick(event)
+
+ assert builder.inds == set()
+ assert builder.segs == set()
+ assert list(builder.lines.get_segments()) == []
+
+
+def test_on_pick_non_right_click_does_nothing():
+ builder = make_test_builder()
+ builder.xy = np.array(
+ [
+ [0.0, 0.0],
+ [10.0, 0.0],
+ ]
+ )
+ builder.tree = KDTree(builder.xy)
+ builder.inds = {(0, 1)}
+ builder.segs = {((0.0, 0.0), (10.0, 0.0))}
+ builder.lines = LineCollection([np.array([[0.0, 0.0], [10.0, 0.0]])])
+ attach_fake_canvas(builder)
+
+ event = SimpleNamespace(
+ mouseevent=SimpleNamespace(button=1),
+ artist=builder.lines,
+ ind=[0],
+ )
+
+ builder.on_pick(event)
+
+ assert builder.inds == {(0, 1)}
+ assert builder.segs == {((0.0, 0.0), (10.0, 0.0))}
+ assert len(builder.lines.get_segments()) == 1
+
+
+# ---------------------------------------------------------------------
+# __init__ lightweight integration
+# ---------------------------------------------------------------------
+
+
+def test_init_loads_dataframe_image_and_existing_skeleton(tmp_path, monkeypatch):
+ project_path = tmp_path / "project"
+ labeled_data = project_path / "labeled-data" / "session1"
+ labeled_data.mkdir(parents=True)
+
+ cfg_path = project_path / "config.yaml"
+ cfg = make_config(
+ project_path=project_path,
+ scorer="TestScorer",
+ skeleton=[
+ ["nose", "tail"],
+ ["missing", "nose"],
+ ], # second pair should be ignored
+ )
+ write_config(cfg_path, cfg)
+
+ index = pd.MultiIndex.from_tuples(
+ [("labeled-data/session1", "img001.png")],
+ names=["folder", "image"],
+ )
+ columns = pd.MultiIndex.from_product(
+ [["TestScorer"], ["nose", "tail"], ["x", "y"]],
+ names=["scorer", "bodyparts", "coords"],
+ )
+ df = pd.DataFrame(
+ [[0.0, 0.0, 10.0, 0.0]],
+ index=index,
+ columns=columns,
+ )
+ h5_path = labeled_data / "CollectedData_TestScorer.h5"
+ df.to_hdf(h5_path, key="df", mode="w")
+
+ monkeypatch.setattr(skeleton_mod.io, "imread", lambda path: np.zeros((5, 5, 3), dtype=np.uint8))
+ monkeypatch.setattr(SkeletonBuilder, "build_ui", lambda self: None)
+ monkeypatch.setattr(SkeletonBuilder, "display", lambda self: None)
+ monkeypatch.setattr(np.random, "shuffle", lambda x: None)
+
+ builder = SkeletonBuilder(str(cfg_path))
+
+ assert builder.config_path == str(cfg_path)
+ assert list(builder.bpts) == ["nose", "tail"]
+ assert builder.xy.shape == (2, 2)
+ assert builder.image.shape == (5, 5, 3)
+ assert builder.inds == {(0, 1)}
+ assert ((0.0, 0.0), (10.0, 0.0)) in builder.segs
+
+
+def test_init_raises_if_no_labeled_data_found(tmp_path, monkeypatch):
+ project_path = tmp_path / "project"
+ (project_path / "labeled-data").mkdir(parents=True)
+
+ cfg_path = project_path / "config.yaml"
+ cfg = make_config(project_path=project_path, scorer="TestScorer")
+ write_config(cfg_path, cfg)
+
+ monkeypatch.setattr(SkeletonBuilder, "build_ui", lambda self: None)
+ monkeypatch.setattr(SkeletonBuilder, "display", lambda self: None)
+
+ with pytest.raises(IOError, match="No labeled data were found"):
+ SkeletonBuilder(str(cfg_path))
diff --git a/testscript_cli.py b/testscript_cli.py
index 7b74543cdc..295a68f729 100644
--- a/testscript_cli.py
+++ b/testscript_cli.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
"""
modified from: https://github.com/DeepLabCut/DeepLabCut-core/testscript_cli.py
by Mackenzie.
@@ -9,25 +8,24 @@
It produces nothing of interest scientifically.
"""
-task = "Testcore" # Enter the name of your experiment Task
-scorer = "Mackenzie" # Enter the name of the experimenter/labeler
+import os
+import platform
+
+import numpy as np
+import pandas as pd
-import os, subprocess, sys
+import deeplabcut as dlc
+from deeplabcut.core.engine import Engine
+task = "Testcore" # Enter the name of your experiment Task
+scorer = "Mackenzie" # Enter the name of the experimenter/labeler
+print("Imported DLC!")
+engine = Engine.PYTORCH
# def install(package):
# subprocess.check_call([sys.executable, "-m", "pip", "install", package])
# install("tensorflow==1.13.1")
-import deeplabcut as dlc
-
-from pathlib import Path
-import pandas as pd
-import numpy as np
-import platform
-
-print("Imported DLC!")
-
basepath = os.path.dirname(os.path.abspath("testscript_cli.py"))
videoname = "reachingvideo1"
video = [
@@ -105,7 +103,7 @@
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -116,33 +114,14 @@
print("CREATING TRAININGSET")
dlc.create_training_dataset(
- path_config_file, net_type=net_type, augmenter_type=augmenter_type
+ path_config_file,
+ net_type=net_type,
+ augmenter_type=augmenter_type,
+ engine=engine,
)
-posefile = os.path.join(
- cfg["project_path"],
- "dlc-models/iteration-"
- + str(cfg["iteration"])
- + "/"
- + cfg["Task"]
- + cfg["date"]
- + "-trainset"
- + str(int(cfg["TrainingFraction"][0] * 100))
- + "shuffle"
- + str(1),
- "train/pose_cfg.yaml",
-)
-
-DLC_config = dlc.auxiliaryfunctions.read_plainconfig(posefile)
-DLC_config["save_iters"] = numiter
-DLC_config["display_iters"] = 2
-DLC_config["multi_step"] = [[0.001, numiter]]
-
-print("CHANGING training parameters to end quickly!")
-dlc.auxiliaryfunctions.write_plainconfig(posefile, DLC_config)
-
print("TRAIN")
-dlc.train_network(path_config_file)
+dlc.train_network(path_config_file, epochs=numiter, displayiters=2)
print("EVALUATE")
dlc.evaluate_network(path_config_file, plotting=True)
@@ -166,7 +145,8 @@
dlc.create_training_dataset(path_config_file, Shuffles=[2],net_type=net_type,augmenter_type=augmenter_type2)
cfg=dlc.auxiliaryfunctions.read_config(path_config_file)
-posefile=os.path.join(cfg['project_path'],'dlc-models/iteration-'+str(cfg['iteration'])+'/'+ cfg['Task'] + cfg['date'] + '-trainset' + str(int(cfg['TrainingFraction'][0] * 100)) + 'shuffle' + str(2),'train/pose_cfg.yaml')
+posefile=os.path.join(cfg['project_path'],'dlc-models/iteration-'+str(cfg['iteration'])+'/'+ cfg['Task'] + cfg['date'] +
+'-trainset' + str(int(cfg['TrainingFraction'][0] * 100)) + 'shuffle' + str(2),'train/pose_cfg.yaml')
DLC_config=dlc.auxiliaryfunctions.read_plainconfig(posefile)
DLC_config['save_iters']=numiter
DLC_config['display_iters']=1
@@ -190,5 +170,6 @@
dlc.export_model(path_config_file, shuffle=1, make_tar=False)
print(
- "ALL DONE!!! - default/imgaug cases of DLCcore training and evaluation are functional (no extract outlier or refinement tested)."
+ "ALL DONE!!! - default/imgaug cases of DLCcore training and evaluation are functional (no extract outlier or"
+ "refinement tested)."
)
diff --git a/tools/README.md b/tools/README.md
index 32389da346..20d1b29f8b 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -1,13 +1,209 @@
# Developer tools useful for maintaining the repository
-## Code headers
+This document summarizes the developer tooling and workflows used in this repo.
-The code headers can be standardized by running
+```bash
+pip install -e . --group dev
+```
+
+---
+
+## 1) Pre-commit (recommended)
+
+Enable the repository hooks locally:
+
+```bash
+pre-commit install
+```
+
+Run on all files:
+
+Steering committee members may edit the `NOTICE.yml` to update the header.
+
+## 2) Ruff cleanup helpers
+
+For **local Ruff backlog work** (not a substitute for CI or pre-commit), see [Ruff cleanup helpers](ruff_cleanup_helpers.md). It documents `generate_ruff_report.py` (Markdown report from Ruff JSON) and `fix_e501_with_autopep8.py` (targeted long-line cleanup plus Ruff fix/format).
+
+---
+
+## 3) License headers
+
+Code headers can be standardized by running:
+
+Please follow the instructions in `CONTRIBUTING.md` for contributing to the codebase, including running tests and pre-commit checks before opening a pull request.
+
+Run from the repository root. Update `NOTICE.yml` to change header content.
+
+---
+
+## 4) Running tests locally
+
+### Run the full test suite
+
+```bash
+pytest
+```
+
+### Run a specific test module or folder
+
+```bash
+coverage run -m pytest
+coverage report
+```
+
+## 5) Intelligent test selection (local + CI)
+
+The repository includes a deterministic test-selection tool to reduce CI runtime by running only the relevant workflows and tests based on changed files.
+
+### What it outputs
+
+The selector emits **orthogonal workflow lanes** plus structured selections:
+
+- `lanes`: which workflow lanes should run
+ - `skip`: skip test execution entirely (for lint-only changes)
+ - `docs`: run docs checks
+ - `fast`: run targeted pytest paths and optional functional scripts
+ - `full`: delegate to the full test workflow / matrix
+- `pytest_paths`: list of pytest path arguments (JSON)
+- `functional_scripts`: list of Python scripts to run (JSON)
+- `provenance`: mapping from each selected test/script to the category rule(s) that selected it
+
+It also emits audit metadata:
+
+- `selected_workflows`: ordered list of enabled lanes (`skip`, `docs`, `fast`, `full`)
+- `lane_reasons`: reasons for each enabled lane
+- `diff_mode`: how the diff range was determined
+- `reasons`: aggregate machine-readable reasons for the decision
+- `changed_files`: files considered for the decision
+- `schema_version`: output schema version
+
+### Rule configuration
+
+Routing rules are defined in `tools/test_selector_config.py`.
+
+That file contains:
+
+- reusable path predicate helpers such as `prefix(...)`, `suffix(...)`, `equals(...)`, `case_insensitive_match(...)`, and `all_of(...)`
+- conservative `FULL_SUITE_TRIGGERS`
+- `LINT_ONLY_FILES`
+- validated `CATEGORY_RULES` built from the `CategoryRule` schema
+- `CATEGORY_RULE_BY_NAME` for stable lookup of named rules such as `docs`
+
+The current refactor keeps the rule predicates simple and location-based while validating the rule structure at import time.
+
+### Run locally (no CI env required)
+
+> [!IMPORTANT]
+> Requires `pydantic>=2,<3`
+
+Print the decision as JSON:
-``` bash
-python tools/update_license_headers.py
+```bash
+python tools/test_selector.py --json
```
-from the repository root.
+Write the decision report (`selection.json` and `decision.md`) under `tmp/test-selection/`:
+
+```bash
+python tools/test_selector.py --report-dir tmp/test-selection --json
+```
+
+Write a GitHub job-summary-compatible Markdown report when `GITHUB_STEP_SUMMARY` is available:
+
+```bash
+python tools/test_selector.py --report-dir tmp/test-selection --write-summary
+```
+
+Override the diff range manually:
+
+```bash
+python tools/test_selector.py --base-sha --head-sha --json
+```
+
+In GitHub Actions, the workflow typically adds `--write-github-output` and `--write-summary`.
+
+### Diff modes
+
+The selector records how the diff was determined in `diff_mode`:
+
+- `pr`: pull request diff using `merge-base(base, head)..head`
+- `push`: push diff using `before..after`
+- `manual`: explicit `--base-sha` / `--head-sha`
+- `fallback`: fallback to `HEAD^..HEAD`
+- `initial`: initial commit (`empty-tree..HEAD`)
+- `fallback_no_head`: could not resolve `HEAD`
+
+### Report files
+
+The selector always writes report artifacts for transparency:
+
+- `tmp/test-selection/selection.json`: machine-readable output
+- `tmp/test-selection/decision.md`: human-readable summary with workflow lanes, reasons, explained changed files, selected tests, and provenance
+
+These reports are especially useful when a change unexpectedly routes to `full`.
+
+### Notes
+
+- The selector can enable more than one lane at once. For example, a PR can legitimately enable both `docs` and `fast`, or `docs` and `full`.
+- Docs changes are **orthogonal** to test routing: docs changes can enable the docs lane while still contributing selected tests/scripts if such rules are configured.
+- `LINT_ONLY_FILES` are ignored for routing. If *only* lint-only files changed, the selector enables the `skip` lane.
+- If category rules match changed files but do not contribute explicit tests/scripts, the selector can fall back to the minimal pytest set defined by `MINIMAL_PYTEST`.
+
+### Troubleshooting the selector
+
+If a workflow run is unexpectedly selecting `full`, check:
+
+- `tmp/test-selection/decision.md`
+- `tmp/test-selection/selection.json`
+- `lane_reasons`
+- `diff_mode`
+- `changed_files`
+
+Common causes include:
+
+- a file matched a conservative full-suite trigger
+- no category rule matched the routed files
+- selected paths configured by a rule no longer exist in the repository
+- diff resolution fell back because CI checkout history was incomplete
+
+---
+
+## 6) Docs: Jupyter Book build (local)
+
+The repo uses Jupyter Book for docs:
+
+```bash
+python -m pip install -U pip
+python -m pip install .[docs]
+jupyter-book build .
+```
+
+`.github/workflows/build-book.yml` is the canonical CI implementation.
+
+---
+
+## 7) Testing the test selector
+
+The selector has dedicated tests covering:
+
+- decision behavior for docs / fast / full / skip routing
+- provenance and deduplicated selections
+- `CategoryRule` schema validation
+- integrity checks for the currently defined rules
+
+Run the selector-focused tests with:
+
+```bash
+pytest tests/tools/test_selector/
+```
+
+---
+
+## 8) Troubleshooting tips
-You can edit the `NOTICE.yml` to update the header.
+- If a workflow run is unexpectedly selecting `full`, inspect the selector reports first.
+- If targeted tests fail due to missing dependencies, either:
+ - broaden the fast-lane install (for example by installing required extras), or
+ - adjust selection rules so that the fast lane only selects tests that run in the minimal environment.
+- If manual diff selection is used, always pass both `--base-sha` and `--head-sha` together.
+- In CI, ensure checkout history is deep enough for `merge-base` / `diff` operations (`fetch-depth: 0` is typically safest).
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tools/docs_and_notebooks_check.py b/tools/docs_and_notebooks_check.py
new file mode 100644
index 0000000000..0d720814d8
--- /dev/null
+++ b/tools/docs_and_notebooks_check.py
@@ -0,0 +1,1259 @@
+"""DeepLabCut docs & notebooks automated checks tool.
+
+Goals
+-----
+- SAFE by default: read-only operations in CI (report/check).
+- Idempotent updates (update mode) that only touch:
+ * Notebook-level metadata for .ipynb (never cells/outputs)
+ * YAML frontmatter for .md docs (optional)
+- Uses pydantic schemas with explicit schema_version for validation.
+- Aims to be contributor-friendly by default: check mode enforces configured policy, while
+ surfacing scan/parsing issues without failing unless strict mode is enabled.
+
+Terminology
+-----------
+last_content_updated
+ Computed from git history, excluding metadata-only commits.
+ (Metadata commits must include META_COMMIT_MARKER in the commit message.)
+
+last_verified
+ Human-controlled date indicating the file was verified to work/be accurate.
+
+verified_for
+ Human-controlled string, typically the project version (e.g. 3.0.0rc13).
+
+tier
+ Optional classification (left unset by default; do not auto-populate).
+
+Usage modes
+-----------
+Report (read-only):
+ python tools/docs_and_notebooks_check.py report
+
+Check (read-only; policy enforcement):
+ python tools/docs_and_notebooks_check.py check
+
+ Runs scans and evaluates configured policy rules.
+ Exits non-zero for policy violations.
+ Scan/parsing errors are always reported in console / JSON / Markdown output,
+ but are non-fatal by default unless strict mode is enabled or they imply a
+ policy violation.
+
+Update content-date field from git (write mode; requires --write):
+ python tools/docs_and_notebooks_check.py update --write --set-content-date-from-git
+
+Update verification fields for selected targets (write mode):
+ python tools/docs_and_notebooks_check.py update --write --targets docs/page.md \
+ --set-last-verified today --set-verified-for 3.0.0rc13
+
+Normalize notebooks deterministically (explicit churn; write mode):
+ python tools/docs_and_notebooks_check.py normalize --write --targets docs/notebook.ipynb
+
+
+Configuration
+-------------
+Uses tools/docs_and_notebooks_report_config.yml by default.
+
+Outputs
+-------
+- docs_nb_checks.json: machine-readable report
+- docs_nb_checks.md: human-readable summary
+
+Notes for CI
+------------
+- Ensure actions/checkout uses fetch-depth: 0 (or sufficiently deep),
+ otherwise git log may not see history.
+- Requires:
+ - pydantic>=2,<3
+ - PyYAML
+ - nbformat>=5
+ to be installed in the environment.
+ Recommended : install in CI job directly (pip install pydantic pyyaml nbformat)
+ rather than adding to requirements, since these are only needed for this tool.
+"""
+
+# tools/docs_and_notebooks_check.py
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import os
+import re
+import subprocess
+from collections.abc import Sequence
+from datetime import date, datetime, timezone
+from pathlib import Path
+from typing import Any, Literal, TypedDict
+
+import nbformat
+import yaml
+from nbformat.validator import NotebookValidationError
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+
+SCHEMA_VERSION = 1
+GLOB_CHARS = set("*?[")
+DLC_NAMESPACE = "deeplabcut"
+OUTPUT_FILENAME = "docs_nb_checks"
+SCRIPT_DIR = Path(__file__).resolve().parent
+DEFAULT_CFG = SCRIPT_DIR / "docs_and_notebooks_report_config.yml"
+
+
+# -----------------------------
+# Metadata commit marker / guidance
+# -----------------------------
+# IMPORTANT:
+# Metadata-only updates and notebook normalization rewrite files and will change
+# "git last touched" timestamps. To preserve meaningful "content age", all such
+# commits must include this marker in the commit message.
+META_COMMIT_MARKER = "chore(metadata)"
+SUGGESTED_TAGGED_COMMIT = f"{META_COMMIT_MARKER}: update docs/notebooks metadata"
+
+
+# -----------------------------
+# Pydantic schemas
+# -----------------------------
+
+
+class DLCMeta(BaseModel):
+ """Metadata embedded in files under the `deeplabcut` namespace."""
+
+ model_config = ConfigDict(extra="allow")
+
+ # Tool-managed: last meaningful content update date (excluding metadata commits)
+ last_content_updated: date | None = None
+
+ # Optional tool-managed: last time metadata/normalization was performed
+ last_metadata_updated: date | None = None
+ # Optional human-managed verification fields
+ last_verified: date | None = None
+ # Version or other string indicating what this file was verified for (e.g. "3.0.0rc13")
+ verified_for: str | None = None
+ # Extra metadata fields for later usage (e.g. allowlist tier classification), but not currently used by the tool
+ tier: str | None = None
+ ignore: bool = False
+ notes: str | None = None
+
+
+class ScanConfig(BaseModel):
+ include: list[str] = Field(default_factory=list)
+ exclude: list[str] = Field(default_factory=list)
+
+
+class PolicyConfig(BaseModel):
+ warn_if_content_older_than_days: int = 365
+ warn_if_verified_older_than_days: int = 365
+ missing_last_verified_is_warning: bool = True
+
+ # Strict-mode toggle: if true, scan/parsing errors also fail `check`
+ fail_on_scan_errors: bool = False
+
+ # Allowlists for strict checks (start empty; ratchet later)
+ require_metadata: list[str] = Field(default_factory=list)
+ require_recent_verification: list[str] = Field(default_factory=list)
+
+ require_notebook_normalized: list[str] = Field(default_factory=list)
+
+
+class ToolConfig(BaseModel):
+ version: int = 1
+ scan: ScanConfig
+ policy: PolicyConfig
+
+
+FileKind = Literal["ipynb", "md", "other"]
+
+
+class FileRecord(BaseModel):
+ path: str
+ kind: FileKind
+
+ # Computed from git (excluding metadata-only commits)
+ last_content_updated: date | None = None
+ # Debug-only: raw git last touched (may be metadata commit)
+ last_git_touched: date | None = None
+
+ # Read from file metadata/frontmatter
+ meta: DLCMeta | None = None
+
+ # Derived
+ days_since_content_update: int | None = None
+ days_since_verified: int | None = None
+
+ warnings: list[str] = Field(default_factory=list)
+ errors: list[str] = Field(default_factory=list)
+
+ # If update mode would change file
+ would_change: bool = False
+
+
+class Report(BaseModel):
+ schema_version: int = SCHEMA_VERSION
+ generated_at: datetime
+ repo_root: str
+ config_path: str
+
+ totals: dict[str, int]
+ records: list[FileRecord]
+
+
+# Rebuild models due to __future__ annotations
+DLCMeta.model_rebuild()
+ScanConfig.model_rebuild()
+PolicyConfig.model_rebuild()
+ToolConfig.model_rebuild()
+FileRecord.model_rebuild()
+Report.model_rebuild()
+
+TargetKind = Literal["invalid", "file", "dir", "glob"]
+
+
+class TargetSpec(TypedDict):
+ raw: str
+ normalized: str
+ kind: TargetKind
+
+
+# -----------------------------
+# Helpers
+# -----------------------------
+def normalize_target_spec(spec: str, repo_root: Path) -> str:
+ """
+ Normalize a CLI target into a repo-relative POSIX-style path/pattern.
+
+ Examples
+ --------
+ .\\docs\\a.md -> docs/a.md
+ ./docs/a.md -> docs/a.md
+ docs\\gui\\ -> docs/gui
+ /abs/path/in/repo/a.md -> docs/a.md (if inside repo)
+ """
+ s = spec.strip()
+ if not s:
+ return s
+
+ # Normalize slashes first so Windows-style input works everywhere
+ s = s.replace("\\", "/")
+
+ # Strip leading ./ repeatedly
+ while s.startswith("./"):
+ s = s[2:]
+
+ # If absolute and inside repo, make it repo-relative
+ p = Path(s)
+ if p.is_absolute():
+ try:
+ s = str(p.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ except ValueError:
+ # Outside repo: keep normalized absolute text so it can fail validation cleanly
+ s = str(p).replace(os.sep, "/")
+
+ # Collapse repeated slashes
+ s = re.sub(r"/+", "/", s)
+
+ # Remove trailing slash for canonical matching
+ if len(s) > 1:
+ s = s.rstrip("/")
+
+ return s
+
+
+def compile_target_specs(targets: list[str] | None, repo_root: Path) -> list[TargetSpec] | None:
+ """
+ Convert raw CLI targets into normalized selector specs.
+
+ Each spec is a dict with:
+ - raw: original user input
+ - normalized: normalized repo-relative selector
+ - kind: file | dir | glob | invalid
+ """
+ if not targets:
+ return None
+
+ specs: list[TargetSpec] = []
+
+ for raw in targets:
+ normalized = normalize_target_spec(raw, repo_root)
+ if not normalized:
+ specs.append({"raw": raw, "normalized": "", "kind": "invalid"})
+ continue
+
+ if any(ch in normalized for ch in GLOB_CHARS):
+ specs.append({"raw": raw, "normalized": normalized, "kind": "glob"})
+ continue
+
+ # Treat explicit trailing slash/backslash as directory intent
+ if raw.endswith(("/", "\\")):
+ specs.append({"raw": raw, "normalized": normalized, "kind": "dir"})
+ continue
+
+ candidate = Path(normalized)
+ abs_candidate = candidate if candidate.is_absolute() else (repo_root / candidate)
+ if abs_candidate.exists() and abs_candidate.is_dir():
+ specs.append({"raw": raw, "normalized": normalized, "kind": "dir"})
+ else:
+ specs.append({"raw": raw, "normalized": normalized, "kind": "file"})
+
+ return specs
+
+
+def target_spec_matches_path(rel_path: str, spec: TargetSpec) -> bool:
+ rel_path = rel_path.replace("\\", "/")
+
+ kind = spec["kind"]
+ normalized = spec["normalized"]
+
+ if kind == "invalid":
+ return False
+
+ if kind == "file":
+ return rel_path == normalized
+
+ if kind == "dir":
+ return rel_path == normalized or rel_path.startswith(normalized + "/")
+
+ if kind == "glob":
+ # Intentionally use simple shell-style matching here so patterns like
+ # docs/**/*.md behave the way users generally expect across platforms.
+ return fnmatch.fnmatchcase(rel_path, normalized)
+
+ return False
+
+
+def target_matches(rel_path: str, specs: list[TargetSpec] | None) -> bool:
+ if specs is None:
+ return True
+ return any(target_spec_matches_path(rel_path, spec) for spec in specs)
+
+
+def iter_scan_candidate_paths(repo_root: Path, cfg: ToolConfig) -> list[str]:
+ """
+ Return all repo-relative paths that are in scope for scanning, before applying --targets.
+ """
+ rels: list[str] = []
+ for p in glob_paths(repo_root, cfg.scan.include):
+ rel = str(p.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ if is_excluded(rel, cfg.scan.exclude):
+ continue
+ rels.append(rel)
+ return sorted(set(rels))
+
+
+def validate_requested_targets(
+ repo_root: Path,
+ cfg: ToolConfig,
+ targets: list[str] | None,
+) -> tuple[list[str], list[str]]:
+ """
+ Validate CLI target selectors against the current scan universe.
+
+ Returns:
+ matched_paths: repo-relative file paths matched by any selector
+ unmatched_targets: raw target strings that matched nothing
+ """
+ if not targets:
+ return [], []
+
+ specs = compile_target_specs(targets, repo_root)
+ candidates = iter_scan_candidate_paths(repo_root, cfg)
+
+ matched_paths = sorted({rel for rel in candidates if target_matches(rel, specs)})
+
+ unmatched_targets: list[str] = []
+ for spec in specs or []:
+ if spec["kind"] == "invalid":
+ unmatched_targets.append(spec["raw"])
+ elif not any(target_spec_matches_path(rel, spec) for rel in candidates):
+ unmatched_targets.append(spec["raw"])
+
+ return matched_paths, unmatched_targets
+
+
+def print_target_match_summary(matched_paths: list[str], requested_targets: list[str] | None) -> None:
+ """
+ Print a small CLI summary whenever --targets is used.
+ """
+ if not requested_targets:
+ return
+
+ print(f"\nMatched {len(matched_paths)} file(s) from --targets:")
+ preview_limit = 50
+ for rel in matched_paths[:preview_limit]:
+ print(f"- {rel}")
+ if len(matched_paths) > preview_limit:
+ print(f"... and {len(matched_paths) - preview_limit} more")
+
+
+def _iso_today() -> date:
+ return datetime.now(timezone.utc).date()
+
+
+def _run_git(args: Sequence[str], cwd: Path) -> tuple[int, str, str]:
+ p = subprocess.run(
+ ["git", *args],
+ cwd=str(cwd),
+ capture_output=True,
+ text=True,
+ )
+ return p.returncode, p.stdout.strip(), p.stderr.strip()
+
+
+def find_repo_root(start: Path) -> Path:
+ cur = start.resolve()
+ for _ in range(50):
+ if (cur / ".git").exists():
+ return cur
+ if cur.parent == cur:
+ break
+ cur = cur.parent
+ code, out, _err = _run_git(["rev-parse", "--show-toplevel"], cwd=start)
+ if code == 0 and out:
+ return Path(out).resolve()
+ raise RuntimeError("Could not locate repository root")
+
+
+def glob_paths(repo_root: Path, patterns: list[str]) -> list[Path]:
+ results: list[Path] = []
+ for pat in patterns:
+ results.extend(repo_root.glob(pat))
+ return sorted({p.resolve() for p in results if p.is_file()})
+
+
+def is_excluded(rel_path: str, exclude_patterns: list[str]) -> bool:
+ return any(fnmatch.fnmatch(rel_path, pat) for pat in exclude_patterns)
+
+
+def file_kind(path: Path) -> str:
+ s = path.suffix.lower()
+ if s == ".ipynb":
+ return "ipynb"
+ if s in {".md", ".markdown"}:
+ return "md"
+ return "other"
+
+
+def _parse_git_iso_date(out: str) -> date | None:
+ out = (out or "").strip()
+ if not out:
+ return None
+
+ try:
+ return date.fromisoformat(out)
+ except Exception:
+ pass
+
+ try:
+ if out.endswith("Z"):
+ out = out[:-1] + "+00:00"
+ return datetime.fromisoformat(out).date()
+ except Exception:
+ return None
+
+
+def _git_log_date(repo_root: Path, rel_path: str, extra_args: Sequence[str] = ()) -> date | None:
+ args = [
+ "log",
+ "-1",
+ "--date=short",
+ "--format=%cd",
+ *extra_args,
+ "--",
+ rel_path,
+ ]
+ code, out, _err = _run_git(args, cwd=repo_root)
+ if code != 0:
+ return None
+ return _parse_git_iso_date(out)
+
+
+def git_last_touched(repo_root: Path, rel_path: str) -> date | None:
+ return _git_log_date(repo_root, rel_path)
+
+
+def git_last_content_updated(repo_root: Path, rel_path: str) -> tuple[date | None, bool]:
+ d = _git_log_date(
+ repo_root,
+ rel_path,
+ extra_args=[
+ "--fixed-strings",
+ "--invert-grep",
+ "--grep",
+ META_COMMIT_MARKER,
+ ],
+ )
+ if d is not None:
+ return d, False
+ return git_last_touched(repo_root, rel_path), True
+
+
+FRONTMATTER_RE = re.compile(r"^---\s*$")
+
+
+def read_md_frontmatter(text: str) -> tuple[dict | None, str, str | None]:
+ lines = text.splitlines(keepends=True)
+ if not lines or not FRONTMATTER_RE.match(lines[0]):
+ return None, text, None
+
+ end_idx = None
+ for i in range(1, min(len(lines), 5000)):
+ if FRONTMATTER_RE.match(lines[i]):
+ end_idx = i
+ break
+
+ if end_idx is None:
+ return None, text, "unterminated_markdown_frontmatter"
+
+ fm_text = "".join(lines[1:end_idx])
+ body = "".join(lines[end_idx + 1 :])
+
+ if yaml is None:
+ raise RuntimeError("PyYAML is required to parse Markdown frontmatter")
+
+ fm = yaml.safe_load(fm_text) if fm_text.strip() else {}
+ if not isinstance(fm, dict):
+ return None, text, "markdown_frontmatter_not_mapping"
+
+ return fm, body, None
+
+
+def dump_md_frontmatter(frontmatter: dict, body: str) -> str:
+ if yaml is None:
+ raise RuntimeError("PyYAML is required to write Markdown frontmatter")
+ fm_text = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True)
+ body_to_write = body
+ if body_to_write.startswith("\n"):
+ body_to_write = body_to_write[1:]
+ return "---\n" + fm_text + "---\n" + body_to_write
+
+
+def read_ipynb_meta(path: Path) -> tuple[Any, dict, bool]:
+ """
+ Read a notebook using nbformat.
+ Returns (notebook_node, deeplabcut_meta_dict, has_dlc_namespace).
+ """
+ nb = nbformat.read(str(path), as_version=4)
+
+ meta = getattr(nb, "metadata", {}) or {}
+ has_dlc = DLC_NAMESPACE in meta
+
+ raw_dlc_meta = meta.get(DLC_NAMESPACE)
+ return nb, raw_dlc_meta, has_dlc
+
+
+def notebook_is_normalized(path: Path, nb: Any) -> bool:
+ original = path.read_text(encoding="utf-8")
+ # Normalize newline style so CRLF vs LF differences do not cause false mismatches
+ original_normalized = original.replace("\r\n", "\n").replace("\r", "\n")
+ normalized = nbformat.writes(nb, version=4, indent=2, ensure_ascii=False) + "\n"
+ return original_normalized == normalized
+
+
+def write_ipynb_meta(path: Path, nb: Any) -> None:
+ """
+ Write a notebook using nbformat.
+
+ Note: nbformat writes JSON in a canonical form; it *will* rewrite the file,
+ so expect diffs if the notebook wasn't previously normalized to the same style.
+ """
+ # Validate before writing (optional but recommended)
+ nbformat.validate(nb)
+
+ # Use a stable indentation to reduce churn (choose 2 if your repo tends that way)
+ text = nbformat.writes(nb, version=4, indent=2, ensure_ascii=False)
+
+ path.write_text(text + "\n", encoding="utf-8")
+
+
+def parse_dlc_meta(raw: Any) -> tuple[DLCMeta | None, bool]:
+ # returns (meta, valid)
+ if raw is None or not isinstance(raw, dict):
+ return None, False
+ try:
+ return DLCMeta.model_validate(raw), True
+ except ValidationError:
+ return None, False
+
+
+def meta_to_jsonable(meta: DLCMeta) -> dict:
+ """
+ Return JSON-serializable metadata (dates become ISO strings).
+ This prevents json.dumps() from failing when writing .ipynb files.
+ """
+ return meta.model_dump(mode="json", exclude_none=True)
+
+
+def compute_days_since(d: date | None, today: date) -> int | None:
+ return None if d is None else (today - d).days
+
+
+def match_allowlist(rel_path: str, allowlist: list[str]) -> bool:
+ # Support exact matches or glob patterns
+ return any(pat == rel_path or fnmatch.fnmatch(rel_path, pat) for pat in allowlist)
+
+
+# -----------------------------
+# Core scanning
+# -----------------------------
+
+
+def load_config(config_path: Path) -> ToolConfig:
+ if yaml is None:
+ raise RuntimeError("PyYAML is required (pip install pyyaml)")
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
+ return ToolConfig.model_validate(raw)
+
+
+def scan_files(repo_root: Path, cfg: ToolConfig, targets: list[str] | None = None) -> list[FileRecord]:
+ today = _iso_today()
+ paths = glob_paths(repo_root, cfg.scan.include)
+ records: list[FileRecord] = []
+ target_specs = compile_target_specs(targets, repo_root)
+
+ for p in paths:
+ rel = str(p.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ if is_excluded(rel, cfg.scan.exclude):
+ continue
+ if not target_matches(rel, target_specs):
+ continue
+ kind = file_kind(p)
+ rec = FileRecord(path=rel, kind=kind)
+
+ rec.last_git_touched = git_last_touched(repo_root, rel)
+ rec.last_content_updated, used_fallback = git_last_content_updated(repo_root, rel)
+ rec.days_since_content_update = compute_days_since(rec.last_content_updated, today)
+ if used_fallback:
+ rec.warnings.append("content_date_fallback_to_git_touched")
+
+ try:
+ if kind == "ipynb":
+ nb, raw_meta, has_dlc = read_ipynb_meta(p)
+
+ try:
+ nbformat.validate(nb)
+ except NotebookValidationError as e:
+ rec.errors.append(f"nbformat_invalid: {e}")
+
+ try:
+ if not notebook_is_normalized(p, nb):
+ rec.warnings.append("notebook_not_normalized")
+ except Exception as e:
+ rec.errors.append(f"notebook_normalization_check_failed: {e}")
+
+ if not has_dlc:
+ rec.meta = None
+ rec.warnings.append("missing_metadata")
+ else:
+ rec.meta, valid = parse_dlc_meta(raw_meta)
+ if not valid:
+ rec.meta = None
+ rec.warnings.append("invalid_metadata")
+
+ elif kind == "md":
+ text = p.read_text(encoding="utf-8")
+ fm, _body, fm_error = read_md_frontmatter(text)
+
+ if fm_error:
+ rec.meta = None
+ rec.warnings.append("invalid_metadata")
+ rec.errors.append(f"markdown_frontmatter_invalid: {fm_error}")
+ else:
+ fm = fm or {}
+ has_dlc = DLC_NAMESPACE in fm
+ raw = fm.get(DLC_NAMESPACE)
+
+ if not has_dlc:
+ rec.meta = None
+ rec.warnings.append("missing_metadata")
+ else:
+ rec.meta, valid = parse_dlc_meta(raw)
+ if not valid:
+ rec.meta = None
+ rec.warnings.append("invalid_metadata")
+
+ else:
+ rec.meta = None
+
+ except Exception as e:
+ rec.errors.append(f"metadata_read_failed: {e}")
+
+ # ignore=True means: keep reporting diagnostics, but skip freshness/policy logic
+ if rec.meta and rec.meta.ignore:
+ records.append(rec)
+ continue
+
+ last_verified = rec.meta.last_verified if rec.meta else None
+ rec.days_since_verified = compute_days_since(last_verified, today)
+
+ # Future dates are data errors
+ if rec.last_content_updated is not None and rec.last_content_updated > today:
+ rec.errors.append("future_last_content_updated")
+ if rec.meta and rec.meta.last_metadata_updated is not None:
+ if rec.meta.last_metadata_updated > today:
+ rec.errors.append("future_last_metadata_updated")
+ if last_verified is not None and last_verified > today:
+ rec.errors.append("future_last_verified")
+
+ pol = cfg.policy
+
+ if (
+ rec.days_since_content_update is not None
+ and rec.days_since_content_update > pol.warn_if_content_older_than_days
+ ):
+ rec.warnings.append(f"content_stale>{pol.warn_if_content_older_than_days}d")
+
+ if last_verified is None and pol.missing_last_verified_is_warning:
+ rec.warnings.append("missing_last_verified")
+ elif rec.days_since_verified is not None and rec.days_since_verified > pol.warn_if_verified_older_than_days:
+ rec.warnings.append(f"verified_stale>{pol.warn_if_verified_older_than_days}d")
+
+ records.append(rec)
+
+ return records
+
+
+# -----------------------------
+# Update mode
+# -----------------------------
+def _require_meta_marker_ack(write: bool, ack_marker: bool) -> None:
+ """
+ Guardrail: writing metadata/normalization without the marker convention will
+ destroy the meaning of content freshness signals. Require an explicit ack.
+ """
+ if not write:
+ return
+ if ack_marker:
+ return
+ raise SystemExit(
+ "Refusing to write without acknowledging metadata-commit convention.\n"
+ "Re-run with --ack-meta-commit-marker and commit with:\n"
+ f" {SUGGESTED_TAGGED_COMMIT}\n"
+ )
+
+
+def update_files(
+ repo_root: Path,
+ cfg: ToolConfig,
+ targets: list[str] | None,
+ write: bool,
+ set_content_date_from_git: bool,
+ set_last_verified: date | None,
+ set_verified_for: str | None,
+ ack_meta_commit_marker: bool,
+) -> list[FileRecord]:
+ today = _iso_today()
+ records = scan_files(repo_root, cfg, targets=targets)
+
+ for rec in records:
+ if rec.kind not in {"ipynb", "md"}:
+ continue
+ if rec.meta and rec.meta.ignore:
+ continue
+
+ meta = rec.meta or DLCMeta()
+
+ # Build the desired metadata WITHOUT touching last_metadata_updated.
+ if set_content_date_from_git and rec.last_content_updated is not None:
+ meta.last_content_updated = rec.last_content_updated
+
+ if set_last_verified is not None:
+ meta.last_verified = set_last_verified
+ if set_verified_for is not None:
+ meta.verified_for = set_verified_for
+
+ desired_base = meta_to_jsonable(meta)
+ abs_path = repo_root / rec.path
+ changed = False
+
+ if rec.kind == "ipynb":
+ nb, _raw, _has_dlc = read_ipynb_meta(abs_path)
+ nb_meta = nb.setdefault("metadata", {})
+ prev = nb_meta.get(DLC_NAMESPACE, {})
+ if not isinstance(prev, dict):
+ prev = {}
+
+ merged_base = dict(prev)
+ merged_base.update(desired_base)
+
+ if merged_base != prev:
+ changed = True
+ if write:
+ _require_meta_marker_ack(write=True, ack_marker=ack_meta_commit_marker)
+
+ meta.last_metadata_updated = today
+ desired_final = meta_to_jsonable(meta)
+
+ merged_final = dict(prev)
+ merged_final.update(desired_final)
+ nb_meta[DLC_NAMESPACE] = merged_final
+ write_ipynb_meta(abs_path, nb)
+
+ elif rec.kind == "md":
+ text = abs_path.read_text(encoding="utf-8")
+ fm, body, fm_error = read_md_frontmatter(text)
+ if fm_error:
+ msg = f"markdown_frontmatter_invalid: {fm_error}"
+ if msg not in rec.errors:
+ rec.errors.append(msg)
+ continue
+
+ fm = fm or {}
+
+ prev = fm.get(DLC_NAMESPACE, {})
+ if not isinstance(prev, dict):
+ prev = {}
+
+ merged_base = dict(prev)
+ merged_base.update(desired_base)
+
+ if merged_base != prev:
+ changed = True
+ if write:
+ _require_meta_marker_ack(write=True, ack_marker=ack_meta_commit_marker)
+
+ meta.last_metadata_updated = today
+ desired_final = meta_to_jsonable(meta)
+
+ merged_final = dict(prev)
+ merged_final.update(desired_final)
+ fm[DLC_NAMESPACE] = merged_final
+ abs_path.write_text(dump_md_frontmatter(fm, body), encoding="utf-8")
+
+ rec.would_change = changed
+ rec.meta = meta
+ rec.days_since_verified = compute_days_since(meta.last_verified, today)
+
+ return records
+
+
+# -----------------------------
+# Notebook formatting
+# -----------------------------
+def normalize_notebooks(
+ repo_root: Path,
+ cfg: ToolConfig,
+ targets: list[str] | None,
+ write: bool,
+ ack_meta_commit_marker: bool,
+) -> list[FileRecord]:
+ """
+ Normalize notebooks deterministically (canonical nbformat JSON).
+ This is intentionally separated from update() because it causes churn.
+ """
+ _require_meta_marker_ack(write=write, ack_marker=ack_meta_commit_marker)
+ records = scan_files(repo_root, cfg, targets=targets)
+ today = _iso_today()
+
+ for rec in records:
+ if rec.kind != "ipynb":
+ continue
+ if rec.meta and rec.meta.ignore:
+ continue
+
+ abs_path = repo_root / rec.path
+ try:
+ nb, _raw, _has_dlc = read_ipynb_meta(abs_path)
+ nbformat.validate(nb)
+
+ if not notebook_is_normalized(abs_path, nb):
+ rec.would_change = True
+ if write:
+ # Update embedded maintenance timestamp
+ meta = rec.meta or DLCMeta()
+ meta.last_metadata_updated = today
+
+ nb_meta = nb.setdefault("metadata", {})
+ prev = nb_meta.get(DLC_NAMESPACE, {})
+ if not isinstance(prev, dict):
+ prev = {}
+ merged = dict(prev)
+ merged.update(meta_to_jsonable(meta))
+ nb_meta[DLC_NAMESPACE] = merged
+
+ # Write to persist metadata update (still canonical)
+ write_ipynb_meta(abs_path, nb)
+ rec.meta = meta
+
+ except Exception as e:
+ rec.errors.append(f"normalize_failed: {e}")
+
+ return records
+
+
+# -----------------------------
+# Output formatting
+# -----------------------------
+
+
+def summarize(records: list[FileRecord]) -> dict[str, int]:
+ return {
+ "files": len(records),
+ "warnings": sum(1 for r in records if r.warnings),
+ "errors": sum(1 for r in records if r.errors),
+ "missing_metadata": sum(1 for r in records if "missing_metadata" in r.warnings),
+ "missing_last_verified": sum(1 for r in records if "missing_last_verified" in r.warnings),
+ "content_stale": sum(1 for r in records if any(w.startswith("content_stale") for w in r.warnings)),
+ "verified_stale": sum(1 for r in records if any(w.startswith("verified_stale") for w in r.warnings)),
+ }
+
+
+def to_markdown(report: Report, cfg: ToolConfig) -> str:
+ pol = cfg.policy
+ t = report.totals
+ lines: list[str] = []
+
+ lines.append("# 🌡️ DeepLabCut freshness report\n")
+ lines.append(f"Generated: {report.generated_at.isoformat()}\n")
+ lines.append(f"Schema: v{report.schema_version}\n\n")
+
+ lines.append("## Summary\n")
+ lines.append(f"- Files scanned: **{t['files']}**\n")
+ lines.append(f"- Files with warnings: **{t['warnings']}**\n")
+ lines.append(f"- Files with scanning errors: **{t['errors']}**\n")
+ lines.append(f"- Missing metadata: **{t['missing_metadata']}**\n")
+ lines.append(f"- Missing last_verified: **{t['missing_last_verified']}**\n")
+ lines.append(f"- Content-stale (> {pol.warn_if_content_older_than_days}d): **{t['content_stale']}**\n")
+ lines.append(f"- Verification-stale (> {pol.warn_if_verified_older_than_days}d): **{t['verified_stale']}**\n\n")
+
+ def fmt_date(d: date | None) -> str:
+ return d.isoformat() if d else "-"
+
+ warn_recs = [r for r in report.records if r.warnings and not (r.meta and r.meta.ignore)]
+ warn_recs.sort(
+ key=lambda r: (
+ -(r.days_since_verified or -1),
+ -(r.days_since_content_update or -1),
+ r.path,
+ )
+ )
+
+ if warn_recs:
+ lines.append("## Warnings\n")
+ for r in warn_recs:
+ meta = r.meta
+ lines.append(f"- **{r.path}** ({r.kind})\n")
+ lines.append(
+ f" - last_content_updated: {fmt_date(r.last_content_updated)} "
+ f"(days: {r.days_since_content_update if r.days_since_content_update is not None else '-'})\n"
+ )
+ if r.last_git_touched:
+ lines.append(f" - last_git_touched: {fmt_date(r.last_git_touched)}\n")
+ if meta and meta.last_metadata_updated:
+ lines.append(f" - last_metadata_updated: {fmt_date(meta.last_metadata_updated)}\n")
+ lv = meta.last_verified if meta else None
+ lines.append(
+ f" - last_verified: {fmt_date(lv)} "
+ f"(days: {r.days_since_verified if r.days_since_verified is not None else '-'})\n"
+ )
+ if meta and meta.verified_for:
+ lines.append(f" - verified_for: {meta.verified_for}\n")
+ if meta and meta.tier:
+ lines.append(f" - tier: {meta.tier}\n")
+ lines.append(f" - warnings: {', '.join(r.warnings)}\n")
+ if r.errors:
+ lines.append(f" - errors: {', '.join(r.errors)}\n")
+ lines.append("\n")
+
+ err_recs = [r for r in report.records if r.errors]
+ if err_recs:
+ lines.append("## Scan errors\n")
+ for r in err_recs:
+ lines.append(f"- **{r.path}**: {', '.join(r.errors)}\n")
+ lines.append("\n")
+
+ lines.append("## Notes\n")
+ lines.append("- 'Out of date' does not necessarily mean 'broken'. Use this as a triage signal.\n")
+ lines.append(
+ "- last_git_touched / last_content_updated are computed from git history. "
+ "last_verified is human-controlled.\n\n"
+ )
+ lines.append(
+ "- In `check` mode, scan/parsing errors are reported for visibility but do not "
+ "fail by default unless strict mode is enabled or they trigger an enforced policy rule.\n"
+ )
+ return "".join(lines)
+
+
+def write_outputs(report: Report, cfg: ToolConfig, out_dir: Path) -> tuple[Path, Path]:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ json_path = out_dir / f"{OUTPUT_FILENAME}.json"
+ md_path = out_dir / f"{OUTPUT_FILENAME}.md"
+
+ payload = report.model_dump(mode="json")
+
+ json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ md_path.write_text(to_markdown(report, cfg), encoding="utf-8")
+ return json_path, md_path
+
+
+# -----------------------------
+# Check enforcement
+# -----------------------------
+def enforce(cfg: ToolConfig, records: list[FileRecord]) -> list[str]:
+ pol = cfg.policy
+ violations: list[str] = []
+ today = _iso_today()
+
+ for r in records:
+ if r.meta and r.meta.ignore:
+ continue
+ if r.kind not in {"ipynb", "md"}:
+ continue
+
+ has_invalid_metadata = "invalid_metadata" in (r.warnings or [])
+
+ if match_allowlist(r.path, pol.require_metadata):
+ if has_invalid_metadata:
+ violations.append(f"{r.path}: invalid metadata")
+ elif r.meta is None:
+ violations.append(f"{r.path}: missing metadata")
+
+ if match_allowlist(r.path, pol.require_recent_verification):
+ if has_invalid_metadata:
+ violations.append(f"{r.path}: invalid metadata")
+ else:
+ lv = r.meta.last_verified if r.meta else None
+ if lv is None:
+ violations.append(f"{r.path}: missing last_verified")
+ else:
+ days = (today - lv).days
+ if days > pol.warn_if_verified_older_than_days:
+ violations.append(
+ f"{r.path}: last_verified is {days}d old (> {pol.warn_if_verified_older_than_days}d)"
+ )
+
+ if r.kind == "ipynb" and match_allowlist(r.path, pol.require_notebook_normalized):
+ if "notebook_not_normalized" in (r.warnings or []):
+ violations.append(f"{r.path}: notebook is not normalized (run update/format)")
+
+ return violations
+
+
+# -----------------------------
+# CLI
+# -----------------------------
+
+
+def parse_date_token(token: str) -> date:
+ token = token.strip().lower()
+ if token in {"today", "now"}:
+ return _iso_today()
+ return date.fromisoformat(token)
+
+
+def collect_scan_issues(records: list[FileRecord], target: Literal["errors", "warnings"]) -> list[str]:
+ items: list[str] = []
+ for r in records:
+ for e in getattr(r, target, []):
+ items.append(f"{r.path}: {e}")
+ return items
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="DeepLabCut checks tool (docs + notebooks)")
+ parser.add_argument("--config", default=str(DEFAULT_CFG), help="Path to YAML config file")
+ parser.add_argument(
+ "--no-step-summary",
+ action="store_true",
+ help="Do not write to GITHUB_STEP_SUMMARY",
+ )
+ parser.add_argument("--out-dir", default=f"tmp/{OUTPUT_FILENAME}", help="Directory to write outputs")
+
+ sub = parser.add_subparsers(dest="cmd", required=True)
+ rep = sub.add_parser("report", help="Generate staleness report (read-only)")
+ rep.add_argument(
+ "--targets",
+ nargs="*",
+ help=(
+ "Optional repo-relative targets to limit the operation. "
+ "Supports exact files, directories, and glob patterns "
+ "(e.g. docs/page.md, docs/gui/, 'docs/**/*.md'). "
+ "Both '/' and '\\' are accepted."
+ ),
+ )
+
+ chk = sub.add_parser(
+ "check",
+ help=(
+ "Run scans + policy checks (read-only). "
+ "Fails on enforced policy violations; scan errors are non-fatal by default."
+ ),
+ )
+ chk.add_argument(
+ "--targets",
+ nargs="*",
+ help=(
+ "Optional list of relative file paths to scan (limits scan to these files). "
+ "Supports exact files, directories, and glob patterns (e.g. docs/page.md, docs/gui/, 'docs/**/*.md'). "
+ "Both '/' and '\\' are accepted."
+ ),
+ )
+ chk.add_argument(
+ "--strict-mode",
+ action="store_true",
+ help="Enable failure on scan/parsing errors (overrides config for this run)",
+ )
+
+ up = sub.add_parser("update", help="Update metadata/frontmatter (write mode requires --write)")
+ up.add_argument(
+ "--write",
+ action="store_true",
+ help="Actually write changes (otherwise dry-run)",
+ )
+ up.add_argument(
+ "--set-content-date-from-git",
+ action="store_true",
+ help="Set embedded last_content_updated from computed git content date",
+ )
+ up.add_argument(
+ "--targets",
+ nargs="*",
+ help=(
+ "Optional list of relative file paths to update. "
+ "Supports exact files, directories, and glob patterns (e.g. docs/page.md, docs/gui/, 'docs/**/*.md'). "
+ "Both '/' and '\\' are accepted."
+ ),
+ )
+ up.add_argument("--set-last-verified", default=None, help="YYYY-MM-DD or 'today'")
+ up.add_argument("--set-verified-for", default=None, help="String like 3.0.0rc13")
+ up.add_argument(
+ "--ack-meta-commit-marker",
+ action="store_true",
+ help=f"Acknowledge that you will commit changes using marker: {META_COMMIT_MARKER}",
+ )
+
+ norm = sub.add_parser(
+ "normalize",
+ help="Normalize notebooks deterministically (write mode requires --write)",
+ )
+ norm.add_argument(
+ "--write",
+ action="store_true",
+ help="Actually write changes (otherwise dry-run)",
+ )
+ norm.add_argument(
+ "--targets",
+ nargs="*",
+ help=(
+ "Optional list of relative notebook paths to normalize. "
+ "Supports exact files, directories, and glob patterns "
+ "(e.g. notebooks/example.ipynb, notebooks/, 'notebooks/**/*.ipynb'). "
+ "Both '/' and '\\' are accepted."
+ ),
+ )
+ norm.add_argument(
+ "--ack-meta-commit-marker",
+ action="store_true",
+ help=f"Acknowledge that you will commit changes using marker: {META_COMMIT_MARKER}",
+ )
+
+ args = parser.parse_args(list(argv) if argv is not None else None)
+
+ config_path = Path(args.config)
+ repo_root = find_repo_root(Path.cwd())
+ cfg = load_config(config_path)
+ out_dir = Path(args.out_dir)
+
+ requested_targets = getattr(args, "targets", None)
+
+ if requested_targets:
+ matched_paths, unmatched_targets = validate_requested_targets(repo_root, cfg, requested_targets)
+ print_target_match_summary(matched_paths, requested_targets)
+
+ if unmatched_targets:
+ print("\nUnmatched --targets:")
+ for raw in unmatched_targets:
+ print(f"- {raw}")
+ print(
+ "\nEach --targets selector must match at least one file in the configured scan set. "
+ "Use repo-relative paths, directories, or glob patterns "
+ "(for example: docs/page.md, docs/gui/, 'docs/**/*.md')."
+ )
+ return 2
+
+ if args.cmd in {"report", "check"}:
+ records = scan_files(repo_root, cfg, targets=getattr(args, "targets", None))
+ elif args.cmd == "update":
+ lv = parse_date_token(args.set_last_verified) if args.set_last_verified else None
+ records = update_files(
+ repo_root,
+ cfg,
+ targets=args.targets,
+ write=bool(args.write),
+ set_content_date_from_git=bool(args.set_content_date_from_git),
+ set_last_verified=lv,
+ set_verified_for=args.set_verified_for,
+ ack_meta_commit_marker=bool(args.ack_meta_commit_marker),
+ )
+ if args.write:
+ print(f"\nSuggested commit message:\n {SUGGESTED_TAGGED_COMMIT}\n")
+
+ else: # normalize
+ records = normalize_notebooks(
+ repo_root,
+ cfg,
+ targets=args.targets,
+ write=bool(args.write),
+ ack_meta_commit_marker=bool(args.ack_meta_commit_marker),
+ )
+ if args.write:
+ print(f"\nSuggested commit message:\n {SUGGESTED_TAGGED_COMMIT}\n")
+
+ report = Report(
+ generated_at=datetime.now(timezone.utc),
+ repo_root=str(repo_root),
+ config_path=str(config_path),
+ totals=summarize(records),
+ records=records,
+ )
+
+ json_path, md_path = write_outputs(report, cfg, out_dir)
+
+ # Emit GitHub Actions job summary if available
+ emit_summary = not getattr(args, "no_step_summary", False)
+ step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
+ if emit_summary and step_summary and md_path.exists():
+ try:
+ content = md_path.read_text(encoding="utf-8")
+ # snippet = "\n".join(content.splitlines()[:220]) + "\n"
+ snippet = "\n".join(content.splitlines()[:]) + "\n"
+ Path(step_summary).write_text(snippet, encoding="utf-8")
+ except Exception:
+ pass
+
+ scan_errors = collect_scan_issues(records, target="errors")
+ if scan_errors:
+ print("\nScan errors detected (non-fatal by default):")
+ for item in scan_errors[:20]:
+ print(f"- {item}")
+ if len(scan_errors) > 20:
+ print(f"... and {len(scan_errors) - 20} more (see report for full details)")
+
+ if args.cmd == "check":
+ violations = enforce(cfg, records)
+ if violations:
+ print("Policy violations:")
+ for v in violations:
+ print(f"- {v}")
+ return 2
+ strict_mode = bool((args.strict_mode) or cfg.policy.fail_on_scan_errors)
+ if strict_mode and scan_errors:
+ print("Strict mode enabled: failing due to scan/parsing errors.")
+ return 1
+
+ # Non-zero if metadata parsing errors occurred for non-report/check commands
+ if args.cmd not in {"report", "check"} and any(r.errors for r in records):
+ return 1
+ else:
+ print("\nReport generated:")
+ print(f"- JSON: {json_path}")
+ print(f"- Markdown: {md_path}")
+
+ if any(r.warnings for r in records):
+ print("Warnings detected; see report for details.")
+ if any(r.errors for r in records):
+ print("Scan errors detected; see report for details.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/docs_and_notebooks_report_config.yml b/tools/docs_and_notebooks_report_config.yml
new file mode 100644
index 0000000000..60dfcb21f8
--- /dev/null
+++ b/tools/docs_and_notebooks_report_config.yml
@@ -0,0 +1,26 @@
+version: 1
+
+scan:
+ include:
+ - "examples/COLAB/**/*.ipynb"
+ - "examples/JUPYTER/**/*.ipynb"
+ - "docs/**/*.md"
+ - "docs/**/*.ipynb" # if notebooks get added to docs (Jupyter Book supports this)
+ exclude:
+ - "**/.ipynb_checkpoints/**"
+ - "**/_build/**"
+ - "**/build/**"
+
+policy:
+ warn_if_content_older_than_days: 365
+ warn_if_verified_older_than_days: 365
+ missing_last_verified_is_warning: true
+
+ # Ratchet lists for tiered verification requirements.
+ # Tiers have to be determined, and crucial targets identified.
+ # Then specific policies can be set for each tier,
+ # e.g. requiring more recent verification for higher tiers,
+ # or requiring verification for more recent versions.
+ require_metadata: []
+ require_recent_verification: []
+ require_notebook_normalized: []
diff --git a/tools/docs_and_notebooks_tool_README.md b/tools/docs_and_notebooks_tool_README.md
new file mode 100644
index 0000000000..fa27c5dc8f
--- /dev/null
+++ b/tools/docs_and_notebooks_tool_README.md
@@ -0,0 +1,190 @@
+# Docs & Notebooks Checks Tool
+
+This tool scans DeepLabCut documentation pages and notebooks and produces **two independent signals**:
+
+- **`last_content_updated`**: computed from git history as the last *meaningful content* update **excluding metadata-only commits**.
+- **`last_verified`**: a human-controlled date indicating the content was verified to work/be accurate.
+
+In addition, the tool can optionally track:
+
+- **`last_metadata_updated`**: when the tool last performed a metadata/normalization write (helps explain “file changed” without implying content changed).
+- **`verified_for`**: a human-controlled string indicating what the content was verified against (e.g. `3.0.0rc13`).
+
+The tool is designed to be:
+
+- **Safe by default**: CI should run **read-only** modes (`report` / `check`).
+- **Deterministic**: stable outputs and normalized notebook formatting when explicitly requested.
+- **Future-proof**: versioned Pydantic schemas (`schema_version`).
+
+---
+
+## What gets scanned
+
+Default include patterns are defined in `tools/docs_and_notebooks_report_config.yml`.
+Typical patterns include:
+
+- `examples/COLAB/**/*.ipynb`
+- `examples/JUPYTER/**/*.ipynb`
+- `docs/**/*.md`
+- `docs/**/*.ipynb` (if notebooks are added under docs)
+
+You can further restrict the scan via `--targets`.
+
+---
+
+## Metadata storage locations
+
+### Notebooks (`.ipynb`)
+
+The tool **only** reads/writes **top-level notebook metadata** under the `deeplabcut` namespace.
+
+> [!IMPORTANT]
+> It never edits notebook cells, outputs, or execution counts.
+
+Example (excerpt):
+
+```json
+{
+ "metadata": {
+ "deeplabcut": {
+ "last_content_updated": "2020-01-01",
+ "last_metadata_updated": "2026-03-05",
+ "last_verified": "2026-02-20",
+ "verified_for": "3.0.0rc13",
+ "ignore": false
+ }
+ }
+}
+```
+
+> [!NOTE]
+> `tier` is intentionally optional and is not auto-populated.
+
+### Markdown (`.md`)
+
+The tool reads/writes YAML frontmatter at the top of the file (if present):
+
+```yaml
+---
+deeplabcut:
+ last_content_updated: 2020-01-01
+ last_metadata_updated: 2026-03-05
+ last_verified: 2026-02-20
+ verified_for: 3.0.0rc13
+ ignore: false
+---
+```
+
+If a doc page has **no** frontmatter, the tool can still report staleness (read-only), and `update` can add/modify metadata when explicitly requested.
+
+---
+
+## The metadata-commit marker (critical)
+
+Because metadata updates and notebook normalization can rewrite files, they would normally make git (correctly) report that the file was “updated now”.
+
+To preserve a meaningful **`last_content_updated`**, **all metadata-only / normalization commits must include the marker**:
+
+- **Marker**: `META_COMMIT_MARKER` (see `tools/docs_and_notebooks_check.py`)
+- **Suggested commit message**: `SUGGESTED_META_COMMIT_MESSAGE`
+
+When you run `update --write` or `normalize --write`, the tool will:
+
+- Require `--ack-meta-commit-marker` (guardrail)
+- Print a suggested commit message
+
+> [!WARNING]
+> If the marker changes in the future, previous iterations still HAVE to be acknowledged to avoid false positives.
+
+---
+
+## Commands
+
+### 1) Report (read-only)
+
+Generate a report (does not modify files):
+
+```bash
+python tools/docs_and_notebooks_check.py report
+```
+
+Writes (by default):
+
+- `tmp/docs_nb_checks/docs_nb_checks.json`
+- `tmp/docs_nb_checks/docs_nb_checks.md`
+
+### 2) Check (read-only; may fail)
+
+Run policy checks. By default, CI will not fail unless allowlists are configured.
+
+```bash
+python tools/docs_and_notebooks_check.py check
+```
+
+The allowlists live in `tools/docs_and_notebooks_report_config.yml`.
+They are currently empty, but can help enforce stricter policies once populated (start empty; "ratchet" later).
+
+### 3) Update metadata (write mode; explicit intent)
+
+> [!WARNING]
+> `update --write` modifies tracked files. Intended for maintainers (manual), not CI.
+
+#### 3a) Set `last_content_updated` from git (excluding meta commits)
+
+```bash
+python tools/docs_and_notebooks_check.py update --write --set-content-date-from-git --ack-meta-commit-marker
+```
+
+#### 3b) Set verification fields (human-controlled)
+
+```bash
+python tools/docs_and_notebooks_check.py update --write --targets docs/page.md examples/JUPYTER/foo.ipynb --set-last-verified today --set-verified-for 3.0.0rc13 --ack-meta-commit-marker
+```
+
+> Tip: omit `--targets` to operate on all scanned files.
+
+### 4) Normalize notebooks (explicit churn)
+
+> [!IMPORTANT]
+> Notebook normalization rewrites the notebook JSON into a canonical form.
+> As such, it is provided as a separate command.
+
+Dry-run (shows which files *would* change):
+
+```bash
+python tools/docs_and_notebooks_check.py normalize --targets docs/notebook.ipynb
+```
+
+Write:
+
+```bash
+python tools/docs_and_notebooks_check.py normalize --write --targets docs/notebook.ipynb --ack-meta-commit-marker
+```
+
+---
+
+## CI integration
+
+Recommended CI usage:
+
+- Run `report` on PRs and upload the outputs as artifacts.
+- Run `check` once allowlists are populated (start empty to avoid failures).
+
+> [!IMPORTANT]
+> Use `actions/checkout` with `fetch-depth: 0` (or sufficiently deep) so `git log` sees history; shallow clones can cause missing or fallback timestamps.
+
+Dependencies required for this tool (install in the CI job):
+
+```bash
+pip install pydantic pyyaml nbformat
+```
+
+---
+
+## Troubleshooting
+
+- If you see `content_date_fallback_to_git_touched`, it usually means one of:
+ - The checkout history is too shallow, or
+ - *All* commits touching the file are metadata commits with the marker.
+
+- If Pydantic raises `class-not-fully-defined` errors, ensure the tool calls `.model_rebuild()` for its models (this is already done in the tool).
diff --git a/tools/find_import_cycles.py b/tools/find_import_cycles.py
new file mode 100644
index 0000000000..743392dc15
--- /dev/null
+++ b/tools/find_import_cycles.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import ast
+from collections import defaultdict
+from pathlib import Path
+
+
+def path_to_module(root: Path, file: Path) -> str:
+ rel = file.relative_to(root)
+ parts = rel.with_suffix("").parts
+ if parts[-1] == "__init__":
+ parts = parts[:-1]
+ return ".".join((root.name, *parts)) if parts else root.name
+
+
+def module_to_file_map(root: Path) -> dict[str, Path]:
+ mapping = {}
+ for file in root.rglob("*.py"):
+ mod = path_to_module(root, file)
+ mapping[mod] = file
+ return mapping
+
+
+def resolve_relative_import(current_module: str, module: str | None, level: int) -> str | None:
+ parts = current_module.split(".")
+ if level > len(parts):
+ return None
+ base = parts[:-level]
+ if module:
+ return ".".join(base + module.split("."))
+ return ".".join(base)
+
+
+def extract_imports(file: Path, current_module: str) -> set[str]:
+ source = file.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(file))
+ imports: set[str] = set()
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ imports.add(alias.name)
+ elif isinstance(node, ast.ImportFrom):
+ if node.level and current_module:
+ resolved = resolve_relative_import(current_module, node.module, node.level)
+ if resolved:
+ imports.add(resolved)
+ elif node.module:
+ imports.add(node.module)
+
+ return imports
+
+
+def internal_edges(root: Path) -> dict[str, set[str]]:
+ mod_to_file = module_to_file_map(root)
+ internal = set(mod_to_file)
+ edges: dict[str, set[str]] = defaultdict(set)
+
+ for mod, file in mod_to_file.items():
+ for imported in extract_imports(file, mod):
+ # Keep only imports that are inside the package
+ for candidate in internal:
+ if imported == candidate or imported.startswith(candidate + "."):
+ edges[mod].add(candidate)
+ break
+
+ return edges
+
+
+def find_cycles(edges: dict[str, set[str]]) -> list[list[str]]:
+ visited = set()
+ stack = []
+ on_stack = set()
+ cycles = []
+
+ def dfs(node: str):
+ visited.add(node)
+ stack.append(node)
+ on_stack.add(node)
+
+ for neighbor in edges.get(node, ()):
+ if neighbor not in visited:
+ dfs(neighbor)
+ elif neighbor in on_stack:
+ idx = stack.index(neighbor)
+ cycle = stack[idx:] + [neighbor]
+ cycles.append(cycle)
+
+ stack.pop()
+ on_stack.remove(node)
+
+ for node in edges:
+ if node not in visited:
+ dfs(node)
+
+ # Deduplicate roughly
+ seen = set()
+ unique = []
+ for cyc in cycles:
+ key = tuple(cyc)
+ if key not in seen:
+ seen.add(key)
+ unique.append(cyc)
+ return unique
+
+
+def main():
+ root = Path("deeplabcut") # change if needed
+ edges = internal_edges(root)
+ cycles = find_cycles(edges)
+
+ if not cycles:
+ print("No cycles found.")
+ return
+
+ print("Import cycles found:\n")
+ for cyc in cycles:
+ print(" -> ".join(cyc))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/ruff_cleanup_helpers.md b/tools/ruff_cleanup_helpers.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tools/ruff_report.py b/tools/ruff_report.py
new file mode 100644
index 0000000000..bfa7e6f206
--- /dev/null
+++ b/tools/ruff_report.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Generate a readable Markdown report from Ruff JSON output.
+
+Usage:
+ python ruff_report.py . --output ruff-report.md
+ python ruff_report.py src tests --output lint/ruff-report.md
+"""
+
+from __future__ import annotations
+
+import argparse
+import collections
+import json
+import os
+import subprocess
+import sys
+from collections.abc import Iterable
+from pathlib import Path
+
+RULE_NOTES = {
+ "F401": "Unused import. Usually safe to delete; verify imports with side effects.",
+ "E501": "Line too long. Prefer wrapping expressions, splitting long strings/comments, or extracting variables.",
+ "E402": "Module import not at top of file. Move imports above executable code if possible.",
+ "F403": "`from x import *` makes names unclear. Replace with explicit imports.",
+ "F405": "Likely consequence of `import *`. Import the name explicitly.",
+ "F821": "Undefined name. Usually a real bug or missing import.",
+ "E722": "Bare `except:`. Catch `Exception` or a narrower exception type.",
+ "B904": "Inside `except`, use `raise ... from e` to preserve exception chaining.",
+ "B007": "Unused loop variable. Rename to `_` or use it.",
+ "UP031": "Old `%` formatting. Convert to f-strings or `.format()` where appropriate.",
+ "E721": "Avoid direct `type(x) == Y`; prefer `isinstance(x, Y)`.",
+ "B008": "Function call in default arg. Use `None` + initialize inside the function.",
+ "B023": "Function closes over loop variable. Bind it via default arg or helper.",
+ "B024": "ABC without abstract method. Add `@abstractmethod` or remove ABC intent.",
+ "F811": "Redefined while unused. Remove duplicate or rename.",
+ "B012": "Jump statement in `finally` can swallow exceptions. Restructure flow.",
+ "B016": "Raise an exception instance/class, not a literal.",
+ "B017": "Use a more specific exception with `assertRaises`.",
+ "B020": "Loop variable overrides iterator. Rename loop variables.",
+ "B027": "Empty method in ABC without abstract decorator. Add `@abstractmethod` or implement it.",
+}
+
+
+def run_ruff(paths: Iterable[str]) -> list[dict]:
+ cmd = [sys.executable, "-m", "ruff", "check", *paths, "--output-format=json", "--exit-zero"]
+ proc = subprocess.run(cmd, capture_output=True, text=True)
+ if proc.returncode not in (0, 1):
+ print(proc.stdout)
+ print(proc.stderr, file=sys.stderr)
+ raise SystemExit(f"Failed to run Ruff: {' '.join(cmd)}")
+ data = json.loads(proc.stdout or "[]")
+ if not isinstance(data, list):
+ raise SystemExit("Unexpected Ruff JSON output")
+ return data
+
+
+def relpath(path: str) -> str:
+ try:
+ return os.path.relpath(path)
+ except Exception:
+ return path
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("paths", nargs="*", default=["."], help="Files/directories to scan")
+ parser.add_argument("--output", default="tmp/ruff-report.md", help="Markdown output path")
+ args = parser.parse_args()
+
+ issues = run_ruff(args.paths)
+
+ by_rule: dict[str, list[dict]] = collections.defaultdict(list)
+ for item in issues:
+ by_rule[item.get("code", "UNKNOWN")].append(item)
+
+ out = Path(args.output)
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ lines: list[str] = []
+ lines.append("# Ruff manual-fix report\n")
+ lines.append(f"Generated from: `{', '.join(args.paths)}`\n")
+ lines.append(f"Total remaining issues: **{len(issues)}**\n")
+
+ lines.append("## Summary\n")
+ lines.append("| Rule | Count | Note |")
+ lines.append("|---|---:|---|")
+ for rule, items in sorted(by_rule.items(), key=lambda kv: (-len(kv[1]), kv[0])):
+ note = RULE_NOTES.get(rule, "")
+ lines.append(f"| `{rule}` | {len(items)} | {note} |")
+ lines.append("")
+
+ lines.append("## Suggested triage order\n")
+ preferred = ["F403", "F405", "F821", "E722", "B904", "E402", "F401", "E501"]
+ present = [r for r in preferred if r in by_rule]
+ if present:
+ for idx, rule in enumerate(present, 1):
+ lines.append(f"{idx}. `{rule}` — {RULE_NOTES.get(rule, '')}")
+ lines.append("")
+
+ lines.append("## Table of contents by rule\n")
+ for rule, items in sorted(by_rule.items(), key=lambda kv: (-len(kv[1]), kv[0])):
+ anchor = rule.lower()
+ lines.append(f"- [{rule} ({len(items)})](#{anchor})")
+ lines.append("")
+
+ for rule, items in sorted(by_rule.items(), key=lambda kv: (-len(kv[1]), kv[0])):
+ lines.append(f"## {rule}\n")
+ lines.append(f"Count: **{len(items)}** ")
+ if rule in RULE_NOTES:
+ lines.append(f"Hint: {RULE_NOTES[rule]} ")
+ lines.append("")
+
+ file_groups: dict[str, list[dict]] = collections.defaultdict(list)
+ for item in items:
+ file_groups[relpath(item["filename"])].append(item)
+
+ lines.append("### Files affected\n")
+ lines.append("| File | Count |")
+ lines.append("|---|---:|")
+ for filename, entries in sorted(file_groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
+ lines.append(f"| `{filename}` | {len(entries)} |")
+ lines.append("")
+
+ lines.append("### Details\n")
+ for filename, entries in sorted(file_groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
+ lines.append(f"#### `{filename}` ({len(entries)})\n")
+ lines.append("| Line | Col | Message |")
+ lines.append("|---:|---:|---|")
+ for e in sorted(
+ entries, key=lambda x: (x.get("location", {}).get("row", 0), x.get("location", {}).get("column", 0))
+ ):
+ loc = e.get("location", {})
+ line = loc.get("row", "")
+ col = loc.get("column", "")
+ msg = (e.get("message", "") or "").replace("|", "\\|")
+ lines.append(f"| {line} | {col} | {msg} |")
+ lines.append("")
+ lines.append("Quick open commands:")
+ lines.append("")
+ lines.append("```powershell")
+ lines.append(f'code -g "{filename}:{entries[0].get("location", {}).get("row", 1)}"')
+ lines.append("```")
+ lines.append("")
+
+ out.write_text("\n".join(lines), encoding="utf-8")
+ print(f"Wrote {out}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/test_selector.py b/tools/test_selector.py
new file mode 100755
index 0000000000..7e50c17dec
--- /dev/null
+++ b/tools/test_selector.py
@@ -0,0 +1,950 @@
+#!/usr/bin/env python3
+"""Deterministic, strictly validated test selector for DeepLabCut.
+
+Outputs orthogonal workflow mode selections plus structured test selections:
+
+ - lanes: which workflow lanes should run (skip, docs, fast, full)
+ - pytest_paths: JSON list of pytest path arguments
+ - functional_scripts: JSON list of python script paths
+ - provenance: JSON mapping each selected test/script to the category rules that selected it
+
+Safety principles
+-----------------
+- Fail-safe: if changes cannot be determined or are ambiguous, the "full" lane is always selected.
+- Deterministic: derives diff range from GitHub Actions event payload when available.
+ * pull_request: uses merge-base(base.sha, head.sha) .. head.sha
+ * push: uses before .. after
+ * manual override: uses exactly --base-sha .. --head-sha
+ * fallback: attempts HEAD~1 .. HEAD
+- Secure: never emits shell command strings; only structured data.
+- Strict: Pydantic schema validation (extra=forbid), SHA validation, path sanitization.
+
+Intended usage in GitHub Actions
+-------------------------------
+- Checkout with sufficient history for merge-base/diff (typically fetch-depth: 0).
+- Run:
+ python tools/test_selector.py --write-github-output --json
+
+This will write the following keys to $GITHUB_OUTPUT:
+ - run_skip (bool): whether to run the skip mode
+ - run_docs (bool): whether to run the docs workflow
+ - run_fast (bool): whether to run targeted test execution
+ - run_full (bool): whether to run the full matrix/full suite workflow
+ - selected_workflows (list): list of selected workflow lanes
+ - lane_reasons (dict): reasons for selecting each workflow lane
+ - diff_mode (str): how the diff was determined
+ - pytest_paths (list): list of pytest path arguments
+ - functional_scripts (list): list of python script paths
+ - reasons (list): aggregate machine-readable reasons for the selection
+ - changed_files (list): list of changed files
+ - provenance (dict): mapping each selected test/script to the category rules that selected it
+
+Notes
+-----
+- This script intentionally keeps the routing rules simple and location-based.
+- Extend CATEGORY_RULES and FULL_SUITE_TRIGGERS as needed, keeping rules auditable.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+from collections import defaultdict
+from collections.abc import Callable, Sequence
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+
+try:
+ from .test_selector_config import (
+ CATEGORY_RULE_BY_NAME,
+ CATEGORY_RULES,
+ FULL_SUITE_TRIGGERS,
+ LINT_ONLY_FILES,
+ MINIMAL_PYTEST,
+ )
+# Allows to run as "python tools/test_selector.py" without installing as a package,
+# but still import the config from the same location.
+except ImportError: # pragma: no cover
+ from test_selector_config import (
+ CATEGORY_RULE_BY_NAME,
+ CATEGORY_RULES,
+ FULL_SUITE_TRIGGERS,
+ LINT_ONLY_FILES,
+ MINIMAL_PYTEST,
+ )
+
+
+SHA_RE = re.compile(r"^[0-9a-f]{7,40}$", re.IGNORECASE)
+# DLC_NAMESPACE = "deeplabcut"
+
+
+class DiffMode(str, Enum):
+ """How the diff was determined, for auditing and reporting."""
+
+ PR = "pr" # merge-base(base, head) .. head
+ PUSH = "push" # before .. after
+ MANUAL = "manual" # explicit base...head from CLI args
+ FALLBACK = "fallback" # HEAD^ .. HEAD
+ INITIAL = "initial" # empty tree .. HEAD
+ FALLBACK_NO_HEAD = "fallback_no_head" # couldn't resolve HEAD
+
+
+MODE_LABELS = {
+ DiffMode.PR: "Pull request (merge-base..HEAD)",
+ DiffMode.PUSH: "Push (before..after)",
+ DiffMode.MANUAL: "Manual override",
+ DiffMode.FALLBACK: "Fallback (HEAD^..HEAD)",
+ DiffMode.INITIAL: "Initial commit (empty tree..HEAD)",
+ DiffMode.FALLBACK_NO_HEAD: "Fallback (couldn't resolve HEAD)",
+}
+
+
+class LaneSelection(BaseModel):
+ """Which workflow lanes should run."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ skip: bool = False # Skip all tests (e.g. lint-only changes only)
+ docs: bool = False # Run docs build checks
+ fast: bool = False # Run targeted pytest + optional functional scripts in test workflow
+ full: bool = False # Delegate to full test workflow/matrix
+
+
+class SelectionProvenance(BaseModel):
+ """Why each selected test/script path was included."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ pytest: dict[str, list[str]] = Field(default_factory=dict)
+ scripts: dict[str, list[str]] = Field(default_factory=dict)
+
+
+class SelectorResult(BaseModel):
+ """Strict output schema."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ schema_version: int = 2
+ diff_mode: DiffMode = DiffMode.FALLBACK_NO_HEAD
+
+ lanes: LaneSelection = Field(default_factory=LaneSelection)
+
+ pytest_paths: list[str] = Field(default_factory=list)
+ functional_scripts: list[str] = Field(default_factory=list)
+ provenance: SelectionProvenance = Field(default_factory=SelectionProvenance)
+
+ reasons: list[str] = Field(default_factory=list)
+ changed_files: list[str] = Field(default_factory=list)
+ lane_reasons: dict[str, list[str]] = Field(default_factory=dict)
+
+
+SelectorResult.model_rebuild() # Ensure model is fully built at import time for validation in main()
+
+
+# -----------------------------
+# Git helpers
+# -----------------------------
+def _run_git(args: Sequence[str], cwd: Path) -> str:
+ proc = subprocess.run(
+ ["git", *args],
+ cwd=str(cwd),
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
+ return proc.stdout.strip()
+
+
+def find_repo_root() -> Path:
+ out = _run_git(["rev-parse", "--show-toplevel"], Path.cwd())
+ return Path(out).resolve()
+
+
+def _validate_sha(label: str, sha: str) -> str:
+ if not sha or not SHA_RE.match(sha):
+ raise ValueError(f"Invalid {label} SHA: {sha!r}")
+ return sha
+
+
+def _ensure_commit_exists(sha: str, cwd: Path) -> None:
+ _run_git(["cat-file", "-e", f"{sha}^{{commit}}"], cwd)
+
+
+def _load_github_event() -> dict[str, Any]:
+ path = os.environ.get("GITHUB_EVENT_PATH")
+ if not path:
+ return {}
+ try:
+ return json.loads(Path(path).read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def _normalize_relpath(p: str) -> str:
+ """Normalize and validate a repo-relative path from git output."""
+ if "\x00" in p:
+ raise ValueError("NUL byte in path")
+ p = p.strip().replace("\\", "/")
+ if not p:
+ raise ValueError("Empty path")
+ if p.startswith("/") or re.match(r"^[A-Za-z]:/", p):
+ raise ValueError(f"Absolute path not allowed: {p}")
+ parts = [x for x in p.split("/") if x not in ("", ".")]
+ if any(x == ".." for x in parts):
+ raise ValueError(f"Path traversal not allowed: {p}")
+ return "/".join(parts)
+
+
+def _empty_tree(repo: Path) -> str:
+ # Avoid hardcoding; derive the empty tree hash deterministically.
+ empty = _run_git(["hash-object", "-t", "tree", os.devnull], repo)
+ return _validate_sha("empty-tree", empty)
+
+
+def determine_diff_range(repo: Path, override_base: str | None, override_head: str | None) -> tuple[str, str, DiffMode]:
+ """Return (base_commit, head_commit, mode)."""
+ zero_sha = "0" * 40
+ event_name = os.environ.get("GITHUB_EVENT_NAME", "")
+ event = _load_github_event()
+
+ if override_base and override_head:
+ base = _validate_sha("base", override_base)
+ head = _validate_sha("head", override_head)
+ _ensure_commit_exists(base, repo)
+ _ensure_commit_exists(head, repo)
+ return base, head, DiffMode.MANUAL
+
+ if event_name == "pull_request" and "pull_request" in event:
+ base_sha = _validate_sha("base", event["pull_request"]["base"]["sha"])
+ head_sha = _validate_sha("head", event["pull_request"]["head"]["sha"])
+ _ensure_commit_exists(base_sha, repo)
+ _ensure_commit_exists(head_sha, repo)
+ # Use merge-base to approximate the PR triple-dot diff base deterministically.
+ merge_base = _run_git(["merge-base", head_sha, base_sha], repo)
+ merge_base = _validate_sha("merge-base", merge_base)
+ _ensure_commit_exists(merge_base, repo)
+ return merge_base, head_sha, DiffMode.PR
+
+ if event_name == "push" and "before" in event and "after" in event:
+ before = _validate_sha("before", event["before"])
+ after = _validate_sha("after", event["after"])
+ _ensure_commit_exists(after, repo)
+
+ if before == zero_sha:
+ empty = _empty_tree(repo)
+ return empty, after, DiffMode.INITIAL
+ try:
+ _ensure_commit_exists(before, repo)
+ return before, after, DiffMode.PUSH
+ except Exception:
+ empty = _empty_tree(repo)
+ return empty, after, DiffMode.INITIAL
+
+ # Fallback: try parent..HEAD; if no parent (initial commit), diff empty-tree..HEAD
+ try:
+ head = _validate_sha("HEAD", _run_git(["rev-parse", "HEAD"], repo))
+ _ensure_commit_exists(head, repo)
+
+ try:
+ prev = _validate_sha("HEAD^", _run_git(["rev-parse", "--verify", "HEAD^"], repo))
+ _ensure_commit_exists(prev, repo)
+ return prev, head, DiffMode.FALLBACK
+ except Exception:
+ # Initial commit (no parent): treat as "everything added"
+ empty = _empty_tree(repo)
+ return empty, head, DiffMode.INITIAL
+ except Exception:
+ return "", "", DiffMode.FALLBACK_NO_HEAD
+
+
+def changed_files(repo: Path, base: str, head: str) -> list[str]:
+ if not base or not head:
+ return []
+ out = _run_git(["diff", "--name-only", "--diff-filter=ACMRTD", base, head], repo)
+ files = [_normalize_relpath(line) for line in out.splitlines() if line.strip()]
+ return sorted(set(files))
+
+
+def _is_safe_relpath(p: str) -> bool:
+ """Safety check for a git-relative path: no absolute, no traversal, no NUL."""
+ return (
+ p
+ and "\x00" not in p
+ and not p.startswith("/")
+ and not re.match(r"^[A-Za-z]:/", p)
+ and ".." not in Path(p).parts
+ )
+
+
+def validate_selected_paths(res: SelectorResult, repo: Path) -> SelectorResult:
+ missing: list[str] = []
+
+ # validate pytest paths (files/dirs)
+ for p in res.pytest_paths:
+ if not _is_safe_relpath(p) or not (repo / p).exists():
+ missing.append(f"pytest:{p}")
+
+ # validate functional scripts (files)
+ for s in res.functional_scripts:
+ if not _is_safe_relpath(s) or not (repo / s).exists():
+ missing.append(f"script:{s}")
+
+ if missing:
+ # Fail-safe escalation: disable fast lane selection, enable full lane.
+ # Preserve docs lane if it was independently selected.
+ res.lanes.fast = False
+ res.lanes.full = True
+ res.pytest_paths = []
+ res.functional_scripts = []
+ res.provenance = SelectionProvenance()
+ res.reasons = res.reasons + ["missing_selected_paths"] + missing
+
+ lane_reasons = dict(res.lane_reasons)
+ lane_reasons.pop("fast", None)
+ full_reasons = list(lane_reasons.get("full", []))
+ full_reasons.extend(["missing_selected_paths", *missing])
+ lane_reasons["full"] = full_reasons
+ res.lane_reasons = lane_reasons
+
+ return res
+
+
+# -----------------------------
+# Decision logic
+# -----------------------------
+
+
+def _matches_any(path: str, preds: Sequence[Callable[[str], bool]]) -> bool:
+ for pred in preds:
+ try:
+ if pred(path):
+ return True
+ except Exception:
+ continue
+ return False
+
+
+def decide(files: list[str]) -> SelectorResult:
+ reasons: list[str] = []
+ lane_reasons: dict[str, list[str]] = {}
+ lanes = LaneSelection()
+
+ if not files:
+ lanes.full = True
+ full_reasons = ["no_changed_files_or_diff_unavailable"]
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=[],
+ functional_scripts=[],
+ provenance=SelectionProvenance(),
+ reasons=full_reasons,
+ changed_files=[],
+ lane_reasons={"full": full_reasons},
+ )
+
+ # Lint-only filtering (routing should ignore these files)
+ lint_only = [f for f in files if f in LINT_ONLY_FILES]
+ routed_files = [f for f in files if f not in LINT_ONLY_FILES]
+
+ if lint_only:
+ reasons.append(f"lint_only_count:{len(lint_only)}")
+
+ # If *only* lint-only files changed, skip all lanes
+ if not routed_files:
+ lanes.skip = True
+ skip_reasons = [*reasons, "lint_only"]
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=[],
+ functional_scripts=[],
+ provenance=SelectionProvenance(),
+ reasons=skip_reasons,
+ changed_files=files,
+ lane_reasons={"skip": skip_reasons},
+ )
+
+ # Docs lane is orthogonal: if any routed file matches docs, enable docs lane.
+ docs_rule = CATEGORY_RULE_BY_NAME.get("docs")
+ docs_touched = bool(docs_rule and any(_matches_any(f, docs_rule.match_any) for f in routed_files))
+ docs_matched_files = {f for f in routed_files if docs_rule and _matches_any(f, docs_rule.match_any)}
+ non_docs_routed_files = [f for f in routed_files if f not in docs_matched_files]
+
+ docs_pytests_sorted: list[str] = []
+ docs_scripts_sorted: list[str] = []
+
+ if docs_touched:
+ lanes.docs = True
+ reasons.append("category:docs")
+ lane_reasons["docs"] = ["category:docs"]
+ docs_pytests_sorted = sorted(set(docs_rule.pytest_paths)) if docs_rule else []
+ docs_scripts_sorted = sorted(set(docs_rule.functional_scripts)) if docs_rule else []
+
+ # Full-suite triggers always win over fast, but docs lane can still remain enabled.
+ triggered: list[tuple[str, str]] = []
+ for f in routed_files:
+ for name, pred in FULL_SUITE_TRIGGERS:
+ if _matches_any(f, [pred]):
+ triggered.append((f, name))
+
+ if triggered:
+ lanes.full = True
+ full_reasons = [
+ "full_suite_trigger",
+ f"full_suite_trigger_count:{len(triggered)}",
+ ]
+ reasons.extend(full_reasons)
+ lane_reasons["full"] = full_reasons
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=[],
+ functional_scripts=[],
+ provenance=SelectionProvenance(),
+ reasons=reasons,
+ changed_files=files,
+ lane_reasons=lane_reasons,
+ )
+
+ # Match NON-doc categories only for test-routing / escalation logic.
+ matched_non_docs = []
+ for rule in CATEGORY_RULES:
+ if rule.name == "docs":
+ continue
+ if any(_matches_any(f, rule.match_any) for f in routed_files):
+ matched_non_docs.append(rule)
+
+ matched_non_docs = sorted(matched_non_docs, key=lambda r: r.name)
+
+ for rule in matched_non_docs:
+ reasons.append(f"category:{rule.name}")
+
+ pytest_paths_set: set[str] = set()
+ functional_set: set[str] = set()
+ pytest_sources: dict[str, set[str]] = defaultdict(set)
+ script_sources: dict[str, set[str]] = defaultdict(set)
+
+ # Docs rules may contribute tests/scripts to the fast lane.
+ if docs_touched:
+ for p in docs_pytests_sorted:
+ pytest_paths_set.add(p)
+ pytest_sources[p].add("docs")
+ for s in docs_scripts_sorted:
+ functional_set.add(s)
+ script_sources[s].add("docs")
+
+ # Non-doc matched categories contribute to fast lane.
+ for rule in matched_non_docs:
+ cat = rule.name
+ for p in rule.pytest_paths or []:
+ pytest_paths_set.add(p)
+ pytest_sources[p].add(cat)
+ for s in rule.functional_scripts or []:
+ functional_set.add(s)
+ script_sources[s].add(cat)
+
+ # If we matched non-doc categories but none provided explicit tests/scripts,
+ # fall back to the minimal pytest lane.
+ fallback_used = False
+ if not pytest_paths_set and not functional_set and matched_non_docs:
+ for p in MINIMAL_PYTEST:
+ pytest_paths_set.add(p)
+ pytest_sources[p].add("fallback_minimal_pytest")
+ reasons.append("fallback_minimal_pytest")
+ fallback_used = True
+
+ # If the routed changes are truly docs-only (no non-doc files remain) and no
+ # tests were selected, return docs lane only.
+ if not pytest_paths_set and not functional_set:
+ if lanes.docs and not non_docs_routed_files:
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=[],
+ functional_scripts=[],
+ provenance=SelectionProvenance(),
+ reasons=reasons,
+ changed_files=files,
+ lane_reasons=lane_reasons,
+ )
+
+ # Otherwise fail-safe to full when nothing matched at all.
+ lanes.full = True
+ full_reasons = ["no_category_matched"]
+ reasons.extend(full_reasons)
+ lane_reasons["full"] = full_reasons
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=[],
+ functional_scripts=[],
+ provenance=SelectionProvenance(),
+ reasons=reasons,
+ changed_files=files,
+ lane_reasons=lane_reasons,
+ )
+
+ # Fast lane selected
+ lanes.fast = True
+
+ fast_reasons: list[str] = []
+ if docs_touched and (docs_pytests_sorted or docs_scripts_sorted):
+ fast_reasons.append("category:docs")
+ fast_reasons.extend(f"category:{rule.name}" for rule in matched_non_docs)
+ if fallback_used:
+ fast_reasons.append("fallback_minimal_pytest")
+ lane_reasons["fast"] = fast_reasons
+
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=sorted(pytest_paths_set),
+ functional_scripts=sorted(functional_set),
+ provenance=SelectionProvenance(
+ pytest={k: sorted(v) for k, v in sorted(pytest_sources.items())},
+ scripts={k: sorted(v) for k, v in sorted(script_sources.items())},
+ ),
+ reasons=reasons,
+ changed_files=files,
+ lane_reasons=lane_reasons,
+ )
+
+
+# -----------------------------
+# Outputs
+# -----------------------------
+def explain_changed_files(files: list[str]) -> dict[str, Any]:
+ """
+ Build an explanation structure for reporting:
+ - per-file: full_trigger_matches, category_matches
+ - grouped: full_triggers, by_category, uncategorized
+ """
+ per_file: dict[str, dict[str, Any]] = {}
+ by_category: dict[str, list[str]] = defaultdict(list)
+ full_trigger_files: dict[str, list[str]] = defaultdict(list)
+ lint_only_files: list[str] = []
+ uncategorized: list[str] = []
+
+ # Prep category predicates
+ categories = [(r.name, r.match_any) for r in CATEGORY_RULES]
+
+ for f in files:
+ # Which full-suite triggers does this file match?
+ ft = []
+ for trig_name, pred in FULL_SUITE_TRIGGERS:
+ try:
+ if pred(f):
+ ft.append(trig_name)
+ except Exception:
+ continue
+
+ # Which categories does it match?
+ cats = []
+ for cat_name, preds in categories:
+ if _matches_any(f, preds):
+ cats.append(cat_name)
+ is_lint_only = f in LINT_ONLY_FILES
+
+ per_file[f] = {
+ "full_triggers": ft,
+ "categories": cats,
+ "lint_only": is_lint_only,
+ }
+
+ if ft:
+ for t in ft:
+ full_trigger_files[t].append(f)
+
+ if cats:
+ for c in cats:
+ by_category[c].append(f)
+
+ if is_lint_only:
+ lint_only_files.append(f)
+ else:
+ # Only uncategorized if it matched no categories AND no full-suite triggers
+ if not ft and not cats:
+ uncategorized.append(f)
+
+ # Deterministic ordering
+ for t in full_trigger_files:
+ full_trigger_files[t] = sorted(set(full_trigger_files[t]))
+ for c in by_category:
+ by_category[c] = sorted(set(by_category[c]))
+
+ return {
+ "per_file": per_file,
+ "full_trigger_files": dict(full_trigger_files),
+ "by_category": dict(by_category),
+ "lint_only": sorted(set(lint_only_files)),
+ "uncategorized": sorted(set(uncategorized)),
+ }
+
+
+def _render_file_line(
+ f: str,
+ info: dict[str, Any],
+ emoji: bool = False,
+ add_tag: bool = True,
+ add_marker: bool = False,
+ category_only: bool = True,
+) -> str:
+ # Optional, single marker only
+ marker = ""
+ if add_marker:
+ if info.get("full_triggers"):
+ marker = "⚠️ " if emoji else ""
+ elif info.get("lint_only"):
+ marker = "🧹 " if emoji else ""
+ elif not info.get("categories"):
+ marker = "❓ " if emoji else ""
+
+ tags = []
+ if add_tag:
+ if info.get("categories"):
+ header = "🏷️ " if emoji else "Category match :"
+ tags.append(f"{header} " + ", ".join(info["categories"]))
+ if info.get("full_triggers") and not category_only:
+ header = "🚨 " if emoji else "Full triggers"
+ tags.append(f"{header} " + ", ".join(info["full_triggers"]))
+ if info.get("lint_only") and not category_only:
+ header = "🧹 " if emoji else "Lint-only :"
+ tags.append(f"{header}")
+
+ tag_str = (" — " + " | ".join(tags)) if tags else ""
+ return f"- {marker}`{f}`{tag_str}"
+
+
+def _enabled_lane_names(res: SelectorResult) -> list[str]:
+ order = ("skip", "docs", "fast", "full")
+ return [name for name in order if getattr(res.lanes, name)]
+
+
+def _lane_label(name: str, emoji: bool = False) -> str:
+ if not emoji:
+ return name
+ return {
+ "skip": "⏩ skip",
+ "docs": "📚 docs",
+ "fast": "⚡ fast",
+ "full": "🧪 full",
+ }.get(name, name)
+
+
+def _compact_reasons(reasons: list[str]) -> list[str]:
+ cats = sorted({r.split(":", 1)[1] for r in reasons if r.startswith("category:")})
+ other = [r for r in reasons if not r.startswith("category:")]
+ out = []
+ if cats:
+ out.append("categories: " + ", ".join(cats))
+ out.extend(other)
+ return out
+
+
+def _details_open(summary: str, add_blank: bool = True) -> str:
+ s = f"{summary} \n"
+ if add_blank:
+ s += "\n"
+ return s
+
+
+def _details_close() -> str:
+ return "\n \n"
+
+
+def _render_decision_markdown(
+ res: SelectorResult,
+ limit: int = 40,
+ style: str = "minimal",
+ emoji: bool = False,
+) -> str:
+ def bullet(items: list[str], limit_: int = limit) -> str:
+ if not items:
+ return "_(none)_"
+ shown = items[:limit_]
+ s = "\n".join(f"- `{x}`" for x in shown)
+ if len(items) > limit_:
+ s += f"\n- … and {len(items) - limit_} more"
+ return s
+
+ # Selection line (minimal, no emoji by default)
+ selected_lanes = _enabled_lane_names(res)
+ if emoji:
+ selected_lanes_label = ", ".join(_lane_label(name, emoji=True) for name in selected_lanes)
+ else:
+ selected_lanes_label = ", ".join(f"`{name}`" for name in selected_lanes)
+
+ if not selected_lanes_label:
+ selected_lanes_label = "_(none)_"
+
+ diff_mode = f"{MODE_LABELS.get(res.diff_mode, res.diff_mode.value)}"
+
+ md: list[str] = []
+ md.append("# Test selection\n")
+ md.append(f"**Selected workflows:** {selected_lanes_label}\n")
+ md.append(f"**Diff mode:** `{diff_mode}`\n")
+
+ # Reasons (compacted)
+ md.append("## Why\n")
+ for r in _compact_reasons(res.reasons):
+ md.append(f"- `{r}`")
+ md.append("")
+
+ if style == "detailed" and res.lane_reasons:
+ md.append("## Workflow lanes\n")
+ for lane in _enabled_lane_names(res):
+ lane_rs = res.lane_reasons.get(lane, [])
+ md.append(f"### `{lane}`")
+ if lane_rs:
+ for r in lane_rs:
+ md.append(f"- `{r}`")
+ else:
+ md.append("_(none)_")
+ md.append("")
+
+ # Explain changed files
+ exp = explain_changed_files(res.changed_files)
+
+ md.append("## Changed files (explained)\n")
+
+ # 1) Collapsible: Files that match full-suite triggers
+ # (Always collapsible if present; otherwise omit section.)
+ if exp["full_trigger_files"]:
+ total_triggered = sum(len(v) for v in exp["full_trigger_files"].values())
+ md.append(_details_open(f"Files that match full-suite triggers ({total_triggered})"))
+ for trig_name in sorted(exp["full_trigger_files"].keys()):
+ files_for_trigger = exp["full_trigger_files"][trig_name]
+ md.append(f"**{trig_name}** ({len(files_for_trigger)})")
+ for f in files_for_trigger[:limit]:
+ md.append(_render_file_line(f, exp["per_file"][f], emoji=emoji))
+ if len(files_for_trigger) > limit:
+ md.append(f"- … and {len(files_for_trigger) - limit} more")
+ md.append("")
+ md.append(_details_close())
+
+ # 2) Files grouped by category (includes uncategorized and lint-only as collapsible lists)
+ md.append("### Files grouped by category\n")
+
+ if exp["by_category"]:
+ for cat in sorted(exp["by_category"].keys()):
+ files = exp["by_category"][cat]
+
+ # Determine if this category has any explicit selection rules attached
+ rule = CATEGORY_RULE_BY_NAME.get(cat)
+ has_rules = bool(rule and (rule.pytest_paths or rule.functional_scripts))
+ note = "" if has_rules else " — no specific testing rules attached"
+
+ md.append(_details_open(f"{cat} ({len(files)}){note}"))
+ for f in files[:limit]:
+ # Already grouped by category; keep lines clean
+ md.append(f"- `{f}`")
+ if len(files) > limit:
+ md.append(f"- … and {len(files) - limit} more")
+ md.append(_details_close())
+ else:
+ md.append("_(none)_\n")
+
+ # Lint-only as collapsible
+ if exp.get("lint_only"):
+ lint_files = exp["lint_only"]
+ md.append(_details_open(f"Lint-only ({len(lint_files)}) — ignored for test selection"))
+ md.append("")
+ for f in lint_files[:limit]:
+ md.append(f"- `{f}`")
+ if len(lint_files) > limit:
+ md.append(f"- … and {len(lint_files) - limit} more")
+ md.append(_details_close())
+
+ # Uncategorized as collapsible — and clarify what it means
+ # IMPORTANT: explain_changed_files() already ensures that files that match ANY category
+ # never land here. This section is only for truly unmatched files.
+ if exp["uncategorized"]:
+ unc_files = exp["uncategorized"]
+ md.append(
+ _details_open(
+ f"Uncategorized ({len(unc_files)}) — no matching category (no specific testing rules attached)"
+ )
+ )
+ md.append("")
+ for f in unc_files[:limit]:
+ md.append(_render_file_line(f, exp["per_file"][f], emoji=emoji))
+ if len(unc_files) > limit:
+ md.append(f"- … and {len(unc_files) - limit} more")
+ md.append(_details_close())
+
+ if style == "detailed":
+ md.append("## Changed files (raw)\n")
+ md.append(bullet(res.changed_files))
+ md.append("")
+
+ # Selected tests
+ md.append("## Selected tests\n")
+ md.append(_details_open("Pytest paths"))
+ md.append(bullet(res.pytest_paths))
+ md.append(_details_close())
+ md.append(_details_open("Functional scripts"))
+ md.append(bullet(res.functional_scripts))
+ md.append(_details_close())
+
+ # Provenance collapsed by default, only if detailed
+ if style == "detailed":
+ md.append("## Provenance\n")
+ md.append(_details_open("Why these tests"))
+ md.append("")
+
+ if res.provenance.pytest:
+ md.append("### Pytest\n")
+ for p, srcs in res.provenance.pytest.items():
+ md.append(f"- `{p}` ← {', '.join(f'`{s}`' for s in srcs)}")
+ else:
+ md.append("### Pytest\n_(none)_")
+
+ if res.provenance.scripts:
+ md.append("\n### Scripts\n")
+ for s, srcs in res.provenance.scripts.items():
+ md.append(f"- `{s}` ← {', '.join(f'`{x}`' for x in srcs)}")
+ else:
+ md.append("\n### Scripts\n_(none)_")
+
+ md.append(_details_close())
+
+ return "\n".join(md)
+
+
+def write_report_files(
+ res: SelectorResult,
+ out_dir: Path,
+ report_style: str = "minimal",
+ no_emoji: bool = False,
+) -> tuple[Path, Path]:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ json_path = out_dir / "selection.json"
+ md_path = out_dir / "decision.md"
+
+ json_path.write_text(res.model_dump_json(indent=2), encoding="utf-8")
+ md_path.write_text(
+ _render_decision_markdown(res, style=report_style, emoji=not no_emoji),
+ encoding="utf-8",
+ )
+ return json_path, md_path
+
+
+def create_job_summary(md_path: Path, overwrite: bool = True) -> None:
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if not summary_path:
+ return
+ # Append markdown to the GitHub Actions Job Summary
+ mode = "w" if overwrite else "a"
+ with open(summary_path, mode, encoding="utf-8") as f:
+ f.write(md_path.read_text(encoding="utf-8"))
+ f.write("\n")
+
+
+def write_github_output(res: SelectorResult) -> None:
+ out_path = os.environ.get("GITHUB_OUTPUT")
+ if not out_path:
+ raise RuntimeError("GITHUB_OUTPUT is not set")
+
+ def j(v) -> str:
+ return json.dumps(v, separators=(",", ":"), ensure_ascii=False)
+
+ selected_workflows = _enabled_lane_names(res)
+
+ with open(out_path, "a", encoding="utf-8") as f:
+ f.write(f"run_skip={str(res.lanes.skip).lower()}\n")
+ f.write(f"run_docs={str(res.lanes.docs).lower()}\n")
+ f.write(f"run_fast={str(res.lanes.fast).lower()}\n")
+ f.write(f"run_full={str(res.lanes.full).lower()}\n")
+ f.write(f"selected_workflows={j(selected_workflows)}\n")
+ f.write(f"lane_reasons={j(res.lane_reasons)}\n")
+ f.write(f"diff_mode={res.diff_mode.value}\n")
+ f.write(f"pytest_paths={j(res.pytest_paths)}\n")
+ f.write(f"functional_scripts={j(res.functional_scripts)}\n")
+ f.write(f"reasons={j(res.reasons)}\n")
+ f.write(f"changed_files={j(res.changed_files)}\n")
+ f.write(f"provenance={j(res.provenance.model_dump())}\n")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description="Deterministic DeepLabCut test selector")
+ ap.add_argument("--json", action="store_true", help="Print JSON result to stdout")
+ ap.add_argument(
+ "--write-github-output",
+ action="store_true",
+ help="Write outputs to $GITHUB_OUTPUT",
+ )
+ ap.add_argument(
+ "--base-sha",
+ default=None,
+ help="Override base commit SHA for manual diff selection (must be used with --head-sha)",
+ )
+ ap.add_argument(
+ "--head-sha",
+ default=None,
+ help="Override head commit SHA for manual diff selection (must be used with --base-sha)",
+ )
+
+ ap.add_argument(
+ "--report-dir",
+ default="tmp/test-selection",
+ help="Directory to write decision report files (selection.json, decision.md)",
+ )
+ ap.add_argument(
+ "--write-summary",
+ action="store_true",
+ help="Append decision.md to GitHub Actions Job Summary if available",
+ )
+ ap.add_argument(
+ "--report-style",
+ choices=["minimal", "detailed"],
+ default="detailed",
+ help="Decision markdown verbosity: minimal or detailed (default: detailed)",
+ )
+ ap.add_argument(
+ "--no-emoji",
+ action="store_true",
+ help="Disable emojis in markdown report (default: off)",
+ )
+
+ args = ap.parse_args(list(argv) if argv is not None else None)
+ if bool(args.base_sha) != bool(args.head_sha):
+ ap.error("Both --base-sha and --head-sha must be provided together")
+
+ repo = find_repo_root()
+
+ base, head, diff_mode = determine_diff_range(repo, args.base_sha, args.head_sha)
+ files = changed_files(repo, base, head)
+
+ res = decide(files)
+ res.diff_mode = diff_mode
+ res.changed_files = files
+ res = validate_selected_paths(res, repo)
+
+ # Strict validation
+ try:
+ res = SelectorResult.model_validate(res.model_dump())
+ except ValidationError as e:
+ raise RuntimeError(f"Output validation failed: {e}") from e
+
+ # Always write report files for transparency
+ report_dir = Path(args.report_dir)
+ json_path, md_path = write_report_files(res, report_dir, report_style=args.report_style, no_emoji=args.no_emoji)
+
+ if args.json:
+ print(res.model_dump_json(indent=2))
+
+ if args.write_github_output:
+ write_github_output(res)
+
+ # Write Job Summary (GitHub renders markdown from $GITHUB_STEP_SUMMARY)
+ if args.write_summary:
+ create_job_summary(md_path)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/test_selector_config.py b/tools/test_selector_config.py
new file mode 100644
index 0000000000..20b5b2c1c8
--- /dev/null
+++ b/tools/test_selector_config.py
@@ -0,0 +1,341 @@
+"""Test selector configuration."""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from pathlib import PurePosixPath
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+PathPred = Callable[[str], bool]
+
+
+def prefix(*values: str) -> PathPred:
+ """Match if path starts with any of the given prefixes."""
+ prefixes = tuple(values)
+ return lambda p: p.startswith(prefixes)
+
+
+def suffix(*values: str) -> PathPred:
+ """Match if path ends with any of the given suffixes."""
+ suffixes = tuple(values)
+ return lambda p: p.endswith(suffixes)
+
+
+def equals(*values: str) -> PathPred:
+ """Match if path equals any of the given exact values."""
+ allowed = frozenset(values)
+ return lambda p: p in allowed
+
+
+def case_insensitive_match(*values: str) -> PathPred:
+ """Case-insensitive substring match against any of the given values."""
+ needles = tuple(v.lower() for v in values)
+ return lambda p: any(n in p.lower() for n in needles)
+
+
+def all_of(*preds: PathPred) -> PathPred:
+ """Logical AND over predicates."""
+ return lambda p: all(pred(p) for pred in preds)
+
+
+# -----------------------------
+# Rules validation models
+# -----------------------------
+_RULE_NAME_RE = re.compile(r"^[a-z0-9_]+$")
+
+
+def _validate_relpath_string(value: str, field_name: str) -> str:
+ """Validate a repo-relative path string used in config."""
+ if not isinstance(value, str):
+ raise TypeError(f"{field_name} entries must be strings")
+
+ value = value.strip()
+ if not value:
+ raise ValueError(f"{field_name} entries must not be empty")
+
+ value = value.replace("\\", "/")
+
+ if value.startswith("/"):
+ raise ValueError(f"{field_name} must be repo-relative, got absolute path: {value!r}")
+
+ if re.match(r"^[A-Za-z]:/", value):
+ raise ValueError(f"{field_name} must not be a Windows absolute path: {value!r}")
+
+ parts = PurePosixPath(value).parts
+ if ".." in parts:
+ raise ValueError(f"{field_name} must not contain path traversal: {value!r}")
+
+ if "\x00" in value:
+ raise ValueError(f"{field_name} must not contain NUL bytes: {value!r}")
+
+ return value
+
+
+class CategoryRule(BaseModel):
+ """Validated test-selection category rule."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ name: str
+ match_any: list[PathPred] = Field(
+ min_length=1,
+ description=("List of predicates; if any predicate matches any changed file, the rule is triggered."),
+ )
+ pytest_paths: list[str] = Field(
+ default_factory=list,
+ description="Pytest paths selected if the rule is triggered.",
+ )
+ functional_scripts: list[str] = Field(
+ default_factory=list,
+ description="Functional test scripts selected if the rule is triggered.",
+ )
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, value: str) -> str:
+ value = value.strip()
+ if not value:
+ raise ValueError("Rule name must not be empty")
+ if not _RULE_NAME_RE.match(value):
+ raise ValueError(f"Rule name must match ^[a-z0-9_]+$ (got {value!r})")
+ return value
+
+ @field_validator("match_any")
+ @classmethod
+ def validate_match_any(cls, preds: list[PathPred]) -> list[PathPred]:
+ if not preds:
+ raise ValueError("match_any must contain at least one predicate")
+ for i, pred in enumerate(preds):
+ if not callable(pred):
+ raise TypeError(f"match_any[{i}] must be callable, got {type(pred).__name__}")
+ return preds
+
+ @field_validator("pytest_paths")
+ @classmethod
+ def validate_pytest_paths(cls, values: list[str]) -> list[str]:
+ return [_validate_relpath_string(v, "pytest_paths") for v in values]
+
+ @field_validator("functional_scripts")
+ @classmethod
+ def validate_functional_scripts(cls, values: list[str]) -> list[str]:
+ return [_validate_relpath_string(v, "functional_scripts") for v in values]
+
+
+def validate_category_rules(rules: list[CategoryRule]) -> list[CategoryRule]:
+ """Validate cross-rule invariants."""
+ seen: dict[str, int] = {}
+
+ for idx, rule in enumerate(rules):
+ if rule.name in seen:
+ first_idx = seen[rule.name]
+ raise ValueError(f"Duplicate CategoryRule name {rule.name!r} at indexes {first_idx} and {idx}")
+ seen[rule.name] = idx
+
+ return rules
+
+
+# -----------------------------
+# Configuration
+# -----------------------------
+MINIMAL_PYTEST = ["tests/test_auxiliaryfunctions.py"]
+
+POSE_TF = "deeplabcut/pose_estimation_tensorflow/"
+POSE_PT = "deeplabcut/pose_estimation_pytorch/"
+
+
+# Conservative full-suite triggers: if any changed file matches, plan=FULL.
+FULL_SUITE_TRIGGERS = [
+ ("Tests files changed", prefix("tests/")),
+ ("pyproject.toml changed", equals("pyproject.toml")),
+ ("lockfile changed", suffix(".lock")),
+ ("DEEPLABCUT.yaml changed", suffix("DEEPLABCUT.yaml")),
+ ("CI workflow changed", prefix(".github/workflows/")),
+]
+
+
+# Files that should be enforced by dedicated lint workflows, not by test selection
+LINT_ONLY_FILES = {
+ ".pre-commit-config.yaml",
+ # ".pre-commit-hooks.yaml",
+}
+
+
+# The per-file/folder rules that determine test selection logic. Each rule has:
+# - a name (for auditing/debugging purposes)
+# - a set of path predicates (match_any) that trigger the rule if any predicate
+# matches any changed file
+# - a list of pytest paths to select if the rule is triggered (can be empty)
+# - a list of functional test scripts to select if the rule is triggered (can be empty)
+CATEGORY_RULES = validate_category_rules(
+ [
+ # DOCS & NOTEBOOKS #
+ CategoryRule(
+ name="docs",
+ match_any=[
+ prefix("docs/"),
+ all_of(suffix(".md", ".rst"), case_insensitive_match("docs")),
+ all_of(suffix(".ipynb"), case_insensitive_match("docs")),
+ equals("_config.yml", "_toc.yml"),
+ equals(".github/workflows/build-book.yml"),
+ ],
+ pytest_paths=[
+ # NOTE:
+ # Optional docs-targeted tests may be attached here.
+ # If present, docs changes will still enable the docs lane, and may also
+ # contribute selections added here to the fast lane.
+ ],
+ functional_scripts=[
+ # NOTE:
+ # Optional docs-targeted functional tests may be attached here.
+ # If present, docs changes will still enable the docs lane, and may also
+ # contribute selections added here to the fast lane.
+ ],
+ ),
+ CategoryRule(
+ name="notebooks_examples",
+ match_any=[
+ prefix("examples/JUPYTER/", "examples/COLAB/"),
+ all_of(suffix(".ipynb"), case_insensitive_match("examples")),
+ ],
+ pytest_paths=MINIMAL_PYTEST,
+ functional_scripts=[],
+ ),
+ # CORE FUNCTIONALITY #
+ CategoryRule(
+ name="superanimal_modelzoo",
+ match_any=[
+ prefix("deeplabcut/modelzoo/"),
+ case_insensitive_match("superanimal"),
+ # case_insensitive_match("modelzoo"), # too broad ?
+ ],
+ pytest_paths=[
+ "tests/test_predict_supermodel.py",
+ "tests/pose_estimation_pytorch/modelzoo/",
+ "tests/pose_estimation_pytorch/other/test_modelzoo.py", # (currently all tests are skipped in this file..) # noqa: E501
+ ],
+ functional_scripts=[
+ # TODO: decide which of these functional testscripts are useful and not too heavy # noqa: E501
+ "examples/testscript_superanimal_adaptation.py", # (runs inference + video adaptation training on shortened video) # noqa: E501
+ # "examples/testscript_superanimal_create_pretrained_project.py", # (runs inference on example videos) # noqa: E501
+ # "examples/testscript_superanimal_inference.py", # (runs inference on multiple videos with multiple models) # noqa: E501
+ # "examples/testscript_superanimal_transfer_learning.py", # (runs full standard training pipeline after weight init) # noqa: E501
+ ],
+ ),
+ CategoryRule(
+ name="multianimal",
+ match_any=[
+ case_insensitive_match("multianimal"),
+ all_of(prefix(POSE_TF), case_insensitive_match("multi")),
+ all_of(prefix(POSE_PT), case_insensitive_match("multi")),
+ ],
+ pytest_paths=[
+ "tests/test_auxfun_multianimal.py",
+ "tests/test_pose_multianimal_imgaug.py",
+ "tests/test_predict_multianimal.py",
+ "tests/test_stitcher.py",
+ "tests/test_trackingutils.py",
+ ],
+ functional_scripts=[
+ "examples/testscript_tensorflow_multi_animal.py",
+ "examples/testscript_pytorch_multi_animal.py",
+ ],
+ ),
+ CategoryRule(
+ name="core",
+ match_any=[
+ prefix(
+ "deeplabcut/core/",
+ "deeplabcut/utils/",
+ POSE_TF,
+ POSE_PT,
+ ),
+ equals("deeplabcut/auxiliaryfunctions.py"),
+ ],
+ pytest_paths=[
+ "tests/test_auxiliaryfunctions.py",
+ "tests/core/",
+ "tests/utils/",
+ ],
+ functional_scripts=[
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_tensorflow_multi_animal.py",
+ "examples/testscript_pytorch_single_animal.py",
+ "examples/testscript_pytorch_multi_animal.py",
+ ],
+ ),
+ CategoryRule(
+ name="pose_estimation_tensorflow",
+ match_any=[
+ prefix(POSE_TF),
+ ],
+ pytest_paths=[
+ "tests/test_dataset_augmentation.py",
+ "tests/test_pose_multianimal_imgaug.py",
+ "tests/test_predict_multianimal.py",
+ "tests/test_evaluate.py",
+ # "tests/test_inferenceutils.py",
+ # "tests/test_crossvalutils.py",
+ ],
+ functional_scripts=[
+ "examples/testscript_tensorflow_multi_animal.py",
+ "examples/testscript_tensorflow_single_animal.py",
+ ],
+ ),
+ CategoryRule(
+ name="pose_estimation_pytorch",
+ match_any=[
+ prefix(POSE_PT),
+ ],
+ pytest_paths=[
+ "tests/pose_estimation_pytorch/",
+ ],
+ functional_scripts=[
+ "examples/testscript_pytorch_single_animal.py",
+ "examples/testscript_pytorch_multi_animal.py",
+ ],
+ ),
+ CategoryRule(
+ name="3d_pose_estimation",
+ match_any=[
+ prefix("deeplabcut/pose_estimation_3d/"),
+ ],
+ pytest_paths=[
+ "tests/test_triangulation.py",
+ ],
+ functional_scripts=[
+ "examples/testscript_3d.py",
+ ],
+ ),
+ CategoryRule(
+ name="generate_training_dataset",
+ match_any=[
+ prefix("deeplabcut/generate_training_dataset/"),
+ ],
+ pytest_paths=[
+ "tests/generate_training_dataset/",
+ ],
+ functional_scripts=[],
+ ),
+ # CI & TOOLING #
+ # CategoryRule(
+ # name="ci_workflows",
+ # match_any=[
+ # prefix(".github/workflows/"),
+ # ],
+ # pytest_paths=MINIMAL_PYTEST,
+ # functional_scripts=[],
+ # ),
+ CategoryRule(
+ name="ci_tools",
+ match_any=[
+ prefix("tools/"),
+ ],
+ pytest_paths=["tests/tools/"],
+ functional_scripts=[],
+ ),
+ ]
+)
+
+CATEGORY_RULE_BY_NAME = {r.name: r for r in CATEGORY_RULES}
diff --git a/tools/trim_lines.py b/tools/trim_lines.py
new file mode 100644
index 0000000000..f962b609e3
--- /dev/null
+++ b/tools/trim_lines.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Reduce Ruff E501 violations using autopep8, then normalize with Ruff.
+
+Usage:
+ python fix_e501_with_autopep8.py . --line-length 88
+ python fix_e501_with_autopep8.py src tests --line-length 100 --check
+
+NOTE: if this creates broken escaped f-strings :
+f"some string with a {
+ var
+}"
+Use the ^[ \t]*\}"[ \t]*$ regex to find and fix them.
+
+Requirements:
+ - ruff
+ - autopep8
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+
+
+def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
+ print("+", " ".join(cmd))
+ proc = subprocess.run(cmd, text=True, capture_output=True)
+ if proc.stdout:
+ print(proc.stdout)
+ if proc.stderr:
+ print(proc.stderr, file=sys.stderr)
+ if check and proc.returncode != 0:
+ raise SystemExit(proc.returncode)
+ return proc
+
+
+def ruff_json(paths: list[str]) -> list[dict]:
+ proc = run(["ruff", "check", *paths, "--output-format=json", "--exit-zero"], check=False)
+ try:
+ data = json.loads(proc.stdout or "[]")
+ except json.JSONDecodeError as e:
+ raise SystemExit(f"Could not parse Ruff JSON: {e}") from e
+ if not isinstance(data, list):
+ raise SystemExit("Unexpected Ruff JSON output")
+ return data
+
+
+def unique_e501_files(paths: list[str]) -> list[str]:
+ data = ruff_json(paths)
+ files = sorted({item["filename"] for item in data if item.get("code") == "E501"})
+ return files
+
+
+def chunked(items: list[str], size: int = 50):
+ for i in range(0, len(items), size):
+ yield items[i : i + size]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("paths", nargs="*", default=["."], help="Files/directories to scan")
+ parser.add_argument("--line-length", type=int, default=88)
+ parser.add_argument("--check", action="store_true", help="Dry run; only show affected files")
+ args = parser.parse_args()
+
+ files = unique_e501_files(args.paths)
+ if not files:
+ print("No E501 files found. Nothing to do.")
+ return 0
+
+ print(f"Found {len(files)} file(s) with E501.")
+ for f in files:
+ print(" -", f)
+
+ if args.check:
+ return 0
+
+ for batch in chunked(files, 50):
+ run(
+ [
+ "autopep8",
+ "--in-place",
+ "--aggressive",
+ f"--max-line-length={args.line_length}",
+ "--select=E501,W291,W292,W391",
+ *batch,
+ ]
+ )
+
+ run(["ruff", "check", *batch, "--fix", "--unsafe-fixes"], check=False)
+ run(["ruff", "format", *batch], check=False)
+
+ after = len(unique_e501_files(args.paths))
+ print(f"Remaining files with E501: {after}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/update_license_headers.py b/tools/update_license_headers.py
index 4a05350e47..ca5381deb2 100644
--- a/tools/update_license_headers.py
+++ b/tools/update_license_headers.py
@@ -1,24 +1,25 @@
"""Apply copyright headers to all code files in the repository.
-This file can be called as a python script without arguments. For
-configuration, see the instructions in NOTICE.yml.
+This file can be called as a python script without arguments. For configuration, see the
+instructions in NOTICE.yml.
"""
-import tempfile
-import glob
-import yaml
import fnmatch
+import glob
import subprocess
+import tempfile
+
+import yaml
def load_config(filename):
- with open(filename, "r") as fh:
+ with open(filename) as fh:
config = yaml.safe_load(fh)
return config
def walk_directory(entry):
- """Talk the directory"""
+ """Talk the directory."""
if "header" not in entry:
raise ValueError("Current entry does not have a header.")
@@ -28,8 +29,7 @@ def walk_directory(entry):
def _list_include():
"""List all files specified in the include list."""
for include_pattern in entry["include"]:
- for filename in glob.iglob(include_pattern, recursive=True):
- yield filename
+ yield from glob.iglob(include_pattern, recursive=True)
def _filter_exclude(iterable):
"""Filter filenames from an iterator by the exclude patterns."""
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000000..b3d096b3f4
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,8412 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+conflicts = [[
+ { package = "deeplabcut", extra = "apple-mchips" },
+ { package = "deeplabcut", extra = "tf" },
+ { package = "deeplabcut", extra = "tf-cu11" },
+ { package = "deeplabcut", extra = "tf-cu12" },
+ { package = "deeplabcut", extra = "tf-latest" },
+], [
+ { package = "deeplabcut", extra = "fmpose3d" },
+ { package = "deeplabcut", extra = "tf-cu11" },
+ { package = "deeplabcut", extra = "tf-cu12" },
+]]
+
+[manifest]
+
+[[manifest.dependency-metadata]]
+name = "openvino-dev"
+version = "2022.1.0"
+
+[[package]]
+name = "absl-py"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
+]
+
+[[package]]
+name = "accessible-pygments"
+version = "0.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" },
+]
+
+[[package]]
+name = "alabaster"
+version = "0.7.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
+]
+
+[[package]]
+name = "albumentations"
+version = "1.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "opencv-python-headless" },
+ { name = "pyyaml" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/c1/4bb46e1afe56a4908bfe6fe58a9231ed5d7e7f46e400559d8bce7fc7dedc/albumentations-1.4.3.tar.gz", hash = "sha256:d0a5ca4edb1695693faa39cdf427d53fdbd7d26f7f8e4100c307e5ff5a463d16", size = 171598, upload-time = "2024-04-03T01:51:05.117Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/01/4202bd81ab337dca5693d7d1cb25c8e9041d97762aee738a24382ff9af2f/albumentations-1.4.3-py3-none-any.whl", hash = "sha256:386a7a61b6978f90163bf678745674f1d9031fd39cf17a40ae01d88aade72608", size = 137007, upload-time = "2024-04-03T01:51:02.83Z" },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "app-model"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "in-n-out" },
+ { name = "psygnal" },
+ { name = "pydantic" },
+ { name = "pydantic-compat" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8d/23/a9537fd7021e8b6ea7db96b73e66fe61e809a3b23338de07d1176e69e4ee/app_model-0.4.0.tar.gz", hash = "sha256:ccf667999f6c659e921ca3490b6da176971e67cf2f41abc34e33caa8cfa18573", size = 120529, upload-time = "2025-06-20T17:41:08.943Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/fa/d7f4ae02a3b115abdf561d05ba8d5cdf9e3b2b5724b45d91e6cf08c163c3/app_model-0.4.0-py3-none-any.whl", hash = "sha256:5b1d69ee023d955b0c7a525771fd2fc02ff9d82cd2f12a982949423c3a901210", size = 65710, upload-time = "2025-06-20T17:41:07.696Z" },
+]
+
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" },
+]
+
+[[package]]
+name = "appnope"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
+[[package]]
+name = "astunparse"
+version = "1.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/af/4182184d3c338792894f34a62672919db7ca008c89abee9b564dd34d8029/astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872", size = 18290, upload-time = "2019-12-22T18:12:13.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/03/13dde6512ad7b4557eb792fbcf0c653af6076b81e5941d36ec61f7ce6028/astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8", size = 12732, upload-time = "2019-12-22T18:12:11.297Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
+]
+
+[[package]]
+name = "blosc2"
+version = "4.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack" },
+ { name = "ndindex" },
+ { name = "numexpr", marker = "platform_machine != 'wasm32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "threadpoolctl", marker = "platform_machine != 'wasm32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/54/fb/c4d83eb40c8b9cb024507dbc4a3a91bfbc7e36ad5f2528c306575a87f42b/blosc2-4.3.1.tar.gz", hash = "sha256:f0c26b1190b24aae8b58ade95abd7c9aca5e7447db0f13c1915fcbe56a0b8167", size = 5354390, upload-time = "2026-05-19T17:34:45.553Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/a9/d952e141cea441a4387bf9c2e8917764569092e148eba2b3209408a0b655/blosc2-4.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c9bcd07ec550960513c6f9c2b901251c15d9bbfe79a554bbb9363162a92f5db", size = 5817410, upload-time = "2026-05-19T17:33:54.765Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ab/fb94743b4511fb27accb4f9e3d5769cfd039d34801a7787b274cb6455c1c/blosc2-4.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8b38dff8a6d3446f08c7b25c8a00d63da32b63a35c05561d6be77bb67cf352de", size = 5026379, upload-time = "2026-05-19T17:33:56.8Z" },
+ { url = "https://files.pythonhosted.org/packages/81/df/a1b9bcb00d028f69fc58e45702d99d86a4eddf78640cd4ad87b2ab7cfa3d/blosc2-4.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:50ec0dec76b75411faaa7e79d2fb5adbfdd90b8b90c0637faa706d03336c87d9", size = 6306638, upload-time = "2026-05-19T17:33:59.029Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/b2515f08969c0ed5dd55337ca11dcf9034e2b23250b16badbe6538b27021/blosc2-4.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a52b29306ff7e5b49f8365a7f9567c8bda9a1d3b2e042a4ca185268a379e05d", size = 6589405, upload-time = "2026-05-19T17:34:01.047Z" },
+ { url = "https://files.pythonhosted.org/packages/51/87/1874f6b0ffec99ba908e99ec547e4d45f3740b5c03f8ab1f310a3e36e22c/blosc2-4.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:320c9d6ba943aab3570f15d413c4c8b634bbd9d70c00199bf2cf74fe01112f71", size = 4126788, upload-time = "2026-05-19T17:34:02.941Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/29/f685930f492ec787557635b5e0e91b229c793cd3fd3d32083e5f088a15b9/blosc2-4.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7a1d8d56a7bbd942dc9f46b2c78178b16075ff5e88d7fb466c96e6fd0ff7fcd6", size = 5811703, upload-time = "2026-05-19T17:34:04.62Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/24/fc29520a463b96acf64d3fdf5c8bd6fd5d2a11dbda6d08af8fb69de8611b/blosc2-4.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93a02d9f11e4308385f4f556a14573e24310d49042f6d9ea61d74b9c42880980", size = 5021613, upload-time = "2026-05-19T17:34:05.941Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/dd/d80c3e198e056eec0aed87065a68cead2e8c17cffbf8d6aad498d37946cc/blosc2-4.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6759c659467dcf28c7e5d72bf90b7e5559d5e17533364461ab69ab85cce3d4f2", size = 6295416, upload-time = "2026-05-19T17:34:07.722Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ba/8a5d0dcfb95126b6601e4f993ee276c9953be4748bd67f45202fee4ee58e/blosc2-4.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:706c27f5b93df7e320b1cdbc446f248d027106f968f762ecb240fde98bbe8af1", size = 6589973, upload-time = "2026-05-19T17:34:09.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/4b/a6fe2d0a496cf467c205cf83993c605dd38422a86adbe710fa2f8f5bcdf5/blosc2-4.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:95c8466beebef66c24ef2dd1993cda3c1f8c3c00ec3be0dcdc098dc5eb6b259c", size = 4124211, upload-time = "2026-05-19T17:34:11.07Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2c/c7db23a891a5eb1893c1b3da206810db48d44cc0180e1b737dab1d5f5733/blosc2-4.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d5aa1e54412f45da894b022fa33387361332884a6689af4121dad1d91c205fa", size = 5857286, upload-time = "2026-05-19T17:34:12.815Z" },
+ { url = "https://files.pythonhosted.org/packages/47/cc/04a147753b4e7fcf227f7a4c6480e3ee545909529d038bbd8a2e0111bd4a/blosc2-4.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf349b41af997c775a94e02af45a4215e8ff3d8ff5e2c8e29b452d697e2c3a90", size = 5020423, upload-time = "2026-05-19T17:34:14.909Z" },
+ { url = "https://files.pythonhosted.org/packages/44/22/8f53101763841e907456e92ae06e5318f87228e6fb4e7aa28134c3abee2f/blosc2-4.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a162b378741233170fee4fee213f33b03799d4be42603a3f8344f5ed0df9f32a", size = 6240864, upload-time = "2026-05-19T17:34:16.522Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/4d/1c8818d8afd691dfbd847fb478bb606042114fdbdb321a8a12d0e707dd01/blosc2-4.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:faa6f6ea348ada734bf6d5c5b6988d47b975ad42fb160952ed92c6b0e8801e86", size = 6539023, upload-time = "2026-05-19T17:34:17.868Z" },
+ { url = "https://files.pythonhosted.org/packages/46/6b/0ecbef3077298fe4f9daf9c6c8ece80a0f98d1c6efc56e71cc45ad158c75/blosc2-4.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:2cedea2ac373b64b00aa956a04f74d885bdef033878e8cf84e53c001bbef6dc8", size = 4121473, upload-time = "2026-05-19T17:34:19.57Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a9/b363f846afb1fec3b49acc4a248aa98bc31c55370effc59c455caa5398ad/blosc2-4.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fae6ed1deb9e79bd703d959e9c4f5a12d6cbfdb2b3713f646ad0f7266c96c114", size = 5855462, upload-time = "2026-05-19T17:34:21.179Z" },
+ { url = "https://files.pythonhosted.org/packages/60/6f/48ad9373d2d7fb82321f177a4525cccece70a996242c0a2205160aa9c87d/blosc2-4.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87c0f387b8d47d4e6e1f27c72d92366ef16ceb1b80629ed5eaab241822c386af", size = 5018781, upload-time = "2026-05-19T17:34:23.04Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/2f/5777d61be67c001d7674e00302350ba7a288cfea706c5181d440b0d2e8d5/blosc2-4.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c48881de2fe195084a1c583acb4ab66ee8b2f0b8f83fba00148a9eb06f4826fe", size = 6248194, upload-time = "2026-05-19T17:34:24.748Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/d6/9ecbfbb73daf8b95149e48dbca9f84d6303e6fac77e3e635c09f281f6f79/blosc2-4.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce881214846959a375279ac2f7ab66e2bfa868bfbd01188ae1329c243f6d1275", size = 6541927, upload-time = "2026-05-19T17:34:26.219Z" },
+ { url = "https://files.pythonhosted.org/packages/be/a7/40ead062061388ae6ac4981fa93127a7ed2da2ca66b2a9c09b191f00b7b1/blosc2-4.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:091202d20394e5593a365950cc43b09c49ececdcac36d3b491ddecc3600f36c2", size = 4121405, upload-time = "2026-05-19T17:34:27.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/c2/a9b75f9a228e9ac3a202d23d8d8fa23d737064919ac2a3c3866cfaa80b5d/blosc2-4.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0c50a9c64cad0228e382a09e605c4fd6cd9aa2576b76ab16005121a4a57f9698", size = 5857264, upload-time = "2026-05-19T17:34:29.317Z" },
+ { url = "https://files.pythonhosted.org/packages/68/08/3561e51ed30c26b6272675b4528457b44c840af11b5b0741fbd9d9d62287/blosc2-4.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98f1eb44f6ff517c6e3476a3eb3a85b357e4604bd3274db354bf52f854fb50bf", size = 5021316, upload-time = "2026-05-19T17:34:31.132Z" },
+ { url = "https://files.pythonhosted.org/packages/12/6c/deff0f7314be3af1cf2fc728bae0be7414084b1ff51cb9a61c5a54997742/blosc2-4.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a31ae1e372f2f53215bc8bcecfe4aa5af20a8cfe4124dccc8dc334093539b2c9", size = 6260060, upload-time = "2026-05-19T17:34:32.648Z" },
+ { url = "https://files.pythonhosted.org/packages/44/56/f941a447f47e3c997acb3ff1459c8a26e08f383ec60adf80e3ce04de6be3/blosc2-4.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565e4d038089c495e06b2e3e5a2881d61619460c8448c0291dbd84840437c28d", size = 6543150, upload-time = "2026-05-19T17:34:34.694Z" },
+ { url = "https://files.pythonhosted.org/packages/77/15/8a91f03a84854615dfa0b2b7afd9ee29b0b3bde5e28ebe38f5f49de438e1/blosc2-4.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d76a639d7287bb3fec73dd1ae8196923d95e00b95069bce3c495eaa00acdfe8", size = 4204741, upload-time = "2026-05-19T17:34:36.095Z" },
+ { url = "https://files.pythonhosted.org/packages/97/68/a246dd2f7ef538a572f61bfeab53d6ae0bcdf3e8cc5187c284189f7f27dc/blosc2-4.3.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:365235f674f3b70ae2eae3e2e7f0cdbda7b5e02fa50b357ab211dd45b91ad386", size = 5895602, upload-time = "2026-05-19T17:34:37.416Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d0/40a9ac3634b520a9a9ffe30569b7b1296fd5e337c858883f379a6844f4c8/blosc2-4.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbd29c7f7d2fdaf164f81f68afa2580941b9832ace77f61c01c39788a48b67f7", size = 5067186, upload-time = "2026-05-19T17:34:38.824Z" },
+ { url = "https://files.pythonhosted.org/packages/91/6a/4607099251c65db83040686dfb6f1711278b536725552611365967d978e5/blosc2-4.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c558251376af56dea742ab60233faa1c52381a5f1b1a3db20dce192d3e8e7fd8", size = 6222994, upload-time = "2026-05-19T17:34:40.628Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/bd/a9f95816a1a680ddd0f18f2d422df98ee4033f59db71960976e66abda361/blosc2-4.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cde49a4cf3b207d36abfeb963eccac998e34b930603baff068cd3593b2d9631f", size = 6512973, upload-time = "2026-05-19T17:34:42.135Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/0e/b1669555dbe381344e97056c9c5c4c9bbf2111026d2f2aa660059bbc541f/blosc2-4.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3424327e0ae91e98b9d9c3a32cc27e76d82dc0ec193e81e5b6c770bce5767e7c", size = 4264125, upload-time = "2026-05-19T17:34:43.931Z" },
+]
+
+[[package]]
+name = "bottleneck"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/14/d8/6d641573e210768816023a64966d66463f2ce9fc9945fa03290c8a18f87c/bottleneck-1.6.0.tar.gz", hash = "sha256:028d46ee4b025ad9ab4d79924113816f825f62b17b87c9e1d0d8ce144a4a0e31", size = 104311, upload-time = "2025-09-08T16:30:38.617Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/38/144fb32c9efb196f651ddb30e7c48f6047a86972e5b350f3f10c9a5f6a16/bottleneck-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40de6be68218ba32cd15addbf4ad7bbbf0075b5c5c4347c579aeae110a5c9a96", size = 100393, upload-time = "2025-09-08T16:29:35.466Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e3/dbbf4b102f4e6aaf49ad3749a6d778f309473a2950c5ce3bb20b94f2ba84/bottleneck-1.6.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ad1882ba8c8da1f404de2610b45b05291e39eec56150270b03b5b25cf2bbb7f", size = 371509, upload-time = "2025-09-08T16:29:37.037Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ea/60fcbddee5fdf32923ba33ce2337a4cf12834b69de4f8e07219b5ef7c931/bottleneck-1.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f29b14b0ba5a816df6ab559add415c88ea8cf2146364e55f5f4c24ff7c85e494", size = 363480, upload-time = "2025-09-08T16:29:38.311Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/9e/a25434dcadf083e05b0c71ece2de71fad5521268f905721e06e0a7efc5db/bottleneck-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17c227ed361cf9a2ab3751a727620298faca9a1e33dd76711ae80834cf34b254", size = 357120, upload-time = "2025-09-08T16:29:39.541Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b9/99580349c827695dfc094ac672eedba6e1ca244b6e745ff7447c0239d6d8/bottleneck-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d278b5633cea38bdae6eaf7df23d54ecb5e4db52f2ebc13fe40c0e738842f2a1", size = 367579, upload-time = "2025-09-08T16:29:40.695Z" },
+ { url = "https://files.pythonhosted.org/packages/95/06/6326994249388ceb2400d07c6a96a50941749d2d9ec80da22a99046e3a38/bottleneck-1.6.0-cp310-cp310-win32.whl", hash = "sha256:26c87c2f6364d82b67eab7218f0346e9c42f336088ca4e19d77dc76eecf272fc", size = 107838, upload-time = "2025-09-08T16:29:41.907Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/75/8f0e8e266ea99ffbc69500a927f0c114a07fe465bfbc59871d6fe22d9ee0/bottleneck-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d33bcd60a13d0603f5db9d953352a3c098242c46f8f919290fd11c54b42b9e5", size = 113364, upload-time = "2025-09-08T16:29:43.437Z" },
+ { url = "https://files.pythonhosted.org/packages/83/96/9d51012d729f97de1e75aad986f3ba50956742a40fc99cbab4c2aa896c1c/bottleneck-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:69ef4514782afe39db2497aaea93b1c167ab7ab3bc5e3930500ef9cf11841db7", size = 100400, upload-time = "2025-09-08T16:29:44.464Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f4/4fcbebcbc42376a77e395a6838575950587e5eb82edf47d103f8daa7ba22/bottleneck-1.6.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:727363f99edc6dc83d52ed28224d4cb858c07a01c336c7499c0c2e5dd4fd3e4a", size = 375920, upload-time = "2025-09-08T16:29:45.52Z" },
+ { url = "https://files.pythonhosted.org/packages/36/13/7fa8cdc41cbf2dfe0540f98e1e0caf9ffbd681b1a0fc679a91c2698adaf9/bottleneck-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:847671a9e392220d1dfd2ff2524b4d61ec47b2a36ea78e169d2aa357fd9d933a", size = 367922, upload-time = "2025-09-08T16:29:46.743Z" },
+ { url = "https://files.pythonhosted.org/packages/13/7d/dccfa4a2792c1bdc0efdde8267e527727e517df1ff0d4976b84e0268c2f9/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:daef2603ab7b4ec4f032bb54facf5fa92dacd3a264c2fd9677c9fc22bcb5a245", size = 361379, upload-time = "2025-09-08T16:29:48.042Z" },
+ { url = "https://files.pythonhosted.org/packages/93/42/21c0fad823b71c3a8904cbb847ad45136d25573a2d001a9cff48d3985fab/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fc7f09bda980d967f2e9f1a746eda57479f824f66de0b92b9835c431a8c922d4", size = 371911, upload-time = "2025-09-08T16:29:49.366Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b0/830ff80f8c74577d53034c494639eac7a0ffc70935c01ceadfbe77f590c2/bottleneck-1.6.0-cp311-cp311-win32.whl", hash = "sha256:1f78bad13ad190180f73cceb92d22f4101bde3d768f4647030089f704ae7cac7", size = 107831, upload-time = "2025-09-08T16:29:51.397Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/42/01d4920b0aa51fba503f112c90714547609bbe17b6ecfc1c7ae1da3183df/bottleneck-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f2adef59fdb9edf2983fe3a4c07e5d1b677c43e5669f4711da2c3daad8321ad", size = 113358, upload-time = "2025-09-08T16:29:52.602Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/72/7e3593a2a3dd69ec831a9981a7b1443647acb66a5aec34c1620a5f7f8498/bottleneck-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3bb16a16a86a655fdbb34df672109a8a227bb5f9c9cf5bb8ae400a639bc52fa3", size = 100515, upload-time = "2025-09-08T16:29:55.141Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d4/e7bbea08f4c0f0bab819d38c1a613da5f194fba7b19aae3e2b3a27e78886/bottleneck-1.6.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0fbf5d0787af9aee6cef4db9cdd14975ce24bd02e0cc30155a51411ebe2ff35f", size = 377451, upload-time = "2025-09-08T16:29:56.718Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/80/a6da430e3b1a12fd85f9fe90d3ad8fe9a527ecb046644c37b4b3f4baacfc/bottleneck-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d08966f4a22384862258940346a72087a6f7cebb19038fbf3a3f6690ee7fd39f", size = 368303, upload-time = "2025-09-08T16:29:57.834Z" },
+ { url = "https://files.pythonhosted.org/packages/30/11/abd30a49f3251f4538430e5f876df96f2b39dabf49e05c5836820d2c31fe/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:604f0b898b43b7bc631c564630e936a8759d2d952641c8b02f71e31dbcd9deaa", size = 361232, upload-time = "2025-09-08T16:29:59.104Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/ac/1c0e09d8d92b9951f675bd42463ce76c3c3657b31c5bf53ca1f6dd9eccff/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d33720bad761e642abc18eda5f188ff2841191c9f63f9d0c052245decc0faeb9", size = 373234, upload-time = "2025-09-08T16:30:00.488Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/ea/382c572ae3057ba885d484726bb63629d1f63abedf91c6cd23974eb35a9b/bottleneck-1.6.0-cp312-cp312-win32.whl", hash = "sha256:a1e5907ec2714efbe7075d9207b58c22ab6984a59102e4ecd78dced80dab8374", size = 108020, upload-time = "2025-09-08T16:30:01.773Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ad/d71da675eef85ac153eef5111ca0caa924548c9591da00939bcabba8de8e/bottleneck-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:81e3822499f057a917b7d3972ebc631ac63c6bbcc79ad3542a66c4c40634e3a6", size = 113493, upload-time = "2025-09-08T16:30:02.872Z" },
+ { url = "https://files.pythonhosted.org/packages/97/1a/e117cd5ff7056126d3291deb29ac8066476e60b852555b95beb3fc9d62a0/bottleneck-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d015de414ca016ebe56440bdf5d3d1204085080527a3c51f5b7b7a3e704fe6fd", size = 100521, upload-time = "2025-09-08T16:30:03.89Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/22/05555a9752357e24caa1cd92324d1a7fdde6386aab162fcc451f8f8eedc2/bottleneck-1.6.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:456757c9525b0b12356f472e38020ed4b76b18375fd76e055f8d33fb62956f5e", size = 377719, upload-time = "2025-09-08T16:30:05.135Z" },
+ { url = "https://files.pythonhosted.org/packages/11/ee/76593af47097d9633109bed04dbcf2170707dd84313ca29f436f9234bc51/bottleneck-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c65254d51b6063c55f6272f175e867e2078342ae75f74be29d6612e9627b2c0", size = 368577, upload-time = "2025-09-08T16:30:06.387Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/f7/4dcacaf637d2b8d89ea746c74159adda43858d47358978880614c3fa4391/bottleneck-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a172322895fbb79c6127474f1b0db0866895f0b804a18d5c6b841fea093927fe", size = 361441, upload-time = "2025-09-08T16:30:07.613Z" },
+ { url = "https://files.pythonhosted.org/packages/05/34/21eb1eb1c42cb7be2872d0647c292fc75768d14e1f0db66bf907b24b2464/bottleneck-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5e81b642eb0d5a5bf00312598d7ed142d389728b694322a118c26813f3d1fa9", size = 373416, upload-time = "2025-09-08T16:30:08.899Z" },
+ { url = "https://files.pythonhosted.org/packages/48/cb/7957ff40367a151139b5f1854616bf92e578f10804d226fbcdecfd73aead/bottleneck-1.6.0-cp313-cp313-win32.whl", hash = "sha256:543d3a89d22880cd322e44caff859af6c0489657bf9897977d1f5d3d3f77299c", size = 108029, upload-time = "2025-09-08T16:30:09.909Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a8/735df4156fa5595501d5d96a6ee102f49c13d2ce9e2a287ad51806bc3ba0/bottleneck-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:48a44307d604ceb81e256903e5d57d3adb96a461b1d3c6a69baa2c67e823bd36", size = 113497, upload-time = "2025-09-08T16:30:10.82Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/5c/8c1260df8ade7cebc2a8af513a27082b5e36aa4a5fb762d56ea6d969d893/bottleneck-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:547e6715115867c4657c9ae8cc5ddac1fec8fdad66690be3a322a7488721b06b", size = 101606, upload-time = "2025-09-08T16:30:11.935Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ea/f03e2944e91ee962922c834ed21e5be6d067c8395681f5dc6c67a0a26853/bottleneck-1.6.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e4a4a6e05b6f014c307969129e10d1a0afd18f3a2c127b085532a4a76677aef", size = 391804, upload-time = "2025-09-08T16:30:13.13Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/58/2b356b8a81eb97637dccee6cf58237198dd828890e38be9afb4e5e58e38e/bottleneck-1.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2baae0d1589b4a520b2f9cf03528c0c8b20717b3f05675e212ec2200cf628f12", size = 383443, upload-time = "2025-09-08T16:30:14.318Z" },
+ { url = "https://files.pythonhosted.org/packages/55/52/cf7d09ed3736ad0d50c624787f9b580ae3206494d95cc0f4814b93eef728/bottleneck-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2e407139b322f01d8d5b6b2e8091b810f48a25c7fa5c678cfcdc420dfe8aea0a", size = 375458, upload-time = "2025-09-08T16:30:15.379Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/e9/7c87a34a24e339860064f20fac49f6738e94f1717bc8726b9c47705601d8/bottleneck-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1adefb89b92aba6de9c6ea871d99bcd29d519f4fb012cc5197917813b4fc2c7f", size = 386384, upload-time = "2025-09-08T16:30:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/59/57/db51855e18a47671801180be748939b4c9422a0544849af1919116346b5f/bottleneck-1.6.0-cp313-cp313t-win32.whl", hash = "sha256:64b8690393494074923780f6abdf5f5577d844b9d9689725d1575a936e74e5f0", size = 109448, upload-time = "2025-09-08T16:30:18.076Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/1e/683c090b624f13a5bf88a0be2241dc301e98b2fb72a45812a7ae6e456cc4/bottleneck-1.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:cb67247f65dcdf62af947c76c6c8b77d9f0ead442cac0edbaa17850d6da4e48d", size = 115190, upload-time = "2025-09-08T16:30:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/77/e2/eb7c08964a3f3c4719f98795ccd21807ee9dd3071a0f9ad652a5f19196ff/bottleneck-1.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98f1d789042511a0f042b3bdcd2903e8567e956d3aa3be189cce3746daeb8550", size = 100544, upload-time = "2025-09-08T16:30:20.22Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ec/c6f3be848f37689f481797ce7d9807d5f69a199d7fc0e46044f9b708c468/bottleneck-1.6.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1fad24c99e39ad7623fc2a76d37feb26bd32e4dd170885edf4dbf4bfce2199a3", size = 378315, upload-time = "2025-09-08T16:30:21.409Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/8f/2d6600836e2ea8f14fcefac592dc83497e5b88d381470c958cb9cdf88706/bottleneck-1.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643e61e50a6f993debc399b495a1609a55b3bd76b057e433e4089505d9f605c7", size = 368978, upload-time = "2025-09-08T16:30:23.458Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/bf72b49f5040212873b985feef5050015645e0a02204b591e1d265fc522a/bottleneck-1.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa668efbe4c6b200524ea0ebd537212da9b9801287138016fdf64119d6fcf201", size = 362074, upload-time = "2025-09-08T16:30:24.71Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/c8/c4891a0604eb680031390182c6e264247e3a9a8d067d654362245396fadf/bottleneck-1.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f7dd35262e89e28fedd79d45022394b1fa1aceb61d2e747c6d6842e50546daa", size = 374019, upload-time = "2025-09-08T16:30:26.438Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2d/ed096f8d1b9147e84914045dd89bc64e3c32eee49b862d1e20d573a9ab0d/bottleneck-1.6.0-cp314-cp314-win32.whl", hash = "sha256:bd90bec3c470b7fdfafc2fbdcd7a1c55a4e57b5cdad88d40eea5bc9bab759bf1", size = 110173, upload-time = "2025-09-08T16:30:27.521Z" },
+ { url = "https://files.pythonhosted.org/packages/33/70/1414acb6ae378a15063cfb19a0a39d69d1b6baae1120a64d2b069902549b/bottleneck-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:b43b6d36a62ffdedc6368cf9a708e4d0a30d98656c2b5f33d88894e1bcfd6857", size = 115899, upload-time = "2025-09-08T16:30:28.524Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ed/4570b5d8c1c85ce3c54963ebc37472231ed54f0b0d8dbb5dde14303f775f/bottleneck-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:53296707a8e195b5dcaa804b714bd222b5e446bd93cd496008122277eb43fa87", size = 101615, upload-time = "2025-09-08T16:30:29.556Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/93/c148faa07ae91f266be1f3fad1fde95aa2449e12937f3f3df2dd720b86e0/bottleneck-1.6.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6df19cc48a83efd70f6d6874332aa31c3f5ca06a98b782449064abbd564cf0e", size = 392411, upload-time = "2025-09-08T16:30:31.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1c/e6ad221d345a059e7efb2ad1d46a22d9fdae0486faef70555766e1123966/bottleneck-1.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96bb3a52cb3c0aadfedce3106f93ab940a49c9d35cd4ed612e031f6deb27e80f", size = 384022, upload-time = "2025-09-08T16:30:32.364Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/40/5b15c01eb8c59d59bc84c94d01d3d30797c961f10ec190f53c27e05d62ab/bottleneck-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1db9e831b69d5595b12e79aeb04cb02873db35576467c8dd26cdc1ee6b74581", size = 376004, upload-time = "2025-09-08T16:30:33.731Z" },
+ { url = "https://files.pythonhosted.org/packages/74/f6/cb228f5949553a5c01d1d5a3c933f0216d78540d9e0bf8dd4343bb449681/bottleneck-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4dd7ac619570865fcb7a0e8925df418005f076286ad2c702dd0f447231d7a055", size = 386909, upload-time = "2025-09-08T16:30:34.973Z" },
+ { url = "https://files.pythonhosted.org/packages/09/9a/425065c37a67a9120bf53290371579b83d05bf46f3212cce65d8c01d470a/bottleneck-1.6.0-cp314-cp314t-win32.whl", hash = "sha256:7fb694165df95d428fe00b98b9ea7d126ef786c4a4b7d43ae2530248396cadcb", size = 111636, upload-time = "2025-09-08T16:30:36.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/23/c41006e42909ec5114a8961818412310aa54646d1eae0495dbff3598a095/bottleneck-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:174b80930ce82bd8456c67f1abb28a5975c68db49d254783ce2cb6983b4fea40", size = 117611, upload-time = "2025-09-08T16:30:37.055Z" },
+]
+
+[[package]]
+name = "build"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (os_name != 'nt' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.10.2' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging" },
+ { name = "pyproject-hooks" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" },
+]
+
+[[package]]
+name = "cachey"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "heapdict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/9c/e3c959c1601013bf8a72e8bf91ea1ebc6fe8a2305bd2324b039ee0403277/cachey-0.2.1.tar.gz", hash = "sha256:0310ba8afe52729fa7626325c8d8356a8421c434bf887ac851e58dcf7cf056a6", size = 6461, upload-time = "2020-03-11T15:34:08.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/f0/e24f3e5d5d539abeb783087b87c26cfb99c259f1126700569e000243745a/cachey-0.2.1-py3-none-any.whl", hash = "sha256:49cf8528496ce3f99d47f1bd136b7c88237e55347a15d880f47cefc0615a83c3", size = 6415, upload-time = "2020-03-11T15:34:07.347Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.4.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
+ { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
+ { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
+ { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
+ { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
+ { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
+ { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
+ { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "cfgv"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" },
+ { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" },
+ { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" },
+ { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" },
+ { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" },
+ { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" },
+ { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
+ { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
+ { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
+ { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
+]
+
+[[package]]
+name = "cloudpickle"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" },
+]
+
+[[package]]
+name = "cmake"
+version = "4.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/07/f1d6f7bcf056a139352cc2972f92a92005ac0ee98103165b1f620873b196/cmake-4.3.2.tar.gz", hash = "sha256:5f47f5f00910c474662d09a0516413c6e9750bde73cdc52dea3988102a274e06", size = 36969, upload-time = "2026-04-23T21:51:35.982Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/15/4c8980d5ceef53f0c490425f5b8e47f3ff863348400b0ea5ba4e349119f1/cmake-4.3.2-py3-none-macosx_10_10_universal2.whl", hash = "sha256:f8f570813753ed4564928cf45c4c13c31e46b3e66b1a07fe695cb9f7b7af185e", size = 52883814, upload-time = "2026-04-23T21:50:16.764Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/50/2f336143dbcf5eca7c2d7e86273a84ef60231a0b7cfa33143d964c62f250/cmake-4.3.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ca739ab0d8960261fdb1bb6e1e6c16b9cd033ae0a98341483cc233ba7c81b22b", size = 29578008, upload-time = "2026-04-23T21:50:21.965Z" },
+ { url = "https://files.pythonhosted.org/packages/52/e9/4b571a24924b5bdbd5c0b0fbb0b6b1008eb6472ceb9f23bca9e08e09632e/cmake-4.3.2-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b81038453a40aed73f8d28185c4c3ef43c3a91a7ff1577ce08e498769ecfe16", size = 30676971, upload-time = "2026-04-23T21:50:25.395Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/d4/87f91ba2030c862a27a5b2df42e4e80d3244910acd10aa4cdcd0111820a9/cmake-4.3.2-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8add046a4ef7c606d8e0a444050415054c7da3b8535b2c8ce1f03e265dda098d", size = 30462837, upload-time = "2026-04-23T21:50:28.785Z" },
+ { url = "https://files.pythonhosted.org/packages/21/ee/5475cd861db5e8e22fd6b3fd323a67f8f62df487163f72bc281739309bcf/cmake-4.3.2-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:7105a5411bbc405242677d29222812028566aafac3f78fda08c1717f03e5ca2a", size = 28377235, upload-time = "2026-04-23T21:50:32.144Z" },
+ { url = "https://files.pythonhosted.org/packages/63/cd/1008be054420fd759b73d3328326d047ea693c3910a55cb2a597d911baee/cmake-4.3.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:339655b93289c1b03c6a72523d46d3b0d19dc51406d3a90f8eefcbec525cb271", size = 29512079, upload-time = "2026-04-23T21:50:36.295Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c4/e7c3649c4941927aff24c464090c4ce7f1f24167077f1b8aff3994def88d/cmake-4.3.2-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:ea95db137fd27f420d5e149c41e1f7621e786869afa3ca0fe18301df9e066607", size = 26628649, upload-time = "2026-04-23T21:50:40.047Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/05/ee3b002e0e7303a36e1f3c52e18d004fe089076a0e7b38df5e64a2328e88/cmake-4.3.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3947bc5973ca3846c76486993abd0fba0cb9119300d58ed9173a672d1eef505b", size = 26769012, upload-time = "2026-04-23T21:50:44.237Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a6/4e42625ce96197ffd72bb6a0e9c02d64506ee3075dd801219bb44fd9dcf7/cmake-4.3.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bc316be89fa43c265697c3b9ffcebde977bcc4515974372cc460c974f458ff98", size = 38595561, upload-time = "2026-04-23T21:50:48.601Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/5b/80ab6fa7aceff0af186a844b6a53c3022e9775467804a1ce2ec3aafb02b3/cmake-4.3.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d10648929eb3294449ceae7a7c0b9714ca445280ddc25c209f427e7a07c6f3a9", size = 35226159, upload-time = "2026-04-23T21:50:53.968Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e3/73fc202fb943221a7ce78a5374c7a73093d26af85378ddab9c04586dc98b/cmake-4.3.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a69e629e6cd973e1f9b11d247d116acb47b35cdd8e39aee4b04b9040851cd8a0", size = 41262833, upload-time = "2026-04-23T21:50:59.402Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/d3/3ef79820bfbcd5b51ef3b8029fc9c96da363daa11aae02ddd82df08fa446/cmake-4.3.2-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:95e0ff31a692d12130f33d1467d0074f8314cf3a79940694896de9367b6e4fae", size = 40437896, upload-time = "2026-04-23T21:51:04.304Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/a8/8a1147fa26a3c1564bf654b744baeb2b8c0efccfde04190d3f9222c27bde/cmake-4.3.2-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:67775407b963385a7942dd56f0567ef3c75453c6336654aeafe43165d78648bb", size = 35492662, upload-time = "2026-04-23T21:51:08.982Z" },
+ { url = "https://files.pythonhosted.org/packages/78/96/42a744beb4c9f6e459f1a57162f391a9941f31850048fc461e88095f98da/cmake-4.3.2-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:aae70bb95762f6da20131cf72da6322557ff6784968755d201d42a44cd9494e5", size = 37723238, upload-time = "2026-04-23T21:51:13.595Z" },
+ { url = "https://files.pythonhosted.org/packages/06/4c/513a73685feef886c824982b6618bb3cc7e7ac8579b34f1fe043bce11af5/cmake-4.3.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1c5c292b1189e48d01f0bed01ed800c31eed967afd033c4beabff9cc97209f2", size = 38554155, upload-time = "2026-04-23T21:51:18.153Z" },
+ { url = "https://files.pythonhosted.org/packages/14/aa/35b387f6cc0990247195109b04523a1c05dbb4f0fd0bae28e7f34c4a1e6f/cmake-4.3.2-py3-none-win32.whl", hash = "sha256:3c55f0c61c70642d9e7f6b4fc638622027f045b388e357d74efcca4a7111e4aa", size = 37819702, upload-time = "2026-04-23T21:51:22.933Z" },
+ { url = "https://files.pythonhosted.org/packages/96/8d/7ceb7223d274e88d621ce00f2160ae74aead18a3d36f61b8fb52cbe6b7ca/cmake-4.3.2-py3-none-win_amd64.whl", hash = "sha256:78049aac277aabe376d8e82993f0be234d086d0e8ad4708755d5a209a04e1138", size = 41272507, upload-time = "2026-04-23T21:51:27.93Z" },
+ { url = "https://files.pythonhosted.org/packages/69/4f/fa4da5330b63d5ce7909892005eced21d1fca58d022ed6b40f216f6f6c52/cmake-4.3.2-py3-none-win_arm64.whl", hash = "sha256:b218d636a99fa0eb23713d37d3e3c3a9c0e707e0579b46780ff908acef229386", size = 39613878, upload-time = "2026-04-23T21:51:33.041Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "comm"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" },
+ { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" },
+ { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" },
+ { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" },
+ { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" },
+ { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" },
+ { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" },
+ { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" },
+ { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" },
+ { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" },
+ { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" },
+ { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" },
+ { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" },
+ { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" },
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" },
+ { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" },
+ { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" },
+ { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" },
+ { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" },
+ { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" },
+ { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" },
+ { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" },
+ { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" },
+ { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" },
+ { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" },
+ { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" },
+ { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" },
+ { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
+ { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
+ { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
+ { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
+ { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
+ { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
+ { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
+ { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
+ { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
+ { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
+ { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
+ { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
+ { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
+ { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
+ { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
+ { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
+ { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
+ { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
+ { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
+ { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
+ { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
+ { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
+ { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
+ { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
+ { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
+ { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
+ { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
+ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+
+[[package]]
+name = "cryptography"
+version = "48.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "(python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and extra == 'extra-10-deeplabcut-tf') or (platform_python_implementation != 'PyPy' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
+ { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
+ { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
+ { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
+ { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
+ { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
+ { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
+ { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
+ { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
+ { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
+ { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
+ { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
+ { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
+]
+
+[[package]]
+name = "cuda-bindings"
+version = "12.9.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+]
+dependencies = [
+ { name = "cuda-pathfinder", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/31/bfcc870f69c6a017c4ad5c42316207fc7551940db6f3639aa4466ec5faf3/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a022c96b8bd847e8dc0675523431149a4c3e872f440e3002213dbb9e08f0331a", size = 11800959, upload-time = "2025-10-21T14:51:26.458Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/d8/b546104b8da3f562c1ff8ab36d130c8fe1dd6a045ced80b4f6ad74f7d4e1/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d3c842c2a4303b2a580fe955018e31aea30278be19795ae05226235268032e5", size = 12148218, upload-time = "2025-10-21T14:51:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/1e/9c8ed3f3dbed7b7d038805fdc65cbc65fda9983e84437778a9571e7092bc/cuda_bindings-12.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:f69107389e6b9948969bfd0a20c4f571fd1aefcfb1d2e1b72cc8ba5ecb7918ab", size = 11464568, upload-time = "2025-10-21T14:51:31.454Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/2b/ebcbb60aa6dba830474cd360c42e10282f7a343c0a1f58d24fbd3b7c2d77/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6a429dc6c13148ff1e27c44f40a3dd23203823e637b87fd0854205195988306", size = 11840604, upload-time = "2025-10-21T14:51:34.565Z" },
+ { url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/be/90d32049e06abcfba4b2e7df1dbcb5e16215c8852eef0cd8b25f38a66bd4/cuda_bindings-12.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:443b0875916879c2e4c3722941e25e42d5ab9bcbf34c9e83404fb100fa1f6913", size = 11490933, upload-time = "2025-10-21T14:51:38.792Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c2/65bfd79292b8ff18be4dd7f7442cea37bcbc1a228c1886f1dea515c45b67/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:694ba35023846625ef471257e6b5a4bc8af690f961d197d77d34b1d1db393f56", size = 11760260, upload-time = "2025-10-21T14:51:40.79Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6b/9c1b1a6c01392bfdd758e9486f52a1a72bc8f49e98f9355774ef98b5fb4e/cuda_bindings-12.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:696ca75d249ddf287d01b9a698b8e2d8a05046495a9c051ca15659dc52d17615", size = 11586961, upload-time = "2025-10-21T14:51:45.394Z" },
+ { url = "https://files.pythonhosted.org/packages/05/8b/b4b2d1c7775fa403b64333e720cfcfccef8dcb9cdeb99947061ca5a77628/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8bfaedc238f3b115d957d1fd6562b7e8435ba57f6d0e2f87d0e7149ccb2da5", size = 11570071, upload-time = "2025-10-21T14:51:47.472Z" },
+ { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" },
+ { url = "https://files.pythonhosted.org/packages/05/d0/d0e4e2e047d8e899f023fa15ad5e9894ce951253f4c894f1cd68490fdb14/cuda_bindings-12.9.4-cp313-cp313-win_amd64.whl", hash = "sha256:a2e82c8985948f953c2be51df45c3fe11c812a928fca525154fb9503190b3e64", size = 11556719, upload-time = "2025-10-21T14:51:52.248Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/07/6aff13bc1e977e35aaa6b22f52b172e2890c608c6db22438cf7ed2bf43a6/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3adf4958dcf68ae7801a59b73fb00a8b37f8d0595060d66ceae111b1002de38d", size = 11566797, upload-time = "2025-10-21T14:51:54.581Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/3c/972edfddb4ae8a9fccd3c3766ed47453b6f805b6026b32f10209dd4b8ad4/cuda_bindings-12.9.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b32d8b685f0e66f5658bcf4601ef034e89fc2843582886f0a58784a4302da06c", size = 11894363, upload-time = "2025-10-21T14:51:58.633Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b5/96a6696e20c4ffd2b327f54c7d0fde2259bdb998d045c25d5dedbbe30290/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f53a7f453d4b2643d8663d036bafe29b5ba89eb904c133180f295df6dc151e5", size = 11624530, upload-time = "2025-10-21T14:52:01.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/87/652796522cc1a7af559460e1ce59b642e05c1468b9c08522a9a096b4cf04/cuda_bindings-12.9.4-cp314-cp314-win_amd64.whl", hash = "sha256:53a10c71fdbdb743e0268d07964e5a996dd00b4e43831cbfce9804515d97d575", size = 11517716, upload-time = "2025-10-21T14:52:06.013Z" },
+ { url = "https://files.pythonhosted.org/packages/39/73/d2fc40c043bac699c3880bf88d3cebe9d88410cd043795382826c93a89f0/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20f2699d61d724de3eb3f3369d57e2b245f93085cab44fd37c3bea036cea1a6f", size = 11565056, upload-time = "2025-10-21T14:52:08.338Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/52/a30f46e822bfa6b4a659d1e8de8c4a4adf908ea075dac568b55362541bd8/cuda_bindings-12.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:53e11991a92ff6f26a0c8a98554cd5d6721c308a6b7bfb08bebac9201e039e43", size = 12055608, upload-time = "2025-10-21T14:52:12.335Z" },
+]
+
+[[package]]
+name = "cuda-bindings"
+version = "13.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "cuda-pathfinder", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ea/81999d01375645f34596c76eb046b4b36d58cc6fe2bddb2410f8a7b7a827/cuda_bindings-13.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:845025438a1b9e20718b9fb42add3e0eb72e85458bcab3eeb80bfd8f0a9dab33", size = 5600047, upload-time = "2026-03-11T00:12:34.848Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" },
+ { url = "https://files.pythonhosted.org/packages/29/5a/0ce1731c48bcd9f40996a4ef1abbf634f1a7fe4a15c5050b1e75ce3a7acf/cuda_bindings-13.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:debb51b211d246f8326f6b6e982506a5d0d9906672c91bc478b66addc7ecc60a", size = 5631363, upload-time = "2026-03-11T00:12:41.58Z" },
+ { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a5/d7f01a415e134546248cef612adad8153c9f1eb10ec79505a7cd8294370b/cuda_bindings-13.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:45815daeb595bf3b405c52671a2542b1f8e9329f3b029494acbfcc74aeaa1f2d", size = 5840830, upload-time = "2026-03-11T00:12:48.43Z" },
+ { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" },
+ { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/84/d3b6220b51cbc02ca14db7387e97445126b4ff5125aaa6c5dd7dcb75e679/cuda_bindings-13.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8cebe3ce4aeeca5af9c490e175f76c4b569bbf4a35a62294b777bc77bf7ac4d8", size = 5796512, upload-time = "2026-03-11T00:12:54.483Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/73/98bcb069778fe420226db75aff54b5dd6c3ecfd0912edabab723326e80b7/cuda_bindings-13.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd658bb5c0e55b7b3e5dd0ed509c6addb298c665db26a9bfba35e1e626000ba2", size = 5938605, upload-time = "2026-03-11T00:13:01.639Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" },
+ { url = "https://files.pythonhosted.org/packages/52/49/4e01cc06447d39476e138d1b1adec8d35c0d04eccd2c8d69befc08cd66e8/cuda_bindings-13.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ccf14e0c1def3b7200100aafff3a9f7e210ecb6e409329e92dcf6cd2c00d5c7", size = 6662637, upload-time = "2026-03-11T00:13:07.881Z" },
+]
+
+[[package]]
+name = "cuda-pathfinder"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" },
+]
+
+[[package]]
+name = "cuda-toolkit"
+version = "13.0.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" },
+]
+
+[package.optional-dependencies]
+cudart = [
+ { name = "nvidia-cuda-runtime", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+cufft = [
+ { name = "nvidia-cufft", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+cufile = [
+ { name = "nvidia-cufile", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+cupti = [
+ { name = "nvidia-cuda-cupti", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+curand = [
+ { name = "nvidia-curand", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+cusolver = [
+ { name = "nvidia-cusolver", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+cusparse = [
+ { name = "nvidia-cusparse", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+nvjitlink = [
+ { name = "nvidia-nvjitlink", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+nvrtc = [
+ { name = "nvidia-cuda-nvrtc", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+nvtx = [
+ { name = "nvidia-nvtx", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
+]
+
+[[package]]
+name = "dask"
+version = "2026.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "cloudpickle" },
+ { name = "fsspec" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging" },
+ { name = "partd" },
+ { name = "pyyaml" },
+ { name = "toolz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d7/2a/5d8cc1579590af86576dde890254440e478c7174b93a02095ecfc2e6ba38/dask-2026.3.0.tar.gz", hash = "sha256:f7d96c8274e8a900d217c1ff6ea8d1bbf0b4c2c21e74a409644498d925eb8f85", size = 11000710, upload-time = "2026-03-18T07:10:14.945Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/f3/00bb1e867fba351e2d784170955713bee200c43ea306c59f30bd7e748192/dask-2026.3.0-py3-none-any.whl", hash = "sha256:be614b9242b0b38288060fb2d7696125946469c98a1c30e174883fd199e0428d", size = 1485630, upload-time = "2026-03-18T07:10:12.832Z" },
+]
+
+[package.optional-dependencies]
+array = [
+ { name = "numpy" },
+]
+dataframe = [
+ { name = "numpy" },
+ { name = "pandas" },
+ { name = "pyarrow" },
+]
+
+[[package]]
+name = "dask-image"
+version = "2025.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dask", extra = ["array", "dataframe"] },
+ { name = "numpy" },
+ { name = "pandas" },
+ { name = "pims" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/c4/7b83217443201469384a415687a8b89da8e55fc7a182e9507a69851a78b9/dask_image-2025.11.0.tar.gz", hash = "sha256:45cf1a9c3a8a1c143c75d43f1494e4fe0827564d3ec6efb93618fb04603ba0b3", size = 79561, upload-time = "2025-11-13T01:57:28.093Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/4b/817da308fa1170da07ef01259585887a3bbb6ab80700b3e61ce4967301ec/dask_image-2025.11.0-py3-none-any.whl", hash = "sha256:4834ece8d7133f8cd7d4e672f7f5a598c9057e687b20f14f3121360e3e1690b4", size = 61936, upload-time = "2025-11-13T01:57:27.133Z" },
+]
+
+[[package]]
+name = "debugpy"
+version = "1.8.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/be/8bd693a0b9d53d48c8978fa5d889e06f3b5b03e45fd1ea1e78267b4887cb/debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64", size = 2099192, upload-time = "2026-01-29T23:03:29.707Z" },
+ { url = "https://files.pythonhosted.org/packages/77/1b/85326d07432086a06361d493d2743edd0c4fc2ef62162be7f8618441ac37/debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642", size = 3088568, upload-time = "2026-01-29T23:03:31.467Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/60/3e08462ee3eccd10998853eb35947c416e446bfe2bc37dbb886b9044586c/debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2", size = 5284399, upload-time = "2026-01-29T23:03:33.678Z" },
+ { url = "https://files.pythonhosted.org/packages/72/43/09d49106e770fe558ced5e80df2e3c2ebee10e576eda155dcc5670473663/debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893", size = 5316388, upload-time = "2026-01-29T23:03:35.095Z" },
+ { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" },
+ { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" },
+ { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" },
+ { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
+]
+
+[[package]]
+name = "deeplabcut"
+version = "3.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "albumentations" },
+ { name = "dlclibrary" },
+ { name = "einops" },
+ { name = "filelock" },
+ { name = "filterpy" },
+ { name = "h5py", marker = "sys_platform == 'darwin' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "huggingface-hub" },
+ { name = "imageio-ffmpeg" },
+ { name = "imgaug" },
+ { name = "matplotlib" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numba" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pandas", extra = ["hdf5", "performance"] },
+ { name = "pillow" },
+ { name = "pycocotools" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "ruamel-yaml" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "statsmodels" },
+ { name = "tables", version = "3.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tables", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "timm" },
+ { name = "torch", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.10.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "torchvision", version = "0.15.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torchvision", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "tqdm" },
+]
+
+[package.optional-dependencies]
+apple-mchips = [
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'darwin'" },
+ { name = "tensorflow", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow", version = "2.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin'" },
+ { name = "tensorpack", marker = "sys_platform == 'darwin'" },
+ { name = "tf-keras", version = "2.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-keras", version = "2.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-slim", marker = "sys_platform == 'darwin'" },
+]
+docs = [
+ { name = "jupyter-book" },
+ { name = "numpydoc" },
+ { name = "sphinxcontrib-mermaid" },
+]
+fmpose3d = [
+ { name = "fmpose3d" },
+]
+gui = [
+ { name = "napari-deeplabcut" },
+ { name = "pyside6" },
+ { name = "qdarkstyle" },
+]
+modelzoo = [
+ { name = "huggingface-hub" },
+]
+openvino = [
+ { name = "openvino-dev" },
+]
+tf = [
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" } },
+ { name = "tensorflow", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow", version = "2.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-io-gcs-filesystem", version = "0.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'win32'" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin'" },
+ { name = "tensorpack" },
+ { name = "tf-keras", version = "2.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-keras", version = "2.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-slim" },
+]
+tf-cu11 = [
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" } },
+ { name = "tensorflow", version = "2.14.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "tensorflow-io-gcs-filesystem", version = "0.31.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform == 'win32'" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin'" },
+ { name = "tensorpack" },
+ { name = "tf-keras", version = "2.14.1", source = { registry = "https://pypi.org/simple" } },
+ { name = "tf-slim" },
+ { name = "torch", version = "2.0.1", source = { registry = "https://pypi.org/simple" } },
+ { name = "torchvision", version = "0.15.2", source = { registry = "https://pypi.org/simple" } },
+]
+tf-cu12 = [
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" } },
+ { name = "tensorflow", version = "2.18.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin'" },
+ { name = "tensorpack" },
+ { name = "tf-keras", version = "2.18.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "tf-slim" },
+ { name = "torch", version = "2.10.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "torchvision", version = "0.25.0", source = { registry = "https://pypi.org/simple" } },
+]
+tf-latest = [
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow", version = "2.18.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin'" },
+ { name = "tensorpack" },
+ { name = "tf-keras", version = "2.18.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-keras", version = "2.20.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tf-slim" },
+]
+wandb = [
+ { name = "wandb" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "coverage" },
+ { name = "nbformat" },
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "albumentations", specifier = "<=1.4.3" },
+ { name = "dlclibrary", specifier = ">=0.0.12" },
+ { name = "einops" },
+ { name = "filelock", specifier = ">=3.12,<3.16" },
+ { name = "filterpy", specifier = ">=1.4.4" },
+ { name = "fmpose3d", marker = "extra == 'fmpose3d'", specifier = ">=0.0.8" },
+ { name = "h5py", marker = "sys_platform == 'darwin'", specifier = ">=3.15.1" },
+ { name = "huggingface-hub", specifier = ">=0.23" },
+ { name = "huggingface-hub", marker = "extra == 'modelzoo'" },
+ { name = "imageio-ffmpeg" },
+ { name = "imgaug", specifier = ">=0.4" },
+ { name = "jupyter-book", marker = "extra == 'docs'", specifier = "==1.0.4.post1" },
+ { name = "matplotlib", specifier = ">=3.3,!=3.7,!=3.7.1,<3.9" },
+ { name = "napari-deeplabcut", marker = "extra == 'gui'", specifier = ">=0.3.1" },
+ { name = "networkx", specifier = ">=2.6" },
+ { name = "numba", specifier = ">=0.54" },
+ { name = "numpy", specifier = ">=1.18.5,<2" },
+ { name = "numpydoc", marker = "extra == 'docs'" },
+ { name = "openvino-dev", marker = "extra == 'openvino'", specifier = "==2022.1" },
+ { name = "packaging", specifier = ">=26" },
+ { name = "pandas", extras = ["hdf5", "performance"], specifier = ">=2.2,<3" },
+ { name = "pillow", specifier = ">=7.1" },
+ { name = "protobuf", marker = "sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = "<7" },
+ { name = "protobuf", marker = "extra == 'tf'", specifier = "<7" },
+ { name = "protobuf", marker = "extra == 'tf-cu11'", specifier = "<7" },
+ { name = "protobuf", marker = "extra == 'tf-cu12'", specifier = "<7" },
+ { name = "protobuf", marker = "extra == 'tf-latest'", specifier = "<7" },
+ { name = "pycocotools" },
+ { name = "pydantic", specifier = ">=2,<3" },
+ { name = "pyside6", marker = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gui'", specifier = "<6.10" },
+ { name = "pyside6", marker = "(platform_machine != 'x86_64' and extra == 'gui') or (sys_platform != 'linux' and extra == 'gui')" },
+ { name = "pyyaml" },
+ { name = "qdarkstyle", marker = "extra == 'gui'", specifier = "==3.1" },
+ { name = "ruamel-yaml", specifier = ">=0.15" },
+ { name = "scikit-image", specifier = ">=0.17" },
+ { name = "scikit-learn", specifier = ">=1" },
+ { name = "scipy", specifier = ">=1.9" },
+ { name = "sphinxcontrib-mermaid", marker = "extra == 'docs'" },
+ { name = "statsmodels", specifier = ">=0.11" },
+ { name = "tables", specifier = ">3.8" },
+ { name = "tensorflow", marker = "python_full_version >= '3.12' and extra == 'tf'", specifier = ">=2.16.1,<2.18" },
+ { name = "tensorflow", marker = "python_full_version < '3.12' and extra == 'tf'", specifier = ">=2.12,<2.16" },
+ { name = "tensorflow", marker = "python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = ">=2.15,<2.18" },
+ { name = "tensorflow", marker = "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = ">=2.12,<2.15" },
+ { name = "tensorflow", marker = "extra == 'tf-cu11'", specifier = "==2.14" },
+ { name = "tensorflow", marker = "extra == 'tf-cu12'", specifier = "==2.18" },
+ { name = "tensorflow", marker = "extra == 'tf-latest'", specifier = ">=2.18" },
+ { name = "tensorflow-io-gcs-filesystem", marker = "python_full_version < '3.12' and sys_platform == 'win32' and extra == 'tf'", specifier = "==0.31" },
+ { name = "tensorflow-io-gcs-filesystem", marker = "sys_platform == 'win32' and extra == 'tf-cu11'", specifier = "==0.31" },
+ { name = "tensorflow-metal", marker = "python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = ">=1.2" },
+ { name = "tensorflow-metal", marker = "python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'tf'", specifier = ">=1.2" },
+ { name = "tensorflow-metal", marker = "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = "==1.2" },
+ { name = "tensorflow-metal", marker = "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'tf'", specifier = "==1.2" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin' and extra == 'tf-cu11'", specifier = "==1.2" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin' and extra == 'tf-cu12'", specifier = "==1.2" },
+ { name = "tensorflow-metal", marker = "sys_platform == 'darwin' and extra == 'tf-latest'", specifier = ">=1.2" },
+ { name = "tensorpack", marker = "sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = ">=0.11" },
+ { name = "tensorpack", marker = "extra == 'tf'", specifier = ">=0.11" },
+ { name = "tensorpack", marker = "extra == 'tf-cu11'", specifier = "==0.11" },
+ { name = "tensorpack", marker = "extra == 'tf-cu12'", specifier = "==0.11" },
+ { name = "tensorpack", marker = "extra == 'tf-latest'", specifier = ">=0.11" },
+ { name = "tf-keras", marker = "python_full_version >= '3.12' and extra == 'tf'", specifier = ">=2.15,<2.18" },
+ { name = "tf-keras", marker = "python_full_version < '3.12' and extra == 'tf'", specifier = "<2.15" },
+ { name = "tf-keras", marker = "sys_platform == 'darwin' and extra == 'apple-mchips'" },
+ { name = "tf-keras", marker = "extra == 'tf-cu11'", specifier = "==2.14.1" },
+ { name = "tf-keras", marker = "extra == 'tf-cu12'", specifier = "==2.18" },
+ { name = "tf-keras", marker = "extra == 'tf-latest'" },
+ { name = "tf-slim", marker = "sys_platform == 'darwin' and extra == 'apple-mchips'", specifier = ">=1.1" },
+ { name = "tf-slim", marker = "extra == 'tf'", specifier = ">=1.1" },
+ { name = "tf-slim", marker = "extra == 'tf-cu11'", specifier = "==1.1" },
+ { name = "tf-slim", marker = "extra == 'tf-cu12'", specifier = "==1.1" },
+ { name = "tf-slim", marker = "extra == 'tf-latest'", specifier = ">=1.1" },
+ { name = "timm" },
+ { name = "torch", specifier = ">=2" },
+ { name = "torch", marker = "extra == 'tf-cu11'", specifier = "<2.1" },
+ { name = "torch", marker = "extra == 'tf-cu12'", specifier = "<2.11" },
+ { name = "torchvision" },
+ { name = "torchvision", marker = "extra == 'tf-cu11'", specifier = "<0.16" },
+ { name = "torchvision", marker = "extra == 'tf-cu12'", specifier = "<0.26" },
+ { name = "tqdm" },
+ { name = "wandb", marker = "extra == 'wandb'" },
+]
+provides-extras = ["gui", "openvino", "docs", "fmpose3d", "tf", "tf-cu11", "tf-cu12", "tf-latest", "apple-mchips", "modelzoo", "wandb"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "coverage" },
+ { name = "nbformat", specifier = ">5" },
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+]
+
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+]
+
+[[package]]
+name = "dlclibrary"
+version = "0.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "ruamel-yaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/bf/14c4a73bfb090642a2f8353ff38a623024ee39f7efb4966962ed2a194c3a/dlclibrary-0.0.12.tar.gz", hash = "sha256:9c1dcb98edcba03f33c31e0c0f9d18ce1c349bef65f8d3cd5bde49d5e76171db", size = 13187, upload-time = "2026-05-19T08:04:33.669Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/57/c0ebe1191a2a445e0a671ce9255b149747ff6285aa6610e355b2e4e72cfe/dlclibrary-0.0.12-py3-none-any.whl", hash = "sha256:be8e22d334fd37cfde6e68936b73ee45a2f0f55baedc83832d773e4f2aedb38b", size = 17488, upload-time = "2026-05-19T08:04:32.456Z" },
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
+[[package]]
+name = "docutils"
+version = "0.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
+]
+
+[[package]]
+name = "einops"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "fastjsonschema"
+version = "2.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.15.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007, upload-time = "2024-06-22T15:59:14.749Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159, upload-time = "2024-06-22T15:59:12.695Z" },
+]
+
+[[package]]
+name = "filterpy"
+version = "1.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/1d/ac8914360460fafa1990890259b7fa5ef7ba4cd59014e782e4ab3ab144d8/filterpy-1.4.5.zip", hash = "sha256:4f2a4d39e4ea601b9ab42b2db08b5918a9538c168cff1c6895ae26646f3d73b1", size = 177985, upload-time = "2018-10-10T22:38:24.63Z" }
+
+[[package]]
+name = "flatbuffers"
+version = "25.12.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
+]
+
+[[package]]
+name = "flexcache"
+version = "0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816, upload-time = "2024-03-09T03:21:07.555Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263, upload-time = "2024-03-09T03:21:05.635Z" },
+]
+
+[[package]]
+name = "flexparser"
+version = "0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799, upload-time = "2024-11-07T02:00:56.249Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625, upload-time = "2024-11-07T02:00:54.523Z" },
+]
+
+[[package]]
+name = "fmpose3d"
+version = "0.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "einops" },
+ { name = "filterpy" },
+ { name = "huggingface-hub" },
+ { name = "numba" },
+ { name = "numpy" },
+ { name = "opencv-python" },
+ { name = "pandas" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "timm" },
+ { name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "tqdm" },
+ { name = "yacs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/88/df6b75e3267f859c542244a6eca5edba7ecda4660bcddc6680aa2c5d74b0/fmpose3d-0.0.9.tar.gz", hash = "sha256:89e0137ed3525d2d8b6a4a6276a810643c5afd5556b32f3899eac909287d85bd", size = 114115, upload-time = "2026-04-10T12:14:09.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/1d/fb7a16fafcf437a590462b691372a99e64219e484ebb0e1fa3f849b5eeb9/fmpose3d-0.0.9-py3-none-any.whl", hash = "sha256:9d3a0aea8fdb54234c2dd9ec8d9df5dad3c16350eb7b9aba09f22345165e6d24", size = 131801, upload-time = "2026-04-10T12:14:08.562Z" },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.63.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" },
+ { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" },
+ { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" },
+ { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" },
+ { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" },
+ { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" },
+ { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" },
+ { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" },
+ { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" },
+ { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" },
+ { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
+ { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
+ { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
+ { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
+ { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
+ { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
+]
+
+[[package]]
+name = "freetype-py"
+version = "2.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/9c/61ba17f846b922c2d6d101cc886b0e8fb597c109cedfcb39b8c5d2304b54/freetype-py-2.5.1.zip", hash = "sha256:cfe2686a174d0dd3d71a9d8ee9bf6a2c23f5872385cf8ce9f24af83d076e2fbd", size = 851738, upload-time = "2024-08-29T18:32:26.37Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/a8/258dd138ebe60c79cd8cfaa6d021599208a33f0175a5e29b01f60c9ab2c7/freetype_py-2.5.1-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d01ded2557694f06aa0413f3400c0c0b2b5ebcaabeef7aaf3d756be44f51e90b", size = 1747885, upload-time = "2024-08-29T18:32:17.604Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/93/280ad06dc944e40789b0a641492321a2792db82edda485369cbc59d14366/freetype_py-2.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2f6b3d68496797da23204b3b9c4e77e67559c80390fc0dc8b3f454ae1cd819", size = 1051055, upload-time = "2024-08-29T18:32:19.153Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/36/853cad240ec63e21a37a512ee19c896b655ce1772d803a3dd80fccfe63fe/freetype_py-2.5.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:289b443547e03a4f85302e3ac91376838e0d11636050166662a4f75e3087ed0b", size = 1043856, upload-time = "2024-08-29T18:32:20.565Z" },
+ { url = "https://files.pythonhosted.org/packages/93/6f/fcc1789e42b8c6617c3112196d68e87bfe7d957d80812d3c24d639782dcb/freetype_py-2.5.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cd3bfdbb7e1a84818cfbc8025fca3096f4f2afcd5d4641184bf0a3a2e6f97bbf", size = 1108180, upload-time = "2024-08-29T18:32:21.871Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/1b/161d3a6244b8a820aef188e4397a750d4a8196316809576d015f26594296/freetype_py-2.5.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3c1aefc4f0d5b7425f014daccc5fdc7c6f914fb7d6a695cc684f1c09cd8c1660", size = 1106792, upload-time = "2024-08-29T18:32:23.134Z" },
+ { url = "https://files.pythonhosted.org/packages/93/6e/bd7fbfacca077bc6f34f1a1109800a2c41ab50f4704d3a0507ba41009915/freetype_py-2.5.1-py3-none-win_amd64.whl", hash = "sha256:0b7f8e0342779f65ca13ef8bc103938366fecade23e6bb37cb671c2b8ad7f124", size = 814608, upload-time = "2024-08-29T18:32:24.648Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" },
+]
+
+[[package]]
+name = "gast"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/f6/e73969782a2ecec280f8a176f2476149dd9dba69d5f8779ec6108a7721e6/gast-0.7.0.tar.gz", hash = "sha256:0bb14cd1b806722e91ddbab6fb86bba148c22b40e7ff11e248974e04c8adfdae", size = 33630, upload-time = "2025-11-29T15:30:05.266Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/33/f1c6a276de27b7d7339a34749cc33fa87f077f921969c47185d34a887ae2/gast-0.7.0-py3-none-any.whl", hash = "sha256:99cbf1365633a74099f69c59bd650476b96baa5ef196fec88032b00b31ba36f7", size = 22966, upload-time = "2025-11-29T15:30:03.983Z" },
+]
+
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.50"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.53.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pyasn1-modules", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" },
+]
+
+[[package]]
+name = "google-auth-oauthlib"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests-oauthlib", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e3/b4/ef2170c5f6aa5bc2461bab959a84e56d2819ce26662b50038d2d0602223e/google-auth-oauthlib-1.0.0.tar.gz", hash = "sha256:e375064964820b47221a7e1b7ee1fd77051b6323c3f9e3e19785f78ab67ecfc5", size = 20530, upload-time = "2023-02-07T20:53:20.679Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/07/8d9a8186e6768b55dfffeb57c719bc03770cf8a970a074616ae6f9e26a57/google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", size = 18926, upload-time = "2023-02-07T20:53:18.837Z" },
+]
+
+[[package]]
+name = "google-pasta"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/35/4a/0bd53b36ff0323d10d5f24ebd67af2de10a1117f5cf4d7add90df92756f1/google-pasta-0.2.0.tar.gz", hash = "sha256:c9f2c8dfc8f96d0d5808299920721be30c9eec37f2389f28904f454565c8a16e", size = 40430, upload-time = "2020-03-13T18:57:50.34Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/de/c648ef6835192e6e2cc03f40b19eeda4382c49b5bafb43d88b931c4c74ac/google_pasta-0.2.0-py3-none-any.whl", hash = "sha256:b32482794a366b5366a32c92a9a9201b107821889935a02b3e51f6b432ea84ed", size = 57471, upload-time = "2020-03-13T18:57:48.872Z" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/03/84359833f7e1d49a883e92777637c592306030e30cee5e2b1e6476f95c88/greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a", size = 283502, upload-time = "2026-04-27T12:20:55.213Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ce/6f9f008266273aa14a2e011945797ac5802b97b8b40efe7afe1ee6c1afc9/greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f", size = 600508, upload-time = "2026-04-27T12:52:37.876Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/b0f3272c2368ea2c1aa19a5ad70db0be8f8dff6e6d3d1eb82efa00cbcf19/greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb", size = 613283, upload-time = "2026-04-27T12:59:37.957Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/ae/1db979ff6ae7958d80b288f63d5f6c30df96682700ea9fc340ce994d94a1/greenlet-3.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd", size = 619894, upload-time = "2026-04-27T13:02:35.13Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ac/0b509b6fb93551ce5a01612ee1acda7f7dda4bbb66c99aeb2ab403d205dc/greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb", size = 613418, upload-time = "2026-04-27T12:25:23.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/94/b0590e3d1978f02419f30502341c40d72f77eb0a2198119fe27df47714ee/greenlet-3.5.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243", size = 415681, upload-time = "2026-04-27T13:05:11.494Z" },
+ { url = "https://files.pythonhosted.org/packages/03/03/2b2b680ec87aaa97998fb5b8d76658d4d3560386864f17efab33ba7c2e24/greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977", size = 1572229, upload-time = "2026-04-27T12:53:23.509Z" },
+ { url = "https://files.pythonhosted.org/packages/61/e4/42b259e7a19aff1a270a4bd82caf6353109ed6860c9454e18f37162b83ae/greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0", size = 1639886, upload-time = "2026-04-27T12:25:22.325Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/b4/733ca47b883b67c57f90d3ecb21055c9ec753597d10754ac201644061f9d/greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858", size = 237795, upload-time = "2026-04-27T12:21:40.118Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
+ { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
+ { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
+ { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
+ { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
+ { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
+ { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
+ { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
+ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
+ { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
+ { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
+ { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
+ { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.80.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/cd/bb7b7e54084a344c03d68144450da7ddd5564e51a298ae1662de65f48e2d/grpcio-1.80.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c", size = 6050363, upload-time = "2026-03-30T08:46:20.894Z" },
+ { url = "https://files.pythonhosted.org/packages/16/02/1417f5c3460dea65f7a2e3c14e8b31e77f7ffb730e9bfadd89eda7a9f477/grpcio-1.80.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388", size = 12026037, upload-time = "2026-03-30T08:46:25.144Z" },
+ { url = "https://files.pythonhosted.org/packages/43/98/c910254eedf2cae368d78336a2de0678e66a7317d27c02522392f949b5c6/grpcio-1.80.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02", size = 6602306, upload-time = "2026-03-30T08:46:27.593Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f8/88ca4e78c077b2b2113d95da1e1ab43efd43d723c9a0397d26529c2c1a56/grpcio-1.80.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc", size = 7301535, upload-time = "2026-03-30T08:46:29.556Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/96/f28660fe2fe0f153288bf4a04e4910b7309d442395135c88ed4f5b3b8b40/grpcio-1.80.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a", size = 6808669, upload-time = "2026-03-30T08:46:31.984Z" },
+ { url = "https://files.pythonhosted.org/packages/47/eb/3f68a5e955779c00aeef23850e019c1c1d0e032d90633ba49c01ad5a96e0/grpcio-1.80.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9", size = 7409489, upload-time = "2026-03-30T08:46:34.684Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/a7/d2f681a4bfb881be40659a309771f3bdfbfdb1190619442816c3f0ffc079/grpcio-1.80.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199", size = 8423167, upload-time = "2026-03-30T08:46:36.833Z" },
+ { url = "https://files.pythonhosted.org/packages/97/8a/29b4589c204959aa35ce5708400a05bba72181807c45c47b3ec000c39333/grpcio-1.80.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81", size = 7846761, upload-time = "2026-03-30T08:46:40.091Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/d2/ed143e097230ee121ac5848f6ff14372dba91289b10b536d54fb1b7cbae7/grpcio-1.80.0-cp310-cp310-win32.whl", hash = "sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069", size = 4156534, upload-time = "2026-03-30T08:46:42.026Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/c9/df8279bb49b29409995e95efa85b72973d62f8aeff89abee58c91f393710/grpcio-1.80.0-cp310-cp310-win_amd64.whl", hash = "sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58", size = 4889869, upload-time = "2026-03-30T08:46:44.219Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
+ { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
+ { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
+ { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
+ { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
+ { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
+ { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
+ { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
+ { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
+ { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
+ { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h5py"
+version = "3.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/6b/231413e58a787a89b316bb0d1777da3c62257e4797e09afd8d17ad3549dc/h5py-3.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e06f864bedb2c8e7c1358e6c73af48519e317457c444d6f3d332bb4e8fa6d7d9", size = 3724137, upload-time = "2026-03-06T13:47:35.242Z" },
+ { url = "https://files.pythonhosted.org/packages/74/f9/557ce3aad0fe8471fb5279bab0fc56ea473858a022c4ce8a0b8f303d64e9/h5py-3.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec86d4fffd87a0f4cb3d5796ceb5a50123a2a6d99b43e616e5504e66a953eca3", size = 3090112, upload-time = "2026-03-06T13:47:37.634Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/e15b3d0dc8a18e56409a839e6468d6fb589bc5207c917399c2e0706eeb44/h5py-3.16.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:86385ea895508220b8a7e45efa428aeafaa586bd737c7af9ee04661d8d84a10d", size = 4844847, upload-time = "2026-03-06T13:47:39.811Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/92/a8851d936547efe30cc0ce5245feac01f3ec6171f7899bc3f775c72030b3/h5py-3.16.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8975273c2c5921c25700193b408e28d6bdd0111c37468b2d4e25dcec4cd1d84d", size = 5065352, upload-time = "2026-03-06T13:47:41.489Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/ae/f2adc5d0ca9626db3277a3d87516e124cbc5d0eea0bd79bc085702d04f2c/h5py-3.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1677ad48b703f44efc9ea0c3ab284527f81bc4f318386aaaebc5fede6bbae56f", size = 4839173, upload-time = "2026-03-06T13:47:43.586Z" },
+ { url = "https://files.pythonhosted.org/packages/64/0b/e0c8c69da1d8838da023a50cd3080eae5d475691f7636b35eff20bb6ef20/h5py-3.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c4dd4cf5f0a4e36083f73172f6cfc25a5710789269547f132a20975bfe2434c", size = 5076216, upload-time = "2026-03-06T13:47:45.315Z" },
+ { url = "https://files.pythonhosted.org/packages/66/35/d88fd6718832133c885004c61ceeeb24dbd6397ef877dbed6b3a64d6a286/h5py-3.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:bdef06507725b455fccba9c16529121a5e1fbf56aa375f7d9713d9e8ff42454d", size = 3183639, upload-time = "2026-03-06T13:47:47.041Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" },
+ { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" },
+ { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" },
+ { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" },
+ { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" },
+ { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" },
+ { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" },
+ { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" },
+]
+
+[[package]]
+name = "heapdict"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/9b/d8963ae7e388270b695f3b556b6dc9adb70ae9618fba09aa1e7b1886652d/HeapDict-1.0.1.tar.gz", hash = "sha256:8495f57b3e03d8e46d5f1b2cc62ca881aca392fd5cc048dc0aa2e1a6d23ecdb6", size = 4274, upload-time = "2019-09-09T18:57:02.154Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/9d/cd4777dbcf3bef9d9627e0fe4bc43d2e294b1baeb01d0422399d5e9de319/HeapDict-1.0.1-py3-none-any.whl", hash = "sha256:6065f90933ab1bb7e50db403b90cab653c853690c5992e69294c2de2b253fc92", size = 3917, upload-time = "2019-09-09T18:57:00.821Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/d8/5c06fc76461418326a7decf8367480c35be11a41fd938633929c60a9ec6b/hf_xet-1.5.0.tar.gz", hash = "sha256:e0fb0a34d9f406eed88233e829a67ec016bec5af19e480eac65a233ea289a948", size = 837196, upload-time = "2026-05-06T06:18:15.583Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/9b/6912c99070915a4f28119e3c5b52a9abd1eec0ad5cb293b8c967a0c6f5a2/hf_xet-1.5.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7d70fe2ce97b9db73b9c9b9c81fe3693640aec83416a966c446afea54acfae3c", size = 4023383, upload-time = "2026-05-06T06:17:53.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/6d/9563cfde59b5d8128a9c7ec972a087f4c782e4f7bac5a85234edfd5d5e49/hf_xet-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:73a0dae8c71de3b0633a45c73f4a4a5ed09e94b43441d82981a781d4f12baa42", size = 3792751, upload-time = "2026-05-06T06:17:51.791Z" },
+ { url = "https://files.pythonhosted.org/packages/07/a5/ed5a0cf35b49a0571af5a8f53416dad1877a718c021c9937c3a53cb45781/hf_xet-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a60290ec57e9b71767fba7c3645ddafdd0759974b540441510c629c6db6db24a", size = 4456058, upload-time = "2026-05-06T06:17:40.735Z" },
+ { url = "https://files.pythonhosted.org/packages/60/fb/3ae8bf2a7a37a4197d0195d7247fd25b3952e15cb8a599e285dfaa6f52b3/hf_xet-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5de0f6deada0dada870bb376a11bcd1f08abf3a968a6d118f33e72d1b1eb480", size = 4250783, upload-time = "2026-05-06T06:17:38.412Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9b/8bae40d4d91525085137196e84eb0ed49cf65b5e96e5c3ecdadd8bd0fac2/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c799d49f1a5544a0ef7591c0ee75e0d6b93d6f56dc7a4979f59f7518d2872216", size = 4445594, upload-time = "2026-05-06T06:18:04.219Z" },
+ { url = "https://files.pythonhosted.org/packages/13/59/c74efbbd4e8728172b2cc72a2bc014d2947a4b7bdced932fbd3f5da1a4e5/hf_xet-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2baea1b0b989e5c152fe81425f7745ddc8901280ba3d97c98d8cdece7b706c60", size = 4663995, upload-time = "2026-05-06T06:18:06.1Z" },
+ { url = "https://files.pythonhosted.org/packages/73/32/8e1e0410af64cda9b139d1dcebdc993a8ff9c8c7c0e2696ae356d75ccc0d/hf_xet-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:526345b3ed45f374f6317349df489167606736c876241ba984105afe7fd4839d", size = 3966608, upload-time = "2026-05-06T06:18:19.74Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/34/a8febc8f4edbea8b3e21b02ebc8b628679b84ba7e45cde624a7736b51500/hf_xet-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:786d28e2eb8315d5035544b9d137b4a842d600c434bb91bf7d0d953cce906ad4", size = 3796946, upload-time = "2026-05-06T06:18:17.568Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/20/8fc8996afe5815fa1a6be8e9e5c02f24500f409d599e905800d498a4e14d/hf_xet-1.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:872d5601e6deea30d15865ede55d29eac6daf5a534ab417b99b6ef6b076dd96c", size = 4023495, upload-time = "2026-05-06T06:18:01.94Z" },
+ { url = "https://files.pythonhosted.org/packages/32/6a/93d84463c00cecb561a7508aa6303e35ee2894294eac14245526924415fe/hf_xet-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9929561f5abf4581c8ea79587881dfef6b8abb2a0d8a51915936fc2a614f4e73", size = 3792731, upload-time = "2026-05-06T06:18:00.021Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/5a/8ec8e0c863b382d00b3c2e2af6ded6b06371be617144a625903a6d562f4b/hf_xet-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f7b7bbae318e583a86fb21e5a4a175d6721d628a2874f4bd022d0e660c32a682", size = 4456738, upload-time = "2026-05-06T06:17:49.574Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/ca/f7effa1a67717da2bcc6b6c28f71c6ca648c77acaec4e2c32f40cbe16d85/hf_xet-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cf7b2dc6f31a4ea754bb50f74cde482dcf5d366d184076d8530b9872787f3761", size = 4251622, upload-time = "2026-05-06T06:17:47.096Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f2/19247dba3e231cf77dec59ddfb878f00057635ff773d099c9b59d37812c3/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8dbcbab554c9ef158ef2c991545c3e970ddd8cc7acdcd0a78c5a41095dab4ded", size = 4445667, upload-time = "2026-05-06T06:18:11.983Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/64/6f116801a3bcfb6f59f5c251f48cadc47ea54026441c4a385079286a94fa/hf_xet-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5906bf7718d3636dc13402914736abe723492cb730f744834f5f5b67d3a12702", size = 4664619, upload-time = "2026-05-06T06:18:13.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e8/069542d37946ed08669b127e1496fa99e78196d71de8d41eda5e9f1b7a58/hf_xet-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f3dc2248fc01cc0a00cd392ab497f1ca373fcbc7e3f2da1f452480b384e839e", size = 3966802, upload-time = "2026-05-06T06:18:28.162Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/91/fc6fdec27b14d04e88c386ac0a0129732b53fa23f7c4a78f4b83a039c567/hf_xet-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b285cea1b5bab46b758772716ba8d6854a1a0310fed1c249d678a8b38601e5a0", size = 3797168, upload-time = "2026-05-06T06:18:26.287Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/fb/69ff198a82cae7eb1a69fb84d93b3a3e4816564d76817fe541ddc96874eb/hf_xet-1.5.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dad0dc84e941b8ba3c860659fe1fdc35c049d47cce293f003287757e971a8f56", size = 4030814, upload-time = "2026-05-06T06:17:57.933Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ff/edcc2b40162bef3ff78e14ab637e5f3b89243d6aee72f5949d3bb6a5af83/hf_xet-1.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fd6e5a9b0fdac4ed03ed45ef79254a655b1aaab514a02202617fbf643f5fdf7a", size = 3798444, upload-time = "2026-05-06T06:17:55.79Z" },
+ { url = "https://files.pythonhosted.org/packages/49/4d/103f76b04310e5e57656696cc184690d20c466af0bca3ca88f8c8ea5d4f3/hf_xet-1.5.0-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3531b1823a0e6d77d80f9ed15ca0e00f0d115094f8ac033d5cae88f4564cc949", size = 4465986, upload-time = "2026-05-06T06:17:44.886Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/a2/546f47f464737b3edbab6f8ddb57f2599b93d2cbb66f06abb475ccb48651/hf_xet-1.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9a0ee58cd18d5ea799f7ed11290bbccbe56bdd8b1d97ca74b9cc49a3945d7a3b", size = 4259865, upload-time = "2026-05-06T06:17:42.639Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7f/1be593c1f28613be2e196473481cd81bfc5910795e30a34e8f744f6cac4f/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e60df5a42e9bed8628b6416af2cba4cba57ae9f02de226a06b020d98e1aab18", size = 4459835, upload-time = "2026-05-06T06:18:08.026Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b2/703569fc881f3284487e68cda7b42179978480da3c438042a6bbbb4a671c/hf_xet-1.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4b35549ce62601b84da4ff9b24d970032ace3d4430f52d91bcbb26c901d6c690", size = 4672414, upload-time = "2026-05-06T06:18:09.864Z" },
+ { url = "https://files.pythonhosted.org/packages/af/37/1b6def445c567286b50aa3b33828158e135b1be44938dde59f11382a500c/hf_xet-1.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:2806c7c17b4d23f8d88f7c4814f838c3b6150773fe339c20af23e1cfaf2797e4", size = 3977238, upload-time = "2026-05-06T06:18:23.621Z" },
+ { url = "https://files.pythonhosted.org/packages/62/94/3b66b148778ee100dcfd69c2ca22b57b41b44d3063ceec934f209e9184ce/hf_xet-1.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:b6c9df403040248c76d808d3e047d64db2d923bae593eb244c41e425cf6cd7be", size = 3806916, upload-time = "2026-05-06T06:18:21.7Z" },
+]
+
+[[package]]
+name = "hsluv"
+version = "5.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/81/af16607fa045724e515579d312577261b436f36f419e7c677e7e88fcc943/hsluv-5.0.4.tar.gz", hash = "sha256:2281f946427a882010042844a38c7bbe9e0d0aaf9d46babe46366ed6f169b72e", size = 543090, upload-time = "2023-09-11T21:46:52.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/36/5bddefea3d7adf22a64f9aa9701492f8a9fe6948223f5cf2602c22ec9be7/hsluv-5.0.4-py2.py3-none-any.whl", hash = "sha256:0138bd10038e2ee1b13eecae9a7d49d4ec8c320b1d7eb4f860832c792e3e4567", size = 5252, upload-time = "2023-09-11T21:46:50.407Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "1.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "tqdm" },
+ { name = "typer" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/b6/e22bd20a25299c34b8c5922c1545a6320825b13906eb0f7298edfd034a0b/huggingface_hub-1.15.0.tar.gz", hash = "sha256:28abfdddda3927fd4de6a63cf26ab012498a2c24dae52baf150c5c6edf98a1d5", size = 784100, upload-time = "2026-05-15T11:42:52.149Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/11/0b64cc9024329b76d7547c19a67604a61d21d3ba678a69d1b220c29d5112/huggingface_hub-1.15.0-py3-none-any.whl", hash = "sha256:a4a59af04cbc41a3fe3fec429b171ef994ef8c971eda10136746f408dd4e3744", size = 663602, upload-time = "2026-05-15T11:42:50.487Z" },
+]
+
+[[package]]
+name = "identify"
+version = "2.6.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+]
+
+[[package]]
+name = "imageio"
+version = "2.37.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" },
+]
+
+[[package]]
+name = "imageio-ffmpeg"
+version = "0.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
+ { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
+]
+
+[[package]]
+name = "imagesize"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" },
+]
+
+[[package]]
+name = "imgaug"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "imageio" },
+ { name = "matplotlib" },
+ { name = "numpy" },
+ { name = "opencv-python" },
+ { name = "pillow" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "shapely" },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/7d/820295b8fdaf06dce9688ef2fdeb5a317896d3276db7723e5a94e85e1253/imgaug-0.4.0.tar.gz", hash = "sha256:46bab63ed38f8980630ff721a09ca2281b7dbd4d8c11258818b6ebcc69ea46c7", size = 937254, upload-time = "2020-02-05T20:54:24.835Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/b1/af3142c4a85cba6da9f4ebb5ff4e21e2616309552caca5e8acefe9840622/imgaug-0.4.0-py2.py3-none-any.whl", hash = "sha256:ce61e65b4eb7405fc62c1b0a79d2fa92fd47f763aaecb65152d29243592111f9", size = 948018, upload-time = "2020-02-05T20:54:22.293Z" },
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "9.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
+]
+
+[[package]]
+name = "in-n-out"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/08/07edfac98a38ab0208557524cbdd94a296f565b0558417ccb2c03d14a6ea/in_n_out-0.2.1.tar.gz", hash = "sha256:43cde2b7de981d41a6d70618a2b7bd989481095922a53ead4dc75f2bbd5dffea", size = 26026, upload-time = "2024-04-22T18:56:50.418Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/06/711a4d105ad3d01d3ef351a1039bb5cc517a57dbf377d7da9a0808e34c77/in_n_out-0.2.1-py3-none-any.whl", hash = "sha256:343e81edb27cf41ec946134a92964f408465abdf6a065c6c55fe96f53bc3c8b7", size = 19951, upload-time = "2024-04-22T18:56:48.572Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "ipykernel"
+version = "6.31.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "appnope", marker = "sys_platform == 'darwin' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "comm" },
+ { name = "debugpy" },
+ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "matplotlib-inline" },
+ { name = "nest-asyncio" },
+ { name = "packaging" },
+ { name = "psutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "8.39.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "colorama", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "decorator", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jedi", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "matplotlib-inline", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pexpect", marker = "(python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "prompt-toolkit", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pygments", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "stack-data", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "traitlets", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.13.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "colorama", marker = "(python_full_version >= '3.11' and sys_platform == 'win32') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "decorator", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jedi", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "matplotlib-inline", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pexpect", marker = "(python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'emscripten' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "prompt-toolkit", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "psutil", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pygments", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "stack-data", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "traitlets", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version == '3.11.*' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "jupyter-book"
+version = "1.0.4.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "jinja2" },
+ { name = "jsonschema" },
+ { name = "linkify-it-py" },
+ { name = "myst-nb" },
+ { name = "myst-parser" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+ { name = "sphinx-book-theme", version = "1.1.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx-book-theme", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx-comments" },
+ { name = "sphinx-copybutton" },
+ { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx-external-toc" },
+ { name = "sphinx-jupyterbook-latex" },
+ { name = "sphinx-multitoc-numbering" },
+ { name = "sphinx-thebe" },
+ { name = "sphinx-togglebutton" },
+ { name = "sphinxcontrib-bibtex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/ee/5d10ce5b161764ad44219853f386e98b535cb3879bcb0d7376961a1e3897/jupyter_book-1.0.4.post1.tar.gz", hash = "sha256:2fe92c49ff74840edc0a86bb034eafdd0f645fca6e48266be367ce4d808b9601", size = 67412, upload-time = "2025-02-28T14:55:48.637Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/86/d45756beaeb4b9b06125599b429451f8640b5db6f019d606f33c85743fd4/jupyter_book-1.0.4.post1-py3-none-any.whl", hash = "sha256:3a27a6b2581f1894ffe8f347d1a3432f06fc616997547919c42cd41c54db625d", size = 45005, upload-time = "2025-02-28T14:55:46.561Z" },
+]
+
+[[package]]
+name = "jupyter-cache"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "click" },
+ { name = "importlib-metadata" },
+ { name = "nbclient" },
+ { name = "nbformat" },
+ { name = "pyyaml" },
+ { name = "sqlalchemy" },
+ { name = "tabulate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bb/f7/3627358075f183956e8c4974603232b03afd4ddc7baf72c2bc9fff522291/jupyter_cache-1.0.1.tar.gz", hash = "sha256:16e808eb19e3fb67a223db906e131ea6e01f03aa27f49a7214ce6a5fec186fb9", size = 32048, upload-time = "2024-11-15T16:03:55.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/6b/67b87da9d36bff9df7d0efbd1a325fa372a43be7158effaf43ed7b22341d/jupyter_cache-1.0.1-py3-none-any.whl", hash = "sha256:9c3cafd825ba7da8b5830485343091143dff903e4d8c69db9349b728b140abf6", size = 33907, upload-time = "2024-11-15T16:03:54.021Z" },
+]
+
+[[package]]
+name = "jupyter-client"
+version = "8.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-core" },
+ { name = "python-dateutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" },
+]
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
+]
+
+[[package]]
+name = "keras"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bf/85/d52a86eb5ae700e1f8694157019249eb33350ae9e477cd03ecdb50939d22/keras-2.14.0.tar.gz", hash = "sha256:22788bdbc86d9988794fe9703bb5205141da797c4faeeb59497c58c3d94d34ed", size = 1251354, upload-time = "2023-09-11T17:21:04.379Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/58/34d4d8f1aa11120c2d36d7ad27d0526164b1a8ae45990a2fede31d0e59bf/keras-2.14.0-py3-none-any.whl", hash = "sha256:d7429d1d2131cc7eb1f2ea2ec330227c7d9d38dab3dfdf2e78defee4ecc43fcd", size = 1709236, upload-time = "2023-09-11T17:21:02.164Z" },
+]
+
+[[package]]
+name = "keras"
+version = "3.12.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "python_full_version < '3.11'" },
+ { name = "h5py", marker = "python_full_version < '3.11'" },
+ { name = "ml-dtypes", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ml-dtypes", version = "0.5.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "namex", marker = "python_full_version < '3.11'" },
+ { name = "numpy", marker = "python_full_version < '3.11'" },
+ { name = "optree", marker = "python_full_version < '3.11'" },
+ { name = "packaging", marker = "python_full_version < '3.11'" },
+ { name = "rich", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/73/19e057f7a2a6d641246bacca21e0bbcb2be341afca98ea461a0f2a9ab92d/keras-3.12.2.tar.gz", hash = "sha256:e19c7c7f8f2a81e44d4f203e567731a15a270d8ef351060982b45a1fafdf3fce", size = 1129833, upload-time = "2026-05-07T21:48:18.396Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/ba/1f2daa7940d7c5c65efc85370453e9e67ace0d06b4a7346b53f0e7355453/keras-3.12.2-py3-none-any.whl", hash = "sha256:0433310d7d626d5cbbc58e98223b3a77ce7d7d4398bf7e169d4e8bdcf9ce0296", size = 1476474, upload-time = "2026-05-07T21:48:16.057Z" },
+]
+
+[[package]]
+name = "keras"
+version = "3.14.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "h5py", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "ml-dtypes", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "ml-dtypes", version = "0.5.4", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "namex", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "numpy", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "optree", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "packaging", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+ { name = "rich", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.11.*' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/35/e7/97a7664581b73e4f9ff1d3a767a493b6ac5d3e0ed1926bd2b6b2c8bbccd7/keras-3.14.1.tar.gz", hash = "sha256:ef479173102ad29db89b53c232efdc3fb5ad57c28bc27ead59f3e78a1eecd05b", size = 1263647, upload-time = "2026-05-07T21:43:35.112Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/03/184267c1d09783dd070f1ddfd0d4beb7503139dfc7bd75b422867cf282fd/keras-3.14.1-py3-none-any.whl", hash = "sha256:ebd2c14d2af3c9de18083604d408483996407fc7d2f9ebd1d565961f96608c29", size = 1628606, upload-time = "2026-05-07T21:43:32.737Z" },
+]
+
+[[package]]
+name = "kiwisolver"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" },
+ { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" },
+ { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" },
+ { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" },
+ { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" },
+ { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" },
+ { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" },
+ { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" },
+ { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" },
+ { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" },
+ { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" },
+ { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" },
+ { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" },
+ { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" },
+ { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" },
+ { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" },
+ { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" },
+ { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
+ { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
+ { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
+ { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
+ { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
+ { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" },
+ { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" },
+ { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
+]
+
+[[package]]
+name = "latexcodec"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/27/dd/4270b2c5e2ee49316c3859e62293bd2ea8e382339d63ab7bbe9f39c0ec3b/latexcodec-3.0.1.tar.gz", hash = "sha256:e78a6911cd72f9dec35031c6ec23584de6842bfbc4610a9678868d14cdfb0357", size = 31222, upload-time = "2025-06-17T18:47:34.051Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/40/23569737873cc9637fd488606347e9dd92b9fa37ba4fcda1f98ee5219a97/latexcodec-3.0.1-py3-none-any.whl", hash = "sha256:a9eb8200bff693f0437a69581f7579eb6bca25c4193515c09900ce76451e452e", size = 18532, upload-time = "2025-06-17T18:47:30.726Z" },
+]
+
+[[package]]
+name = "lazy-loader"
+version = "0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" },
+]
+
+[[package]]
+name = "libclang"
+version = "18.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/ca35e19a4f142adffa27e3d652196b7362fa612243e2b916845d801454fc/libclang-18.1.1.tar.gz", hash = "sha256:a1214966d08d73d971287fc3ead8dfaf82eb07fb197680d8b3859dbbbbf78250", size = 39612, upload-time = "2024-03-17T16:04:37.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/49/f5e3e7e1419872b69f6f5e82ba56e33955a74bd537d8a1f5f1eff2f3668a/libclang-18.1.1-1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:0b2e143f0fac830156feb56f9231ff8338c20aecfe72b4ffe96f19e5a1dbb69a", size = 25836045, upload-time = "2024-06-30T17:40:31.646Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e5/fc61bbded91a8830ccce94c5294ecd6e88e496cc85f6704bf350c0634b70/libclang-18.1.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6f14c3f194704e5d09769108f03185fce7acaf1d1ae4bbb2f30a72c2400cb7c5", size = 26502641, upload-time = "2024-03-18T15:52:26.722Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/1df62b44db2583375f6a8a5e2ca5432bbdc3edb477942b9b7c848c720055/libclang-18.1.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:83ce5045d101b669ac38e6da8e58765f12da2d3aafb3b9b98d88b286a60964d8", size = 26420207, upload-time = "2024-03-17T15:00:26.63Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fc/716c1e62e512ef1c160e7984a73a5fc7df45166f2ff3f254e71c58076f7c/libclang-18.1.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:c533091d8a3bbf7460a00cb6c1a71da93bffe148f172c7d03b1c31fbf8aa2a0b", size = 24515943, upload-time = "2024-03-17T16:03:45.942Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/3d/f0ac1150280d8d20d059608cf2d5ff61b7c3b7f7bcf9c0f425ab92df769a/libclang-18.1.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:54dda940a4a0491a9d1532bf071ea3ef26e6dbaf03b5000ed94dd7174e8f9592", size = 23784972, upload-time = "2024-03-17T16:12:47.677Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/2f/d920822c2b1ce9326a4c78c0c2b4aa3fde610c7ee9f631b600acb5376c26/libclang-18.1.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:cf4a99b05376513717ab5d82a0db832c56ccea4fd61a69dbb7bccf2dfb207dbe", size = 20259606, upload-time = "2024-03-17T16:17:42.437Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c2/de1db8c6d413597076a4259cea409b83459b2db997c003578affdd32bf66/libclang-18.1.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:69f8eb8f65c279e765ffd28aaa7e9e364c776c17618af8bff22a8df58677ff4f", size = 24921494, upload-time = "2024-03-17T16:14:20.132Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2d/3f480b1e1d31eb3d6de5e3ef641954e5c67430d5ac93b7fa7e07589576c7/libclang-18.1.1-py2.py3-none-win_amd64.whl", hash = "sha256:4dd2d3b82fab35e2bf9ca717d7b63ac990a3519c7e312f19fa8e86dcc712f7fb", size = 26415083, upload-time = "2024-03-17T16:42:21.703Z" },
+ { url = "https://files.pythonhosted.org/packages/71/cf/e01dc4cc79779cd82d77888a88ae2fa424d93b445ad4f6c02bfc18335b70/libclang-18.1.1-py2.py3-none-win_arm64.whl", hash = "sha256:3f0e1f49f04d3cd198985fea0511576b0aee16f9ff0e0f0cad7f9c57ec3c20e8", size = 22361112, upload-time = "2024-03-17T16:42:59.565Z" },
+]
+
+[[package]]
+name = "linkify-it-py"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "uc-micro-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
+]
+
+[[package]]
+name = "lit"
+version = "18.1.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/b4/d7e210971494db7b9a9ac48ff37dfa59a8b14c773f9cf47e6bda58411c0d/lit-18.1.8.tar.gz", hash = "sha256:47c174a186941ae830f04ded76a3444600be67d5e5fb8282c3783fba671c4edb", size = 161127, upload-time = "2024-06-25T14:33:14.489Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/06/b36f150fa7c5bcc96a31a4d19a20fddbd1d965b6f02510b57a3bb8d4b930/lit-18.1.8-py3-none-any.whl", hash = "sha256:a873ff7acd76e746368da32eb7355625e2e55a2baaab884c9cc130f2ee0300f7", size = 96365, upload-time = "2024-06-25T14:33:12.101Z" },
+]
+
+[[package]]
+name = "llvmlite"
+version = "0.47.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/f5/a1bde3aa8c43524b0acaf3f72fb3d80a32dd29dbb42d7dc434f84584cdcc/llvmlite-0.47.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41270b0b1310717f717cf6f2a9c68d3c43bd7905c33f003825aebc361d0d1b17", size = 37232772, upload-time = "2026-03-31T18:28:12.198Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fb/76d88fc05ee1f9c1a6efe39eb493c4a727e5d1690412469017cd23bcb776/llvmlite-0.47.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f9d118bc1dd7623e0e65ca9ac485ec6dd543c3b77bc9928ddc45ebd34e1e30a7", size = 56275179, upload-time = "2026-03-31T18:28:15.725Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/08/29da7f36217abd56a0c389ef9a18bea47960826e691ced1a36c92c6ce93c/llvmlite-0.47.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea5cfb04a6ab5b18e46be72b41b015975ba5980c4ddb41f1975b83e19031063", size = 55128632, upload-time = "2026-03-31T18:28:19.946Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f8/5e12e9ed447d65f04acf6fcf2d79cded2355640b5131a46cee4c99a5949d/llvmlite-0.47.0-cp310-cp310-win_amd64.whl", hash = "sha256:166b896a2262a2039d5fc52df5ee1659bd1ccd081183df7a2fba1b74702dd5ea", size = 38138402, upload-time = "2026-03-31T18:28:23.327Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0b/b9d1911cfefa61399821dfb37f486d83e0f42630a8d12f7194270c417002/llvmlite-0.47.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74090f0dcfd6f24ebbef3f21f11e38111c4d7e6919b54c4416e1e357c3446b07", size = 37232770, upload-time = "2026-03-31T18:28:26.765Z" },
+ { url = "https://files.pythonhosted.org/packages/46/27/5799b020e4cdfb25a7c951c06a96397c135efcdc21b78d853bbd9c814c7d/llvmlite-0.47.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca14f02e29134e837982497959a8e2193d6035235de1cb41a9cb2bd6da4eedbb", size = 56275177, upload-time = "2026-03-31T18:28:31.01Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/51/48a53fedf01cb1f3f43ef200be17ebf83c8d9a04018d3783c1a226c342c2/llvmlite-0.47.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a69d4bb05f402f30477e21eeabe81911e7c251cecb192bed82cd83c9db10d8", size = 55128631, upload-time = "2026-03-31T18:28:36.046Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/50/59227d06bdc96e23322713c381af4e77420949d8cd8a042c79e0043096cc/llvmlite-0.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:c37d6eb7aaabfa83ab9c2ff5b5cdb95a5e6830403937b2c588b7490724e05327", size = 38138400, upload-time = "2026-03-31T18:28:40.076Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" },
+ { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" },
+ { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" },
+ { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" },
+ { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" },
+ { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" },
+ { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" },
+]
+
+[[package]]
+name = "locket"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350, upload-time = "2022-04-20T22:04:44.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" },
+]
+
+[[package]]
+name = "magicgui"
+version = "0.10.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docstring-parser" },
+ { name = "psygnal" },
+ { name = "qtpy" },
+ { name = "superqt", extra = ["iconify"] },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/9c/0d918ee0a9b31f16e9b76c1b86b81b5d4b7bc491354c76030b87790f0cab/magicgui-0.10.2.tar.gz", hash = "sha256:ae7a4cbb7ef2028b827b1877cf0b06743d756074fe6ef849391d62448ab7b65d", size = 20946219, upload-time = "2026-04-10T14:45:28.927Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/bb/5ba264d3ccc13294e74c48c3ef0c2e96b8b13765562628515654012cc47e/magicgui-0.10.2-py3-none-any.whl", hash = "sha256:0f304d4da1a4309ad15d38cb809ef73269cea73a3195ac944f38d7c56d8e0fd5", size = 128254, upload-time = "2026-04-10T14:45:27.173Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
+ { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
+ { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
+ { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.8.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/4f/8487737a74d8be4ab5fbe6019b0fae305c1604cf7209500969b879b5f462/matplotlib-3.8.4.tar.gz", hash = "sha256:8aac397d5e9ec158960e31c381c5ffc52ddd52bd9a47717e2a694038167dffea", size = 35934425, upload-time = "2024-04-04T01:47:18.594Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/c0/1f88491656d21a2fecd90fbfae999b2f87bc44d439ef301ec8e0e4a937a0/matplotlib-3.8.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:abc9d838f93583650c35eca41cfcec65b2e7cb50fd486da6f0c49b5e1ed23014", size = 7603557, upload-time = "2024-04-04T01:47:46.363Z" },
+ { url = "https://files.pythonhosted.org/packages/86/9c/aa059a4fb8154d5875a5ddd33f8d0a42d77c0225fe4325e9b9358f39b0bf/matplotlib-3.8.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f65c9f002d281a6e904976007b2d46a1ee2bcea3a68a8c12dda24709ddc9106", size = 7497421, upload-time = "2024-04-04T01:47:54.074Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/67/ded5217d42de1532193cd87db925c67997d23c68b20c3eaa9e4c6a0adb67/matplotlib-3.8.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce1edd9f5383b504dbc26eeea404ed0a00656c526638129028b758fd43fc5f10", size = 11377985, upload-time = "2024-04-04T01:48:04.955Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/07/061f97211f942101070a46fecd813a6b1bd83590ed7b07c473cabd707fe7/matplotlib-3.8.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecd79298550cba13a43c340581a3ec9c707bd895a6a061a78fa2524660482fc0", size = 11608003, upload-time = "2024-04-04T01:48:16.25Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/d3/5d0bb1d905e219543fdfd7ab04e9d641a766367c83a5ffbcea60d2b2cf2d/matplotlib-3.8.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:90df07db7b599fe7035d2f74ab7e438b656528c68ba6bb59b7dc46af39ee48ef", size = 9535368, upload-time = "2024-04-04T01:48:26.265Z" },
+ { url = "https://files.pythonhosted.org/packages/62/5a/a5108ae3db37f35f8a2be8a57d62da327af239214c9661464ce09ee32d7d/matplotlib-3.8.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac24233e8f2939ac4fd2919eed1e9c0871eac8057666070e94cbf0b33dd9c338", size = 7656037, upload-time = "2024-04-04T01:48:34.761Z" },
+ { url = "https://files.pythonhosted.org/packages/36/11/62250ea25780d4b59c2c6044ec161235c47cc05a18d0ec0a05657de75b7d/matplotlib-3.8.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:72f9322712e4562e792b2961971891b9fbbb0e525011e09ea0d1f416c4645661", size = 7606117, upload-time = "2024-04-04T01:48:42.545Z" },
+ { url = "https://files.pythonhosted.org/packages/14/60/12d4f27b859a74359306662da69c2d08826a2b05cfe7f96e66b490f41573/matplotlib-3.8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:232ce322bfd020a434caaffbd9a95333f7c2491e59cfc014041d95e38ab90d1c", size = 7500108, upload-time = "2024-04-04T01:48:50.21Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ba/9e4f7f34dccf2d2768504410410db8d551c940457a2bec658dc4fa3b5aa2/matplotlib-3.8.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6addbd5b488aedb7f9bc19f91cd87ea476206f45d7116fcfe3d31416702a82fa", size = 11382998, upload-time = "2024-04-04T01:49:01.346Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3b/e363612ac1a514abfb5505aa209dd5b724b3232a6de98710d7759559706a/matplotlib-3.8.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc4ccdc64e3039fc303defd119658148f2349239871db72cd74e2eeaa9b80b71", size = 11613309, upload-time = "2024-04-04T01:49:13.428Z" },
+ { url = "https://files.pythonhosted.org/packages/32/4c/63164901acadb3ada55c5e0fd6b7f29c9033d7e131302884cd735611b77a/matplotlib-3.8.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b7a2a253d3b36d90c8993b4620183b55665a429da8357a4f621e78cd48b2b30b", size = 9546019, upload-time = "2024-04-04T01:49:23.752Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d5/6227732ecab9165586966ccb54301e3164f61b470c954c4cf6940654fbe1/matplotlib-3.8.4-cp311-cp311-win_amd64.whl", hash = "sha256:8080d5081a86e690d7688ffa542532e87f224c38a6ed71f8fbed34dd1d9fedae", size = 7658174, upload-time = "2024-04-04T01:49:32.066Z" },
+ { url = "https://files.pythonhosted.org/packages/91/eb/65f3bd78ce757dadd455c220273349428384b162485cd8aa380b61a867ed/matplotlib-3.8.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6485ac1f2e84676cff22e693eaa4fbed50ef5dc37173ce1f023daef4687df616", size = 7604083, upload-time = "2024-04-04T01:49:40.442Z" },
+ { url = "https://files.pythonhosted.org/packages/da/2b/2bb6073ca8d336da07ace7d98bf7bb9da8233f55876bb3db6a5ee924f3e9/matplotlib-3.8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c89ee9314ef48c72fe92ce55c4e95f2f39d70208f9f1d9db4e64079420d8d732", size = 7496013, upload-time = "2024-04-04T01:49:48.174Z" },
+ { url = "https://files.pythonhosted.org/packages/61/cd/976d3a9c10328da1d2fe183f7c92c45f1e125536226a6eb3a820c4753cd1/matplotlib-3.8.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50bac6e4d77e4262c4340d7a985c30912054745ec99756ce213bfbc3cb3808eb", size = 11376749, upload-time = "2024-04-04T01:49:58.572Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ba/412149958e951876096198609b958b90a8a2c9bc07a96eeeaa9e2c480f30/matplotlib-3.8.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f51c4c869d4b60d769f7b4406eec39596648d9d70246428745a681c327a8ad30", size = 11600837, upload-time = "2024-04-04T01:50:09.279Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/4f/e5b56ca109d8ab6bae37f519f15b891fc18809ddb8bc1aa26e0bfca83e25/matplotlib-3.8.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b12ba985837e4899b762b81f5b2845bd1a28f4fdd1a126d9ace64e9c4eb2fb25", size = 9538883, upload-time = "2024-04-04T01:50:19.268Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ca/e7bd1876a341ed8c456095962a582696cac1691cb6e55bd5ead15a755c5d/matplotlib-3.8.4-cp312-cp312-win_amd64.whl", hash = "sha256:7a6769f58ce51791b4cb8b4d7642489df347697cd3e23d88266aaaee93b41d9a", size = 7659712, upload-time = "2024-04-04T01:50:26.938Z" },
+]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "ml-dtypes"
+version = "0.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fa/47/09ca9556bf99cfe7ddf129a3423642bd482a27a717bf115090493fa42429/ml_dtypes-0.2.0.tar.gz", hash = "sha256:6488eb642acaaf08d8020f6de0a38acee7ac324c1e6e92ee0c0fea42422cb797", size = 698948, upload-time = "2023-06-06T15:14:43.679Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/9f/3c133f83f3e5a7959345585e9ac715ef8bf6e8987551f240032e1b0d3ce6/ml_dtypes-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df6a76e1c8adf484feb138ed323f9f40a7b6c21788f120f7c78bec20ac37ee81", size = 1154492, upload-time = "2023-06-06T15:14:11.966Z" },
+ { url = "https://files.pythonhosted.org/packages/19/05/7a6480a69f8555a047a56ae6af9490bcdc5e432658208f3404d8e8442d02/ml_dtypes-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc29a0524ef5e23a7fbb8d881bdecabeb3fc1d19d9db61785d077a86cb94fab2", size = 1012633, upload-time = "2023-06-06T15:14:14.055Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1d/d5cf76e5e40f69dbd273036e3172ae4a614577cb141673427b80cac948df/ml_dtypes-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f08c391c2794f2aad358e6f4c70785a9a7b1df980ef4c232b3ccd4f6fe39f719", size = 1017764, upload-time = "2023-06-06T15:14:15.632Z" },
+ { url = "https://files.pythonhosted.org/packages/55/51/c430b4f5f4a6df00aa41c1ee195e179489565e61cfad559506ca7442ce67/ml_dtypes-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:75015818a7fccf99a5e8ed18720cb430f3e71a8838388840f4cdf225c036c983", size = 938593, upload-time = "2023-06-06T15:14:17.473Z" },
+ { url = "https://files.pythonhosted.org/packages/15/da/43bee505963da0c730ee50e951c604bfdb90d4cccc9c0044c946b10e68a7/ml_dtypes-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e70047ec2c83eaee01afdfdabee2c5b0c133804d90d0f7db4dd903360fcc537c", size = 1154491, upload-time = "2023-06-06T15:14:19.199Z" },
+ { url = "https://files.pythonhosted.org/packages/49/a0/01570d615d16f504be091b914a6ae9a29e80d09b572ebebc32ecb1dfb22d/ml_dtypes-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36d28b8861a8931695e5a31176cad5ae85f6504906650dea5598fbec06c94606", size = 1012631, upload-time = "2023-06-06T15:14:21.51Z" },
+ { url = "https://files.pythonhosted.org/packages/87/91/d57c2d22e4801edeb7f3e7939214c0ea8a28c6e16f85208c2df2145e0213/ml_dtypes-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e85ba8e24cf48d456e564688e981cf379d4c8e644db0a2f719b78de281bac2ca", size = 1017764, upload-time = "2023-06-06T15:14:24.116Z" },
+ { url = "https://files.pythonhosted.org/packages/08/89/c727fde1a3d12586e0b8c01abf53754707d76beaa9987640e70807d4545f/ml_dtypes-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:832a019a1b6db5c4422032ca9940a990fa104eee420f643713241b3a518977fa", size = 938744, upload-time = "2023-06-06T15:14:25.77Z" },
+]
+
+[[package]]
+name = "ml-dtypes"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "(python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version == '3.12.*' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/15/76f86faa0902836cc133939732f7611ace68cf54148487a99c539c272dc8/ml_dtypes-0.4.1.tar.gz", hash = "sha256:fad5f2de464fd09127e49b7fd1252b9006fb43d2edc1ff112d390c324af5ca7a", size = 692594, upload-time = "2024-09-13T19:07:11.624Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/9e/76b84f77c7afee3b116dc8407903a2d5004ba3059a8f3dcdcfa6ebf33fff/ml_dtypes-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1fe8b5b5e70cd67211db94b05cfd58dace592f24489b038dc6f9fe347d2e07d5", size = 397975, upload-time = "2024-09-13T19:06:44.265Z" },
+ { url = "https://files.pythonhosted.org/packages/03/7b/32650e1b2a2713a5923a0af2a8503d0d4a8fc99d1e1e0a1c40e996634460/ml_dtypes-0.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c09a6d11d8475c2a9fd2bc0695628aec105f97cab3b3a3fb7c9660348ff7d24", size = 2182570, upload-time = "2024-09-13T19:06:46.189Z" },
+ { url = "https://files.pythonhosted.org/packages/16/86/a9f7569e7e4f5395f927de38a13b92efa73f809285d04f2923b291783dd2/ml_dtypes-0.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f5e8f75fa371020dd30f9196e7d73babae2abd51cf59bdd56cb4f8de7e13354", size = 2160365, upload-time = "2024-09-13T19:06:48.198Z" },
+ { url = "https://files.pythonhosted.org/packages/04/1b/9a3afb437702503514f3934ec8d7904270edf013d28074f3e700e5dfbb0f/ml_dtypes-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:15fdd922fea57e493844e5abb930b9c0bd0af217d9edd3724479fc3d7ce70e3f", size = 126633, upload-time = "2024-09-13T19:06:50.656Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/76/9835c8609c29f2214359e88f29255fc4aad4ea0f613fb48aa8815ceda1b6/ml_dtypes-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2d55b588116a7085d6e074cf0cdb1d6fa3875c059dddc4d2c94a4cc81c23e975", size = 397973, upload-time = "2024-09-13T19:06:51.748Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/99/e68c56fac5de973007a10254b6e17a0362393724f40f66d5e4033f4962c2/ml_dtypes-0.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e138a9b7a48079c900ea969341a5754019a1ad17ae27ee330f7ebf43f23877f9", size = 2185134, upload-time = "2024-09-13T19:06:53.197Z" },
+ { url = "https://files.pythonhosted.org/packages/28/bc/6a2344338ea7b61cd7b46fb24ec459360a5a0903b57c55b156c1e46c644a/ml_dtypes-0.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74c6cfb5cf78535b103fde9ea3ded8e9f16f75bc07789054edc7776abfb3d752", size = 2163661, upload-time = "2024-09-13T19:06:54.519Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d3/ddfd9878b223b3aa9a930c6100a99afca5cfab7ea703662e00323acb7568/ml_dtypes-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:274cc7193dd73b35fb26bef6c5d40ae3eb258359ee71cd82f6e96a8c948bdaa6", size = 126727, upload-time = "2024-09-13T19:06:55.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/1a/99e924f12e4b62139fbac87419698c65f956d58de0dbfa7c028fa5b096aa/ml_dtypes-0.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:827d3ca2097085cf0355f8fdf092b888890bb1b1455f52801a2d7756f056f54b", size = 405077, upload-time = "2024-09-13T19:06:57.538Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/8c/7b610bd500617854c8cc6ed7c8cfb9d48d6a5c21a1437a36a4b9bc8a3598/ml_dtypes-0.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:772426b08a6172a891274d581ce58ea2789cc8abc1c002a27223f314aaf894e7", size = 2181554, upload-time = "2024-09-13T19:06:59.196Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/c6/f89620cecc0581dc1839e218c4315171312e46c62a62da6ace204bda91c0/ml_dtypes-0.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126e7d679b8676d1a958f2651949fbfa182832c3cd08020d8facd94e4114f3e9", size = 2160488, upload-time = "2024-09-13T19:07:03.131Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/11/a742d3c31b2cc8557a48efdde53427fd5f9caa2fa3c9c27d826e78a66f51/ml_dtypes-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:df0fb650d5c582a9e72bb5bd96cfebb2cdb889d89daff621c8fbc60295eba66c", size = 127462, upload-time = "2024-09-13T19:07:04.916Z" },
+]
+
+[[package]]
+name = "ml-dtypes"
+version = "0.5.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "(python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/3a/c5b855752a70267ff729c349e650263adb3c206c29d28cc8ea7ace30a1d5/ml_dtypes-0.5.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b95e97e470fe60ed493fd9ae3911d8da4ebac16bd21f87ffa2b7c588bf22ea2c", size = 679735, upload-time = "2025-11-17T22:31:31.367Z" },
+ { url = "https://files.pythonhosted.org/packages/41/79/7433f30ee04bd4faa303844048f55e1eb939131c8e5195a00a96a0939b64/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4b801ebe0b477be666696bda493a9be8356f1f0057a57f1e35cd26928823e5a", size = 5051883, upload-time = "2025-11-17T22:31:33.658Z" },
+ { url = "https://files.pythonhosted.org/packages/10/b1/8938e8830b0ee2e167fc75a094dea766a1152bde46752cd9bfc57ee78a82/ml_dtypes-0.5.4-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:388d399a2152dd79a3f0456a952284a99ee5c93d3e2f8dfe25977511e0515270", size = 5030369, upload-time = "2025-11-17T22:31:35.595Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/a3/51886727bd16e2f47587997b802dd56398692ce8c6c03c2e5bb32ecafe26/ml_dtypes-0.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:4ff7f3e7ca2972e7de850e7b8fcbb355304271e2933dd90814c1cb847414d6e2", size = 210738, upload-time = "2025-11-17T22:31:37.43Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" },
+ { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" },
+ { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" },
+ { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" },
+ { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" },
+ { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" },
+ { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" },
+ { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
+ { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
+ { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
+ { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
+ { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
+ { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
+ { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
+ { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
+ { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
+ { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
+ { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
+ { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
+ { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
+ { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
+ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
+]
+
+[[package]]
+name = "msgpack-numpy"
+version = "0.4.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/94/61e8aee142733ebfdc400a05bdac6e1763c4514bba3b42743d223f388450/msgpack-numpy-0.4.8.tar.gz", hash = "sha256:c667d3180513422f9c7545be5eec5d296dcbb357e06f72ed39cc683797556e69", size = 10923, upload-time = "2022-06-09T03:43:08.739Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl", hash = "sha256:773c19d4dfbae1b3c7b791083e2caf66983bb19b40901646f61d8731554ae3da", size = 6919, upload-time = "2022-06-09T03:43:06.82Z" },
+]
+
+[[package]]
+name = "myst-nb"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "importlib-metadata" },
+ { name = "ipykernel" },
+ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jupyter-cache" },
+ { name = "myst-parser" },
+ { name = "nbclient" },
+ { name = "nbformat" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/b4/ff1abeea67e8cfe0a8c033389f6d1d8b0bfecfd611befb5cbdeab884fce6/myst_nb-1.4.0.tar.gz", hash = "sha256:c145598de62446a6fd009773dd071a40d3b76106ace780de1abdfc6961f614c2", size = 82285, upload-time = "2026-03-02T21:14:56.95Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/93/0a378b48488879a1d925b42a804edfc6e0cd0ef854220f2dce738a46e7e9/myst_nb-1.4.0-py3-none-any.whl", hash = "sha256:0e2c86e7d3b82c3aa51383f82d6268f7714f3b772c23a796ab09538a8e68b4e4", size = 82555, upload-time = "2026-03-02T21:14:55.652Z" },
+]
+
+[[package]]
+name = "myst-parser"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "jinja2" },
+ { name = "markdown-it-py" },
+ { name = "mdit-py-plugins" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" },
+]
+
+[[package]]
+name = "namex"
+version = "0.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/c0/ee95b28f029c73f8d49d8f52edaed02a1d4a9acb8b69355737fdb1faa191/namex-0.1.0.tar.gz", hash = "sha256:117f03ccd302cc48e3f5c58a296838f6b89c83455ab8683a1e85f2a430aa4306", size = 6649, upload-time = "2025-05-26T23:17:38.918Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/bc/465daf1de06409cdd4532082806770ee0d8d7df434da79c76564d0f69741/namex-0.1.0-py3-none-any.whl", hash = "sha256:e2012a474502f1e2251267062aae3114611f07df4224b6e06334c57b0f2ce87c", size = 5905, upload-time = "2025-05-26T23:17:37.695Z" },
+]
+
+[[package]]
+name = "napari"
+version = "0.6.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "app-model" },
+ { name = "appdirs" },
+ { name = "cachey" },
+ { name = "certifi" },
+ { name = "dask", extra = ["array"] },
+ { name = "imageio" },
+ { name = "jsonschema" },
+ { name = "lazy-loader" },
+ { name = "magicgui" },
+ { name = "napari-console" },
+ { name = "napari-plugin-engine" },
+ { name = "napari-svg" },
+ { name = "npe2" },
+ { name = "numpy" },
+ { name = "numpydoc" },
+ { name = "pandas" },
+ { name = "pillow" },
+ { name = "pint", version = "0.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pint", version = "0.25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "psutil" },
+ { name = "psygnal" },
+ { name = "pydantic" },
+ { name = "pygments" },
+ { name = "pyopengl" },
+ { name = "pywin32", marker = "sys_platform == 'win32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pyyaml" },
+ { name = "qtpy" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, extra = ["data"], marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, extra = ["data"], marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "superqt" },
+ { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "toolz" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+ { name = "vispy" },
+ { name = "wrapt", version = "1.14.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/a4/f1573c137aeccb86f8a57cae2470b28e22f55b8fa9fa5a2c2621ff1457de/napari-0.6.6.tar.gz", hash = "sha256:8e1adfc737c55c2a619689fad8d9e4d115582e56f09096cf771816b0ec75c3a7", size = 3251386, upload-time = "2025-10-16T09:25:58.223Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/d8/61c29378edc7e75aa5f877b3d1b851ca357cb47b8dd4b6268d07abf7d9d3/napari-0.6.6-py3-none-any.whl", hash = "sha256:c65cc13de1901ec2bf8d11c665e8df9f39e678d98b76cf690d1094e9decaca69", size = 3537483, upload-time = "2025-10-16T09:25:55.679Z" },
+]
+
+[[package]]
+name = "napari-console"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ipykernel" },
+ { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "qtconsole" },
+ { name = "qtpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/03/6e1fcd9aa9ac4746ce2b44050ea8f7192d883f4d3da4e7ff08589ac3ad3b/napari_console-0.1.4.tar.gz", hash = "sha256:e185e4d36d8171ae23ca383dc69c38df76592a984d6c99ad08372d188a1fbb9b", size = 20152, upload-time = "2025-10-15T14:24:18.456Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/72/2067f28fd0ae87978f3b61e8ec30c1d085bbed03f64eb58e43949d526b3a/napari_console-0.1.4-py3-none-any.whl", hash = "sha256:565df1fa15db579552af9e9d9d3883067c00191be282ad47d80f9b0d50b4e5ad", size = 9786, upload-time = "2025-10-15T14:24:17.677Z" },
+]
+
+[[package]]
+name = "napari-deeplabcut"
+version = "0.3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dask-image" },
+ { name = "matplotlib" },
+ { name = "napari" },
+ { name = "natsort" },
+ { name = "numpy" },
+ { name = "opencv-python-headless" },
+ { name = "pandas" },
+ { name = "pyside6" },
+ { name = "pyyaml" },
+ { name = "qtpy" },
+ { name = "scikit-image", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scikit-image", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "shiboken6" },
+ { name = "tables", version = "3.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tables", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/0f/3e3956a7278c17ba4b263d24a270404cd782b1bcec3de6f860cbdeca057f/napari_deeplabcut-0.3.1.0.tar.gz", hash = "sha256:c4bc3a3643dd984f2045e59d5c06f299c7388fce1832ee7478ee0be7fddc5872", size = 1941735, upload-time = "2026-05-19T10:46:46.116Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/13/b43e8f1bc5d279178509c6b6929bb8c84ad16d73be5da0afc2452dc8d60a/napari_deeplabcut-0.3.1.0-py3-none-any.whl", hash = "sha256:84ee5c3cf8a2eecb0d8a59ec522d77e690987b77d33862a35b8dd4d9522253ac", size = 1194984, upload-time = "2026-05-19T10:46:44.313Z" },
+]
+
+[[package]]
+name = "napari-plugin-engine"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/72/c653308edaf7f7c84d82e388a1c46bc8f26c385027af58b5bf728f600b47/napari_plugin_engine-0.2.1.tar.gz", hash = "sha256:46829cf02f368c8f2f1aa8b998ec73bcf14a2c1f5c15abd94b82154d7aef510d", size = 55809, upload-time = "2026-02-10T08:31:20.385Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/bc/2509813cddd0e02736121e21bef54deeec7f0f89af2c6096d753ee1feb09/napari_plugin_engine-0.2.1-py3-none-any.whl", hash = "sha256:de30babe6fd9477816f037207938a2da7faeddc0e9e8663cb29f3d74235b6dc5", size = 33849, upload-time = "2026-02-10T08:31:19.037Z" },
+]
+
+[[package]]
+name = "napari-svg"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "imageio" },
+ { name = "numpy" },
+ { name = "vispy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a7/90/802f8288d16c1513b908d644779e733461a53b6c1a2c7561f1464c9f1516/napari_svg-0.2.1.tar.gz", hash = "sha256:031f13b34b0948afbdcb11eb00728fe32ef7e4e3aa3905f923001d6871a08ad9", size = 17533, upload-time = "2025-01-14T07:26:30.657Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/ae/0eeb22806c4157a9199f65a93374e5ff5c4d2cc1411b5d25053bcd9e6b91/napari_svg-0.2.1-py3-none-any.whl", hash = "sha256:9eaa54fbbf9bfd5078b67b7d1edc9eccfd872dab89fd586374909fef4ed89a49", size = 16458, upload-time = "2025-01-14T07:26:29.328Z" },
+]
+
+[[package]]
+name = "natsort"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
+]
+
+[[package]]
+name = "nbclient"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "nbformat" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" },
+]
+
+[[package]]
+name = "nbformat"
+version = "5.10.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastjsonschema" },
+ { name = "jsonschema" },
+ { name = "jupyter-core" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
+]
+
+[[package]]
+name = "ndindex"
+version = "1.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/92/4b9d2f4e0f3eabcfc7b02b48261f6e5ad36a3e2c1bbdcc4e3b7b6c768fa6/ndindex-1.10.1.tar.gz", hash = "sha256:0f6113c1f031248f8818cbee1aa92aa3c9472b7701debcce9fddebcd2f610f11", size = 271395, upload-time = "2025-11-19T20:40:08.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/71/aff23bd84111d038efdcdaea4d218b463a0b2129ff49f30613cbc6f535ff/ndindex-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8644c76e74c0fbbdaa54752de30b7c6b98b1e8f6c05f0c6228632a29c862d83f", size = 172022, upload-time = "2025-11-19T20:38:12.429Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a6/adcc17b685b24362983b00f965ee5c8607f74e7c68049a20facbd7ceb0b6/ndindex-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9a211ec2198994cb3600cd46adb335a740f27e4d406b40d48ed7b98d2d2a89b", size = 171057, upload-time = "2025-11-19T20:38:13.846Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/28/b0b1bde7818d2ccd5c288802c1f24b69705e03f3975bc948c005eccab25a/ndindex-1.10.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdb86a4176f2ae23bd4bcd0401ca35d5dad2d1ed0d0dca1ff64480ebe41b75d9", size = 498925, upload-time = "2025-11-19T20:38:17.214Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/46/55c3800048ef5310de542f188e1aad00e0b1d37713230c0eae980e88c895/ndindex-1.10.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ce3bd0882572269ca09285112cf38ce84baa2aaa5891551af968ca7c18f84bb", size = 495662, upload-time = "2025-11-19T20:38:20.026Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a4/0103c3ee3778d7079c3ff7dd879c79362afe3a7e9d3b8dcdaa25b49ca413/ndindex-1.10.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d6442ecce9b395aade5e9f2431e169e01393953a069f6d2d53a63b6c94d1d06", size = 1471263, upload-time = "2025-11-19T20:38:21.545Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5a/eaa38b18757c3d8e7b2438faa5001a02f193b51a68a5558d6066f3c407e6/ndindex-1.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bada24abee6bc6ca438b2e6b68a752fc9b58b67bdcb54008e2bc6330ecb0a777", size = 1522878, upload-time = "2025-11-19T20:38:23.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/93/a40920c849fa128c9439bc3eb0add814696216dde235497eaa415f14d5e7/ndindex-1.10.1-cp310-cp310-win32.whl", hash = "sha256:bc236d1612714cbd80610cf25a6ef92584ff1402e9d5a5c50e926195716f7d22", size = 149268, upload-time = "2025-11-19T20:38:25.12Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d9/baf1655d0b2d36eb46134fddf7dd0ef0093203c9c91d17f8ce01b9060366/ndindex-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:4cea15cff221e76abd12e3e940c26124184735cf421c229307f5db6742e14dd7", size = 157151, upload-time = "2025-11-19T20:38:27.229Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d9/c94ab6151c9fdd199c2b560f23e3759a9fb86a7a1275855e0b97291bf05a/ndindex-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e2ad917bcdf8dc5ba1e21f01054c991d26862d4d01c3c203a50e907096d558ac", size = 172128, upload-time = "2025-11-19T20:38:28.977Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/34/880c4073750766e44492d51280d025f28e36475394ca3d741b0a4adad4b0/ndindex-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e851990a68937db5f485cd9f3e760c1fd47fa0f2a99f63a5e2cc880908faf3bb", size = 171423, upload-time = "2025-11-19T20:38:30.357Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/1e/0342da55dabe4075efc2b2ab91a6a22ed3047c5bd511ef771a7a3f822c90/ndindex-1.10.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27385939f317b55773ea53f6bf9334810cf1d66206034c0a6a6f2a88f2001c3c", size = 519590, upload-time = "2025-11-19T20:38:32.464Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cb/7a02b6f29b15a16cd0002f4591d14493eff8e9236f7ca4c02ee4d4bcefbd/ndindex-1.10.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf3ca16efcdfbb8800aa88fbab1bc6528e6a0504bcb9cf7af4cb9d50e9f5d9", size = 516676, upload-time = "2025-11-19T20:38:34.276Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d5/38da808f968a54b0fead2d7e15ca011d3df93c96a07f4914e8ef3974506e/ndindex-1.10.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3307817bdc92846b18f309fae3582856f567dd6e0742fb0b41ac68682bfc4e2a", size = 1491141, upload-time = "2025-11-19T20:38:35.785Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/1f/8c66ef982a01ae4cbdabba679a2bc711f262cedf23bfb9682293146f8a98/ndindex-1.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae73cd2d66b09ef2f2a7d7f93bad396d6abf168d1ee825e403c6c5fb8ae1341c", size = 1543876, upload-time = "2025-11-19T20:38:37.456Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/7c7e3a3c6e81b4284fd0d53cbaec51d9e5b90df26dd78e9bde06cb307217/ndindex-1.10.1-cp311-cp311-win32.whl", hash = "sha256:890bb92f0a779e6f16bdbcc8bd2e06c32bcc0239e5893ba246114eb924aecaaa", size = 149149, upload-time = "2025-11-19T20:38:38.911Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/38/99e1fb0effdef74b883be615ea0053ebcea28a53fd8b896263f4e99b0113/ndindex-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:1827a40301405b44ad709e388c5b48cf35cd90a67f77e63f0f17d87f6000fa81", size = 157246, upload-time = "2025-11-19T20:38:40.197Z" },
+ { url = "https://files.pythonhosted.org/packages/65/90/774ddd08b2a1b41faa56da111f0fbfeb4f17ee537214c938ef41d61af949/ndindex-1.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:87f83e8c35a7f49a68cd3a3054c406e6c22f8c1315f3905f7a778c657669187e", size = 177348, upload-time = "2025-11-19T20:38:41.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ee/a423e857f5b45da3adc8ddbcfbfd4a0e9a047edce3915d3e3d6e189b6bd9/ndindex-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf9e05986b2eb8c5993bce0f911d6cedd15bda30b5e35dd354b1ad1f4cc3599d", size = 176561, upload-time = "2025-11-19T20:38:43.06Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/40/139b6b050ba2b2a0bb40e0381a352b1eb6551302dcb8f86fb4c97dd34e92/ndindex-1.10.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046c1e88d46b2bd2fd3483e06d27b4e85132b55bc693f2fca2db0bb56eea1e78", size = 542901, upload-time = "2025-11-19T20:38:44.43Z" },
+ { url = "https://files.pythonhosted.org/packages/27/ae/defd665dbbeb2fffa077491365ed160acaec49274ce8d4b979f55db71f18/ndindex-1.10.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03cf1e6cdac876bd8fc92d3b65bb223496b1581d10eab3ba113f7c195121a959", size = 546875, upload-time = "2025-11-19T20:38:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/59/43/6d54d48e8eaee25cdab70d3e4c4f579ddb0255e4f1660040d5ad55e029c6/ndindex-1.10.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:752e78a5e87911ded117c57a7246596f26c9c6da066de3c2b533b3db694949bb", size = 1510036, upload-time = "2025-11-19T20:38:47.444Z" },
+ { url = "https://files.pythonhosted.org/packages/09/61/e28ba3b98eacd18193176526526b34d7d70d2a6f9fd2b4d8309ab5692678/ndindex-1.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9dd58d91220b1c1fe516324bfcf4114566c98e84b1cbbe416abe345c75bd557", size = 1571849, upload-time = "2025-11-19T20:38:48.951Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/63/83fff78a3712cb9f478dd84a19ec389acf6f8c7b01dc347a65ae74e6123d/ndindex-1.10.1-cp312-cp312-win32.whl", hash = "sha256:3b0d9ce2c8488444499ab6d40e92e09867bf4413f5cf04c01635de923f44aa67", size = 149792, upload-time = "2025-11-19T20:38:50.959Z" },
+ { url = "https://files.pythonhosted.org/packages/52/fd/a5e3c8c043d0dddea6cd4567bfaea568f022ac197301882b3d85d9c1e9b3/ndindex-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c026dbbf2455d97ce6456d8a50b349aee8fefa11027d020638c89e9be2c9c4c", size = 158164, upload-time = "2025-11-19T20:38:52.242Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ea/03676266cb38cc671679a9d258cc59bfc58c69726db87b0d6eeafb308895/ndindex-1.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:157b5c34a1b779f5d27b790d9bd7e7b156d284e76be83c591a3ba003984f4956", size = 176323, upload-time = "2025-11-19T20:38:53.528Z" },
+ { url = "https://files.pythonhosted.org/packages/89/f4/2d350439031b108b0bb8897cad315390c5ad88c14d87419a54c2ffa95c80/ndindex-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f99b3e89220da3244d03c9c5473669c7107d361c129fd9b064622744dee1ce15", size = 175584, upload-time = "2025-11-19T20:38:57.968Z" },
+ { url = "https://files.pythonhosted.org/packages/77/34/a51b7c6f7159718a6a0a694fc1058b94d793c416d9a4fd649f1924cce5f8/ndindex-1.10.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6928e47fb008903f2e41309b7ff1e59b16abbcd59e2e945454571c28b2433c9e", size = 524127, upload-time = "2025-11-19T20:38:59.412Z" },
+ { url = "https://files.pythonhosted.org/packages/21/91/d8f19f0b8fc9c5585b50fda44c05415da0bdc5fa9c9c69011015dac27880/ndindex-1.10.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69a2cb1ac7be955c3c77f1def83f410775a81525c9ce2d4c0a3f2a61589ed47", size = 528213, upload-time = "2025-11-19T20:39:00.882Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/a9/77d9d037e871a3faa8579b354ca2dd09cc5bbf3e085d9e3c67f786d55ee3/ndindex-1.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cb76e0f3f235d8b1c768b17e771de48775d281713795c3aa045e8114ad61bdda", size = 1492172, upload-time = "2025-11-19T20:39:02.387Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/29/ad13676fc9312e0aa1a80a7c04bcb0b502b877ed4956136117ad663eced0/ndindex-1.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7da34a78410c14341d5fff73be5ce924bd36500bf7f640fc59b8607d3a0df95e", size = 1552614, upload-time = "2025-11-19T20:39:04.232Z" },
+ { url = "https://files.pythonhosted.org/packages/63/34/e6e6fd81423810c07ae623c4d36e099f42a812994977e8e3bfa182c02472/ndindex-1.10.1-cp313-cp313-win32.whl", hash = "sha256:9599fcb7411ffe601c367f0a5d4bc0ed588e3e7d9dc7604bdb32c8f669456b9e", size = 149330, upload-time = "2025-11-19T20:39:05.727Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/d3/830a20626e2ec0e31a926be90e67068a029930f99e6cfebf2f9768e7b7b1/ndindex-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:ef3ef22390a892d16286505083ee5b326317b21c255a0c7f744b1290a0b964a6", size = 157309, upload-time = "2025-11-19T20:39:07.394Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/73/3bdeecd1f6ec0ad81478a53d96da4ba9be74ed297c95f2b4fbe2b80843e1/ndindex-1.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:72af787dcee3661f36fff9d144d989aacefe32e2c8b51ceef9babd46afb93a18", size = 181022, upload-time = "2025-11-19T20:39:10.487Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b1/0d97ba134b5aa71b5ed638fac193a7ec4d987e091e2f4e4162ebdaacbda1/ndindex-1.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa60637dfae1ee3fc057e420a52cc4ace38cf2c0d1a0451af2a3cba84d281842", size = 181289, upload-time = "2025-11-19T20:39:11.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/d7/1df02df24880ce3f3c8137b6f3ca5a901a58d9079dcfd8c818419277ff87/ndindex-1.10.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ebdba2fade3f6916fe21fd49e2a0935af4f58c56100a60f3f2eb26e20baee7", size = 632517, upload-time = "2025-11-19T20:39:13.259Z" },
+ { url = "https://files.pythonhosted.org/packages/34/96/b509c2b14e9b10710fe6ab6ba8bda1ee6ce36ab16397ff2f5bbb33bbbba3/ndindex-1.10.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:346a4bf09f5771548665c8206e81daadb6b9925d409746e709894bdd98adc701", size = 616179, upload-time = "2025-11-19T20:39:14.757Z" },
+ { url = "https://files.pythonhosted.org/packages/38/e3/f89d60cf351c33a484bf1a4546a5dee6f4e7a6a973613ffa12bd316b14ad/ndindex-1.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:23d35696f802548143b5cc199bf2f171efb0061aa7934959251dd3bae56d038c", size = 1588373, upload-time = "2025-11-19T20:39:16.62Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/19/002fc1e6a4abeef8d92e9aa2e43aea4d462f6b170090f7752ea8887f4897/ndindex-1.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a91e1a0398120233d5c3b23ccb2d4b78e970d66136f1a7221fa9a53873c3d5c5", size = 1636436, upload-time = "2025-11-19T20:39:18.266Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8f/28b1ad78c787ac8fafd6e26419a80366617784b1779e3857fa687492f6bc/ndindex-1.10.1-cp313-cp313t-win32.whl", hash = "sha256:78bfe25941d2dac406391ddd9baf0b0fce163807b98ecc2c47a3030ee8466319", size = 158780, upload-time = "2025-11-19T20:39:20.454Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/56/b81060607a19865bb8be8d705b1b3e8aefb8747c0fbd383e38b4cae4bd71/ndindex-1.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:08bfdc1f7a0b408d15b3ce61d141ebbebdb47a25341967e425e104c5bd512a5c", size = 167485, upload-time = "2025-11-19T20:39:21.733Z" },
+ { url = "https://files.pythonhosted.org/packages/da/9b/aac1131e9f3a5635ba7b0312c3bfa610511ab4108f85c0d914a32887aa00/ndindex-1.10.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b5297f207ebc068c7cdf9e3cd7b95aa5c9ec04295d0a7e56b529f66787d4685", size = 176478, upload-time = "2025-11-19T20:39:23.747Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/05/a0d8ca0432c84550bc17af6d6479a803936895b8b8403a1216c5a55475fb/ndindex-1.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c5e9762452b163e33cfb6e821f86e45ba0b53bdfcd23ab5d57b48a8f566898cb", size = 175480, upload-time = "2025-11-19T20:39:25.365Z" },
+ { url = "https://files.pythonhosted.org/packages/09/4a/028ab78a9f29fd2a7e86a90337cde4658eaa77b425c63045d83a1d2e4f26/ndindex-1.10.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf80241b40adffdc3276b2c9fb63a96c6c98b4a9d941892738de8add65083962", size = 528125, upload-time = "2025-11-19T20:39:26.798Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a9/bd823b345fb06c83ade6ef1c1933521d4357cd04490e684d4fa30126926c/ndindex-1.10.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf5855881884b8467dfcf45764ccf2e4279075be14b155b89c96994bb08d2e6f", size = 527328, upload-time = "2025-11-19T20:39:28.292Z" },
+ { url = "https://files.pythonhosted.org/packages/91/4f/40b9c15588cbf9dde43c4fb88a31dd1f636a913fa29649f18f8e3ebca36a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e81a9bd36fe054b6c9fcc53d26bc9a28cf15d1ab52a0f5b854f894116f3a54e1", size = 1497508, upload-time = "2025-11-19T20:39:30.735Z" },
+ { url = "https://files.pythonhosted.org/packages/24/8f/b8048f7837d2e9dff0af507b398307fa84a2aa9ea3db71b4aa800b21da4a/ndindex-1.10.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:588e8875d836a93b3cd9af482c8074bb02288ae1aff92cf277e1f02d9ae0f992", size = 1552625, upload-time = "2025-11-19T20:39:32.404Z" },
+ { url = "https://files.pythonhosted.org/packages/20/aa/0ecb53c7e690a44769f2f92a843723ccb1d0ce080d93ba1ea811304cca12/ndindex-1.10.1-cp314-cp314-win32.whl", hash = "sha256:28741daca5926adff402247cd406f453ed5bb6042e82d6855938f805190e5ce9", size = 151237, upload-time = "2025-11-19T20:39:34.847Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/4e/197982fa8b4e6e6b9d15c38505c41076d1c552921f09f4d35acbbbbc0b70/ndindex-1.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:59a3222befc0f7cdc85fb9b90a567ae890f70a864bdeb660517e9ebcb36bf1bc", size = 158925, upload-time = "2025-11-19T20:39:37.149Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ad/116b6154046a69fc04e2d4490905801d3839a3f21290c0b4d49b1044e251/ndindex-1.10.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967b87b88dadb62555ec1039695c347254eccb8ca3d124c0e5dbe084c525fa93", size = 181724, upload-time = "2025-11-19T20:39:38.635Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/00/3ce4351366c890bcc87a5e9f1f90102547962eef356ac7c799bfdd0dddce/ndindex-1.10.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c67dde588c0fb89d872931a4ed5f9b4d21c1c70a3d92fdf0812a1de154239816", size = 181653, upload-time = "2025-11-19T20:39:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/05/a6fda696a2f02a3f8dd2ee9d816cb2edff6423bf0110a4876cc3b1259732/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c65ca639a7abf72d79f22424f4abd18dece1f289a2b7b028a0ca455edd2168d4", size = 630898, upload-time = "2025-11-19T20:39:41.495Z" },
+ { url = "https://files.pythonhosted.org/packages/73/78/eb2e5d067d4c054451e33eaece74cbdcb58236dc60516e73d783dae34c7e/ndindex-1.10.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c3634a8df43e7928122225a3d64d850c8957bd1edf2e403907deacb478af27b", size = 614419, upload-time = "2025-11-19T20:39:43.254Z" },
+ { url = "https://files.pythonhosted.org/packages/78/51/261bfb49eb7920c2a7314cacba5821930a529911dce48c7c6cd786096a5a/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9d581f931e61f182478f18bdf5edd3955899df5da4892ed0d5de547a4cfd5b6f", size = 1587517, upload-time = "2025-11-19T20:39:44.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/37/084a332ecdf8b0049151bd78001a7baf2daf7f500d043beb8a1f95d0f4e3/ndindex-1.10.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:78ce45106ebf67aeba99714818c721d8fd5fb9534daebd2565665a2d64b50fc9", size = 1635372, upload-time = "2025-11-19T20:39:47.231Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f4/716580fbb03018ab1daa86ed12c1925c67e79689db5fee82393e840758a2/ndindex-1.10.1-cp314-cp314t-win32.whl", hash = "sha256:fe5341e24dc992b09c258456ac90a09a6d25efdc2cb86dcc91d32c8891e1df9a", size = 162186, upload-time = "2025-11-19T20:39:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/20/28f669c09a470e7f523b0cc10b94336664d9648594015e3f2a1ec29047b1/ndindex-1.10.1-cp314-cp314t-win_amd64.whl", hash = "sha256:37f87f0e7690ae0324334740e0661d6297f2e62c9bf925127d249fb7eddd0ad8", size = 171077, upload-time = "2025-11-19T20:39:50.108Z" },
+]
+
+[[package]]
+name = "nest-asyncio"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
+]
+
+[[package]]
+name = "npe2"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "build" },
+ { name = "platformdirs" },
+ { name = "psygnal" },
+ { name = "pydantic" },
+ { name = "pydantic-extra-types" },
+ { name = "pyyaml" },
+ { name = "rich" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tomli-w" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/29/0a/1a6ed44b038484a064a0ac8e5f96aae6ddb8768789145cf7e65e699b324b/npe2-0.8.2.tar.gz", hash = "sha256:6e41cc1f2c873257d864980dd281b5bb649a84cef02feeb0cdda1a9d23fd8f8b", size = 122768, upload-time = "2026-04-04T20:55:11.9Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a6/f318fbc7bbb62361d36cb694f399f765f57fb282fdadc3aa2fef4dafd62d/npe2-0.8.2-py3-none-any.whl", hash = "sha256:80eff5fef50352cf0f3407c4c1122a7ae186aede40a21fd595cf91988cd55a9d", size = 93921, upload-time = "2026-04-04T20:55:10.552Z" },
+]
+
+[[package]]
+name = "numba"
+version = "0.65.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "llvmlite" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/1b/3c5a7daf683a95465bf23504bcd1a2d5db8cd5e5e276ca87505d020dffe9/numba-0.65.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:9d993ed0a257aa4116e6f553f114004bcfdee540c7276ab8ea48f650d514c452", size = 2680870, upload-time = "2026-04-24T02:02:10.623Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/a4/1831836814018a898e7d252aebe09c0f3ce1f26d145b68264b4ae0be6822/numba-0.65.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f098109f361681e57295f7e84d8ab2426902539a141811de0703ace52826981", size = 3739780, upload-time = "2026-04-24T02:02:13.097Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/1b/a813ddc81def09e257d2b1f67521982ce4b06204a87268796ffc8187271c/numba-0.65.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973fd8173f2312815e6b7aaae887c4ce8a817eeff46a4f8840b828305b75bc95", size = 3446722, upload-time = "2026-04-24T02:02:15.083Z" },
+ { url = "https://files.pythonhosted.org/packages/09/52/ee1d8b3becda384fe0552221641e05aa668a35e8a77470db4db7f6475000/numba-0.65.1-cp310-cp310-win_amd64.whl", hash = "sha256:c63aa0c4193694026452da55d0ef9d85156c1a7a333454c103bb30dec81b7bf8", size = 2747539, upload-time = "2026-04-24T02:02:16.79Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b3/650500c2eab4534d98e9166f4298e0f3c69c742afdf24e6eabccd1f16ad8/numba-0.65.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:7020d74b19cdb8cff16506542fdd510756e28c5e7f3bd0b7f574f0f42272fcd9", size = 2680563, upload-time = "2026-04-24T02:02:18.414Z" },
+ { url = "https://files.pythonhosted.org/packages/44/0b/0615dbedb98f5b32a35a53290fbdc6e22306968109278d7e58df82d7a9f6/numba-0.65.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f80ed83774b5173abd6581cd8d2165d1d38e13d2e5c8155c0c0b421784745420", size = 3745018, upload-time = "2026-04-24T02:02:20.252Z" },
+ { url = "https://files.pythonhosted.org/packages/49/aa/4361698f35bf63bff67dfe6c90493731177f48ede954f77b0588731537bc/numba-0.65.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ed425a43b0a5f9772f2f4e2dd0bbd12eabecae1af0b24efcfd4e053f012aac6", size = 3450962, upload-time = "2026-04-24T02:02:22.449Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9a/af61ec03b3116c161fd7a06b9e8a265729a8718458333e8ffbb06d9a3978/numba-0.65.1-cp311-cp311-win_amd64.whl", hash = "sha256:df40a5028a975b9ea66f6a2a3f7abbdbd541a863070e34ed367aff21141248e4", size = 2747417, upload-time = "2026-04-24T02:02:24.43Z" },
+ { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" },
+ { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" },
+ { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" },
+ { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" },
+ { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" },
+ { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" },
+ { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" },
+ { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" },
+ { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" },
+]
+
+[[package]]
+name = "numexpr"
+version = "2.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/2f/fdba158c9dbe5caca9c3eca3eaffffb251f2fb8674bf8e2d0aed5f38d319/numexpr-2.14.1.tar.gz", hash = "sha256:4be00b1086c7b7a5c32e31558122b7b80243fe098579b170967da83f3152b48b", size = 119400, upload-time = "2025-10-13T16:17:27.351Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/91/ccd504cbe5b88d06987c77f42ba37a13ef05065fdab4afe6dcfeb2961faf/numexpr-2.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0fab3fd06a04f6b86102552b26aa5d85e20ac7d8296c15764c726eeabae6cc8", size = 163200, upload-time = "2025-10-13T16:16:25.47Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/89/6b07977baf2af75fb6692f9e7a1fb612a15f600fc921f3f565366de01f4a/numexpr-2.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:64ae5dfd62d74a3ef82fe0b37f80527247f3626171ad82025900f46ffca4b39a", size = 152085, upload-time = "2025-10-13T16:16:29.508Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c2/c5775541256c4bf16b4d88fa1cffa74a0126703e513093c8774d911b0bb7/numexpr-2.14.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:955c92b064f9074d2970cf3138f5e3b965be673b82024962ed526f39bc25a920", size = 449435, upload-time = "2025-10-13T16:13:16.257Z" },
+ { url = "https://files.pythonhosted.org/packages/34/d4/d1a410901c620f7a6a3c5c2b1fc9dab22170be05a89d2c02ae699e27bd3f/numexpr-2.14.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75440c54fc01e130396650fdf307aa9d41a67dc06ddbfb288971b591c13a395b", size = 440197, upload-time = "2025-10-13T16:14:44.109Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c8/fa85f0cc5c39db587ba4927b862a92477c017ee8476e415e8120a100457b/numexpr-2.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dde9fa47ed319e1e1728940a539df3cb78326b7754bc7c6ab3152afc91808f9b", size = 1414125, upload-time = "2025-10-13T16:13:19.882Z" },
+ { url = "https://files.pythonhosted.org/packages/08/72/a58ddc05e0eabb3fa8d3fcd319f3d97870e6b41520832acfd04a6734c2c0/numexpr-2.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76db0bc6267e591ab9c4df405ffb533598e4c88239db7338d11ae9e4b368a85a", size = 1463041, upload-time = "2025-10-13T16:14:47.502Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c5/bdd1862302bb71a78dba941eaf7060e1274f1cf6af2d1b0f1880bfcb289b/numexpr-2.14.1-cp310-cp310-win32.whl", hash = "sha256:0d1dcbdc4d0374c0d523cee2f94f06b001623cbc1fd163612841017a3495427c", size = 166833, upload-time = "2025-10-13T16:17:03.543Z" },
+ { url = "https://files.pythonhosted.org/packages/18/af/26773a246716922794388786529e5640676399efabb0ee217ce034df9d27/numexpr-2.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:823cd82c8e7937981339f634e7a9c6a92cb2d0b9d0a5cf627a5e394fffc05377", size = 160068, upload-time = "2025-10-13T16:17:05.191Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a3/67999bdd1ed1f938d38f3fedd4969632f2f197b090e50505f7cc1fa82510/numexpr-2.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2d03fcb4644a12f70a14d74006f72662824da5b6128bf1bcd10cc3ed80e64c34", size = 163195, upload-time = "2025-10-13T16:16:31.212Z" },
+ { url = "https://files.pythonhosted.org/packages/25/95/d64f680ea1fc56d165457287e0851d6708800f9fcea346fc1b9957942ee6/numexpr-2.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2773ee1133f77009a1fc2f34fe236f3d9823779f5f75450e183137d49f00499f", size = 152088, upload-time = "2025-10-13T16:16:33.186Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/7f/3bae417cb13ae08afd86d08bb0301c32440fe0cae4e6262b530e0819aeda/numexpr-2.14.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebe4980f9494b9f94d10d2e526edc29e72516698d3bf95670ba79415492212a4", size = 451126, upload-time = "2025-10-13T16:13:22.248Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/1a/edbe839109518364ac0bd9e918cf874c755bb2c128040e920f198c494263/numexpr-2.14.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a381e5e919a745c9503bcefffc1c7f98c972c04ec58fc8e999ed1a929e01ba6", size = 442012, upload-time = "2025-10-13T16:14:51.416Z" },
+ { url = "https://files.pythonhosted.org/packages/66/b1/be4ce99bff769a5003baddac103f34681997b31d4640d5a75c0e8ed59c78/numexpr-2.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d08856cfc1b440eb1caaa60515235369654321995dd68eb9377577392020f6cb", size = 1415975, upload-time = "2025-10-13T16:13:26.088Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/33/b33b8fdc032a05d9ebb44a51bfcd4b92c178a2572cd3e6c1b03d8a4b45b2/numexpr-2.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03130afa04edf83a7b590d207444f05a00363c9b9ea5d81c0f53b1ea13fad55a", size = 1464683, upload-time = "2025-10-13T16:14:58.87Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/b2/ddcf0ac6cf0a1d605e5aecd4281507fd79a9628a67896795ab2e975de5df/numexpr-2.14.1-cp311-cp311-win32.whl", hash = "sha256:db78fa0c9fcbaded3ae7453faf060bd7a18b0dc10299d7fcd02d9362be1213ed", size = 166838, upload-time = "2025-10-13T16:17:06.765Z" },
+ { url = "https://files.pythonhosted.org/packages/64/72/4ca9bd97b2eb6dce9f5e70a3b6acec1a93e1fb9b079cb4cba2cdfbbf295d/numexpr-2.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:e9b2f957798c67a2428be96b04bce85439bed05efe78eb78e4c2ca43737578e7", size = 160069, upload-time = "2025-10-13T16:17:08.752Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/c473fc04a371f5e2f8c5749e04505c13e7a8ede27c09e9f099b2ad6f43d6/numexpr-2.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ebae0ab18c799b0e6b8c5a8d11e1fa3848eb4011271d99848b297468a39430", size = 162790, upload-time = "2025-10-13T16:16:34.903Z" },
+ { url = "https://files.pythonhosted.org/packages/45/93/b6760dd1904c2a498e5f43d1bb436f59383c3ddea3815f1461dfaa259373/numexpr-2.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47041f2f7b9e69498fb311af672ba914a60e6e6d804011caacb17d66f639e659", size = 152196, upload-time = "2025-10-13T16:16:36.593Z" },
+ { url = "https://files.pythonhosted.org/packages/72/94/cc921e35593b820521e464cbbeaf8212bbdb07f16dc79fe283168df38195/numexpr-2.14.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d686dfb2c1382d9e6e0ee0b7647f943c1886dba3adbf606c625479f35f1956c1", size = 452468, upload-time = "2025-10-13T16:13:29.531Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/43/560e9ba23c02c904b5934496486d061bcb14cd3ebba2e3cf0e2dccb6c22b/numexpr-2.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee6d4fbbbc368e6cdd0772734d6249128d957b3b8ad47a100789009f4de7083", size = 443631, upload-time = "2025-10-13T16:15:02.473Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/6c/78f83b6219f61c2c22d71ab6e6c2d4e5d7381334c6c29b77204e59edb039/numexpr-2.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a2839efa25f3c8d4133252ea7342d8f81226c7c4dda81f97a57e090b9d87a48", size = 1417670, upload-time = "2025-10-13T16:13:33.464Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/bb/1ccc9dcaf46281568ce769888bf16294c40e98a5158e4b16c241de31d0d3/numexpr-2.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9f9137f1351b310436662b5dc6f4082a245efa8950c3b0d9008028df92fefb9b", size = 1466212, upload-time = "2025-10-13T16:15:12.828Z" },
+ { url = "https://files.pythonhosted.org/packages/31/9f/203d82b9e39dadd91d64bca55b3c8ca432e981b822468dcef41a4418626b/numexpr-2.14.1-cp312-cp312-win32.whl", hash = "sha256:36f8d5c1bd1355df93b43d766790f9046cccfc1e32b7c6163f75bcde682cda07", size = 166996, upload-time = "2025-10-13T16:17:10.369Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/67/ffe750b5452eb66de788c34e7d21ec6d886abb4d7c43ad1dc88ceb3d998f/numexpr-2.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:fdd886f4b7dbaf167633ee396478f0d0aa58ea2f9e7ccc3c6431019623e8d68f", size = 160187, upload-time = "2025-10-13T16:17:11.974Z" },
+ { url = "https://files.pythonhosted.org/packages/73/b4/9f6d637fd79df42be1be29ee7ba1f050fab63b7182cb922a0e08adc12320/numexpr-2.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09078ba73cffe94745abfbcc2d81ab8b4b4e9d7bfbbde6cac2ee5dbf38eee222", size = 162794, upload-time = "2025-10-13T16:16:38.291Z" },
+ { url = "https://files.pythonhosted.org/packages/35/ae/d58558d8043de0c49f385ea2fa789e3cfe4d436c96be80200c5292f45f15/numexpr-2.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dce0b5a0447baa7b44bc218ec2d7dcd175b8eee6083605293349c0c1d9b82fb6", size = 152203, upload-time = "2025-10-13T16:16:39.907Z" },
+ { url = "https://files.pythonhosted.org/packages/13/65/72b065f9c75baf8f474fd5d2b768350935989d4917db1c6c75b866d4067c/numexpr-2.14.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06855053de7a3a8425429bd996e8ae3c50b57637ad3e757e0fa0602a7874be30", size = 455860, upload-time = "2025-10-13T16:13:35.811Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/f9/c9457652dfe28e2eb898372da2fe786c6db81af9540c0f853ee04a0699cc/numexpr-2.14.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f9366d23a2e991fd5a8b5e61a17558f028ba86158a4552f8f239b005cdf83c", size = 446574, upload-time = "2025-10-13T16:15:17.367Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/99/8d3879c4d67d3db5560cf2de65ce1778b80b75f6fa415eb5c3e7bd37ba27/numexpr-2.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5f1b1605695778896534dfc6e130d54a65cd52be7ed2cd0cfee3981fd676bf5", size = 1417306, upload-time = "2025-10-13T16:13:42.813Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/05/6bddac9f18598ba94281e27a6943093f7d0976544b0cb5d92272c64719bd/numexpr-2.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a4ba71db47ea99c659d88ee6233fa77b6dc83392f1d324e0c90ddf617ae3f421", size = 1466145, upload-time = "2025-10-13T16:15:27.464Z" },
+ { url = "https://files.pythonhosted.org/packages/24/5d/cbeb67aca0c5a76ead13df7e8bd8dd5e0d49145f90da697ba1d9f07005b0/numexpr-2.14.1-cp313-cp313-win32.whl", hash = "sha256:638dce8320f4a1483d5ca4fda69f60a70ed7e66be6e68bc23fb9f1a6b78a9e3b", size = 166996, upload-time = "2025-10-13T16:17:13.803Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/23/9281bceaeb282cead95f0aa5f7f222ffc895670ea689cc1398355f6e3001/numexpr-2.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fdcd4735121658a313f878fd31136d1bfc6a5b913219e7274e9fca9f8dac3bb", size = 160189, upload-time = "2025-10-13T16:17:15.417Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/76/7aac965fd93a56803cbe502aee2adcad667253ae34b0badf6c5af7908b6c/numexpr-2.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:557887ad7f5d3c2a40fd7310e50597045a68e66b20a77b3f44d7bc7608523b4b", size = 163524, upload-time = "2025-10-13T16:16:42.213Z" },
+ { url = "https://files.pythonhosted.org/packages/58/65/79d592d5e63fbfab3b59a60c386853d9186a44a3fa3c87ba26bdc25b6195/numexpr-2.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:af111c8fe6fc55d15e4c7cab11920fc50740d913636d486545b080192cd0ad73", size = 152919, upload-time = "2025-10-13T16:16:44.229Z" },
+ { url = "https://files.pythonhosted.org/packages/84/78/3c8335f713d4aeb99fa758d7c62f0be1482d4947ce5b508e2052bb7aeee9/numexpr-2.14.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33265294376e7e2ae4d264d75b798a915d2acf37b9dd2b9405e8b04f84d05cfc", size = 465972, upload-time = "2025-10-13T16:13:45.061Z" },
+ { url = "https://files.pythonhosted.org/packages/35/81/9ee5f69b811e8f18746c12d6f71848617684edd3161927f95eee7a305631/numexpr-2.14.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83647d846d3eeeb9a9255311236135286728b398d0d41d35dedb532dca807fe9", size = 456953, upload-time = "2025-10-13T16:15:31.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/39/9b8bc6e294d85cbb54a634e47b833e9f3276a8bdf7ce92aa808718a0212d/numexpr-2.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6e575fd3ad41ddf3355d0c7ef6bd0168619dc1779a98fe46693cad5e95d25e6e", size = 1426199, upload-time = "2025-10-13T16:13:48.231Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/ce/0d4fcd31ab49319740d934fba1734d7dad13aa485532ca754e555ca16c8b/numexpr-2.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:67ea4771029ce818573b1998f5ca416bd255156feea017841b86176a938f7d19", size = 1474214, upload-time = "2025-10-13T16:15:38.893Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/47/b2a93cbdb3ba4e009728ad1b9ef1550e2655ea2c86958ebaf03b9615f275/numexpr-2.14.1-cp313-cp313t-win32.whl", hash = "sha256:15015d47d3d1487072d58c0e7682ef2eb608321e14099c39d52e2dd689483611", size = 167676, upload-time = "2025-10-13T16:17:17.351Z" },
+ { url = "https://files.pythonhosted.org/packages/86/99/ee3accc589ed032eea68e12172515ed96a5568534c213ad109e1f4411df1/numexpr-2.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:94c711f6d8f17dfb4606842b403699603aa591ab9f6bf23038b488ea9cfb0f09", size = 161096, upload-time = "2025-10-13T16:17:19.174Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/36/9db78dfbfdfa1f8bf0872993f1a334cdd8fca5a5b6567e47dcb128bcb7c2/numexpr-2.14.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ede79f7ff06629f599081de644546ce7324f1581c09b0ac174da88a470d39c21", size = 162848, upload-time = "2025-10-13T16:16:46.216Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c1/a5c78ae637402c5550e2e0ba175275d2515d432ec28af0cdc23c9b476e65/numexpr-2.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2eac7a5a2f70b3768c67056445d1ceb4ecd9b853c8eda9563823b551aeaa5082", size = 152270, upload-time = "2025-10-13T16:16:47.92Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ed/aabd8678077848dd9a751c5558c2057839f5a09e2a176d8dfcd0850ee00e/numexpr-2.14.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aedf38d4c0c19d3cecfe0334c3f4099fb496f54c146223d30fa930084bc8574", size = 455918, upload-time = "2025-10-13T16:13:50.338Z" },
+ { url = "https://files.pythonhosted.org/packages/88/e1/3db65117f02cdefb0e5e4c440daf1c30beb45051b7f47aded25b7f4f2f34/numexpr-2.14.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439ec4d57b853792ebe5456e3160312281c3a7071ecac5532ded3278ede614de", size = 446512, upload-time = "2025-10-13T16:15:42.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/fb/7ceb9ee55b5f67e4a3e4d73d5af4c7e37e3c9f37f54bee90361b64b17e3f/numexpr-2.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e23b87f744e04e302d82ac5e2189ae20a533566aec76a46885376e20b0645bf8", size = 1417845, upload-time = "2025-10-13T16:13:53.836Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2d/9b5764d0eafbbb2889288f80de773791358acf6fad1a55767538d8b79599/numexpr-2.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:44f84e0e5af219dbb62a081606156420815890e041b87252fbcea5df55214c4c", size = 1466211, upload-time = "2025-10-13T16:15:48.985Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/21/204db708eccd71aa8bc55bcad55bc0fc6c5a4e01ad78e14ee5714a749386/numexpr-2.14.1-cp314-cp314-win32.whl", hash = "sha256:1f1a5e817c534539351aa75d26088e9e1e0ef1b3a6ab484047618a652ccc4fc3", size = 168835, upload-time = "2025-10-13T16:17:20.82Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/3e/d83e9401a1c3449a124f7d4b3fb44084798e0d30f7c11e60712d9b94cf11/numexpr-2.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:587c41509bc373dfb1fe6086ba55a73147297247bedb6d588cda69169fc412f2", size = 162608, upload-time = "2025-10-13T16:17:22.228Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/d6/ec947806bb57836d6379a8c8a253c2aeaa602b12fef2336bfd2462bb4ed5/numexpr-2.14.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec368819502b64f190c3f71be14a304780b5935c42aae5bf22c27cc2cbba70b5", size = 163525, upload-time = "2025-10-13T16:16:50.133Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/77/048f30dcf661a3d52963a88c29b52b6d5ce996d38e9313a56a922451c1e0/numexpr-2.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e87f6d203ac57239de32261c941e9748f9309cbc0da6295eabd0c438b920d3a", size = 152917, upload-time = "2025-10-13T16:16:52.055Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/956a13e628d722d649fbf2fded615134a308c082e122a48bad0e90a99ce9/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd72d8c2a165fe45ea7650b16eb8cc1792a94a722022006bb97c86fe51fd2091", size = 466242, upload-time = "2025-10-13T16:13:55.795Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/dd/abe848678d82486940892f2cacf39e82eec790e8930d4d713d3f9191063b/numexpr-2.14.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70d80fcb418a54ca208e9a38e58ddc425c07f66485176b261d9a67c7f2864f73", size = 457149, upload-time = "2025-10-13T16:15:52.036Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/bb/797b583b5fb9da5700a5708ca6eb4f889c94d81abb28de4d642c0f4b3258/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:edea2f20c2040df8b54ee8ca8ebda63de9545b2112872466118e9df4d0ae99f3", size = 1426493, upload-time = "2025-10-13T16:13:59.244Z" },
+ { url = "https://files.pythonhosted.org/packages/77/c4/0519ab028fdc35e3e7ee700def7f2b4631b175cd9e1202bd7966c1695c33/numexpr-2.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790447be6879a6c51b9545f79612d24c9ea0a41d537a84e15e6a8ddef0b6268e", size = 1474413, upload-time = "2025-10-13T16:15:59.211Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/4a/33044878c8f4a75213cfe9c11d4c02058bb710a7a063fe14f362e8de1077/numexpr-2.14.1-cp314-cp314t-win32.whl", hash = "sha256:538961096c2300ea44240209181e31fae82759d26b51713b589332b9f2a4117e", size = 169502, upload-time = "2025-10-13T16:17:23.829Z" },
+ { url = "https://files.pythonhosted.org/packages/41/a2/5a1a2c72528b429337f49911b18c302ecd36eeab00f409147e1aa4ae4519/numexpr-2.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a40b350cd45b4446076fa11843fa32bbe07024747aeddf6d467290bf9011b392", size = 163589, upload-time = "2025-10-13T16:17:25.696Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "1.26.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" },
+ { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" },
+ { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" },
+ { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" },
+ { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" },
+ { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" },
+ { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" },
+ { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" },
+ { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" },
+ { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" },
+ { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" },
+]
+
+[[package]]
+name = "numpydoc"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/3c/dfccc9e7dee357fb2aa13c3890d952a370dd0ed071e0f7ed62ed0df567c1/numpydoc-1.10.0.tar.gz", hash = "sha256:3f7970f6eee30912260a6b31ac72bba2432830cd6722569ec17ee8d3ef5ffa01", size = 94027, upload-time = "2025-12-02T16:39:12.937Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/5e/3a6a3e90f35cea3853c45e5d5fb9b7192ce4384616f932cf7591298ab6e1/numpydoc-1.10.0-py3-none-any.whl", hash = "sha256:3149da9874af890bcc2a82ef7aae5484e5aa81cb2778f08e3c307ba6d963721b", size = 69255, upload-time = "2025-12-02T16:39:11.561Z" },
+]
+
+[[package]]
+name = "nvidia-cublas"
+version = "13.1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cuda-nvrtc", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9e/2f562daf80eb8f7a685fb7bea4fda71f6048e4f359d6fdd1b6e70206cb2f/nvidia_cublas-13.1.1.3-py3-none-win_amd64.whl", hash = "sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f", size = 404358158, upload-time = "2026-04-08T18:47:26.987Z" },
+]
+
+[[package]]
+name = "nvidia-cublas-cu11"
+version = "11.10.3.66"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/41/fdeb62b5437996e841d83d7d2714ca75b886547ee8017ee2fe6ea409d983/nvidia_cublas_cu11-11.10.3.66-py3-none-manylinux1_x86_64.whl", hash = "sha256:d32e4d75f94ddfb93ea0a5dda08389bcc65d8916a25cb9f37ac89edaeed3bded", size = 317097917, upload-time = "2022-08-11T17:32:30.789Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/08/57e6b6481af73590259a9600c32a68eb853966e354fca147cde17ed9ea27/nvidia_cublas_cu11-11.10.3.66-py3-none-win_amd64.whl", hash = "sha256:8ac17ba6ade3ed56ab898a036f9ae0756f1e81052a317bf98f8c6d18dc3ae49e", size = 311065222, upload-time = "2022-08-03T21:16:08.044Z" },
+]
+
+[[package]]
+name = "nvidia-cublas-cu12"
+version = "12.8.4.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/99/db44d685f0e257ff0e213ade1964fc459b4a690a73293220e98feb3307cf/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0", size = 590537124, upload-time = "2025-03-07T01:43:53.556Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
+ { url = "https://files.pythonhosted.org/packages/70/61/7d7b3c70186fb651d0fbd35b01dbfc8e755f69fd58f817f3d0f642df20c3/nvidia_cublas_cu12-12.8.4.1-py3-none-win_amd64.whl", hash = "sha256:47e9b82132fa8d2b4944e708049229601448aaad7e6f296f630f2d1a32de35af", size = 567544208, upload-time = "2025-03-07T01:53:30.535Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
+ { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/df/b74b10025c1205695c5676373f2edd3e87a7202cc62ead0dfbc373b0f6ea/nvidia_cuda_cupti-13.0.85-py3-none-win_amd64.whl", hash = "sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00", size = 7736776, upload-time = "2025-09-04T08:38:08.38Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti-cu11"
+version = "11.7.101"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/9d/dd0cdcd800e642e3c82ee3b5987c751afd4f3fb9cc2752517f42c3bc6e49/nvidia_cuda_cupti_cu11-11.7.101-py3-none-manylinux1_x86_64.whl", hash = "sha256:e0cfd9854e1f2edaa36ca20d21cd0bdd5dcfca4e3b9e130a082e05b33b6c5895", size = 11845698, upload-time = "2022-08-03T20:58:36.733Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/4e/f314475cc4740b6138daf9c6496b165bab1f07c161bd4ac5e69285ab07d6/nvidia_cuda_cupti_cu11-11.7.101-py3-none-win_amd64.whl", hash = "sha256:7cc5b8f91ae5e1389c3c0ad8866b3b016a175e827ea8f162a672990a402ab2b0", size = 8461264, upload-time = "2022-08-03T21:14:45.554Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/1f/b3bd73445e5cb342727fd24fe1f7b748f690b460acadc27ea22f904502c8/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed", size = 9533318, upload-time = "2025-03-07T01:40:10.421Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
+ { url = "https://files.pythonhosted.org/packages/41/bc/83f5426095d93694ae39fe1311431b5d5a9bb82e48bf0dd8e19be2765942/nvidia_cuda_cupti_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:bb479dcdf7e6d4f8b0b01b115260399bf34154a1a2e9fe11c85c517d87efd98e", size = 7015759, upload-time = "2025-03-07T01:51:11.355Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/af/345fedb9f4c76c84ab4fa445b36bd4048a4d9db60e6bc76b4f913ff4b852/nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872", size = 76807835, upload-time = "2025-09-04T08:39:15.274Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc-cu11"
+version = "11.7.99"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/25/922c5996aada6611b79b53985af7999fc629aee1d5d001b6a22431e18fec/nvidia_cuda_nvrtc_cu11-11.7.99-2-py3-none-manylinux1_x86_64.whl", hash = "sha256:9f1562822ea264b7e34ed5930567e89242d266448e936b85bc97a3370feabb03", size = 21011023, upload-time = "2022-09-21T23:12:53.384Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/8d/0709ba16c2831c17ec1c2ea1eeb89ada11ffa8d966d773cce0a7463b22bb/nvidia_cuda_nvrtc_cu11-11.7.99-py3-none-manylinux1_x86_64.whl", hash = "sha256:f7d9610d9b7c331fa0da2d1b2858a4a8315e6d49765091d28711c8946e7425e7", size = 21010447, upload-time = "2022-08-03T20:59:13.991Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/10/c9fc448f33d439981d6a74b693526871c4ef13e8d81a7b4de12e3a12a1b9/nvidia_cuda_nvrtc_cu11-11.7.99-py3-none-win_amd64.whl", hash = "sha256:f2effeb1309bdd1b3854fc9b17eaf997808f8b25968ce0c7070945c4265d64a3", size = 17040212, upload-time = "2022-08-03T21:15:19.801Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc-cu12"
+version = "12.8.93"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d1/e50d0acaab360482034b84b6e27ee83c6738f7d32182b987f9c7a4e32962/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8", size = 43106076, upload-time = "2025-03-07T01:41:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/45/51/52a3d84baa2136cc8df15500ad731d74d3a1114d4c123e043cb608d4a32b/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:7a4b6b2904850fe78e0bd179c4b655c404d4bb799ef03ddc60804247099ae909", size = 73586838, upload-time = "2025-03-07T01:52:13.483Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime"
+version = "13.0.96"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/94/6b867483bec07da24ffa32736c79fabb94ef3a7af4d787a9d4a974868576/nvidia_cuda_runtime-13.0.96-py3-none-win_amd64.whl", hash = "sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492", size = 2927037, upload-time = "2025-10-09T09:04:23.782Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime-cu11"
+version = "11.7.99"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/92/89cf558b514125d2ebd8344dd2f0533404b416486ff681d5434a5832a019/nvidia_cuda_runtime_cu11-11.7.99-py3-none-manylinux1_x86_64.whl", hash = "sha256:cc768314ae58d2641f07eac350f40f99dcb35719c4faff4bc458a7cd2b119e31", size = 849253, upload-time = "2022-08-03T20:58:27.979Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2c/d89ea2b4051fbabff8d2edda8c735dabae6d5d1b8d5215f9749d38dcdb72/nvidia_cuda_runtime_cu11-11.7.99-py3-none-win_amd64.whl", hash = "sha256:bc77fa59a7679310df9d5c70ab13c4e34c64ae2124dd1efd7e5474b71be125c7", size = 991354, upload-time = "2022-08-03T21:14:37.958Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/75/f865a3b236e4647605ea34cc450900854ba123834a5f1598e160b9530c3a/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d", size = 965265, upload-time = "2025-03-07T01:39:43.533Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a5/a515b7600ad361ea14bfa13fb4d6687abf500adc270f19e89849c0590492/nvidia_cuda_runtime_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:c0c6027f01505bfed6c3b21ec546f69c687689aad5f1a377554bc6ca4aa993a8", size = 944318, upload-time = "2025-03-07T01:51:01.794Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu11"
+version = "8.5.0.96"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/30/66d4347d6e864334da5bb1c7571305e501dcb11b9155971421bb7bb5315f/nvidia_cudnn_cu11-8.5.0.96-2-py3-none-manylinux1_x86_64.whl", hash = "sha256:402f40adfc6f418f9dae9ab402e773cfed9beae52333f6d86ae3107a1b9527e7", size = 557141533, upload-time = "2022-09-21T21:55:09.845Z" },
+ { url = "https://files.pythonhosted.org/packages/db/69/4d28d4706946f89fffe3f87373a079ae95dc17f9c0fcd840fe570c67e36b/nvidia_cudnn_cu11-8.5.0.96-py3-none-manylinux1_x86_64.whl", hash = "sha256:71f8111eb830879ff2836db3cccf03bbd735df9b0d17cd93761732ac50a8a108", size = 557140881, upload-time = "2022-08-10T00:14:42.613Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu12"
+version = "9.10.2.21"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu13"
+version = "9.20.0.48"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" },
+ { url = "https://files.pythonhosted.org/packages/78/39/21507455b1bca8b5702a9e9fc6ce73735f216f558dac2c9ede58e4d456b8/nvidia_cudnn_cu13-9.20.0.48-py3-none-win_amd64.whl", hash = "sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24", size = 350712614, upload-time = "2026-03-09T19:31:11.398Z" },
+]
+
+[[package]]
+name = "nvidia-cufft"
+version = "12.0.0.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b2/f8af21a2ed1beed337a6a02c5a28aeb85441f4d578ec3d529543c775ea4b/nvidia_cufft-12.0.0.61-py3-none-win_amd64.whl", hash = "sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb", size = 213342123, upload-time = "2025-09-04T08:40:51.145Z" },
+]
+
+[[package]]
+name = "nvidia-cufft-cu11"
+version = "10.9.0.58"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/79/b912a77e38e41f15a0581a59f5c3548d1ddfdda3225936fb67c342719e7a/nvidia_cufft_cu11-10.9.0.58-py3-none-manylinux1_x86_64.whl", hash = "sha256:222f9da70c80384632fd6035e4c3f16762d64ea7a843829cb278f98b3cb7dd81", size = 168405414, upload-time = "2022-10-03T23:29:47.505Z" },
+ { url = "https://files.pythonhosted.org/packages/71/7a/a2ad9951d57c3cc23f4fa6d84b146afd9f375ffbc744b38935930ac4393f/nvidia_cufft_cu11-10.9.0.58-py3-none-manylinux2014_aarch64.whl", hash = "sha256:34b7315104e615b230dc3c2d1861f13bff9ec465c5d3b4bb65b4986d03a1d8d4", size = 111231060, upload-time = "2024-08-17T00:00:57.04Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c8/133717b43182ba063803e983e7680a94826a9f4ff5734af0ca315803f1b3/nvidia_cufft_cu11-10.9.0.58-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e21037259995243cc370dd63c430d77ae9280bedb68d5b5a18226bfc92e5d748", size = 168405419, upload-time = "2024-08-17T00:02:03.562Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b4/e432a74f8db0e84f734dc14d36c0e529225132bf7e239da21f55893351a6/nvidia_cufft_cu11-10.9.0.58-py3-none-win_amd64.whl", hash = "sha256:c4d316f17c745ec9c728e30409612eaf77a8404c3733cdf6c9c1569634d1ca03", size = 172237004, upload-time = "2022-10-03T23:39:58.288Z" },
+]
+
+[[package]]
+name = "nvidia-cufft-cu12"
+version = "11.3.3.83"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ec/ce1629f1e478bb5ccd208986b5f9e0316a78538dd6ab1d0484f012f8e2a1/nvidia_cufft_cu12-11.3.3.83-py3-none-win_amd64.whl", hash = "sha256:7a64a98ef2a7c47f905aaf8931b69a3a43f27c55530c698bb2ed7c75c0b42cb7", size = 192216559, upload-time = "2025-03-07T01:53:57.106Z" },
+]
+
+[[package]]
+name = "nvidia-cufile"
+version = "1.15.1.6"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
+]
+
+[[package]]
+name = "nvidia-cufile-cu12"
+version = "1.13.1.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f5/5607710447a6fe9fd9b3283956fceeee8a06cda1d2f56ce31371f595db2a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a", size = 1120705, upload-time = "2025-03-07T01:45:41.434Z" },
+]
+
+[[package]]
+name = "nvidia-curand"
+version = "10.4.0.35"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
+ { url = "https://files.pythonhosted.org/packages/99/27/72103153b1ffc00e09fdc40ac970235343dcd1ea8bd762e84d2d73219ffa/nvidia_curand-10.4.0.35-py3-none-win_amd64.whl", hash = "sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f", size = 55242481, upload-time = "2025-08-04T10:30:41.831Z" },
+]
+
+[[package]]
+name = "nvidia-curand-cu11"
+version = "10.2.10.91"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/11/af78d54b2420e64a4dd19e704f5bb69dcb5a6a3138b4465d6a48cdf59a21/nvidia_curand_cu11-10.2.10.91-py3-none-manylinux1_x86_64.whl", hash = "sha256:eecb269c970fa599a2660c9232fa46aaccbf90d9170b96c462e13bcb4d129e2c", size = 54628716, upload-time = "2022-08-03T21:13:08.944Z" },
+ { url = "https://files.pythonhosted.org/packages/45/76/b98f30e058c9bbd9a56eb9b1102b9aab775704bad9286bf8e3998147e2e9/nvidia_curand_cu11-10.2.10.91-py3-none-win_amd64.whl", hash = "sha256:f742052af0e1e75523bde18895a9ed016ecf1e5aa0ecddfcc3658fd11a1ff417", size = 54342083, upload-time = "2022-08-03T21:17:21.537Z" },
+]
+
+[[package]]
+name = "nvidia-curand-cu12"
+version = "10.3.9.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/5e/92aa15eca622a388b80fbf8375d4760738df6285b1e92c43d37390a33a9a/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd", size = 63625754, upload-time = "2025-03-07T01:46:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/75/70c05b2f3ed5be3bb30b7102b6eb78e100da4bbf6944fd6725c012831cab/nvidia_curand_cu12-10.3.9.90-py3-none-win_amd64.whl", hash = "sha256:f149a8ca457277da854f89cf282d6ef43176861926c7ac85b2a0fbd237c587ec", size = 62765309, upload-time = "2025-03-07T01:54:20.478Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver"
+version = "12.0.4.66"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "nvidia-cusparse", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "nvidia-nvjitlink", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ef/332a0101260ca78a1daef046bf0b06199e8ed4dac1d2aa698289c358169c/nvidia_cusolver-12.0.4.66-py3-none-win_amd64.whl", hash = "sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65", size = 193551444, upload-time = "2025-09-04T08:41:46.813Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver-cu11"
+version = "11.4.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/77/66149e3153b19312fb782ea367f3f950123b93916a45538b573fe373570a/nvidia_cusolver_cu11-11.4.0.1-2-py3-none-manylinux1_x86_64.whl", hash = "sha256:72fa7261d755ed55c0074960df5904b65e2326f7adce364cbe4945063c1be412", size = 102594907, upload-time = "2022-09-21T23:13:22.001Z" },
+ { url = "https://files.pythonhosted.org/packages/25/4b/272f9aa7838e545b47878e4aec4f09b0fecf17dbd312cf5c5dc398b0637f/nvidia_cusolver_cu11-11.4.0.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:700b781bfefd57d161443aff9ace1878584b93e0b2cfef3d6e9296d96febbf99", size = 102592389, upload-time = "2022-08-03T21:13:24.499Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/42/f682b0b001562d16664bd7b015165cf2c2d392a8d0506472f28b2833953e/nvidia_cusolver_cu11-11.4.0.1-py3-none-win_amd64.whl", hash = "sha256:00f70b256add65f8c1eb3b6a65308795a93e7740f6df9e273eccbba770d370c4", size = 100095353, upload-time = "2022-08-03T21:17:36.992Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver-cu12"
+version = "11.7.3.90"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" },
+ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c0/76ca8551b8a84146ffa189fec81c26d04adba4bc0dbe09cd6e6fd9b7de04/nvidia_cusolver_cu12-11.7.3.90-py3-none-win_amd64.whl", hash = "sha256:4a550db115fcabc4d495eb7d39ac8b58d4ab5d8e63274d3754df1c0ad6a22d34", size = 256720438, upload-time = "2025-03-07T01:54:39.898Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse"
+version = "12.6.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
+ { url = "https://files.pythonhosted.org/packages/02/b0/b043d6f3480f102f885cf87fc3ffd3edcb5e23b855025a50e2ef4d059185/nvidia_cusparse-12.6.3.3-py3-none-win_amd64.whl", hash = "sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79", size = 143783033, upload-time = "2025-09-04T08:42:12.391Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse-cu11"
+version = "11.7.4.91"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/6f/6d032cc1bb7db88a989ddce3f4968419a7edeafda362847f42f614b1f845/nvidia_cusparse_cu11-11.7.4.91-py3-none-manylinux1_x86_64.whl", hash = "sha256:a3389de714db63321aa11fbec3919271f415ef19fda58aed7f2ede488c32733d", size = 173182291, upload-time = "2022-08-03T21:13:55.407Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3e/d32da819d918b0b9ef3fa89ed8238d2c3b8e315ac32441229783a4b0c4ce/nvidia_cusparse_cu11-11.7.4.91-py3-none-win_amd64.whl", hash = "sha256:304a01599534f5186a8ed1c3756879282c72c118bc77dd890dc1ff868cad25b9", size = 172505954, upload-time = "2022-08-03T21:18:07.425Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse-cu12"
+version = "12.5.8.93"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
+ { url = "https://files.pythonhosted.org/packages/62/07/f3b2ad63f8e3d257a599f422ae34eb565e70c41031aecefa3d18b62cabd1/nvidia_cusparse_cu12-12.5.8.93-py3-none-win_amd64.whl", hash = "sha256:9a33604331cb2cac199f2e7f5104dfbb8a5a898c367a53dfda9ff2acb6b6b4dd", size = 284937404, upload-time = "2025-03-07T01:55:07.742Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu12"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" },
+ { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d8/a6b0d0d0c2435e9310f3e2bb0d9c9dd4c33daef86aa5f30b3681defd37ea/nvidia_cusparselt_cu12-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f67fbb5831940ec829c9117b7f33807db9f9678dc2a617fbe781cac17b4e1075", size = 271020911, upload-time = "2025-02-26T00:14:47.204Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu13"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" },
+ { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/31/83/f3647ce26916c94a6ca4ff1810623e2c405cff2dea6e78d29516b2514df9/nvidia_cusparselt_cu13-0.8.1-py3-none-win_amd64.whl", hash = "sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215", size = 156885108, upload-time = "2025-09-05T18:51:35.958Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu11"
+version = "2.14.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/92/914cdb650b6a5d1478f83148597a25e90ea37d739bd563c5096b0e8a5f43/nvidia_nccl_cu11-2.14.3-py3-none-manylinux1_x86_64.whl", hash = "sha256:5e5534257d1284b8e825bc3a182c6f06acd6eb405e9f89d49340e98cd8f136eb", size = 177099966, upload-time = "2022-08-31T23:13:15.915Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu12"
+version = "2.27.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/1c/857979db0ef194ca5e21478a0612bcdbbe59458d7694361882279947b349/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31432ad4d1fb1004eb0c56203dc9bc2178a1ba69d1d9e02d64a6938ab5e40e7a", size = 322400625, upload-time = "2025-06-26T04:11:04.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu13"
+version = "2.29.7"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/01/07530b0e37546231052e30234540289c42eaffa486f1a34a87fed340157b/nvidia_nvjitlink-13.0.88-py3-none-win_amd64.whl", hash = "sha256:634e96e3da9ef845ae744097a1f289238ecf946ce0b82e93cdce14b9782e682f", size = 36035115, upload-time = "2025-09-04T08:43:03.001Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink-cu12"
+version = "12.8.93"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a2/8cee5da30d13430e87bf99bb33455d2724d0a4a9cb5d7926d80ccb96d008/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7", size = 38386204, upload-time = "2025-03-07T01:49:43.612Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/d7/34f02dad2e30c31b10a51f6b04e025e5dd60e5f936af9045a9b858a05383/nvidia_nvjitlink_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:bd93fbeeee850917903583587f4fc3a4eafa022e34572251368238ab5e6bd67f", size = 268553710, upload-time = "2025-03-07T01:56:24.13Z" },
+]
+
+[[package]]
+name = "nvidia-nvshmem-cu12"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/6a/03aa43cc9bd3ad91553a88b5f6fb25ed6a3752ae86ce2180221962bc2aa5/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b48363fc6964dede448029434c6abed6c5e37f823cb43c3bcde7ecfc0457e15", size = 138936938, upload-time = "2025-09-06T00:32:05.589Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" },
+]
+
+[[package]]
+name = "nvidia-nvshmem-cu13"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/50/0e2220f8620a177de994211186ffc5bfa9f2ce1e1282797f8f90096f9f88/nvidia_nvtx-13.0.85-py3-none-win_amd64.whl", hash = "sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519", size = 137066, upload-time = "2025-09-04T08:39:25.649Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx-cu11"
+version = "11.7.91"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/d5/09493ff0e64fd77523afbbb075108f27a13790479efe86b9ffb4587671b5/nvidia_nvtx_cu11-11.7.91-py3-none-manylinux1_x86_64.whl", hash = "sha256:b22c64eee426a62fc00952b507d6d29cf62b4c9df7a480fcc417e540e05fd5ac", size = 98579, upload-time = "2022-08-03T20:59:22.605Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/e1/37b5a5da8ec3594890356e9d60617feb36cfea1223ac511a78c615870916/nvidia_nvtx_cu11-11.7.91-py3-none-win_amd64.whl", hash = "sha256:dfd7fcb2a91742513027d63a26b757f38dd8b07fecac282c4d132a9d373ff064", size = 65886, upload-time = "2022-08-03T21:15:27.865Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/c0/1b303feea90d296f6176f32a2a70b5ef230f9bdeb3a72bddb0dc922dc137/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615", size = 91161, upload-time = "2025-03-07T01:42:23.922Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/99/4c9c0c329bf9fc125008c3b54c7c94c0023518d06fc025ae36431375e1fe/nvidia_nvtx_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:619c8304aedc69f02ea82dd244541a83c3d9d40993381b3b590f1adaed3db41e", size = 56492, upload-time = "2025-03-07T01:52:24.69Z" },
+]
+
+[[package]]
+name = "oauthlib"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
+]
+
+[[package]]
+name = "opencv-python"
+version = "4.11.0.86"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/06/68c27a523103dad5837dc5b87e71285280c4f098c60e4fe8a8db6486ab09/opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4", size = 95171956, upload-time = "2025-01-16T13:52:24.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/4d/53b30a2a3ac1f75f65a59eb29cf2ee7207ce64867db47036ad61743d5a23/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a", size = 37326322, upload-time = "2025-01-16T13:52:25.887Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/84/0a67490741867eacdfa37bc18df96e08a9d579583b419010d7f3da8ff503/opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66", size = 56723197, upload-time = "2025-01-16T13:55:21.222Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/bd/29c126788da65c1fb2b5fb621b7fed0ed5f9122aa22a0868c5e2c15c6d23/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202", size = 42230439, upload-time = "2025-01-16T13:51:35.822Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8b/90eb44a40476fa0e71e05a0283947cfd74a5d36121a11d926ad6f3193cc4/opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d", size = 62986597, upload-time = "2025-01-16T13:52:08.836Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/d7/1d5941a9dde095468b288d989ff6539dd69cd429dbf1b9e839013d21b6f0/opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b", size = 29384337, upload-time = "2025-01-16T13:52:13.549Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/f1c30a92854540bf789e9cd5dde7ef49bbe63f855b85a2e6b3db8135c591/opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec", size = 39488044, upload-time = "2025-01-16T13:52:21.928Z" },
+]
+
+[[package]]
+name = "opencv-python-headless"
+version = "4.11.0.86"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929, upload-time = "2025-01-16T13:53:40.22Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460, upload-time = "2025-01-16T13:52:57.015Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330, upload-time = "2025-01-16T13:55:45.731Z" },
+ { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060, upload-time = "2025-01-16T13:51:59.625Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856, upload-time = "2025-01-16T13:53:29.654Z" },
+ { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425, upload-time = "2025-01-16T13:52:49.048Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386, upload-time = "2025-01-16T13:52:56.418Z" },
+]
+
+[[package]]
+name = "openvino-dev"
+version = "2022.1.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/99/9e55ddb1abce5ff7def768470810044a6de48a5da99b9195498ad75dfe8a/openvino_dev-2022.1.0-7019-py3-none-any.whl", hash = "sha256:3d911b31a89e92dcb92a29026cfb396920211aca9525ea50032419f10169098d", size = 5774377, upload-time = "2022-03-22T13:07:17.333Z" },
+]
+
+[[package]]
+name = "opt-einsum"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/b9/2ac072041e899a52f20cf9510850ff58295003aa75525e58343591b0cbfb/opt_einsum-3.4.0.tar.gz", hash = "sha256:96ca72f1b886d148241348783498194c577fa30a8faac108586b14f1ba4473ac", size = 63004, upload-time = "2024-09-26T14:33:24.483Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/cd/066e86230ae37ed0be70aae89aabf03ca8d9f39c8aea0dec8029455b5540/opt_einsum-3.4.0-py3-none-any.whl", hash = "sha256:69bb92469f86a1565195ece4ac0323943e83477171b91d24c35afe028a90d7cd", size = 71932, upload-time = "2024-09-26T14:33:23.039Z" },
+]
+
+[[package]]
+name = "optree"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/63/92328a17ab7836562fe0129e605f685a88db35ce98427c34ff48ee4ec157/optree-0.19.1.tar.gz", hash = "sha256:4497d1c9197b8c6842e511368163d318ce536521ebdcff8bebb7551dcdfac532", size = 177531, upload-time = "2026-05-06T02:32:39.704Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/48/53367634a0ab6c2f0e502d83f8d6e27b70b6848ff1e1ff9cf042d1e1f1a0/optree-0.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1b28b0d89def1b4554051f3de2a1ed81e20216b6454a59a0d16c9f55c08cff77", size = 398400, upload-time = "2026-05-06T02:30:31.384Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/4f/350c82cd77a510f0f495e38a6f333b4b45a413dbc224142bc59975bc09d6/optree-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14f959bc6bea6e0532f9239c67ea6952f3b8d0755ea9b4dd498284b649275aba", size = 370049, upload-time = "2026-05-06T02:30:33.065Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e1/81b660daea2a75f574549e62c198d0b4e8e148b5de6f5f72e90a5cc1c334/optree-0.19.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1687c962bb1691525178a6e90dde5840197cd7a7ad914b407eb7b635f15d47cb", size = 390143, upload-time = "2026-05-06T02:30:34.585Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ec/ee009b5a31227b089d72fec2af3bb6bc0efd95bbe87ffe46f11061b9d371/optree-0.19.1-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d7edead66cace8b3b905488e391b38487614f75ae4fa7f3b612c7fe0e54b8a90", size = 445740, upload-time = "2026-05-06T02:30:35.891Z" },
+ { url = "https://files.pythonhosted.org/packages/15/5c/2fe8ac73b7e979f3ed477ad99b7e034a11207d728b84ed2f52da259e7cda/optree-0.19.1-cp310-cp310-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9fb767746231ff279d273e8ff71af2a8f89c0c3870ca367c45fd4526d331ae4b", size = 446631, upload-time = "2026-05-06T02:30:36.958Z" },
+ { url = "https://files.pythonhosted.org/packages/21/42/489fb272de36e0233149d46887879deb9497edc4a0214674bd2a80b8d4ec/optree-0.19.1-cp310-cp310-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:75fe038a1bed44f487a084af7a978874c51bba55f850bc12bf8068f3242463d6", size = 442053, upload-time = "2026-05-06T02:30:38.24Z" },
+ { url = "https://files.pythonhosted.org/packages/90/09/1f0bc2b584a51702407592bbccfe2b404187f6f5ee5b4b0c112a73e1a7ec/optree-0.19.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fff5fd89a9b333d91a05a7ca2e66c8e6632d0bdbc94c1725a341b77001f09511", size = 425490, upload-time = "2026-05-06T02:30:39.432Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/f4/d8685b55323c1f42695c1ed647d6541ee9c289eb821abc6e0cb84b0e4f72/optree-0.19.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d4bac18638fa56efd2377cf8c43e17cd083aa566e69a31ce10f7fdaefd9676a3", size = 390552, upload-time = "2026-05-06T02:30:40.506Z" },
+ { url = "https://files.pythonhosted.org/packages/07/84/ee12e234ddcf4fd4b7893ce03ec37f3c3edabdac911fd5384aa3f5c04c05/optree-0.19.1-cp310-cp310-win32.whl", hash = "sha256:ef2409d4efda1c5a6eb69f83ffff89fb04d5607fd056704552ec359fb865cd6c", size = 303117, upload-time = "2026-05-06T02:30:41.61Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b5/4e23965aacae04eb4cf42cd8108405a6628e645ee3ab759277e03063af0e/optree-0.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:01c88294235b118b7478b5e80d360e5f110977cdf79f84d61dae21c2eb1d4cdd", size = 327866, upload-time = "2026-05-06T02:30:42.664Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/f2/4671a78193f96e86c1343fe04324091e163973d0058b292c10bc3387bb70/optree-0.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a496b864fe1fe0b5ea23d1ee3d1ef958d910704661808db2b2d2e16a0cfac96c", size = 414314, upload-time = "2026-05-06T02:30:43.812Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/93/7decea24656f416d61fa57b7113b1fbdbc042b7ab421399a84e1755676a1/optree-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1667e502e0eda9477925fb17c2ad879b199a2283ac98f18e6453692819b7811", size = 385006, upload-time = "2026-05-06T02:30:44.895Z" },
+ { url = "https://files.pythonhosted.org/packages/af/2e/9d1bd2527481681c4399beeeabba11dca36b16ec814579f2e8cc6bc2af96/optree-0.19.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e367a9d81e57c31a23247094727987a2f64b708901233a42a24d44d24e93f6", size = 406124, upload-time = "2026-05-06T02:30:46.13Z" },
+ { url = "https://files.pythonhosted.org/packages/df/29/cdb40de6307809fa8e9452e4f9a65881a3140d01d9d589a07e9d054d8e1c/optree-0.19.1-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:96fad6c7b3a6fde3a0c8655fd003359cd247f8400749217502591a5ffc328699", size = 466772, upload-time = "2026-05-06T02:30:47.766Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/15/4645e1816e815a1306bbb7e3e2e6ba124f6dc325f8088a2db69301219a0c/optree-0.19.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3a900df0ffb9b8259961b337289754531a7e0a5de2f681e9c80866b6a7cb74e", size = 466203, upload-time = "2026-05-06T02:30:49.04Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/d2/5758c76bdd7034b721d84c7f0fd911f3b39dcb489eeb27f674aaae8a5f5c/optree-0.19.1-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:27c8dc0f89ade9233aa7ed25ce15991da188e6950eb17cc0c313fc1f327c5b0b", size = 465030, upload-time = "2026-05-06T02:30:50.254Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b9/f668bc51129c0fec7728ae8b43180417fe1c1fe99f71d302739f6cc50944/optree-0.19.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38f2e503fad50aff58cade85db448002d4adc72f4b3b50dcc7f3ef4bcd3b0173", size = 447141, upload-time = "2026-05-06T02:30:51.42Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/08/a7b8862e4465bf250c3ccc78db4d10b9a2cf90ce4db3681cbdf7eb076fb7/optree-0.19.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:5dc35cb31540ab6ed9850b0f8865ccd400994ebd51fcf0c156cc772073f43c04", size = 410016, upload-time = "2026-05-06T02:30:52.695Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/04b71a34cf5e663a1df029acceb5efc8a96c8dc4b0b6af6e98486638e913/optree-0.19.1-cp311-cp311-win32.whl", hash = "sha256:d32b1261be71211f77837e839e43a3e3e8fc57707091d2454d0a88590fb6abe8", size = 311810, upload-time = "2026-05-06T02:30:53.879Z" },
+ { url = "https://files.pythonhosted.org/packages/22/64/3cc7b08cb1c0f1949895f9490217ca8db6ced7f3bf75c65a5bf31c07bf1e/optree-0.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:cd28a527bb363a1d7d28e8b2fb62816ace6743418bb86e9c5f27ea6877dcdf6c", size = 337620, upload-time = "2026-05-06T02:30:55.262Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/14/85f4b05765287658529f09ede10461224161dcf0e29e6fce1ae488451cfe/optree-0.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:7853b58aa084e882ea078f390936bd92e46972eb8f9b5e654360b6480ca7283b", size = 349337, upload-time = "2026-05-06T02:30:56.647Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/a7/cb5567029a608a296b0ca224025d03bba0365b41df19085b9b580191f6f2/optree-0.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:96e5c7c3b9144f08ae40c3d9848cfbcfa36b6bead0f8215ad071d5922ee6c4a5", size = 424023, upload-time = "2026-05-06T02:30:57.732Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/a1/3651fb32fa8617108204aa4056d283af742020e0987d106f41402005d800/optree-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5d9d198343e1e6ced18bef0cbff84091c1877964fc4a121df33f18840e073a01", size = 394782, upload-time = "2026-05-06T02:30:59.239Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/1e/676470909aa64d7aba7c5edf83b171dc83b7af901d9ebb8e6d7512fe913a/optree-0.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a1202371d9fe3aa75f3e886b1f871aac4991a655aadb65e54f58a3ae9388ab2", size = 413157, upload-time = "2026-05-06T02:31:00.339Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/41/1a4c58f2af5742b9d9e21ea9e45c6c3c49463b5e2a0537e84ead1e9597ca/optree-0.19.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d41ccc4c20bfeae01d1d221c057a6d026e84e32229664952eddcdbe4b9b71417", size = 476923, upload-time = "2026-05-06T02:31:01.492Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c1/f62167bd9d6f6c948b191a0943923404678d47100f777f4a8fb37816e6f8/optree-0.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d934f240b109c6891dd06b2e30400b123b8a4b6ed31dcd0db2ae2378d30a6e8", size = 475385, upload-time = "2026-05-06T02:31:02.836Z" },
+ { url = "https://files.pythonhosted.org/packages/30/5e/5323c5fa3024fdd900bdd8f14621139ed844c2247bf1a26e7cf5c1116188/optree-0.19.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddeefb7ca799c09647e332ebc1a5f6c09888a5a0e51f2dff4ca55e65b42a8c14", size = 474406, upload-time = "2026-05-06T02:31:04.023Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/6a/54e4c47e61a51504a5224c933722e0c8a69925aacec4c08175e9675aeb81/optree-0.19.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0ce49f64f804f7f35f2f9c2a21e3ba94c090199fccdcfd40e3ded4426c5c175", size = 457596, upload-time = "2026-05-06T02:31:05.695Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/12/bba07c0b769586c6bd54e81f1f734cad103dbe30abbadee940fe7d3e330e/optree-0.19.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e0f02600832ab8d0f6c934dcb5c339e17a36938d477641a45798e02625ebe107", size = 417900, upload-time = "2026-05-06T02:31:07.251Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/8f/6ae994bb47f9394b33912a14593f9247737dd6c3303811550e5a3e918107/optree-0.19.1-cp312-cp312-win32.whl", hash = "sha256:f10d58c1a17e1b32f9d9b5e1b9d1ad964d99c1113d9df0b9f62f2fe7dde19909", size = 317302, upload-time = "2026-05-06T02:31:08.627Z" },
+ { url = "https://files.pythonhosted.org/packages/31/97/d7e3ec79dcdde81f785a0446acf75fea77723f5ca4b98556350d7877986f/optree-0.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:06f5c8a4cf356a1a276ce5cec1be44719ed260690f79c036d04b4d427e801258", size = 341362, upload-time = "2026-05-06T02:31:09.689Z" },
+ { url = "https://files.pythonhosted.org/packages/33/97/813afb84a81fd8ae65444730907c05f0775fd6c79d3359c9e84bd3370445/optree-0.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:a33bd23fc5c67ecb9ff491b75fde10cd9b53f47f8a876de842090e8c7a2437e1", size = 351838, upload-time = "2026-05-06T02:31:11.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/7b/0f2f3c9d55dda5127624daf68ff802ab624b739dd4b32aef505dac0c8e02/optree-0.19.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:f144cfd65fb17c6aa2c51818614eb009e6052d3d6ace91f7e570b1318cdcac4c", size = 929090, upload-time = "2026-05-06T02:31:12.267Z" },
+ { url = "https://files.pythonhosted.org/packages/15/e2/670d260dfd0532d64272dd6f7edd540a09d7040c0342b6cc6cf773568ea4/optree-0.19.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:39a006735d2a0a68751a3bc33d670184fddcd86db63b0293e1e819739e8105e4", size = 391528, upload-time = "2026-05-06T02:31:14.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/96/46c15e80b0c97e2ba6aba11339008a37cabc5ccf55c31c6c60aecdb79638/optree-0.19.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d2cb43c36638f469f5d8f4cf638e914de90c62242d8bed29f1b4487e0346ab94", size = 398231, upload-time = "2026-05-06T02:31:15.519Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/39/9d7d22cdaeb9a40ace2485f91c5b7c5f3a7f688575e2621e436561211cc1/optree-0.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e70faa00ab69331f49f8337d45021bed09ae2265d1db72eea9d7817af2b73c64", size = 429852, upload-time = "2026-05-06T02:31:16.992Z" },
+ { url = "https://files.pythonhosted.org/packages/79/4c/1da9e8375e7b7fd9671dc5987682b042f6412c4d6fd9da03296403818d9f/optree-0.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1c5d21176b670407f4555aae40711668832599c4fb0627000c5ce3ed0d6e2dae", size = 398688, upload-time = "2026-05-06T02:31:18.113Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/50/cd2d178099618093f5a9fd1c9de80af2b428879922eae1e9f27f1002c8be/optree-0.19.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f658fa46305b2bdccdc5bb2cb07818aeaef88a1085499deda5be48a0a58d2971", size = 417560, upload-time = "2026-05-06T02:31:19.391Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b0/f22ff5632083b5032caa80208dd202f8e963ed4aac11afa0a0f6a307fd68/optree-0.19.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:e757079d44a00319447f43df5c51e55bf9b62d9f05eea0e2db5ff7c7ca5ec71d", size = 482937, upload-time = "2026-05-06T02:31:20.799Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/d4/7499d28be8b11eb40668262d27802119fe7e6ec4cd8816b76a1acd7b08f5/optree-0.19.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9690c132822d9dee479cf7dff8cc52a67c8af42a4f7529d21f0f4f1d99e4c84e", size = 477864, upload-time = "2026-05-06T02:31:22.077Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/6e/6c6fa6f1159ac68f4ee7666610127fb4c14d47a2fa7a0a48de3aecc24d4b/optree-0.19.1-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:544b70958dbd7e732bc6874e0180c609c9052115937d0ec28123bb49c1a574aa", size = 478319, upload-time = "2026-05-06T02:31:23.266Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b5/8a2427bbe4ee59e2ce26a14125728e3b48c7030c80984ba07d0e5d804d37/optree-0.19.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dde5b756946c1f1458aeab248a7a9b0c01bb06b5787de9f06d52ad38b745557", size = 462379, upload-time = "2026-05-06T02:31:24.543Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/0c/a073eeaea4d4f68e02d5883ed8268746a296e6749e3c46e0124ca45f306c/optree-0.19.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:f1d7838e8b1b62258abd73a5911afad1153ed76822070558c3ba7e0bb5b44192", size = 423061, upload-time = "2026-05-06T02:31:25.652Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/34/637b151d071ca94aea0087322f470ce84c5828ef6b9c0de7dc7b4420a1cf/optree-0.19.1-cp313-cp313-win32.whl", hash = "sha256:9870d33ec50cca0c46c2b431cea24c6247457da15fd4ad66ccb8ab78145c1490", size = 317439, upload-time = "2026-05-06T02:31:27.304Z" },
+ { url = "https://files.pythonhosted.org/packages/50/52/49b8a8d9e94c57c6fa5008953f84a1c36a4119a3b90dcb7df745f1f05a00/optree-0.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:aa0845b725bcd0029e179cf9b4bc2cc016c7358e56fc7c0d2c43bf4d514c96cf", size = 343906, upload-time = "2026-05-06T02:31:28.774Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a9/1ae0a9685f5301f454f01d2490065b98df6956f90b1b2fd1cea9daa6d820/optree-0.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:6f0b1efc177bed6495f78d39d5aa495ccb31cc20bcf64bb1b806ca4c919f4049", size = 353146, upload-time = "2026-05-06T02:31:29.976Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/77/4c8108cbce2c8ae2aa4b6adc7874082882e32cf131cb64b3a4411f50dec4/optree-0.19.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b964bcdb5cfe367cdf56447e80ba5a49123098d8c4e8e68b41c20890eec6e58e", size = 469723, upload-time = "2026-05-06T02:31:31.425Z" },
+ { url = "https://files.pythonhosted.org/packages/64/33/ce9b54646ed4ab5773a9dc59767dadfe3de8bb2e97a3ed19205b995a7a31/optree-0.19.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ccec0ee5a565eb5aa4fe30383016a358627ea23d968ec8ab28b1f2ce4ce3d8", size = 437071, upload-time = "2026-05-06T02:31:33.027Z" },
+ { url = "https://files.pythonhosted.org/packages/79/55/04260128a726e3550b49467a65bff859452897144b68bae54b2f2e5c27f1/optree-0.19.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:672588408906051d3e9a99aca6c0af93c6e0b638137a701418088eaa0bb6c719", size = 433503, upload-time = "2026-05-06T02:31:34.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/99/6a4cc29389667efa089a0c476b7c36b7d0a66e10dd2d8c2d19c776977566/optree-0.19.1-cp313-cp313t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d16cef4d0555d49ce221d80249f1285a2d3faf932e451c3ce6cb8ccb6a846767", size = 496305, upload-time = "2026-05-06T02:31:35.835Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/46/506aa1a64abce69e2f4cec9cdac3da0cae207cf04c5e70e7f143bf8b29d8/optree-0.19.1-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc2db0b449baff53aa7e583306101de0ade5e5ae9e6fce78400eb2319bbd23dc", size = 492759, upload-time = "2026-05-06T02:31:37.265Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/28/2210de9a68722007fe007da3cae1a5971b92fc8113b5eecef66a04637959/optree-0.19.1-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:76b3e9e5d37e6b05ec82fff91758c8c0e27e159b35faea4b33d5eb975d720257", size = 495447, upload-time = "2026-05-06T02:31:38.505Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/61/40c3463e52914d552c66c760ae15e673137c4cc1d1d9f8da0d745656193a/optree-0.19.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03faa8e23fdaf3a18f9a1568c2c0eb0641a6aa05baf3a20639bd11fb34664700", size = 475564, upload-time = "2026-05-06T02:31:39.732Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/66/1603680fa924e68e5697c1229510c0645db0a9c633a12d1a9bfdbfc9cb74/optree-0.19.1-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:a9b9c7e9148ec470124dc4c1d1cd1485dbeb35973357b5911b181a79090426d2", size = 442414, upload-time = "2026-05-06T02:31:40.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/58/34820bab11f28ba6b03fe9e151880ad591b43f26648f058c94451fbdfc3a/optree-0.19.1-cp313-cp313t-win32.whl", hash = "sha256:ab8ad9803376d553a2958471b6bb6842b7e15888e19cc6aeb76da96c6afd948d", size = 348644, upload-time = "2026-05-06T02:31:42.038Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/2b/0be3f8b9765f366e3e12d0590e9c6514de110d0c5b3b9002f49e56bf15b1/optree-0.19.1-cp313-cp313t-win_amd64.whl", hash = "sha256:afd4abeb2783b2367093287bc6268ac9af244b20c8d9b01696ccfe817483b66c", size = 382445, upload-time = "2026-05-06T02:31:43.166Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/fa/8c0882cdd42e28a23c1998297c8ad1202194510cbba8b050251429c641c0/optree-0.19.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b9120510d3f951e268e417a3f64f335bc1c539e1e80bff2129ddc6fb60ac7b56", size = 388040, upload-time = "2026-05-06T02:31:44.661Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/da/4e16e26375c56c9e40760697af4e2b72f196c2099e96cc783b63dcc862a8/optree-0.19.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:e1951ddc870f67430310fd17393971c30510ee9fd290525b44c12afe25f3c307", size = 927808, upload-time = "2026-05-06T02:31:45.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/87/ff1c6bb6b79a5d0b70b83f7ae8b78811a406a749b3ae4478a2122a7afb66/optree-0.19.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ae9d42718ebf985cdad3182364b5cf829193b8fd2c6d993fbb4111d38e2bdf96", size = 390981, upload-time = "2026-05-06T02:31:47.38Z" },
+ { url = "https://files.pythonhosted.org/packages/82/25/fc648710102960f87d18cd8fc8a24afe14a5ec7827c64dfb1340230c0794/optree-0.19.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:930268ebfdebca43a8808f6293910d6ade2fe7c84fa784692017d7120d285226", size = 397756, upload-time = "2026-05-06T02:31:48.76Z" },
+ { url = "https://files.pythonhosted.org/packages/24/f6/a7bf5d75a6481038bbb61846d87d43124d63741385796ef7b37d326f46bd/optree-0.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b2757c5d922aab76cfc9b870c373fb35209c2094e3c912733b326c043e85a0c6", size = 427424, upload-time = "2026-05-06T02:31:49.838Z" },
+ { url = "https://files.pythonhosted.org/packages/49/cc/14dd93887295859457e507fc46a847b68ae8f20c42b2fde4d8a749c94bbc/optree-0.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b17a7b70ff8bd406c2142914c5ab0a57f8bcfb9f52181f7012e32406bbdbfdda", size = 398242, upload-time = "2026-05-06T02:31:51.262Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b5/ac51aa118dd918761519fbc031865b1d6f850453e9a7ac0c3da21109c4f0/optree-0.19.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:987bba55366917d9829f45b5ee86499ecc87a30e9103072db9ab8d67f9958179", size = 419568, upload-time = "2026-05-06T02:31:52.349Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/41/25144e61f76278b9e0a5d4189c7083fe853164c5f7328a1f5aac43d964c2/optree-0.19.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d3bba2af7a5fce0c25e99024688e68dfe9be41e3d6e92720febbefdc879fba38", size = 482797, upload-time = "2026-05-06T02:31:53.471Z" },
+ { url = "https://files.pythonhosted.org/packages/22/47/2c76c7ce937323988770c41126e0e380bcb73a816f68a767f23b5c33aced/optree-0.19.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dae6c247cc8751bd2f167951468769f5c98f8cfdae31c0db0f2eb4145a6ec560", size = 479794, upload-time = "2026-05-06T02:31:54.843Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ca/bd9553f94bec0bc7860f10ae177c14ca265ab19ddb463122be22fa335ee8/optree-0.19.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17a986fd91ccdc18bb7b587ca1f508c1761580a93517e6db33a13b22e46acb9b", size = 481084, upload-time = "2026-05-06T02:31:56.261Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/1a/4834b1f2fb1847412353d7342eb7a1d001a4f3bd9d24155e057135a4aa44/optree-0.19.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d0e1493429ae1d1a5e34855774ee604c974a8f76656bd0e562cdbf9466c9b1f", size = 462955, upload-time = "2026-05-06T02:31:57.829Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/88/598fb91c06fee3d8b08568779b011225dc2b66140927bd0b2b2d9b40a566/optree-0.19.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:f61a01ed9991193ed6f3db8e956ede05218190a32ca2ddfb71cfc40c8daba1d5", size = 423754, upload-time = "2026-05-06T02:31:59.291Z" },
+ { url = "https://files.pythonhosted.org/packages/20/8a/83c64ecadc686e08310fc9c20bc0bbe6453e89b69257e08887818dac7886/optree-0.19.1-cp314-cp314-win32.whl", hash = "sha256:b0c920579bddc3b18a0e051850f017618e24efcc19ba83dcd415cf74db5fd904", size = 325214, upload-time = "2026-05-06T02:32:00.802Z" },
+ { url = "https://files.pythonhosted.org/packages/96/c3/4f2f318b98465376bbb7a06a33da553c688b3ed39dafbb8307f824eef74a/optree-0.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:50d77b91a8cd01adf422472b7edf39fc445b0268816176a868a385d28f8367c2", size = 351654, upload-time = "2026-05-06T02:32:01.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ab/55d7508e87055c730fe7207cfd0c45183a07ddf1f91d9e73d017a7f8c1f4/optree-0.19.1-cp314-cp314-win_arm64.whl", hash = "sha256:c682ab6711b7a623503711fa661a2bba7886e1c21dc06c3b7febba101b458051", size = 361610, upload-time = "2026-05-06T02:32:03.003Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/2d/4f7facd482d56079b7adb8ce3fede19f41629bc0463e8ee25907f1dba36c/optree-0.19.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:068edb89fadd94f6f57fdb51f4ad2c764b5a0bfd00903c55ffe433c2863a8037", size = 469130, upload-time = "2026-05-06T02:32:04.395Z" },
+ { url = "https://files.pythonhosted.org/packages/92/60/f7539012aa8a7488c1e34f66b76eadc384c3152dd9800973f1b5fe045dfd/optree-0.19.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a609c90e4f64e4f3e2b5b3cc022210314834737e0e61a745485e33b33eae773b", size = 437286, upload-time = "2026-05-06T02:32:05.527Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3f/a5f8fb3ec3840f885de52d7a793ba57ace17990e3a9b3797218425ffe842/optree-0.19.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfae64c4c371640a4b3e2a9e3e6aa3a3e8cdf2da5247a88fef5b632614b948a6", size = 431954, upload-time = "2026-05-06T02:32:06.83Z" },
+ { url = "https://files.pythonhosted.org/packages/68/dc/6d0ef14bc82bd54046c1a066d25fa6854123a6b29fd691f1f95dec3ab45f/optree-0.19.1-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:470742544ff2d4b63843023f38dcfb83e82c3a9877c783dee0e69cbb974de6d1", size = 494631, upload-time = "2026-05-06T02:32:08.038Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/9a/9e183c610c414cba581f9afda7610589d89cae229d627b14f8480425d975/optree-0.19.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a74e0656ccef45b1fec07b9d964ce97f3def8bab73711f56175076c4259884f", size = 491786, upload-time = "2026-05-06T02:32:09.363Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/73/266b9de8eb5b16bfe7010c90c55840517d5d61ee6e0ca64901440296d97a/optree-0.19.1-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55841132ba8a34dbbd85e0c2cf990602384eea0e4638df986cd3266482f4a17", size = 490876, upload-time = "2026-05-06T02:32:11.388Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/8d/42a8ca6277ef93d47ab0986e30a25134206afe0c6e6c3425c8736b2677ba/optree-0.19.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5f8383952f18d5a4ec6b248d8ae6fe27012434ad9750aa33a821ad4846da5af", size = 475079, upload-time = "2026-05-06T02:32:12.768Z" },
+ { url = "https://files.pythonhosted.org/packages/63/91/e363f4adda292f891ca0cf5748010fea955737bdf494cc11d4c3bcda6935/optree-0.19.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:de8acbed5965beae6f6b0456fcb8d1afaea1fe300810739e88645e22138849bc", size = 440119, upload-time = "2026-05-06T02:32:14.096Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/eb/489d22ef3cadb2f5f3bbd6e6099d17b5a521ff533e086f78f005c3358017/optree-0.19.1-cp314-cp314t-win32.whl", hash = "sha256:312048e69dc88de26915674f961bf38980a765a6b48ead2f1672858a39402c41", size = 357465, upload-time = "2026-05-06T02:32:15.424Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/34/7f48b7034ff75d2eb3e94e2196709ddbf762798fb621f9508899fa66b44e/optree-0.19.1-cp314-cp314t-win_amd64.whl", hash = "sha256:60e9345405d7b06cafdf1b1dd2e2261ceddddce10f35729240f90e2bab845a0b", size = 397783, upload-time = "2026-05-06T02:32:16.853Z" },
+ { url = "https://files.pythonhosted.org/packages/07/42/6d6f93416c66820cb8753e65b5ff43c47480af9c4911bd2b8406ff0f7f27/optree-0.19.1-cp314-cp314t-win_arm64.whl", hash = "sha256:4e103e212d1e8fe0399ed076eff80a905fb14929729bbd994d3660110a27a252", size = 396064, upload-time = "2026-05-06T02:32:18.077Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d4/ffeedc86f8b91e5c17994f38bd1f7aa2e20f9b70a6d3ae906af16414626c/optree-0.19.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3f4f1c276fa06227cdaf58349d22a3231b3dd3d47de1f90a86222ebf831fc397", size = 417543, upload-time = "2026-05-06T02:32:32.592Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0b/80fb1b289940e34858cb89f05bc7ce23d6d1272886c2f78bc7e3ab1a306b/optree-0.19.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:77d93eafbd0046c7350bc592ab8e3814abbd39a6d716b5b1e5d652cc486f445c", size = 390184, upload-time = "2026-05-06T02:32:34.273Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/67/f31784a7a2dcc0c1f84b691afc552ea5b26db5f56657692a12954a828db4/optree-0.19.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3507ae5db5827eef3da42d04c5a41df649cedc2e42d5d39dc0f869d36915a00b", size = 409025, upload-time = "2026-05-06T02:32:35.817Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a5/647b93eb16244cc7f6dfccc025ac495245e306ff4cb8f9ad15718219141a/optree-0.19.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:732c4581fb666869b8b391ec4ca13d2729795f9abe72b5aec2e582bcbea1975d", size = 449514, upload-time = "2026-05-06T02:32:37.014Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8e/d251c9338771ef0f9db8e538bd77810100c495734b57494464c7e223f0d0/optree-0.19.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e12ee3776a16f6feaa8263b92469ad546b870af71d50602745855d8449219221", size = 341586, upload-time = "2026-05-06T02:32:38.308Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pandas"
+version = "2.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" },
+ { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" },
+ { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" },
+ { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" },
+ { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" },
+ { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" },
+ { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" },
+ { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" },
+ { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" },
+ { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" },
+ { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" },
+ { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" },
+ { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" },
+ { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" },
+ { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" },
+ { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" },
+ { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" },
+ { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" },
+ { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" },
+ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" },
+]
+
+[package.optional-dependencies]
+hdf5 = [
+ { name = "tables", version = "3.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tables", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+performance = [
+ { name = "bottleneck" },
+ { name = "numba" },
+ { name = "numexpr" },
+]
+
+[[package]]
+name = "parso"
+version = "0.8.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
+]
+
+[[package]]
+name = "partd"
+version = "1.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "locket" },
+ { name = "toolz" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029, upload-time = "2024-05-06T19:51:41.945Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" },
+]
+
+[[package]]
+name = "patsy"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
+ { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
+ { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
+]
+
+[[package]]
+name = "pims"
+version = "0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "imageio" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "slicerator" },
+ { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b8/02/5bf3639f5b77e9b183011c08541c5039ba3d04f5316c70312b48a8e003a9/pims-0.7.tar.gz", hash = "sha256:55907a4c301256086d2aa4e34a5361b9109f24e375c2071e1117b9491e82946b", size = 87779, upload-time = "2024-06-10T19:20:42.842Z" }
+
+[[package]]
+name = "pint"
+version = "0.24.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "flexcache", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flexparser", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "platformdirs", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/bb/52b15ddf7b7706ed591134a895dbf6e41c8348171fb635e655e0a4bbb0ea/pint-0.24.4.tar.gz", hash = "sha256:35275439b574837a6cd3020a5a4a73645eb125ce4152a73a2f126bf164b91b80", size = 342225, upload-time = "2024-11-07T16:29:46.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/16/bd2f5904557265882108dc2e04f18abc05ab0c2b7082ae9430091daf1d5c/Pint-0.24.4-py3-none-any.whl", hash = "sha256:aa54926c8772159fcf65f82cc0d34de6768c151b32ad1deb0331291c38fe7659", size = 302029, upload-time = "2024-11-07T16:29:43.976Z" },
+]
+
+[[package]]
+name = "pint"
+version = "0.25.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "flexcache", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flexparser", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "platformdirs", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/9d/b1379cdbd33a49d17d627bc24e2b63cca06a1c5343b38072d2889499e82e/pint-0.25.3.tar.gz", hash = "sha256:f8f5df6cf65314d74da1ade1bf96f8e3e4d0c41b51577ac53c49e7d44ca5acee", size = 255106, upload-time = "2026-03-19T21:57:08.72Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/dd/a9fe6a0a09512da23951c68bf36466aeecd89def3183dc095edbc807ddc5/pint-0.25.3-py3-none-any.whl", hash = "sha256:27eb25143bd5de9fcc4d5a4b484f16faf6b4615aa93ece6b3373a8c1a3c1b97d", size = 307488, upload-time = "2026-03-19T21:57:07.022Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.9.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pooch"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" },
+]
+
+[[package]]
+name = "pre-commit"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.52"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "4.25.9"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/d08c41a8c004e1d437ef467e7c4f9c3295cd784eba48ed5d1d01f94b1dad/protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff", size = 381040, upload-time = "2026-03-25T23:09:36.423Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/e9/59435bd04bdd46cb38c42a336b22f9843e8e586ff83c35a5423f8b14704e/protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03", size = 392879, upload-time = "2026-03-25T23:09:21.32Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/16/42a5c7f1001783d2b5bfcecde10127f09010f78982c86ae409122ce3ece6/protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06", size = 413900, upload-time = "2026-03-25T23:09:23.589Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5b/0074a0a9eb01f3d1c4648ca5e81b22090c811b210b61df9018ac6d6c5cda/protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904", size = 394826, upload-time = "2026-03-25T23:09:25.194Z" },
+ { url = "https://files.pythonhosted.org/packages/54/aa/b2dba856f64c36b2a06c67be1472de98cca07a2322d0f0cbf03279a40e5b/protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791", size = 294191, upload-time = "2026-03-25T23:09:26.613Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5c/53f18822017b8bda6bd8bb4e02048e911fdc79a3dafdc83ab994fe922a84/protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be", size = 295178, upload-time = "2026-03-25T23:09:27.839Z" },
+ { url = "https://files.pythonhosted.org/packages/16/28/d5065b212685875d3924bcdb3201cbf467cb4d58a18aa19a8dfd99ea80a9/protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4", size = 156822, upload-time = "2026-03-25T23:09:34.957Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "5.29.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
+ { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.33.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
+ { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
+ { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "psygnal"
+version = "0.15.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/44/ab13cb6147d010258826a43e574ad94599af0de29df13795fff9efee656c/psygnal-0.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ee55e3997f796fd84d4fdbd829bb1b19d323e087c43d072744604a3016c8851", size = 587322, upload-time = "2026-01-04T16:38:04.827Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/a2/68c042a607ca613e9450dfee99cc5c2a4d10d95392fb1de2ba932dd0a605/psygnal-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:912bcf110bfe7b4aa121d24987b6a58afb967ff090a049dad136eaf3cbcc7bea", size = 576207, upload-time = "2026-01-04T16:38:06.183Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/86/123c7b169ad32994a0cd801cd1f11c1a2be84555807e9c8a8a4682c67a9f/psygnal-0.15.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e860c11fe075fd80c93a24081c577ef7ec5c9da41f0e75990aa4cccf3f79cf", size = 864261, upload-time = "2026-01-04T16:38:07.895Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f1/886cec7bec2f27fe453cfa32bfcaac08a83aab2a04895af68f93e1c493b8/psygnal-0.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8bebcf99699ef50b6ef572868a490f6d191dc4466e5bd9818ca27e17cd581", size = 872582, upload-time = "2026-01-04T16:38:09.745Z" },
+ { url = "https://files.pythonhosted.org/packages/21/a3/da972a05568ee8a9dc6c6567bee2c0cc5af8c681baebcb9fdbbf3cceb4f7/psygnal-0.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:06e0a90490e1205620d97ac52fbbe3282a22b126a26d02b3e1196bb46de16c7a", size = 411043, upload-time = "2026-01-04T16:38:11.588Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" },
+ { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" },
+ { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" },
+ { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" },
+ { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" },
+ { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" },
+ { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" },
+ { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
+]
+
+[[package]]
+name = "py-cpuinfo"
+version = "9.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
+]
+
+[[package]]
+name = "pyarrow"
+version = "24.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/91/13/13e1069b351bdc3881266e11147ffccf687505dbb0ea74036237f5d454a5/pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", size = 1180261, upload-time = "2026-04-21T10:51:25.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/bf/a34fee1d624152124fa8355c42f34195ad5fe5233ce5bb87946432047d52/pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", size = 35076681, upload-time = "2026-04-21T08:51:46.845Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/41/64180033d7027afce12dc96d0fe1f504c6fa112190582b458acea2399530/pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", size = 36684260, upload-time = "2026-04-21T08:51:53.642Z" },
+ { url = "https://files.pythonhosted.org/packages/57/02/9b9320e673dd8a99411fac78690f3df92f6dd6f59754c750110bca66d64e/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", size = 45698566, upload-time = "2026-04-21T10:46:02.133Z" },
+ { url = "https://files.pythonhosted.org/packages/67/33/f75e91b9a64c3f33c787e263c93b871ad91b8a4a68c1d5cebddd9840e835/pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", size = 48835562, upload-time = "2026-04-21T10:46:10.278Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/63/097510448e47e4091faa41c43ba92f97cecaab8f4535b56a3d149578f634/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", size = 49394997, upload-time = "2026-04-21T10:46:18.08Z" },
+ { url = "https://files.pythonhosted.org/packages/60/6b/c047d6222ab279024a062742d1807e2fbaf27bba88a98637299ff47b9236/pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", size = 51911424, upload-time = "2026-04-21T10:46:25.347Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ba/464cc70761c2a525d97ebd84e21c31ebd47f3ef4bdcee117009f51c46f24/pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", size = 27251730, upload-time = "2026-04-21T10:46:30.913Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c9/a47ab7ece0d86cbe6678418a0fbd1ac4bb493b9184a3891dfa0e7f287ae0/pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", size = 35068898, upload-time = "2026-04-21T10:46:36.599Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/bc/8db86617a9a58008acf8913d6fed68ea2a46acb6de928db28d724c891a68/pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", size = 36679915, upload-time = "2026-04-21T10:46:42.602Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/8e/fb178720400ef69db251eb4a9c3ccf4af269bc1feb5055529b8fc87170d1/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", size = 45697931, upload-time = "2026-04-21T10:46:48.403Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/27/99c42abe8e21b44f4917f62631f3aa31404882a2c41d8a4cd5c110e13d52/pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", size = 48837449, upload-time = "2026-04-21T10:46:55.329Z" },
+ { url = "https://files.pythonhosted.org/packages/36/b6/333749e2666e9032891125bf9c691146e92901bece62030ac1430e2e7c88/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", size = 49395949, upload-time = "2026-04-21T10:47:01.869Z" },
+ { url = "https://files.pythonhosted.org/packages/17/25/c5201706a2dd374e8ba6ee3fd7a8c89fb7ffc16eed5217a91fd2bd7f7626/pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", size = 51912986, upload-time = "2026-04-21T10:47:09.872Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d2/4d1bbba65320b21a49678d6fbdc6ff7c649251359fdcfc03568c4136231d/pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", size = 27255371, upload-time = "2026-04-21T10:47:15.943Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a9/9686d9f07837f91f775e8932659192e02c74f9d8920524b480b85212cc68/pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", size = 34981559, upload-time = "2026-04-21T10:47:22.17Z" },
+ { url = "https://files.pythonhosted.org/packages/80/b6/0ddf0e9b6ead3474ab087ae598c76b031fc45532bf6a63f3a553440fb258/pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", size = 36663654, upload-time = "2026-04-21T10:47:28.315Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3b/926382efe8ce27ba729071d3566ade6dfb86bdf112f366000196b2f5780a/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", size = 45679394, upload-time = "2026-04-21T10:47:34.821Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/7a/829f7d9dfd37c207206081d6dad474d81dde29952401f07f2ba507814818/pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", size = 48863122, upload-time = "2026-04-21T10:47:42.056Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e8/f88ce625fe8babaae64e8db2d417c7653adb3019b08aae85c5ed787dc816/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", size = 49376032, upload-time = "2026-04-21T10:47:48.967Z" },
+ { url = "https://files.pythonhosted.org/packages/36/7a/82c363caa145fff88fb475da50d3bf52bb024f61917be5424c3392eaf878/pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", size = 51929490, upload-time = "2026-04-21T10:47:55.981Z" },
+ { url = "https://files.pythonhosted.org/packages/66/1c/e3e72c8014ad2743ca64a701652c733cc5cbcee15c0463a32a8c55518d9e/pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", size = 27355660, upload-time = "2026-04-21T10:48:01.718Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d3/a1abf004482026ddc17f4503db227787fa3cfe41ec5091ff20e4fea55e57/pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", size = 34976759, upload-time = "2026-04-21T10:48:07.258Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/4a/34f0a36d28a2dd32225301b79daad44e243dc1a2bb77d43b60749be255c4/pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", size = 36658471, upload-time = "2026-04-21T10:48:13.347Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/78/543b94712ae8bb1a6023bcc1acf1a740fbff8286747c289cd9468fced2a5/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", size = 45675981, upload-time = "2026-04-21T10:48:20.201Z" },
+ { url = "https://files.pythonhosted.org/packages/84/9f/8fb7c222b100d314137fa40ec050de56cd8c6d957d1cfff685ce72f15b17/pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", size = 48859172, upload-time = "2026-04-21T10:48:27.541Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/d3/1ea72538e6c8b3b475ed78d1049a2c518e655761ea50fe1171fc855fcab7/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", size = 49385733, upload-time = "2026-04-21T10:48:34.7Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/be/c3d8b06a1ba35f2260f8e1f771abbee7d5e345c0937aab90675706b1690a/pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", size = 51934335, upload-time = "2026-04-21T10:48:42.099Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/62/89e07a1e7329d2cde3e3c6994ba0839a24977a2beda8be6005ea3d860b99/pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", size = 27271748, upload-time = "2026-04-21T10:49:42.532Z" },
+ { url = "https://files.pythonhosted.org/packages/17/1a/cff3a59f80b5b1658549d46611b67163f65e0664431c076ad728bf9d5af4/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", size = 35238554, upload-time = "2026-04-21T10:48:48.526Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/99/cce0f42a327bfef2c420fb6078a3eb834826e5d6697bf3009fe11d2ad051/pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", size = 36782301, upload-time = "2026-04-21T10:48:55.181Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/66/8e560d5ff6793ca29aca213c53eec0dd482dd46cb93b2819e5aab52e4252/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", size = 45721929, upload-time = "2026-04-21T10:49:03.676Z" },
+ { url = "https://files.pythonhosted.org/packages/27/0c/a26e25505d030716e078d9f16eb74973cbf0b33b672884e9f9da1c83b871/pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", size = 48825365, upload-time = "2026-04-21T10:49:11.714Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/771f9ecb0c65e73fe9dccdd1717901b9594f08c4515d000c7c62df573811/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", size = 49451819, upload-time = "2026-04-21T10:49:21.474Z" },
+ { url = "https://files.pythonhosted.org/packages/48/da/61ae89a88732f5a785646f3ec6125dbb640fa98a540eb2b9889caa561403/pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", size = 51909252, upload-time = "2026-04-21T10:49:31.164Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/80/d022a34ff05d2cbedd8ccf841fc1f532ecfa9eb5ed1711b56d0e0ea71fc9/pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", size = 35007997, upload-time = "2026-04-21T10:49:48.796Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/ff/f01485fda6f4e5d441afb8dd5e7681e4db18826c1e271852f5d3957d6a80/pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", size = 36678720, upload-time = "2026-04-21T10:49:55.858Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c2/2d2d5fea814237923f71b36495211f20b43a1576f9a4d6da7e751a64ec6f/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", size = 45741852, upload-time = "2026-04-21T10:50:04.624Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/3a/28ba9c1c1ebdbb5f1b94dfebb46f207e52e6a554b7fe4132540fde29a3a0/pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", size = 48889852, upload-time = "2026-04-21T10:50:12.293Z" },
+ { url = "https://files.pythonhosted.org/packages/df/51/4a389acfd31dca009f8fb82d7f510bb4130f2b3a8e18cf00194d0687d8ac/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", size = 49445207, upload-time = "2026-04-21T10:50:20.677Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4b/0bab2b23d2ae901b1b9a03c0efd4b2d070256f8ce3fc43f6e58c167b2081/pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", size = 51954117, upload-time = "2026-04-21T10:50:29.14Z" },
+ { url = "https://files.pythonhosted.org/packages/29/88/f4e9145da0417b3d2c12035a8492b35ff4a3dbc653e614fcfb51d9dedb38/pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", size = 28001155, upload-time = "2026-04-21T10:51:22.337Z" },
+ { url = "https://files.pythonhosted.org/packages/79/4f/46a49a63f43526da895b1a45bbb51d5baf8e4d77159f8528fc3e5490007f/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", size = 35250387, upload-time = "2026-04-21T10:50:35.552Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/da/d5e0cd5ef00796922404806d5f00325cdadc3441ce2c13fe7115f2df9a64/pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", size = 36797102, upload-time = "2026-04-21T10:50:42.417Z" },
+ { url = "https://files.pythonhosted.org/packages/34/c7/5904145b0a593a05236c882933d439b5720f0a145381179063722fbfc123/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072", size = 45745118, upload-time = "2026-04-21T10:50:49.324Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d3/cca42fe166d1c6e4d5b80e530b7949104d10e17508a90ae202dac205ce2a/pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", size = 48844765, upload-time = "2026-04-21T10:50:55.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/49/942c3b79878ba928324d1e17c274ed84581db8c0a749b24bcf4cbdf15bd3/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", size = 49471890, upload-time = "2026-04-21T10:51:02.439Z" },
+ { url = "https://files.pythonhosted.org/packages/76/97/ff71431000a75d84135a1ace5ca4ba11726a231a8007bbb320a4c54075d5/pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", size = 51932250, upload-time = "2026-04-21T10:51:10.576Z" },
+ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" },
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
+[[package]]
+name = "pybtex"
+version = "0.26.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "latexcodec" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f5/f30da9c93f0fa6d619332b2f69597219b625f35780473a05164a9981fd9a/pybtex-0.26.1.tar.gz", hash = "sha256:2e5543bea424e60e9e42eef70bff597be48649d8f68ba061a7a092b2477d5464", size = 692991, upload-time = "2026-04-03T13:05:39.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/f6/775eb92e865b28cdb4ad1f2bed7a5446197516f76b58a950faa3be3fd08d/pybtex-0.26.1-py3-none-any.whl", hash = "sha256:e26c0412cc54f5f21b2a6d9d175762a2d2af9ccf3a8f651cdb89ec035db77aa1", size = 126134, upload-time = "2026-04-03T13:05:40.623Z" },
+]
+
+[[package]]
+name = "pybtex-docutils"
+version = "1.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "pybtex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7e/84/796ea94d26188a853660f81bded39f8de4cfe595130aef0dea1088705a11/pybtex-docutils-1.0.3.tar.gz", hash = "sha256:3a7ebdf92b593e00e8c1c538aa9a20bca5d92d84231124715acc964d51d93c6b", size = 18348, upload-time = "2023-08-22T18:47:54.833Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/b1/ce1f4596211efb5410e178a803f08e59b20bedb66837dcf41e21c54f9ec1/pybtex_docutils-1.0.3-py3-none-any.whl", hash = "sha256:8fd290d2ae48e32fcb54d86b0efb8d573198653c7e2447d5bec5847095f430b9", size = 6385, upload-time = "2023-08-22T06:43:20.513Z" },
+]
+
+[[package]]
+name = "pycocotools"
+version = "2.0.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/df/32354b5dda963ffdfc8f75c9acf8828ef7890723a4ed57bb3ff2dc1d6f7e/pycocotools-2.0.11.tar.gz", hash = "sha256:34254d76da85576fcaf5c1f3aa9aae16b8cb15418334ba4283b800796bd1993d", size = 25381, upload-time = "2025-12-15T22:31:46.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/4b/0c040fcda2c4fa4827b1a64e3185d99d5f954e45cc9463ba7385a1173a77/pycocotools-2.0.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:484d33515353186aadba9e2a290d81b107275cdb9565084e31a5568a52a0b120", size = 160351, upload-time = "2025-12-15T22:30:53.998Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fe/861db6515824815eaabce27734653a6b100ddb22364b3345dd862b2c5b65/pycocotools-2.0.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca9f120f719ec405ad0c74ccfdb8402b0c37bd5f88ab5b6482a0de2efd5a36f4", size = 463947, upload-time = "2025-12-15T22:30:55.419Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a1/b4b49b85763043372e66baa10dffa42337cf4687d6db22546c27f3a4d732/pycocotools-2.0.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e40a3a898c6e5340b8d70cf7984868b9bff8c3d80187de9a3b661d504d665978", size = 472455, upload-time = "2025-12-15T22:30:56.895Z" },
+ { url = "https://files.pythonhosted.org/packages/48/70/fac670296e6a2b45eb7434d0480b9af6cb85a8de4f4848b49b01154bc859/pycocotools-2.0.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7cd4cdfd2c676f30838aa0b1047441892fb4f97d70bf3df480bcc7a18a64d7d4", size = 457911, upload-time = "2025-12-15T22:30:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/33/f5/6158de63354dfcb677c8da34a4d205cc532e3277338ab7e6dea1310ba8de/pycocotools-2.0.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08c79789fd79e801ae4ecfcfeec32b31e36254e7a2b4019af28c104975d5e730", size = 476472, upload-time = "2025-12-15T22:30:59.736Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/01/46d2a782cda19ba1beb7c431f417e1e478f0bf1273fa5fe5d10de7c18d76/pycocotools-2.0.11-cp310-cp310-win_amd64.whl", hash = "sha256:f78cbb1a32d061fcad4bdba083de70a39a21c1c3d9235a3f77d8f007541ec5ef", size = 80165, upload-time = "2025-12-15T22:31:00.886Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/5c/6bd945781bb04c2148929183d1d67b05ce07996313b0f87bb88c6a805493/pycocotools-2.0.11-cp310-cp310-win_arm64.whl", hash = "sha256:e21311ea71f85591680d8992858e2d44a2a156dc3b2bf1c5c901c4a19348177b", size = 69358, upload-time = "2025-12-15T22:31:01.815Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/3f/41ce3fce61b7721158f21b61727eb054805babc0088cfa48506935b80a36/pycocotools-2.0.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:81bdceebb4c64e9265213e2d733808a12f9c18dfb14457323cc6b9af07fa0e61", size = 158947, upload-time = "2025-12-15T22:31:03.291Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/9b/a739705b246445bd1376394bf9d1ec2dd292b16740e92f203461b2bb12ed/pycocotools-2.0.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c05f91ccc658dfe01325267209c4b435da1722c93eeb5749fabc1d087b6882", size = 485174, upload-time = "2025-12-15T22:31:04.395Z" },
+ { url = "https://files.pythonhosted.org/packages/34/70/7a12752784e57d8034a76c245c618a2f88a9d2463862b990f314aea7e5d6/pycocotools-2.0.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18ba75ff58cedb33a85ce2c18f1452f1fe20c9dd59925eec5300b2bf6205dbe1", size = 493172, upload-time = "2025-12-15T22:31:05.504Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/fc/d703599ac728209dba08aea8d4bee884d5adabfcd9041abed1658d863747/pycocotools-2.0.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:693417797f0377fd094eb815c0a1e7d1c3c0251b71e3b3779fce3b3cf24793c5", size = 480506, upload-time = "2025-12-15T22:31:06.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/d9/e1cfc320bbb2cd58c3b4398c3821cbe75d93c16ed3135ac9e774a18a02d3/pycocotools-2.0.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6a07071c441d0f5e480a8f287106191582e40289d4e242dfe684e0c8a751088", size = 497595, upload-time = "2025-12-15T22:31:08.277Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/23/d17f6111c2a6ae8631d4fa90202bea05844da715d61431fbc34d276462d5/pycocotools-2.0.11-cp311-cp311-win_amd64.whl", hash = "sha256:8e159232adae3aef6b4e2d37b008bff107b26e9ed3b48e70ea6482302834bd34", size = 80519, upload-time = "2025-12-15T22:31:09.613Z" },
+ { url = "https://files.pythonhosted.org/packages/00/4c/76b00b31a724c3f5ccdab0f85e578afb2ca38d33be0a0e98f1770cafd958/pycocotools-2.0.11-cp311-cp311-win_arm64.whl", hash = "sha256:4fc9889e819452b9c142036e1eabac8a13a8bd552d8beba299a57e0da6bfa1ec", size = 69304, upload-time = "2025-12-15T22:31:10.592Z" },
+ { url = "https://files.pythonhosted.org/packages/87/12/2f2292332456e4e4aba1dec0e3de8f1fc40fb2f4fdb0ca1cb17db9861682/pycocotools-2.0.11-cp312-abi3-macosx_10_13_universal2.whl", hash = "sha256:a2e9634bc7cadfb01c88e0b98589aaf0bd12983c7927bde93f19c0103e5441f4", size = 147795, upload-time = "2025-12-15T22:31:11.519Z" },
+ { url = "https://files.pythonhosted.org/packages/63/3c/68d7ea376aada9046e7ea2d7d0dad0d27e1ae8b4b3c26a28346689390ab2/pycocotools-2.0.11-cp312-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fd4121766cc057133534679c0ec3f9023dbd96e9b31cf95c86a069ebdac2b65", size = 398434, upload-time = "2025-12-15T22:31:12.558Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/dc81895beff4e1207a829d40d442ea87cefaac9f6499151965f05c479619/pycocotools-2.0.11-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a82d1c9ed83f75da0b3f244f2a3cf559351a283307bd9b79a4ee2b93ab3231dd", size = 411685, upload-time = "2025-12-15T22:31:13.995Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/0b/5a8a7de300862a2eb5e2ecd3cb015126231379206cd3ebba8f025388d770/pycocotools-2.0.11-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:89e853425018e2c2920ee0f2112cf7c140a1dcf5f4f49abd9c2da112c3e0f4b3", size = 390500, upload-time = "2025-12-15T22:31:15.138Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b5/519bb68647f06feea03d5f355c33c05800aeae4e57b9482b2859eb00752e/pycocotools-2.0.11-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:87af87b8d06d5b852a885a319d9362dca3bed9f8bbcc3feb6513acb1f88ea242", size = 409790, upload-time = "2025-12-15T22:31:16.326Z" },
+ { url = "https://files.pythonhosted.org/packages/83/b4/f6708404ff494706b80e714b919f76dc4ec9845a4007affd6d6b0843f928/pycocotools-2.0.11-cp312-abi3-win_amd64.whl", hash = "sha256:ffe806ce535f5996445188f9a35643791dc54beabc61bd81e2b03367356d604f", size = 77570, upload-time = "2025-12-15T22:31:17.703Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/63/778cd0ddc9d4a78915ac0a72b56d7fb204f7c3fabdad067d67ea0089762e/pycocotools-2.0.11-cp312-abi3-win_arm64.whl", hash = "sha256:c230f5e7b14bd19085217b4f40bba81bf14a182b150b8e9fab1c15d504ade343", size = 64564, upload-time = "2025-12-15T22:31:18.652Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/31c81e99d596a20c137d8a2e7a25f39a88f88fada5e0b253fce7323ecf0d/pycocotools-2.0.11-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd72b9734e6084b217c1fc3945bfd4ec05bdc75a44e4f0c461a91442bb804973", size = 168931, upload-time = "2025-12-15T22:31:19.845Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/63/fdd488e4cd0fdc6f93134f2cd68b1fce441d41566e86236bf6156961ef9b/pycocotools-2.0.11-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7eb43b79448476b094240450420b7425d06e297880144b8ea6f01e9b4340e43", size = 484856, upload-time = "2025-12-15T22:31:21.231Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/fc/c83648a8fb7ea3b8e2ce2e761b469807e6cadb81577bf1af31c4f2ef0d87/pycocotools-2.0.11-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3546b93b39943347c4f5b0694b5824105cbe2174098a416bcad4acd9c21e957", size = 480994, upload-time = "2025-12-15T22:31:22.426Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2d/35e1122c0d007288aa9545be9549cbc7a4987b2c22f21d75045260a8b5b8/pycocotools-2.0.11-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:efd1694b2075f2f10c5828f10f6e6c4e44368841fd07dae385c3aa015c8e25f9", size = 467956, upload-time = "2025-12-15T22:31:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/ff/30cfe8142470da3e45abe43a9842449ca0180d993320559890e2be19e4a5/pycocotools-2.0.11-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:368244f30eb8d6cae7003aa2c0831fbdf0153664a32859ec7fbceea52bfb6878", size = 474658, upload-time = "2025-12-15T22:31:24.883Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/62/254ca92604106c7a5af3258e589e465e681fe0166f9b10f97d8ca70934d6/pycocotools-2.0.11-cp313-cp313t-win_amd64.whl", hash = "sha256:ac8aa17263e6489aa521f9fa91e959dfe0ea3a5519fde2cbf547312cdce7559e", size = 89681, upload-time = "2025-12-15T22:31:26.025Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/f0/c019314dc122ad5e6281de420adc105abe9b59d00008f72ef3ad32b1e328/pycocotools-2.0.11-cp313-cp313t-win_arm64.whl", hash = "sha256:04480330df5013f6edd94891a0ee8294274185f1b5093d1b0f23d51778f0c0e9", size = 70520, upload-time = "2025-12-15T22:31:26.999Z" },
+ { url = "https://files.pythonhosted.org/packages/66/2b/58b35c88f2086c043ff1c87bd8e7bf36f94e84f7b01a5e00b6f5fabb92a7/pycocotools-2.0.11-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a6b13baf6bfcf881b6d6ac6e23c776f87a68304cd86e53d1d6b9afa31e363c4e", size = 169883, upload-time = "2025-12-15T22:31:28.233Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c0/b970eefb78746c8b4f8b3fa1b49d9f3ec4c5429ef3c5d4bbcc55abebe478/pycocotools-2.0.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78bae4a9de9d34c4759754a848dfb3306f9ef1c2fcb12164ffbd3d013d008321", size = 486894, upload-time = "2025-12-15T22:31:29.283Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/f7/db7436820a1948d96fa9764b6026103e808840979be01246049f2c1e7f94/pycocotools-2.0.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d896f4310379849dfcfa7893afb0ff21f4f3cdb04ab3f61b05dd98953dd0ad", size = 483249, upload-time = "2025-12-15T22:31:31.687Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/a6/a14a12c9f50c41998fdc0d31fd3755bcbce124bac9abb1d6b99d1853cafd/pycocotools-2.0.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:eebd723503a2eb2c8b285f56ea3be1d9f3875cd7c40d945358a428db94f14015", size = 469070, upload-time = "2025-12-15T22:31:32.821Z" },
+ { url = "https://files.pythonhosted.org/packages/46/de/aa4f65ece3da8e89310a1be00cad0700170fd13f41a3aaae2712291269d5/pycocotools-2.0.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bd7a1e19ef56a828a94bace673372071d334a9232cd32ae3cd48845a04d45c4f", size = 475589, upload-time = "2025-12-15T22:31:34.188Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6f/04a30df03ae6236b369b361df0c50531d173d03678978806aa2182e02d1e/pycocotools-2.0.11-cp314-cp314t-win_amd64.whl", hash = "sha256:63026e11a56211058d0e84e8263f74cbccd5e786fac18d83fd221ecb9819fcc7", size = 93863, upload-time = "2025-12-15T22:31:35.38Z" },
+ { url = "https://files.pythonhosted.org/packages/da/05/8942b640d6307a21c3ede188e8c56f07bedf246fac0e501437dbda72a350/pycocotools-2.0.11-cp314-cp314t-win_arm64.whl", hash = "sha256:8cedb8ccb97ffe9ed2c8c259234fa69f4f1e8665afe3a02caf93f6ef2952c07f", size = 72038, upload-time = "2025-12-15T22:31:36.768Z" },
+]
+
+[[package]]
+name = "pyconify"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/7f/94d424dc756a6287271cf40cf1b2a44c10e3f137bf3246a2b4a7416ca3d3/pyconify-0.2.1.tar.gz", hash = "sha256:8dd53757d9fbed41711434460932b2b5dbc25da720cd9f9a44af0187b2dfc07d", size = 22478, upload-time = "2025-02-06T13:20:53.592Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/40/50dd2e8bfec81676e4619903bd452c10dc0d8efac1533e79e67cc76759b5/pyconify-0.2.1-py3-none-any.whl", hash = "sha256:d3b53eee1f8a2d60c1d135610f42e789774dbe71c6d8af68af0a21d3b3ec9eb7", size = 19459, upload-time = "2025-02-06T13:20:51.613Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-compat"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9c/7e/43400b6e0800065a982efcfa3e87c8f8d247d60ea75ca1a9d01702e050f8/pydantic_compat-0.1.2.tar.gz", hash = "sha256:c5c5bca39ca2d22cad00c02898e400e1920e5127649a8e860637f15566739373", size = 12838, upload-time = "2023-10-24T23:25:09.544Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/65/2edf586ff7b3dfc520977c6529c9b718c86ef8459ece088f1ef1f74bf1d4/pydantic_compat-0.1.2-py3-none-any.whl", hash = "sha256:37a4df48565a35aedc947f0fde5edbdff270a30836d995923287292bb59d5677", size = 13092, upload-time = "2023-10-24T23:25:08.155Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
+ { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
+ { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
+ { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
+ { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
+ { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+ { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
+ { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
+ { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
+]
+
+[[package]]
+name = "pydantic-extra-types"
+version = "2.11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
+]
+
+[[package]]
+name = "pydata-sphinx-theme"
+version = "0.15.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "accessible-pygments", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "babel", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "beautifulsoup4", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "docutils", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pygments", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/ea/3ab478cccacc2e8ef69892c42c44ae547bae089f356c4b47caf61730958d/pydata_sphinx_theme-0.15.4.tar.gz", hash = "sha256:7762ec0ac59df3acecf49fd2f889e1b4565dbce8b88b2e29ee06fdd90645a06d", size = 2400673, upload-time = "2024-06-25T19:28:45.041Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/d3/c622950d87a2ffd1654208733b5bd1c5645930014abed8f4c0d74863988b/pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6", size = 4640157, upload-time = "2024-06-25T19:28:42.383Z" },
+]
+
+[[package]]
+name = "pydata-sphinx-theme"
+version = "0.16.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "accessible-pygments", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "babel", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "beautifulsoup4", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "docutils", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pygments", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264, upload-time = "2024-12-17T10:53:35.645Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyopengl"
+version = "3.1.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[package]]
+name = "pyproject-hooks"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
+]
+
+[[package]]
+name = "pyside6"
+version = "6.9.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-addons" },
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/15/1fa71e44d3b345b9eca82d38cf7e6d7505168b978fe98b3610c0e25d8c0e/pyside6-6.9.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:6fd9fbbc14e2a5707df611bfacbf3f71283c637b8a29b28708801eeb28bfcb69", size = 554720, upload-time = "2025-09-30T12:02:07.235Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0e/1e9841cc46196c55ac3eac0b8e08044a88cc70c8cc29e9dc1e33b2ced2b7/pyside6-6.9.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6485aebec8eba4e55d1ec1cebe68ca1413589880cc8ccd8a49acae852ec6cfb3", size = 554849, upload-time = "2025-09-30T12:02:08.909Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/6b/6aeb7124a5c08ac537776d7d0be1011cf22ccfc6f95cf901fe1eb1a16e91/pyside6-6.9.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8f4ff61b24a64153373b68a96339bd765fc010d02c4d98d0f6dba2a6c9686e11", size = 554846, upload-time = "2025-09-30T12:02:10.762Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/29/4eb0ab29ee7d60da4c5fefdb514c22ae3c91c3f855f46608cfbe23816518/pyside6-6.9.3-cp39-abi3-win_amd64.whl", hash = "sha256:71b41b9ebd1c044c3777f2b32278d3919f07bda4f15c504bc165b643bc3cec01", size = 561082, upload-time = "2025-09-30T12:02:12.411Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/c2/b936d50974a14846ccb883cbce0034ec431e1013af7f2aa29d3746a1565f/pyside6-6.9.3-cp39-abi3-win_arm64.whl", hash = "sha256:33fb70addd02e1adaa45573485c431bca43cf12bb7b2596535b824ff169138ce", size = 545910, upload-time = "2025-09-30T12:02:13.746Z" },
+]
+
+[[package]]
+name = "pyside6-addons"
+version = "6.9.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/56/714c55e4514ec6603be8126355baf416e507557a73e7fc76870e6f5c20b9/pyside6_addons-6.9.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:189c9a9a2fdaffa95e91731f5c0afdc47ba231f5f683d3f8977b22c233749ba4", size = 316906068, upload-time = "2025-09-30T12:03:28.268Z" },
+ { url = "https://files.pythonhosted.org/packages/17/fe/d5c67665f866b8859d02aa1a859f101a1b2fd348cb61746a3e16fd98fb20/pyside6_addons-6.9.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:68932327e1c33d729d79b2b94242f97b77601efe0427e757cd3fd588939ea479", size = 167175405, upload-time = "2025-09-30T12:04:11.793Z" },
+ { url = "https://files.pythonhosted.org/packages/66/f2/66da0d8ba8e4eb934d2bb042f2199664d2366c121844e36376f724b53fd9/pyside6_addons-6.9.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:61f4f4859bd1711eea2202fe364a701597140ed4f3900ddf1f90d91ae631fdf9", size = 163102125, upload-time = "2025-09-30T12:04:55.193Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/fb/849599666942ce35e9f250bce1abc7b1248eccdcbe8b3fe697a0f98a1326/pyside6_addons-6.9.3-cp39-abi3-win_amd64.whl", hash = "sha256:0aece2a81ccf16ef9b750b09601b6876aa116bbc700e848ce82df42906f04c5c", size = 160681555, upload-time = "2025-09-30T12:05:36.44Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/2d/e71face0149519d6ded4e77b5c5e266c094ce5ef8c5c96c2af2f0afadf3c/pyside6_addons-6.9.3-cp39-abi3-win_arm64.whl", hash = "sha256:3b8749c9c2b297f53c980192e89ce38ac59019f290a2669fe1abb3128f9a09d9", size = 33759045, upload-time = "2025-09-30T12:05:48.373Z" },
+]
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.9.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/a9/51549844900837c70e5c89cbd840956c2533732ef4f05c2b656e35ca8182/pyside6_essentials-6.9.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ad3664ff0ced9f92ed7872e512c86328894d29f262e6c3400400232a36dda357", size = 134586559, upload-time = "2025-09-30T12:06:22.681Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e8/9396cf11a60f80175bb3c5c1d498d84e87b7af653ab4ea001acf821a3981/pyside6_essentials-6.9.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c70d5544e892b201a677b615156fab6a0fef865e7fc287f55a0eae00a682e83f", size = 97495307, upload-time = "2025-09-30T12:06:49.213Z" },
+ { url = "https://files.pythonhosted.org/packages/57/a5/0187b2845a6fa534217988f9351c0b85ba1c04e8fd31f1e3c13ba1c4386e/pyside6_essentials-6.9.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:e22e517c592657e291c0cbe7d04078ab415cb225188d7e895f8f7adcdba755d2", size = 95199568, upload-time = "2025-09-30T12:07:13.726Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/52/b558c79bc32310361a37d1e894c2251a3557daec380be5f6d2a9223e8ef3/pyside6_essentials-6.9.3-cp39-abi3-win_amd64.whl", hash = "sha256:21f98077c135864473089e59a6a0bd828e64c6644b3dd7267b102da4a2ee8f21", size = 73593457, upload-time = "2025-09-30T12:07:33.98Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/22/4ec828f6360e6e9bcd6fde2dec20fa4865fe1a77cb6bac6812f7d0aa55b3/pyside6_essentials-6.9.3-cp39-abi3-win_arm64.whl", hash = "sha256:2e34081933e005686d79265cc04370a28fea3844ab63d432e493adcd4465070c", size = 54301788, upload-time = "2025-09-30T12:07:49.504Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" },
+ { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
+ { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "pyzmq"
+version = "27.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "implementation_name == 'pypy' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" },
+ { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" },
+ { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" },
+ { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" },
+ { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" },
+ { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" },
+ { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" },
+ { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" },
+ { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
+ { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
+ { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
+ { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
+ { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
+ { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
+ { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
+ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" },
+ { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" },
+ { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" },
+ { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" },
+ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" },
+]
+
+[[package]]
+name = "qdarkstyle"
+version = "3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "qtpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/1c/00ca31b13727ade22d1b42b61dc86056493a72f01912082a61cb34e5abf6/QDarkStyle-3.1.tar.gz", hash = "sha256:600584d625343e0ddd128de08393d3c35637786a49827f174d29aa7caa8279c1", size = 698602, upload-time = "2022-05-30T15:56:52.06Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/59/01f454d0eacb6670c77add68611b7a572455ae69ba902d270ed761869f87/QDarkStyle-3.1-py2.py3-none-any.whl", hash = "sha256:679a38fcd040de9fac8b8cae483310302fdb12c8d912845249c41dc54974a9b2", size = 870167, upload-time = "2022-05-30T15:56:49.502Z" },
+]
+
+[[package]]
+name = "qtconsole"
+version = "5.7.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ipykernel" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "qtpy" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/28/4070eb0bacb99bc00bf60173fda25fb6a559d6600040035ba96c196d3647/qtconsole-5.7.2.tar.gz", hash = "sha256:27b485b9161925924c1d8e78e66bb342e6e3bc49bf675d0a67b49bad9c291521", size = 436661, upload-time = "2026-03-25T02:24:38.895Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/60/aba9f3c3f1f48c7fbdf03bed45b576a916cd09c08242939b5294b85e37b4/qtconsole-5.7.2-py3-none-any.whl", hash = "sha256:e1d1f6a792123363626e643a7a4ee561217773571043992693fba7eccfa89f95", size = 125883, upload-time = "2026-03-25T02:24:36.95Z" },
+]
+
+[[package]]
+name = "qtpy"
+version = "2.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "requests-oauthlib"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "oauthlib", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" },
+ { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" },
+ { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
+ { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
+ { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
+ { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
+ { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
+ { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
+ { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
+ { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
+ { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
+ { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
+ { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
+ { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
+ { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
+ { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
+ { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
+ { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
+ { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
+ { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
+ { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
+ { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
+ { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
+ { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
+ { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
+ { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
+ { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
+ { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
+ { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
+ { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
+ { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
+ { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
+ { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
+ { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
+]
+
+[[package]]
+name = "ruamel-yaml"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
+ { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
+ { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
+]
+
+[[package]]
+name = "safetensors"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6a/4d08d89a6fcbe905c5ae68b8b34f0791850882fc19782d0d02c65abbdf3b/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737", size = 492430, upload-time = "2025-11-19T15:18:11.884Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/29/59ed8152b30f72c42d00d241e58eaca558ae9dbfa5695206e2e0f54c7063/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd", size = 503977, upload-time = "2025-11-19T15:18:17.523Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/0b/4811bfec67fa260e791369b16dab105e4bae82686120554cc484064e22b4/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2", size = 623890, upload-time = "2025-11-19T15:18:22.666Z" },
+ { url = "https://files.pythonhosted.org/packages/58/5b/632a58724221ef03d78ab65062e82a1010e1bef8e8e0b9d7c6d7b8044841/safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3", size = 531885, upload-time = "2025-11-19T15:18:27.146Z" },
+]
+
+[[package]]
+name = "scikit-image"
+version = "0.25.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "imageio", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "lazy-loader", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pillow", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2025.5.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/cb/016c63f16065c2d333c8ed0337e18a5cdf9bc32d402e4f26b0db362eb0e2/scikit_image-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d3278f586793176599df6a4cf48cb6beadae35c31e58dc01a98023af3dc31c78", size = 13988922, upload-time = "2025-02-18T18:04:11.069Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ca/ff4731289cbed63c94a0c9a5b672976603118de78ed21910d9060c82e859/scikit_image-0.25.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5c311069899ce757d7dbf1d03e32acb38bb06153236ae77fcd820fd62044c063", size = 13192698, upload-time = "2025-02-18T18:04:15.362Z" },
+ { url = "https://files.pythonhosted.org/packages/39/6d/a2aadb1be6d8e149199bb9b540ccde9e9622826e1ab42fe01de4c35ab918/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be455aa7039a6afa54e84f9e38293733a2622b8c2fb3362b822d459cc5605e99", size = 14153634, upload-time = "2025-02-18T18:04:18.496Z" },
+ { url = "https://files.pythonhosted.org/packages/96/08/916e7d9ee4721031b2f625db54b11d8379bd51707afaa3e5a29aecf10bc4/scikit_image-0.25.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c464b90e978d137330be433df4e76d92ad3c5f46a22f159520ce0fdbea8a09", size = 14767545, upload-time = "2025-02-18T18:04:22.556Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ee/c53a009e3997dda9d285402f19226fbd17b5b3cb215da391c4ed084a1424/scikit_image-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:60516257c5a2d2f74387c502aa2f15a0ef3498fbeaa749f730ab18f0a40fd054", size = 12812908, upload-time = "2025-02-18T18:04:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" },
+ { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" },
+ { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" },
+ { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" },
+]
+
+[package.optional-dependencies]
+data = [
+ { name = "pooch", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+
+[[package]]
+name = "scikit-image"
+version = "0.26.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "imageio", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "lazy-loader", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pillow", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tifffile", version = "2026.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/16/8a407688b607f86f81f8c649bf0d68a2a6d67375f18c2d660aba20f5b648/scikit_image-0.26.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1ede33a0fb3731457eaf53af6361e73dd510f449dac437ab54573b26788baf0", size = 12355510, upload-time = "2025-12-20T17:10:31.628Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f9/7efc088ececb6f6868fd4475e16cfafc11f242ce9ab5fc3557d78b5da0d4/scikit_image-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7af7aa331c6846bd03fa28b164c18d0c3fd419dbb888fb05e958ac4257a78fdd", size = 12056334, upload-time = "2025-12-20T17:10:34.559Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/1e/bc7fb91fb5ff65ef42346c8b7ee8b09b04eabf89235ab7dbfdfd96cbd1ea/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ea6207d9e9d21c3f464efe733121c0504e494dbdc7728649ff3e23c3c5a4953", size = 13297768, upload-time = "2025-12-20T17:10:37.733Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/2a/e71c1a7d90e70da67b88ccc609bd6ae54798d5847369b15d3a8052232f9d/scikit_image-0.26.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74aa5518ccea28121f57a95374581d3b979839adc25bb03f289b1bc9b99c58af", size = 13711217, upload-time = "2025-12-20T17:10:40.935Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/59/9637ee12c23726266b91296791465218973ce1ad3e4c56fc81e4d8e7d6e1/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d5c244656de905e195a904e36dbc18585e06ecf67d90f0482cbde63d7f9ad59d", size = 14337782, upload-time = "2025-12-20T17:10:43.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/5c/a3e1e0860f9294663f540c117e4bf83d55e5b47c281d475cc06227e88411/scikit_image-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21a818ee6ca2f2131b9e04d8eb7637b5c18773ebe7b399ad23dcc5afaa226d2d", size = 14805997, upload-time = "2025-12-20T17:10:45.93Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/c6/2eeacf173da041a9e388975f54e5c49df750757fcfc3ee293cdbbae1ea0a/scikit_image-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:9490360c8d3f9a7e85c8de87daf7c0c66507960cf4947bb9610d1751928721c7", size = 11878486, upload-time = "2025-12-20T17:10:48.246Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/a4/a852c4949b9058d585e762a66bf7e9a2cd3be4795cd940413dfbfbb0ce79/scikit_image-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:0baa0108d2d027f34d748e84e592b78acc23e965a5de0e4bb03cf371de5c0581", size = 11346518, upload-time = "2025-12-20T17:10:50.575Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e8/e13757982264b33a1621628f86b587e9a73a13f5256dad49b19ba7dc9083/scikit_image-0.26.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d454b93a6fa770ac5ae2d33570f8e7a321bb80d29511ce4b6b78058ebe176e8c", size = 12376452, upload-time = "2025-12-20T17:10:52.796Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/be/f8dd17d0510f9911f9f17ba301f7455328bf13dae416560126d428de9568/scikit_image-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3409e89d66eff5734cd2b672d1c48d2759360057e714e1d92a11df82c87cba37", size = 12061567, upload-time = "2025-12-20T17:10:55.207Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/2b/c70120a6880579fb42b91567ad79feb4772f7be72e8d52fec403a3dde0c6/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c717490cec9e276afb0438dd165b7c3072d6c416709cc0f9f5a4c1070d23a44", size = 13084214, upload-time = "2025-12-20T17:10:57.468Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/a2/70401a107d6d7466d64b466927e6b96fcefa99d57494b972608e2f8be50f/scikit_image-0.26.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df650e79031634ac90b11e64a9eedaf5a5e06fcd09bcd03a34be01745744466", size = 13561683, upload-time = "2025-12-20T17:10:59.49Z" },
+ { url = "https://files.pythonhosted.org/packages/13/a5/48bdfd92794c5002d664e0910a349d0a1504671ef5ad358150f21643c79a/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cefd85033e66d4ea35b525bb0937d7f42d4cdcfed2d1888e1570d5ce450d3932", size = 14112147, upload-time = "2025-12-20T17:11:02.083Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b5/ac71694da92f5def5953ca99f18a10fe98eac2dd0a34079389b70b4d0394/scikit_image-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f5bf622d7c0435884e1e141ebbe4b2804e16b2dd23ae4c6183e2ea99233be70", size = 14661625, upload-time = "2025-12-20T17:11:04.528Z" },
+ { url = "https://files.pythonhosted.org/packages/23/4d/a3cc1e96f080e253dad2251bfae7587cf2b7912bcd76fd43fd366ff35a87/scikit_image-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:abed017474593cd3056ae0fe948d07d0747b27a085e92df5474f4955dd65aec0", size = 11911059, upload-time = "2025-12-20T17:11:06.61Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8a/d1b8055f584acc937478abf4550d122936f420352422a1a625eef2c605d8/scikit_image-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d57e39ef67a95d26860c8caf9b14b8fb130f83b34c6656a77f191fa6d1d04d8", size = 11348740, upload-time = "2025-12-20T17:11:09.118Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" },
+ { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" },
+ { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" },
+ { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" },
+ { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" },
+ { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" },
+ { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" },
+ { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" },
+ { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" },
+ { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" },
+ { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" },
+]
+
+[package.optional-dependencies]
+data = [
+ { name = "pooch", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "joblib", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "threadpoolctl", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" },
+ { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" },
+ { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" },
+ { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" },
+ { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" },
+ { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" },
+ { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" },
+ { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" },
+ { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" },
+ { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "joblib", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "threadpoolctl", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
+ { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
+ { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
+ { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
+ { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
+ { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
+ { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
+ { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
+ { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
+ { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
+ { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
+ { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
+ { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
+ { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
+ { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.15.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" },
+ { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" },
+ { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" },
+ { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" },
+ { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" },
+ { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" },
+ { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" },
+ { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" },
+ { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" },
+ { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" },
+ { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" },
+ { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" },
+ { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
+ { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
+ { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
+ { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
+ { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
+ { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
+ { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
+ { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
+ { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
+ { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
+ { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
+ { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
+ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.60.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "81.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
+]
+
+[[package]]
+name = "shapely"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/89/c3548aa9b9812a5d143986764dededfa48d817714e947398bdda87c77a72/shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f", size = 1825959, upload-time = "2025-09-24T13:50:00.682Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8a/7ebc947080442edd614ceebe0ce2cdbd00c25e832c240e1d1de61d0e6b38/shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea", size = 1629196, upload-time = "2025-09-24T13:50:03.447Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/86/c9c27881c20d00fc409e7e059de569d5ed0abfcec9c49548b124ebddea51/shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f", size = 2951065, upload-time = "2025-09-24T13:50:05.266Z" },
+ { url = "https://files.pythonhosted.org/packages/50/8a/0ab1f7433a2a85d9e9aea5b1fbb333f3b09b309e7817309250b4b7b2cc7a/shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142", size = 3058666, upload-time = "2025-09-24T13:50:06.872Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/c6/5a30ffac9c4f3ffd5b7113a7f5299ccec4713acd5ee44039778a7698224e/shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4", size = 3966905, upload-time = "2025-09-24T13:50:09.417Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/72/e92f3035ba43e53959007f928315a68fbcf2eeb4e5ededb6f0dc7ff1ecc3/shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0", size = 4129260, upload-time = "2025-09-24T13:50:11.183Z" },
+ { url = "https://files.pythonhosted.org/packages/42/24/605901b73a3d9f65fa958e63c9211f4be23d584da8a1a7487382fac7fdc5/shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e", size = 1544301, upload-time = "2025-09-24T13:50:12.521Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/89/6db795b8dd3919851856bd2ddd13ce434a748072f6fdee42ff30cbd3afa3/shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f", size = 1722074, upload-time = "2025-09-24T13:50:13.909Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" },
+ { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" },
+ { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" },
+ { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" },
+ { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" },
+ { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" },
+ { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" },
+ { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" },
+ { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" },
+ { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" },
+ { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" },
+ { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" },
+ { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "shiboken6"
+version = "6.9.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/ed/eac552326349629f8c3a89b57a754a860d9665aea78c172777395905d36b/shiboken6-6.9.3-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:e9b240828790b8e21a50e66449a5aa8b99f9b8a538c80c1a325fa04f8364985e", size = 400999, upload-time = "2025-09-30T12:00:08.123Z" },
+ { url = "https://files.pythonhosted.org/packages/be/82/c1c6932f9849bc5e75c93c38a29419505a6e3e0037261e28f3e7ecbf2751/shiboken6-6.9.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f3f5337a3a8fc660ba1462265bd9a2bdda9588f8d90fbc3d5ac4ce3134c11e59", size = 204927, upload-time = "2025-09-30T12:00:10.334Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/fc/026f4c8660494e513fd8c6d95d4d694d490795c6880f1fd23ec996a83e13/shiboken6-6.9.3-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:f66e82510e3a3170a42e3ef865329559b69811ce83102ad1ee57920319427c10", size = 200684, upload-time = "2025-09-30T12:00:11.967Z" },
+ { url = "https://files.pythonhosted.org/packages/50/be/f6bb3fc89623dd01ac67cd6be357e585ea5081536b275008b6c8d6fa7c28/shiboken6-6.9.3-cp39-abi3-win_amd64.whl", hash = "sha256:f1498176d2d5bcade7d662e1fc5143980fb008b6c78b966289d29a4ae86cd0c6", size = 1166242, upload-time = "2025-09-30T12:00:13.447Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/66/097a5f80ed1058071466c8c4fd72f94886851f6e03028f980ef0c42a7b54/shiboken6-6.9.3-cp39-abi3-win_arm64.whl", hash = "sha256:0c87bcfd483a1980794e03cf5ec6b2061691a1b075f92b78edb0d13847139ecf", size = 1727949, upload-time = "2025-09-30T12:00:15.23Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "slicerator"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/52/f38586b82b2935f8b59a09b0a79c545a22ed062e728c9418bafeb51f61e0/slicerator-1.1.0.tar.gz", hash = "sha256:44010a7f5cd87680c07213b5cabe81d1fb71252962943e5373ee7d14605d6046", size = 38283, upload-time = "2022-04-07T18:54:08.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/ae/fa6cd331b364ad2bbc31652d025f5747d89cbb75576733dfdf8efe3e4d62/slicerator-1.1.0-py3-none-any.whl", hash = "sha256:167668d48c6d3a5ba0bd3d54b2688e81ee267dc20aef299e547d711e6f3c441a", size = 10274, upload-time = "2022-04-07T18:54:07.029Z" },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
+]
+
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
+]
+
+[[package]]
+name = "sphinx"
+version = "7.4.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+ { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" },
+]
+
+[[package]]
+name = "sphinx-book-theme"
+version = "1.1.4"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "pydata-sphinx-theme", version = "0.15.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/19/d002ed96bdc7738c15847c730e1e88282d738263deac705d5713b4d8fa94/sphinx_book_theme-1.1.4.tar.gz", hash = "sha256:73efe28af871d0a89bd05856d300e61edce0d5b2fbb7984e84454be0fedfe9ed", size = 439188, upload-time = "2025-02-20T16:32:32.581Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/9e/c41d68be04eef5b6202b468e0f90faf0c469f3a03353f2a218fd78279710/sphinx_book_theme-1.1.4-py3-none-any.whl", hash = "sha256:843b3f5c8684640f4a2d01abd298beb66452d1b2394cd9ef5be5ebd5640ea0e1", size = 433952, upload-time = "2025-02-20T16:32:31.009Z" },
+]
+
+[[package]]
+name = "sphinx-book-theme"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "pydata-sphinx-theme", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sphinx", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/f7/154786f3cfb7692cd7acc24b6dfe4dcd1146b66f376b17df9e47125555e9/sphinx_book_theme-1.2.0.tar.gz", hash = "sha256:4a7ebfc7da4395309ac942ddfc38fbec5c5254c3be22195e99ad12586fbda9e3", size = 443962, upload-time = "2026-03-09T23:20:30.442Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/bf/6f506a37c7f8ecc4576caf9486e303c7af249f6d70447bb51dde9d78cb99/sphinx_book_theme-1.2.0-py3-none-any.whl", hash = "sha256:709605d308e1991c5ef0cf19c481dbe9084b62852e317fafab74382a0ee7ccfa", size = 455936, upload-time = "2026-03-09T23:20:28.788Z" },
+]
+
+[[package]]
+name = "sphinx-comments"
+version = "0.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/75/5bbf29e83eaf79843180cf424d0d550bda14a1792ca51dcf79daa065ba93/sphinx-comments-0.0.3.tar.gz", hash = "sha256:00170afff27019fad08e421da1ae49c681831fb2759786f07c826e89ac94cf21", size = 7960, upload-time = "2020-08-12T00:07:31.183Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/97/a5c39f619375d4f81d5422377fb027075898efa6b6202c1ccf1e5bb38a32/sphinx_comments-0.0.3-py3-none-any.whl", hash = "sha256:1e879b4e9bfa641467f83e3441ac4629225fc57c29995177d043252530c21d00", size = 4591, upload-time = "2020-08-12T00:07:30.297Z" },
+]
+
+[[package]]
+name = "sphinx-copybutton"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" },
+]
+
+[[package]]
+name = "sphinx-design"
+version = "0.6.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "sphinx", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" },
+]
+
+[[package]]
+name = "sphinx-design"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "sphinx", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" },
+]
+
+[[package]]
+name = "sphinx-external-toc"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+ { name = "sphinx-multitoc-numbering" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c5/f8/85bcd2f1c142e580a1394c18920506d9399b8e8e97e4899bbee9c74a896e/sphinx_external_toc-1.1.0.tar.gz", hash = "sha256:f81833865006f6b4a9b2550a2474a6e3d7e7f2cb23ba23309260577ea65552f6", size = 37194, upload-time = "2026-01-16T13:15:59.03Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/80/1704c9179012e289dee2178354e385277ea51f4fa827c4bf7e36c77b0f4b/sphinx_external_toc-1.1.0-py3-none-any.whl", hash = "sha256:26c390b8d85aa641366fed2d3674910ec6820f48b91027affef485a2655ad7d0", size = 30609, upload-time = "2026-01-16T13:15:57.926Z" },
+]
+
+[[package]]
+name = "sphinx-jupyterbook-latex"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/80/29/18a1fc30e9315e72f068637079169525069a7c0b2fbe51cf689af0576214/sphinx_jupyterbook_latex-1.0.0.tar.gz", hash = "sha256:f54c6674c13f1616f9a93443e98b9b5353f9fdda8e39b6ec552ccf0b3e5ffb62", size = 11945, upload-time = "2023-12-11T15:37:25.034Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/1f/1d4ecaf58b17fe61497644655f40b04d84a88348e41a6f0c6392394d95e4/sphinx_jupyterbook_latex-1.0.0-py3-none-any.whl", hash = "sha256:e0cd3e9e1c5af69136434e21a533343fdf013475c410a414d5b7b4922b4f3891", size = 13319, upload-time = "2023-12-11T15:37:23.25Z" },
+]
+
+[[package]]
+name = "sphinx-multitoc-numbering"
+version = "0.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/37/1e/577bae038372885ebc34bd8c0f290295785a0250cac6528eb6d50e4b92d5/sphinx-multitoc-numbering-0.1.3.tar.gz", hash = "sha256:c9607671ac511236fa5d61a7491c1031e700e8d498c9d2418e6c61d1251209ae", size = 4542, upload-time = "2021-03-15T12:01:43.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/9f/902f2030674cd9473fdbe5a2c2dec2618c27ec853484c35f82cf8df40ece/sphinx_multitoc_numbering-0.1.3-py3-none-any.whl", hash = "sha256:33d2e707a9b2b8ad636b3d4302e658a008025106fe0474046c651144c26d8514", size = 4616, upload-time = "2021-03-15T12:01:42.419Z" },
+]
+
+[[package]]
+name = "sphinx-thebe"
+version = "0.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/fd/926ba4af1eb2708b1ac0fa4376e4bfb11d9a32b2a00e3614137a569c1ddf/sphinx_thebe-0.3.1.tar.gz", hash = "sha256:576047f45560e82f64aa5f15200b1eb094dcfe1c5b8f531a8a65bd208e25a493", size = 20789, upload-time = "2024-02-07T13:31:57.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/7c/a53bdb465fd364bc3d255d96d5d70e6ba5183cfb4e45b8aa91c59b099124/sphinx_thebe-0.3.1-py3-none-any.whl", hash = "sha256:e7e7edee9f0d601c76bc70156c471e114939484b111dd8e74fe47ac88baffc52", size = 9030, upload-time = "2024-02-07T13:31:55.286Z" },
+]
+
+[[package]]
+name = "sphinx-togglebutton"
+version = "0.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "setuptools" },
+ { name = "sphinx" },
+ { name = "wheel" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/2e/3dd55564928c5d61f92827d4b91307dde7911a40fbe0000645d73202eea9/sphinx_togglebutton-0.4.5-py3-none-any.whl", hash = "sha256:74eac6d2426110c3e1e6f989a98e07d7823141a335df1ad8a9d637bdf6a7af62", size = 44907, upload-time = "2026-03-27T13:50:40.94Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-bibtex"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "pybtex" },
+ { name = "pybtex-docutils" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/15/6a/8e0b2c2420286389e7fed78ff361ec30e2f1d58c8560af8d64df5e7b61e0/sphinxcontrib_bibtex-2.7.0.tar.gz", hash = "sha256:fee700f7aae29bb8f654c62913f00d34ac44fc0b8ca0fa67ac922ff4453addee", size = 120669, upload-time = "2026-05-06T09:29:24.935Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/c0/d28e62407f4733bbe0169287bc012f0ac3b4a2021066b285570654119c8b/sphinxcontrib_bibtex-2.7.0-py3-none-any.whl", hash = "sha256:28cf0ec7a957d1c7548d5749317ed472ce877e1b629f430f88e3789aa51f87b1", size = 40287, upload-time = "2026-05-06T09:29:23.253Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-mermaid"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "pyyaml" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/3a1cc926da8c563c58ddc124a7b3fe5ccadcae96c96e3a6f8ac3653a210a/sphinxcontrib_mermaid-2.0.2.tar.gz", hash = "sha256:f09576c78ca93fa0e3034fd9c45aaffa7c44ab449de9c43b8b8d262afe52bc66", size = 19265, upload-time = "2026-05-05T13:59:02.959Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/8d/93be7e0f7fa915a576859b3bfac7a7baa3303181c44d7db7eefbd3e8a69f/sphinxcontrib_mermaid-2.0.2-py3-none-any.whl", hash = "sha256:d862e514991279fb4816302c5cfe167d2557bf3ce7125ae0cb47dac80a0f46ce", size = 14094, upload-time = "2026-05-05T13:59:01.585Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.49"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/76/f908955139842c362aa877848f42f9249642d5b69e06cee9eae5111da1bd/sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f", size = 2159321, upload-time = "2026-04-03T16:50:11.8Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e2/17ba0b7bfbd8de67196889b6d951de269e8a46057d92baca162889beb16d/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b", size = 3238937, upload-time = "2026-04-03T16:54:45.731Z" },
+ { url = "https://files.pythonhosted.org/packages/90/1e/410dd499c039deacff395eec01a9da057125fcd0c97e3badc252c6a2d6a7/sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1", size = 3237188, upload-time = "2026-04-03T16:56:53.217Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/06/e797a8b98a3993ac4bc785309b9b6d005457fc70238ee6cefa7c8867a92e/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339", size = 3190061, upload-time = "2026-04-03T16:54:47.489Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d3/5a9f7ef580af1031184b38235da6ac58c3b571df01c9ec061c44b2b0c5a6/sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d", size = 3211477, upload-time = "2026-04-03T16:56:55.056Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ec/7be8c8cb35f038e963a203e4fe5a028989167cc7299927b7cf297c271e37/sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3", size = 2119965, upload-time = "2026-04-03T17:00:50.009Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/31/0defb93e3a10b0cf7d1271aedd87251a08c3a597ee4f353281769b547b5a/sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75", size = 2142935, upload-time = "2026-04-03T17:00:51.675Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
+ { url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
+ { url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
+ { url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
+ { url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
+ { url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
+ { url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
+ { url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
+ { url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
+ { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
+ { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
+ { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
+ { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
+[[package]]
+name = "statsmodels"
+version = "0.14.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pandas" },
+ { name = "patsy" },
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" },
+ { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" },
+ { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" },
+ { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" },
+ { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" },
+ { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" },
+ { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" },
+ { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" },
+ { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" },
+ { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" },
+ { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" },
+ { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" },
+ { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" },
+]
+
+[[package]]
+name = "superqt"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+ { name = "qtpy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/84/fe/919245507b15b4633cd40292d69c3b6afa8a650bf3ec33f3329d26314a3c/superqt-0.8.2.tar.gz", hash = "sha256:faa3bd00ef1c9b209b1f31b154dd41c596fec3824f61a70c251fb5569acd7ba6", size = 110190, upload-time = "2026-05-18T14:33:47.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/b9/748c3cbcdba20ef01c055a35905cad5c771cc1df29be11b9a82da6fd8e0d/superqt-0.8.2-py3-none-any.whl", hash = "sha256:ce8400ae7b577d090368aae5bdbabb06cd6bc893f6ca73485bb686835bf20943", size = 101595, upload-time = "2026-05-18T14:33:45.462Z" },
+]
+
+[package.optional-dependencies]
+iconify = [
+ { name = "pyconify" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "tables"
+version = "3.10.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "blosc2", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numexpr", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "py-cpuinfo", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/5d/96708a84e9fcd29d1f684d56d4c38a23d29b1c934599a072a49f27ccfa71/tables-3.10.1.tar.gz", hash = "sha256:4aa07ac734b9c037baeaf44aec64ec902ad247f57811b59f30c4e31d31f126cf", size = 4762413, upload-time = "2024-08-17T09:57:47.127Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/69/a768ec8104ada032c9be09f521f548766ddd0351bc941c9d42fa5db001de/tables-3.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bca9d11a570ca1bc57f0845e54e55c3093d5a1ace376faee639e09503a73745b", size = 6823691, upload-time = "2024-08-17T09:56:50.229Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/2d/074bc14b39de9b552eec02ee583eff2997d903da1355f4450506335a6055/tables-3.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b62881cb682438d1e92b9178db42b160638aef3ca23341f7d98e9b27821b1eb4", size = 5471221, upload-time = "2024-08-17T09:56:54.84Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/30/29411ab804b5ac4bee25c82ba38f4e7a8c0b52c6a1cdbeea7d1db33a53fe/tables-3.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9cf1bfd8b0e0195196205fc8a134628219cff85d20da537facd67a291e6b347", size = 7170201, upload-time = "2024-08-17T09:56:59.011Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/7d/3165c7538b8e89b22fa17ad68e04106cca7023cf68e94011ae7b3b6d2a78/tables-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77f0e6dd45b91d99bf3976c8655c48fe3816baf390b9098e4fb2f0fdf9da7078", size = 7571035, upload-time = "2024-08-17T09:57:03.115Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/985a23d2cf27aad383301a5e99e1851228a1941b868515612b5357bded5f/tables-3.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:d90542ec172d1d60df0b796c48ad446f2b69a5d5cd3077bd6450891b854d1ffb", size = 6311650, upload-time = "2024-08-17T09:57:06.593Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/04/957264eb35e60251830a965e2d02332eb36ed14fbd8345df06981bbf3ece/tables-3.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8917262a2bb3cd79d37e108557e34ec4b365fdcc806e01dd10765a84c65dab6", size = 6790492, upload-time = "2024-08-17T09:57:10.247Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/19/eb7af9d92aaf6766f5fedfce11a97ab03cf39856561c5f562dc0c769a682/tables-3.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f93f6db623b484bb6606537c2a71e95ee34fae19b0d891867642dd8c7be05af6", size = 5506835, upload-time = "2024-08-17T09:57:13.883Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/8f/897324e1ad543ca439b2c91f04c406f3eeda6e7ff2f43b4cd939f05043e4/tables-3.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01ca51624bca1a87e703d6d6b796368bc3460ff007ea8b1341be03bedd863833", size = 7166960, upload-time = "2024-08-17T09:57:17.463Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5c/3f21d1135bf60af99ac79a17bbffd333d69763df2197ba04f47dd30bbd4e/tables-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9372516c76be3a05a573df63a69ce38315d03b5816d2a1e89c48129ec8b161b0", size = 7568724, upload-time = "2024-08-17T09:57:23.02Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e3/3ee6b66263902eccadc4e0e23bca7fb480fd190904b7ce0bea4777b5b799/tables-3.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:09190fb504888aeacafb7739c13d5c5a3e87af3d261f4d2f832b1f8407be133a", size = 6312200, upload-time = "2024-08-17T09:57:26.322Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ec/ea6c476e33602c172c797fe8f8ab96d007d964137068276d142b142a28e5/tables-3.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a7090af37909e3bf229d5599fa442633e5a93b6082960b01038dc0106e07a8da", size = 6791597, upload-time = "2024-08-17T09:57:29.598Z" },
+ { url = "https://files.pythonhosted.org/packages/74/02/a967a506e9204e3328a8c03f67e6f3c919defc8df11aba83ae5b2abf7b0f/tables-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:203ed50c0c5f30f007df7633089b2a567b99856cd25d68f19d91624a8db2e7ad", size = 5474779, upload-time = "2024-08-17T09:57:32.43Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/26/925793f753664ec698b2c6315c818269313db143da38150897cf260405c2/tables-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e36ce9f10471c69c1f0b06c6966de762558a35d62592c55df7994a8019adaf0c", size = 7130683, upload-time = "2024-08-17T09:57:36.181Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/79/2b34f22284459e940a84e71dba19b2a34c7cc0ce3cdf685923c50d5b9611/tables-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f233e78cc9fa4157ec4c3ef2abf01a731fe7969bc6ed73539e5f4cd3b94c98b2", size = 7531367, upload-time = "2024-08-17T09:57:39.864Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/27/5a23830f611e26dd7ee104096c6bb82e481b16f3f17ccaed3075f8d48312/tables-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:34357d2f2f75843a44e6fe54d1f11fc2e35a8fd3cb134df3d3362cff78010adb", size = 6295046, upload-time = "2024-08-17T09:57:43.561Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/d4/e7c25df877e054b05f146d6ccb920bcdbe8d39b35a0962868b80547532c7/tables-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6fc5b46a4f359249c3ab9a0a0a2448d7e680e68cffd63fdf3fb7171781edd46e", size = 6824253, upload-time = "2024-11-09T19:26:06.428Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/49/091865d75090a24493bd1b66e52d72f4d9627ff42983a13d4dcd89455d02/tables-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ecabd7f459d40b7f9f5256850dd5f43773fda7b789f827de92c3d26df1e320f", size = 5499587, upload-time = "2024-11-09T19:26:12.402Z" },
+ { url = "https://files.pythonhosted.org/packages/23/83/9dac8af333149fa01add439f710d4a312b70faf81c2f59a16b8bfaebb75e/tables-3.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40a4ee18f3c9339d9dd8fd3777c75cda5768f2ff347064a2796f59161a190af8", size = 7128236, upload-time = "2024-11-09T19:26:15.716Z" },
+ { url = "https://files.pythonhosted.org/packages/89/fd/62f31643596f6ab71fc6d2a87acdee0bc01a03fbe1a7f3f6dc0c91e2546d/tables-3.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:757c6ea257c174af8036cf8f273ede756bbcd6db5ac7e2a4d64e788b0f371152", size = 7527953, upload-time = "2024-11-09T19:26:20.229Z" },
+]
+
+[[package]]
+name = "tables"
+version = "3.11.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "blosc2", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numexpr", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "py-cpuinfo", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/a3/d213ebe7376d48055bd55a29cd9f99061afa0dcece608f94a5025d797b0a/tables-3.11.1.tar.gz", hash = "sha256:78abcf413091bc7c1e4e8c10fbbb438d1ac0b5a87436c5b972c3e8253871b6fb", size = 4790533, upload-time = "2026-03-01T11:43:36.036Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/bb/4a9cde6628563388db26fa86c64adb0f2475a757e72af0ec185fd520b72f/tables-3.11.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:eb30684c42a77bbecdef2b9c763c4372b0ddc9cc5bd8b2a2055f2042eee67217", size = 7045977, upload-time = "2026-03-01T11:42:48.605Z" },
+ { url = "https://files.pythonhosted.org/packages/78/74/6568c8d3aabf9982ab89fe3e378afbd7aad4894bde4570991a3246169ef4/tables-3.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0367d2e3df0f10ea63ccf4279f3fe58e32ec481767320301a483e2b3cd83efc", size = 6264947, upload-time = "2026-03-01T11:42:53.192Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a3/ec228901fca4c996306b17f5c60a4105144df0bbd07b3a4a816f91f37b4a/tables-3.11.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56bf6fb9132ead989b7e76695d7613d6d08f071a8019038d6565ba90c66b9f3e", size = 6903733, upload-time = "2026-03-01T11:42:58.349Z" },
+ { url = "https://files.pythonhosted.org/packages/99/29/c2dc674ea70fa9a4819417289a9c0d3e4780835beeed573eb66964cfb763/tables-3.11.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e78fe190fdeb4afe430b79651bae2a4f341904eb85aa8dbafe5f1caee1c7f67", size = 7241357, upload-time = "2026-03-01T11:43:03.938Z" },
+ { url = "https://files.pythonhosted.org/packages/60/b5/a59b62af4127790c618eb11c06c106706e07509a3fb9e346b2a3ffa74419/tables-3.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:7fa6cb03f6fe55ae4f85e89ec5450e5c40cc4c52d8c3b60eb157a445c2219e89", size = 6526565, upload-time = "2026-03-01T11:43:08.58Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/ce/561c82496e7c8c15ebf19b53b12c0ef91b322a66869db762db9711102764/tables-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a4bbd95036a4d0cc5c86c1f87fbb490b4c53cd70982f1c01b3ed6dcb3085cbb9", size = 7111409, upload-time = "2026-03-01T11:43:13.424Z" },
+ { url = "https://files.pythonhosted.org/packages/84/18/bac920aee8239b572c506459607c6dd8742bc6275a43d51d2dd6ae1a1541/tables-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e3cfe79484351f7216eb8f3767bfa1217bfd271b04428f79cfa7ef6d7491919d", size = 6380142, upload-time = "2026-03-01T11:43:17.213Z" },
+ { url = "https://files.pythonhosted.org/packages/59/3c/f4a694aa744d2b14d536e172c28dd70c84445f4787083a82d6d44a39e39f/tables-3.11.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a9c35f87fcb6a48c79fbc4e3ab15ca8f6053c4ce13063d6ca2ec36cbb58f40f", size = 7014135, upload-time = "2026-03-01T11:43:22.359Z" },
+ { url = "https://files.pythonhosted.org/packages/45/82/94d4320d6c0fe5bd55230eec90cd142d58cda37b7cce00a318ac2a6abd93/tables-3.11.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cf3218b76ba78d156d6ee75c19fb757d50682f6c7b4905370441afbfc9d77f3", size = 7349293, upload-time = "2026-03-01T11:43:27.569Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/02/a0f61a602ce2f2be8cc2e6146cc51acdaa8a1bb9b823b3863e70d3e0505d/tables-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:a6f7a3b82dbf0ae0f30de635ca88bb42dd87938b0950369d0ee4289c52ae6de2", size = 6854713, upload-time = "2026-03-01T11:43:31.934Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" },
+]
+
+[[package]]
+name = "tensorboard"
+version = "2.14.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-auth", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-auth-oauthlib", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "markdown", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard-data-server", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "werkzeug", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/a2/66ed644f6ed1562e0285fcd959af17670ea313c8f331c46f79ee77187eb9/tensorboard-2.14.1-py3-none-any.whl", hash = "sha256:3db108fb58f023b6439880e177743c5f1e703e9eeb5fb7d597871f949f85fd58", size = 5508920, upload-time = "2023-09-27T23:37:16.71Z" },
+]
+
+[[package]]
+name = "tensorboard"
+version = "2.17.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "markdown", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard-data-server", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "werkzeug", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/41/dccba8c5f955bc35b6110ff78574e4e5c8226ad62f08e732096c3861309b/tensorboard-2.17.1-py3-none-any.whl", hash = "sha256:253701a224000eeca01eee6f7e978aea7b408f60b91eb0babdb04e78947b773e", size = 5502989, upload-time = "2024-08-14T18:10:47.657Z" },
+]
+
+[[package]]
+name = "tensorboard"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "markdown", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard-data-server", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "werkzeug", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/de/021c1d407befb505791764ad2cbd56ceaaa53a746baed01d2e2143f05f18/tensorboard-2.18.0-py3-none-any.whl", hash = "sha256:107ca4821745f73e2aefa02c50ff70a9b694f39f790b11e6f682f7d326745eab", size = 5503036, upload-time = "2024-09-25T21:21:50.169Z" },
+]
+
+[[package]]
+name = "tensorboard"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "python_full_version < '3.13'" },
+ { name = "grpcio", marker = "python_full_version < '3.13'" },
+ { name = "markdown", marker = "python_full_version < '3.13'" },
+ { name = "numpy", marker = "python_full_version < '3.13'" },
+ { name = "packaging", marker = "python_full_version < '3.13'" },
+ { name = "pillow", marker = "python_full_version < '3.13'" },
+ { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+ { name = "setuptools", marker = "python_full_version < '3.13'" },
+ { name = "tensorboard-data-server", marker = "python_full_version < '3.13'" },
+ { name = "werkzeug", marker = "python_full_version < '3.13'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/d9/a5db55f88f258ac669a92858b70a714bbbd5acd993820b41ec4a96a4d77f/tensorboard-2.20.0-py3-none-any.whl", hash = "sha256:9dc9f978cb84c0723acf9a345d96c184f0293d18f166bb8d59ee098e6cfaaba6", size = 5525680, upload-time = "2025-07-17T19:20:49.638Z" },
+]
+
+[[package]]
+name = "tensorboard-data-server"
+version = "0.7.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" },
+]
+
+[[package]]
+name = "tensorflow"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "astunparse", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flatbuffers", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "gast", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-pasta", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "h5py", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "2.14.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "libclang", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ml-dtypes", version = "0.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "opt-einsum", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard", version = "2.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-estimator", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-io-gcs-filesystem", version = "0.31.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-io-gcs-filesystem", version = "0.37.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "termcolor", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wrapt", version = "1.14.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf') or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/51/ad9ebf4ef29754b813a057d64a0634feb12aef27cabcbdb7433dc5cd4cb4/tensorflow-2.14.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:318b21b18312df6d11f511d0f205d55809d9ad0f46d5f9c13d8325ce4fe3b159", size = 229634719, upload-time = "2023-09-26T22:51:39.857Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/e0/1db7b4b382e7d654dd176ee3e09af201f0735ea1a3233c087c3e63f054e9/tensorflow-2.14.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:927868c9bd4b3d2026ac77ec65352226a9f25e2d24ec3c7d088c68cff7583c9b", size = 2108, upload-time = "2023-09-26T22:53:05.115Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/40/da089d1cabd9141543dfeb462e16f6c6741a76ac326174f168b7ce53d54f/tensorflow-2.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3870063433aebbd1b8da65ed4dcb09495f9239397f8cb5a8822025b6bb65e04", size = 2122, upload-time = "2023-09-26T22:56:24.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/7a/c7762c698fb1ac41a7e3afee51dc72aa3ec74ae8d2f57ce19a9cded3a4af/tensorflow-2.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c9c1101269efcdb63492b45c8e83df0fc30c4454260a252d507dfeaebdf77ff", size = 489833115, upload-time = "2023-09-26T22:53:51.969Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c3/17c6aa1dd5bc8cea5bf00d0c3a021a5dd1680c250861cc877a7e556e4b9b/tensorflow-2.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:0b7eaab5e034f1695dc968f7be52ce7ccae4621182d1e2bf6d5b3fab583be98c", size = 2099, upload-time = "2023-09-26T22:55:27.537Z" },
+ { url = "https://files.pythonhosted.org/packages/22/50/1e211cbb5e1f52e55eeae1605789c9d24403962d37581cf0deb3e6b33377/tensorflow-2.14.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:00c42e7d8280c660b10cf5d0b3164fdc5e38fd0bf16b3f9963b7cd0e546346d8", size = 229677851, upload-time = "2023-09-26T22:51:59.935Z" },
+ { url = "https://files.pythonhosted.org/packages/de/ea/90267db2c02fb61f4d03b9645c7446d3cbca6d5c08522e889535c88edfcd/tensorflow-2.14.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c92f5526c2029d31a036be06eb229c71f1c1821472876d34d0184d19908e318c", size = 2106, upload-time = "2023-09-26T22:53:08.777Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ba/0b9dc0a69e518cca919587fd32ec22a81c99bcdf94c8482f00440fff72d0/tensorflow-2.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c224c076160ef9f60284e88f59df2bed347d55e64a0ca157f30f9ca57e8495b0", size = 2122, upload-time = "2023-09-26T22:56:28.206Z" },
+ { url = "https://files.pythonhosted.org/packages/09/63/25e76075081ea98ec48f23929cefee58be0b42212e38074a9ec5c19e838c/tensorflow-2.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a80cabe6ab5f44280c05533e5b4a08e5b128f0d68d112564cffa3b96638e28aa", size = 489875759, upload-time = "2023-09-26T22:54:19.219Z" },
+ { url = "https://files.pythonhosted.org/packages/80/6f/57d36f6507e432d7fc1956b2e9e8530c5c2d2bfcd8821bcbfae271cd6688/tensorflow-2.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:0587ece626c4f7c4fcb2132525ea6c77ad2f2f5659a9b0f4451b1000be1b5e16", size = 2099, upload-time = "2023-09-26T22:55:30.95Z" },
+]
+
+[[package]]
+name = "tensorflow"
+version = "2.17.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "astunparse", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flatbuffers", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "gast", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-pasta", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "h5py", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "libclang", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ml-dtypes", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "opt-einsum", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard", version = "2.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "termcolor", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/81/d658100476affe0c68ccfb0ad72813ef487578187cadba1c896a26cac8e0/tensorflow-2.17.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:61f45ca991cf3dddf0b1069674c455fdbf38edf749dab962bb4bb8a3f99fb25f", size = 236135046, upload-time = "2024-10-24T22:57:23.159Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/dc5fa720f2381f6dfeb955dfc330261e183ef91e57128cb39aecb8440d1c/tensorflow-2.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8aa202e17894dcb0582283e5a5c703391d793ccce11c5c02b1fe8f839ae09f3c", size = 223903722, upload-time = "2024-10-24T22:57:37.41Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/39/ca18413eb576ee0231a1fe6e9d9499afcef614fd94154e0aaf14f32ba3eb/tensorflow-2.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618cc21b0adf695fc8b4323f56ccd17c1408379422e1a177481d4fd8523fa8e", size = 601258535, upload-time = "2024-10-24T22:57:50.405Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/0f/28abaa2e4e6df19d77a2d41cc2b62589250c403dcd505c45433919526fd7/tensorflow-2.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:4c23498b370c9d6b2521722b6acc89fab61e6a593e886df16bed3075584bf1c7", size = 7521, upload-time = "2024-10-24T22:58:03.64Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/fd/92db1ecf5550f8937c21c28f84228959adb9ccba5f19e283ec7f22825b11/tensorflow-2.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:595c220b0febe2295a150e0d870c742a75c56b145a63ed878f78d64aa43c6ca6", size = 236176925, upload-time = "2024-10-24T22:58:08.728Z" },
+ { url = "https://files.pythonhosted.org/packages/45/56/d1f227b56a82cf168d4d5b3fa6094857079f36891af4bc851281afa5cc23/tensorflow-2.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc41bc3e31d205dcf6e4b8afc0514de05445303d93fade03549085ef8c45a2b2", size = 223945776, upload-time = "2024-10-24T22:58:18.161Z" },
+ { url = "https://files.pythonhosted.org/packages/df/9a/f5b1f4b2c08295ae1cb8760d1fb6043485459f0d8c107dd900e76a6ba25d/tensorflow-2.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68841e17e573301d8ba9192f929b8096b0b341567cb81414096305c723de68d0", size = 601300475, upload-time = "2024-10-24T22:58:31.181Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/e6/8dbd20c4942b578aa18ef61e8d7858ddbd3650bbea731539c1fdadbaa466/tensorflow-2.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:a6bd9474f1e0dedb7deb331c8e93cf2d5997da8781a1949f75c4a7cf8923d2e3", size = 7518, upload-time = "2024-10-24T22:58:42.649Z" },
+ { url = "https://files.pythonhosted.org/packages/af/29/29bfb68a1cfd61649cd51f5f08474a9eab343c2402f8998af6f1f6120aa5/tensorflow-2.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d43698039fd057ee6d7c3f908c4e9a6e310be6e77adda419bae3fee5ca7192fe", size = 236283570, upload-time = "2024-10-24T22:58:46.871Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/86/3af00483ff5215e52ebd9d5be2987798a6985f60db413091522a97459c2c/tensorflow-2.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:695741b80ea9b2603a8d3f423051476c02a9cf48f4721d18c4dafbe0a8a5ca91", size = 224039295, upload-time = "2024-10-24T22:58:55.317Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/15/c81ec3b1ca9980cba8a8123a4df0a3f3b96ae4f49d024cd0c4175aa443c0/tensorflow-2.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c3452ab09940cbc3be896641b256855ba92154b79a90c864c5b2be79db78a64", size = 601418583, upload-time = "2024-10-24T22:59:08.262Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/53/0eafd55dd5802c60ac69f6ad01dec61cdb894b591a1f3e471e2a01edbe99/tensorflow-2.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:faf032fae35de0071f20850abd23d78e0e3ae116ce7fbfb9d034da2acc900543", size = 7521, upload-time = "2024-10-24T22:59:21.763Z" },
+]
+
+[[package]]
+name = "tensorflow"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "astunparse", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flatbuffers", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "gast", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-pasta", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "h5py", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "libclang", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ml-dtypes", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "opt-einsum", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard", version = "2.18.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow-io-gcs-filesystem", version = "0.37.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "termcolor", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/e8/d5d54e18ff6fe67c75c3c65415c98ecd31bd0ff7613d47a1390f062993b5/tensorflow-2.18.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:8da90a9388a1f6dd00d626590d2b5810faffbb3e7367f9783d80efff882340ee", size = 239373575, upload-time = "2024-10-25T00:14:11.549Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/58/99ba9d580c218fd866e6044b10915eb415f60af38c03dca6ff2df7f83337/tensorflow-2.18.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:589342fb9bdcab2e9af0f946da4ca97757677e297d934fcdc087e87db99d6353", size = 231677108, upload-time = "2024-10-25T00:14:25.787Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/80/1567ccc375ccda4d28af28c960cca7f709f7c259463ac1436554697e8868/tensorflow-2.18.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1eb77fae50d699442726d1b23c7512c97cd688cc7d857b028683d4535bbf3709", size = 615262200, upload-time = "2024-10-25T00:14:39.672Z" },
+ { url = "https://files.pythonhosted.org/packages/59/63/5ca1b06cf17dda9c52927917a7911612953a7d91865b1288c7f9eac2b53b/tensorflow-2.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:46f5a8b4e6273f488dc069fc3ac2211b23acd3d0437d919349c787fa341baa8a", size = 7519, upload-time = "2024-10-25T00:14:52.683Z" },
+ { url = "https://files.pythonhosted.org/packages/26/08/556c4159675c1a30e077ec2a942eeeb81b457cc35c247a5b4a59a1274f05/tensorflow-2.18.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:453cb60638a02fd26316fb36c8cbcf1569d33671f17c658ca0cf2b4626f851e7", size = 239492146, upload-time = "2024-10-25T00:14:56.741Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/3d/45956345442e3a7b335df6f13d068121d8454c243f31b1f44244705ac584/tensorflow-2.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85f1e7369af6d329b117b52e86093cd1e0458dd5404bf5b665853f873dd00b48", size = 231839918, upload-time = "2024-10-25T00:15:05.71Z" },
+ { url = "https://files.pythonhosted.org/packages/84/76/c55967ac9968ddaede25a4dce37aba37e9030656f02c12676151ce1b6f22/tensorflow-2.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b8dd70fa3600bfce66ab529eebb804e1f9d7c863d2f71bc8fe9fc7a1ec3976", size = 615407268, upload-time = "2024-10-25T00:15:20.224Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/24/271e77c22724f370c24c705f394b8035b4d27e4c2c6339f3f45ab9b8258e/tensorflow-2.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e8b0f499ef0b7652480a58e358a73844932047f21c42c56f7f3bdcaf0803edc", size = 7516, upload-time = "2024-10-25T00:15:31.667Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/bf/4cc283db323fd723f630e2454b2857054d2c81ff5012c1857659e72470f1/tensorflow-2.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ec4133a215c59314e929e7cbe914579d3afbc7874d9fa924873ee633fe4f71d0", size = 239565465, upload-time = "2024-10-25T00:15:35.566Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e4/55aaac2b15af4dad079e5af329a79d961e5206589d0e02b1e8da221472ed/tensorflow-2.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4822904b3559d8a9c25f0fe5fef191cfc1352ceca42ca64f2a7bc7ae0ff4a1f5", size = 231898760, upload-time = "2024-10-25T00:15:45.725Z" },
+ { url = "https://files.pythonhosted.org/packages/50/29/61ce80da0bfea3948326697dd1d832d28c863c9dacf90a27ee80fd4c1d31/tensorflow-2.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfdd65ea7e064064283dd78d529dd621257ee617218f63681935fd15817c6286", size = 615520727, upload-time = "2024-10-25T00:16:01.386Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f1/828bbccc84a72db960a7d116f55f3f6aec9f5658f5d32ce9db20142d5742/tensorflow-2.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:a701c2d3dca5f2efcab315b2c217f140ebd3da80410744e87d77016b3aaf53cb", size = 7520, upload-time = "2024-10-25T00:16:12.418Z" },
+]
+
+[[package]]
+name = "tensorflow"
+version = "2.18.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "astunparse", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "flatbuffers", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "gast", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "google-pasta", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "grpcio", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "h5py", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "libclang", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "ml-dtypes", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "opt-einsum", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "packaging", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorboard", version = "2.18.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "termcolor", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/5e/955a719c2359430a6fa6ec596bafc903b31285844ef44ae53e83bb91ac62/tensorflow-2.18.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:8baba2b0f9f286f8115a0005d17c020d2febf95e434302eaf758f2020c1c4de5", size = 239430540, upload-time = "2025-03-12T00:11:40.574Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/83/4631df86b7880c18ce221b16e9f6f08e8100143d99d68bd6612d8ec534f8/tensorflow-2.18.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd7284768f5a6b10e41a700e8141de70756dc62ed5d0b93360d131ccc0a6ba8", size = 231774989, upload-time = "2025-03-12T00:11:50.957Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/f6/43ed0e0accc63747cb9b6250cbef6515a449f848d4eda0af9d591ac1cea9/tensorflow-2.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f929842999d60e7da67743ae5204b477259f3b771c02e5e437d232267e49f18", size = 615365234, upload-time = "2025-03-12T00:12:07.74Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/27/75d313117d8a9f8aadb8b9121cc33d44793a2d704c3b3f5866e632821b82/tensorflow-2.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:db1d186c17b6a7c51813e275d0a83e964669822372aa01d074cf64b853ee76ac", size = 368995257, upload-time = "2025-03-12T00:12:26.63Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/88/57e2acd11a2587cc5c0a6612a389a57f3bda3cd60d132934cb7a9bb00a81/tensorflow-2.18.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:661029cd769b311db910b79a3a6ef50a5a61ecc947172228c777a49989722508", size = 239549037, upload-time = "2025-03-12T00:12:38.202Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/b3/902588dcffbc0603893f1df482840ff9c596430155d62e159bc8fc155230/tensorflow-2.18.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a6485edd2148f70d011dbd1d8dc2c775e91774a5a159466e83d0d1f21580944", size = 231937898, upload-time = "2025-03-12T00:12:47.544Z" },
+ { url = "https://files.pythonhosted.org/packages/45/c6/05d862ebeaaf63343dffc4f97dab62ac493e8c2bbc6b1a256199bcc78ed4/tensorflow-2.18.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9f87e5d2a680a4595f5dc30daf6bbaec9d4129b46d7ef1b2af63c46ac7d2828", size = 615510377, upload-time = "2025-03-12T00:13:03.792Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2a/5f5ade4be81e521a16e143234747570ffd0d1a90e001ecc2688aa54bb419/tensorflow-2.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:99223d0dde08aec4ceebb3bf0f80da7802e18462dab0d5048225925c064d2af7", size = 369183850, upload-time = "2025-03-12T00:13:24.786Z" },
+ { url = "https://files.pythonhosted.org/packages/67/8c/1cad54f8634897ad9421de8f558df9aa63d3f2747eb803ce5dbb2db1ef5b/tensorflow-2.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:98afa9c7f21481cdc6ccd09507a7878d533150fbb001840cc145e2132eb40942", size = 239622377, upload-time = "2025-03-12T00:13:36.89Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c2/35a3542a91f4ffd4cf01e72d7f0fb59596cd5f467ff64878b0caef8e0f31/tensorflow-2.18.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ba52b9c06ab8102b31e50acfaf56899b923171e603c8942f2bfeb181d6bb59e", size = 231996787, upload-time = "2025-03-12T00:13:47.54Z" },
+ { url = "https://files.pythonhosted.org/packages/64/42/812539a8878c242eb0bacf106f5ea8936c2cc4d7f663868bd872a79772ac/tensorflow-2.18.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442d2a774811789a8ad948e7286cb950fe3d87d3754e8cc6449d53b03dbfdaa6", size = 615623178, upload-time = "2025-03-12T00:14:03.541Z" },
+ { url = "https://files.pythonhosted.org/packages/20/28/9c5e935b76eebdf46df524980d49700a9c9af56abc8c62bfd93f57709563/tensorflow-2.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:210baf6d421f3e044b6e09efd04494a33b75334922fe6cf11970e2885172620a", size = 369234070, upload-time = "2025-03-12T00:14:23.423Z" },
+]
+
+[[package]]
+name = "tensorflow"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "absl-py", marker = "python_full_version < '3.13'" },
+ { name = "astunparse", marker = "python_full_version < '3.13'" },
+ { name = "flatbuffers", marker = "python_full_version < '3.13'" },
+ { name = "gast", marker = "python_full_version < '3.13'" },
+ { name = "google-pasta", marker = "python_full_version < '3.13'" },
+ { name = "grpcio", marker = "python_full_version < '3.13'" },
+ { name = "h5py", marker = "python_full_version < '3.13'" },
+ { name = "keras", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "keras", version = "3.14.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.11' and python_full_version < '3.13' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "libclang", marker = "python_full_version < '3.13'" },
+ { name = "ml-dtypes", version = "0.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+ { name = "numpy", marker = "python_full_version < '3.13'" },
+ { name = "opt-einsum", marker = "python_full_version < '3.13'" },
+ { name = "packaging", marker = "python_full_version < '3.13'" },
+ { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+ { name = "requests", marker = "python_full_version < '3.13'" },
+ { name = "setuptools", marker = "python_full_version < '3.13'" },
+ { name = "six", marker = "python_full_version < '3.13'" },
+ { name = "tensorboard", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+ { name = "termcolor", marker = "python_full_version < '3.13'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "wrapt", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/0e/9408083cb80d85024829eb78aa0aa799ca9f030a348acac35631b5191d4b/tensorflow-2.20.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e5f169f8f5130ab255bbe854c5f0ae152e93d3d1ac44f42cb1866003b81a5357", size = 200387116, upload-time = "2025-08-13T16:50:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/07/ea91ac67a9fd36d3372099f5a3e69860ded544f877f5f2117802388f4212/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02a0293d94f5c8b7125b66abf622cc4854a33ae9d618a0d41309f95e091bbaea", size = 259307122, upload-time = "2025-08-13T16:50:47.909Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/9e/0d57922cf46b9e91de636cd5b5e0d7a424ebe98f3245380a713f1f6c2a0b/tensorflow-2.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abd7f3a010e0d354dc804182372779a722d474c4d8a3db8f4a3f5baef2a591e", size = 620425510, upload-time = "2025-08-13T16:51:02.608Z" },
+ { url = "https://files.pythonhosted.org/packages/74/b5/d40e1e389e07de9d113cf8e5d294c04d06124441d57606febfd0fb2cf5a6/tensorflow-2.20.0-cp310-cp310-win_amd64.whl", hash = "sha256:4a69ac2c2ce20720abf3abf917b4e86376326c0976fcec3df330e184b81e4088", size = 331664937, upload-time = "2025-08-13T16:51:17.719Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/69/de33bd90dbddc8eede8f99ddeccfb374f7e18f84beb404bfe2cbbdf8df90/tensorflow-2.20.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5f964016c5035d09b85a246a6b739be89282a7839743f3ea63640224f0c63aee", size = 200507363, upload-time = "2025-08-13T16:51:28.27Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a3d455db88ab5b35ce53ab885ec0dd9f28d905a86a2250423048bc8cafa0/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e9568c8efcb05c0266be223e3269c62ebf7ad3498f156438311735f6fa5ced5", size = 259465882, upload-time = "2025-08-13T16:51:39.546Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0c/7df285ee8a88139fab0b237003634d90690759fae9c18f55ddb7c04656ec/tensorflow-2.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:481499fd0f824583de8945be61d5e827898cdaa4f5ea1bc2cc28ca2ccff8229e", size = 620570129, upload-time = "2025-08-13T16:51:55.104Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f8/9246d3c7e185a29d7359d8b12b3d70bf2c3150ecf1427ec1382290e71a56/tensorflow-2.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:7551558a48c2e2f6c32a1537f06c654a9df1408a1c18e7b99c3caafbd03edfe3", size = 331845735, upload-time = "2025-08-13T16:52:12.863Z" },
+ { url = "https://files.pythonhosted.org/packages/35/31/47712f425c09cc8b8dba39c6c45aee939c4636a6feb8c81376a4eae653e0/tensorflow-2.20.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:52b122f0232fd7ab10f28d537ce08470d0b6dcac7fff9685432daac7f8a06c8f", size = 200540302, upload-time = "2025-08-13T16:52:22.146Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b4/f028a5de27d0fda10ba6145bc76e40c37ff6d2d1e95b601adb5ae17d635e/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bfbfb3dd0e22bffc45fe1e922390d27753e99261fab8a882e802cf98a0e078f", size = 259533109, upload-time = "2025-08-13T16:52:31.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d1/6aa15085d672056d5f08b5f28b1c7ce01c4e12149a23b0c98e3c79d04441/tensorflow-2.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25265b0bc527e0d54b1e9cc60c44a24f44a809fe27666b905f0466471f9c52ec", size = 620682547, upload-time = "2025-08-13T16:52:46.396Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/37/b97abb360b551fbf5870a0ee07e39ff9c655e6e3e2f839bc88be81361842/tensorflow-2.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:1590cbf87b6bcbd34d8e9ad70d0c696135e0aa71be31803b27358cf7ed63f8fc", size = 331887041, upload-time = "2025-08-13T16:53:05.532Z" },
+ { url = "https://files.pythonhosted.org/packages/04/82/af283f402f8d1e9315644a331a5f0f326264c5d1de08262f3de5a5ade422/tensorflow-2.20.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:197f0b613b38c0da5c6a12a8295ad4a05c78b853835dae8e0f9dfae3ce9ce8a5", size = 200671458, upload-time = "2025-08-13T16:53:16.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/4c/c1aa90c5cc92e9f7f9c78421e121ef25bae7d378f8d1d4cbad46c6308836/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47c88e05a07f1ead4977b4894b3ecd4d8075c40191065afc4fd9355c9db3d926", size = 259663776, upload-time = "2025-08-13T16:53:24.507Z" },
+ { url = "https://files.pythonhosted.org/packages/43/fb/8be8547c128613d82a2b006004026d86ed0bd672e913029a98153af4ffab/tensorflow-2.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa3729b0126f75a99882b89fb7d536515721eda8014a63e259e780ba0a37372", size = 620815537, upload-time = "2025-08-13T16:53:42.577Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/9e/02e201033f8d6bd5f79240b7262337de44c51a6cfd85c23a86c103c7684d/tensorflow-2.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:c25edad45e8cb9e76366f7a8c835279f9169028d610f3b52ce92d332a1b05438", size = 332012220, upload-time = "2025-08-13T16:53:57.303Z" },
+]
+
+[[package]]
+name = "tensorflow-estimator"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/da/4f264c196325bb6e37a6285caec5b12a03def489b57cc1fdac02bb6272cd/tensorflow_estimator-2.14.0-py2.py3-none-any.whl", hash = "sha256:820bf57c24aa631abb1bbe4371739ed77edb11361d61381fd8e790115ac0fd57", size = 440664, upload-time = "2023-09-11T18:41:50.481Z" },
+]
+
+[[package]]
+name = "tensorflow-io-gcs-filesystem"
+version = "0.31.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/f1/30c558bf7e795d02415e6e836f6190367130e0dfb8de585b8b81ddde5c7f/tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:a71421f8d75a093b6aac65b4c8c8d2f768c3ca6215307cf8c16192e62d992bcf", size = 1642401, upload-time = "2023-02-25T19:31:32.234Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/e0/802488d94fdd14832d9832bd1fbd89cebac2519270d99ce1fe1c75eeb4b2/tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:359134ecbd3bf938bb0cf65be4526106c30da461b2e2ce05446a229ed35f6832", size = 2381672, upload-time = "2023-02-25T19:31:34.644Z" },
+ { url = "https://files.pythonhosted.org/packages/be/79/b18c800a17a10abeae3e8aa420fb452646c8f501bac82d61626437c48b0e/tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b658b33567552f155af2ed848130f787bfda29381fa78cd905d5ee8254364f3c", size = 2572150, upload-time = "2023-02-25T19:31:36.499Z" },
+ { url = "https://files.pythonhosted.org/packages/78/51/437068ed6b44162d54addb8ac0ddfe9e406d07ac6f9c8a6cf96869ec2262/tensorflow_io_gcs_filesystem-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:961353b38c76471fa296bb7d883322c66b91415e7d47087236a6706db3ab2758", size = 1486315, upload-time = "2023-02-25T19:31:38.297Z" },
+ { url = "https://files.pythonhosted.org/packages/84/00/900ca310ff2e46eb3127f8f54af0b0000a6cc786be6a54dc2cfe841f4683/tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:8909c4344b0e96aa356230ab460ffafe5900c33c1aaced65fafae71d177a1966", size = 1642401, upload-time = "2023-02-25T19:31:40.204Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/0d44ef93add3432ce43f37fe0c205cc7b6fd685fca80054fb4a646a9dbe3/tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e417faf8755aafe52d8f8c6b5ae5bae6e4fae8326ee3acd5e9181b83bbfbae87", size = 2381673, upload-time = "2023-02-25T19:31:41.992Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2b/3064195efa016fff942009fe965ecbbbbd7d70bf34ee22d4ff31a0f3443a/tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c40e3c4ee1f8dda3b545deea6b8839192c82037d8021db9f589908034ad975", size = 2572150, upload-time = "2023-02-25T19:31:43.874Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/4e/9566a313927be582ca99455a9523a097c7888fc819695bdc08415432b202/tensorflow_io_gcs_filesystem-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:4bb37d23f21c434687b11059cb7ffd094d52a7813368915ba1b7057e3c16e414", size = 1486315, upload-time = "2023-02-25T19:31:45.641Z" },
+]
+
+[[package]]
+name = "tensorflow-io-gcs-filesystem"
+version = "0.37.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/a3/12d7e7326a707919b321e2d6e4c88eb61596457940fd2b8ff3e9b7fac8a7/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:249c12b830165841411ba71e08215d0e94277a49c551e6dd5d72aab54fe5491b", size = 2470224, upload-time = "2024-07-01T23:44:15.341Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/55/3849a188cc15e58fefde20e9524d124a629a67a06b4dc0f6c881cb3c6e39/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:257aab23470a0796978efc9c2bcf8b0bc80f22e6298612a4c0a50d3f4e88060c", size = 3479613, upload-time = "2024-07-01T23:44:17.445Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/19/9095c69e22c879cb3896321e676c69273a549a3148c4f62aa4bc5ebdb20f/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8febbfcc67c61e542a5ac1a98c7c20a91a5e1afc2e14b1ef0cb7c28bc3b6aa70", size = 4842078, upload-time = "2024-07-01T23:44:18.977Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/48/47b7d25572961a48b1de3729b7a11e835b888e41e0203cca82df95d23b91/tensorflow_io_gcs_filesystem-0.37.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9679b36e3a80921876f31685ab6f7270f3411a4cc51bc2847e80d0e4b5291e27", size = 5085736, upload-time = "2024-07-01T23:44:21.034Z" },
+ { url = "https://files.pythonhosted.org/packages/40/9b/b2fb82d0da673b17a334f785fc19c23483165019ddc33b275ef25ca31173/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:32c50ab4e29a23c1f91cd0f9ab8c381a0ab10f45ef5c5252e94965916041737c", size = 2470224, upload-time = "2024-07-01T23:44:23.039Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/cc/16634e76f3647fbec18187258da3ba11184a6232dcf9073dc44579076d36/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b02f9c5f94fd62773954a04f69b68c4d576d076fd0db4ca25d5479f0fbfcdbad", size = 3479613, upload-time = "2024-07-01T23:44:24.399Z" },
+ { url = "https://files.pythonhosted.org/packages/de/bf/ba597d3884c77d05a78050f3c178933d69e3f80200a261df6eaa920656cd/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e1f2796b57e799a8ca1b75bf47c2aaa437c968408cc1a402a9862929e104cda", size = 4842079, upload-time = "2024-07-01T23:44:26.825Z" },
+ { url = "https://files.pythonhosted.org/packages/66/7f/e36ae148c2f03d61ca1bff24bc13a0fef6d6825c966abef73fc6f880a23b/tensorflow_io_gcs_filesystem-0.37.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee7c8ee5fe2fd8cb6392669ef16e71841133041fee8a330eff519ad9b36e4556", size = 5085736, upload-time = "2024-07-01T23:44:28.618Z" },
+ { url = "https://files.pythonhosted.org/packages/70/83/4422804257fe2942ae0af4ea5bcc9df59cb6cb1bd092202ef240751d16aa/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:ffebb6666a7bfc28005f4fbbb111a455b5e7d6cd3b12752b7050863ecb27d5cc", size = 2470224, upload-time = "2024-07-01T23:44:30.232Z" },
+ { url = "https://files.pythonhosted.org/packages/43/9b/be27588352d7bd971696874db92d370f578715c17c0ccb27e4b13e16751e/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fe8dcc6d222258a080ac3dfcaaaa347325ce36a7a046277f6b3e19abc1efb3c5", size = 3479614, upload-time = "2024-07-01T23:44:32.316Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/46/962f47af08bd39fc9feb280d3192825431a91a078c856d17a78ae4884eb1/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fbb33f1745f218464a59cecd9a18e32ca927b0f4d77abd8f8671b645cc1a182f", size = 4842077, upload-time = "2024-07-01T23:44:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9b/790d290c232bce9b691391cf16e95a96e469669c56abfb1d9d0f35fa437c/tensorflow_io_gcs_filesystem-0.37.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:286389a203a5aee1a4fa2e53718c661091aa5fea797ff4fa6715ab8436b02e6c", size = 5085733, upload-time = "2024-07-01T23:44:36.663Z" },
+]
+
+[[package]]
+name = "tensorflow-metal"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six", marker = "(platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "wheel", marker = "(platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/da/463240cc8ff13a57119db62a676e2edca86fb93905a13527872dadf1926e/tensorflow_metal-1.2.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:bc735e36c97874e41f77ec2e7421ff745d2ec36ee641141c8091e4cc2dbcc819", size = 1357400, upload-time = "2025-01-31T00:52:54.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/09/91b253511cd59b9964672567f36b412daf3c70f75fcb5e84468fafa939ac/tensorflow_metal-1.2.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5fa7cee627031c14f45bd97ff0ef422cd6c3866199ff99cf29b94db6674ceb42", size = 1357400, upload-time = "2025-01-31T00:52:56.634Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/bf/988b619322d5617a928e7f31cbb1ed8dd7f375f69dfa73dab26409a00382/tensorflow_metal-1.2.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4bece0ecb154b713b9f5ad4aec676a366d312822161e3cf0e1dea737c64cec04", size = 1357400, upload-time = "2025-01-31T00:52:57.924Z" },
+]
+
+[[package]]
+name = "tensorpack"
+version = "0.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "msgpack-numpy", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "numpy", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "psutil", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pyzmq", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "six", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tabulate", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "termcolor", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tqdm", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d2/f0/edfda47ca6cc9ece30a893362c336b9121b691529e4cdf3b8732565be790/tensorpack-0.11.tar.gz", hash = "sha256:022b610e416e62e3575424cd08e60af27808a5fb6914294615391caf582cbd4f", size = 223526, upload-time = "2021-01-22T08:44:04.326Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/8c/63e5f5a4a04dea36b75850f9daa885ccbfad64bec1fae0ee4ca9f31b3eaa/tensorpack-0.11-py2.py3-none-any.whl", hash = "sha256:afcdaccf6e8e7d61c98970646d57b1c22372ddd712c462477a90f53e3994c4a1", size = 296324, upload-time = "2021-01-22T08:44:03.089Z" },
+]
+
+[[package]]
+name = "termcolor"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
+]
+
+[[package]]
+name = "tf-keras"
+version = "2.14.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/e7/883de3afc2032c89d6351fca7eb119ee197de21a2189e3889e9171d64862/tf_keras-2.14.1.tar.gz", hash = "sha256:4ae6871c4989720ea335d771b75b2e520d789ec1600302800bb5ed1a855af2fe", size = 1250245, upload-time = "2023-09-29T01:57:43.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/c7a98446afd921b7c4a0688e6eb30bf8f48040d069c349c772e7763636e6/tf_keras-2.14.1-py3-none-any.whl", hash = "sha256:903cf3f1739d3f92d2de80e07d2b36b32cc79f60b7affcee09a7f4721c02fca6", size = 1714936, upload-time = "2023-09-29T01:57:41.02Z" },
+]
+
+[[package]]
+name = "tf-keras"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "tensorflow", version = "2.17.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/2b/d647100a2e80d159b020f1dbc2ef2c6787ed33c914951a63b3c88cd805d0/tf_keras-2.17.0.tar.gz", hash = "sha256:fda97c18da30da0f72a5a7e80f3eee343b09f4c206dad6c57c944fb2cd18560e", size = 1260098, upload-time = "2024-07-15T21:31:01.6Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/8b/75f7572ec0273ed8da50bc19defe08aaaafcc15fda3407db53f49acec814/tf_keras-2.17.0-py3-none-any.whl", hash = "sha256:cc97717e4dc08487f327b0740a984043a9e0123c7a4e21206711669d3ec41c88", size = 1724905, upload-time = "2024-07-15T21:30:58.941Z" },
+]
+
+[[package]]
+name = "tf-keras"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "tensorflow", version = "2.18.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "tensorflow", version = "2.18.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/a4/7d0acc28cde2b29b8114552ce3258dafdc6b2186d24bf8e912de713dd74f/tf_keras-2.18.0.tar.gz", hash = "sha256:ebf744519b322afead33086a2aba872245473294affd40973694f3eb7c7ad77d", size = 1260765, upload-time = "2024-10-24T22:58:06.652Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/ed/e08afca471299b04a34cd548e64e89d0153eda0e6cf9b715356777e24774/tf_keras-2.18.0-py3-none-any.whl", hash = "sha256:c431d04027eef790fcd3261cf7fdf93eb74f3cb32e05078b57b7f5a54bd53262", size = 1725427, upload-time = "2024-10-24T22:58:03.918Z" },
+]
+
+[[package]]
+name = "tf-keras"
+version = "2.20.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "tensorflow", version = "2.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/38/6060f6c7472439bb3890b9094d69d31d9f8d5da123b16c738773e70fff91/tf_keras-2.20.1.tar.gz", hash = "sha256:884be5938fb0b2b53b1583c1ae2b660ef87215377c29b5b6a77fd221b472aeaf", size = 1254487, upload-time = "2025-09-04T21:23:41.81Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/6b/d9a8202bfe5c9e3b078cf550bafab962aa9d6b1a1f1180f0065399d4c9b2/tf_keras-2.20.1-py3-none-any.whl", hash = "sha256:3f0e0a34d9a4c8758f24fdc1053e6e335f16ab5534c7d34f1899b8924779760c", size = 1694335, upload-time = "2025-09-04T21:23:40.153Z" },
+]
+
+[[package]]
+name = "tf-slim"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "absl-py", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/97/b0f4a64df018ca018cc035d44f2ef08f91e2e8aa67271f6f19633a015ff7/tf_slim-1.1.0-py2.py3-none-any.whl", hash = "sha256:fa2bab63b3925bd42601102e7f178dce997f525742596bf404fa8a6918e146ff", size = 352133, upload-time = "2020-05-07T22:18:55.976Z" },
+]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+]
+
+[[package]]
+name = "tifffile"
+version = "2025.5.10"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/d0/18fed0fc0916578a4463f775b0fbd9c5fed2392152d039df2fb533bfdd5d/tifffile-2025.5.10.tar.gz", hash = "sha256:018335d34283aa3fd8c263bae5c3c2b661ebc45548fde31504016fcae7bf1103", size = 365290, upload-time = "2025-05-10T19:22:34.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/06/bd0a6097da704a7a7c34a94cfd771c3ea3c2f405dd214e790d22c93f6be1/tifffile-2025.5.10-py3-none-any.whl", hash = "sha256:e37147123c0542d67bc37ba5cdd67e12ea6fbe6e86c52bee037a9eb6a064e5ad", size = 226533, upload-time = "2025-05-10T19:22:27.279Z" },
+]
+
+[[package]]
+name = "tifffile"
+version = "2026.3.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "python_full_version >= '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" },
+]
+
+[[package]]
+name = "timm"
+version = "1.0.27"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "pyyaml" },
+ { name = "safetensors" },
+ { name = "torch", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.10.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "torchvision", version = "0.15.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torchvision", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/54/ece85b0eef3700c90db8271a43669b05a0ebbe2edb1962329c34374a297e/timm-1.0.27.tar.gz", hash = "sha256:315dfe63186ca9fb7ff941268941231fd5be259f2b4bb4afa28560ae1015cb9a", size = 2439861, upload-time = "2026-05-08T19:38:36.844Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/2e/26bab7686ff4aed48f8f5f6c23e2aa37b7a37ddd9effe3aa61e908fd518f/timm-1.0.27-py3-none-any.whl", hash = "sha256:5ff07c9ddf53cbada88eab1c93ff175c64cab683b5a2fddf863bcee985926f89", size = 2589280, upload-time = "2026-05-08T19:38:35.034Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
+]
+
+[[package]]
+name = "toolz"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "filelock", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jinja2", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cublas-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-cupti-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-nvrtc-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-runtime-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cudnn-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cufft-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-curand-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusolver-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusparse-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nccl-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvtx-cu11", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sympy", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "triton", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/4d/17e07377c9c3d1a0c4eb3fde1c7c16b5a0ce6133ddbabc08ceef6b7f2645/torch-2.0.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:8ced00b3ba471856b993822508f77c98f48a458623596a4c43136158781e306a", size = 619913492, upload-time = "2023-05-08T16:35:32.915Z" },
+ { url = "https://files.pythonhosted.org/packages/21/33/4925decd863ce88ed9190a4bd872b01c146243ee68db08c72923984fe335/torch-2.0.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:359bfaad94d1cda02ab775dc1cc386d585712329bb47b8741607ef6ef4950747", size = 74032191, upload-time = "2023-05-08T17:03:58.074Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/e7/c216fe520b877cf4fe03858c825cd2031ca3e81e455b89639c9b5ec91981/torch-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:7c84e44d9002182edd859f3400deaa7410f5ec948a519cc7ef512c2f9b34d2c4", size = 172339532, upload-time = "2023-05-08T16:38:31.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/27/5c912ccc490ec78585cd463198e80be27b53db77f02e7398b41305606399/torch-2.0.1-cp310-none-macosx_10_9_x86_64.whl", hash = "sha256:567f84d657edc5582d716900543e6e62353dbe275e61cdc36eda4929e46df9e7", size = 143409489, upload-time = "2023-05-08T16:41:49.802Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/77/778954c0aad4f7901a1ba02a129bca7467c64a19079108e6b1d6ce8ae575/torch-2.0.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:787b5a78aa7917465e9b96399b883920c88a08f4eb63b5a5d2d1a16e27d2f89b", size = 55830861, upload-time = "2023-05-08T17:01:35.636Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/21/25020cfdd9f564a72f400ee491610e50cb212e8add8031abaa959af6451e/torch-2.0.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:e617b1d0abaf6ced02dbb9486803abfef0d581609b09641b34fa315c9c40766d", size = 619895009, upload-time = "2023-05-08T17:28:02.693Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/61/7273dea60a17c63d9eaef04ae8fee02351e0cb477e76df4ea211896ae124/torch-2.0.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:b6019b1de4978e96daa21d6a3ebb41e88a0b474898fe251fd96189587408873e", size = 74042514, upload-time = "2023-05-08T17:29:51.438Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c8/f0dc8642e3ce0a3ae5f05e5149ab9df5375d569294f7be9a1ab1d95a1d76/torch-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:dbd68cbd1cd9da32fe5d294dd3411509b3d841baecb780b38b3b7b06c7754434", size = 172343262, upload-time = "2023-05-08T17:29:02.117Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/9b/f20686a5ebd09c6feacced771cf4041a521c411c5bb10359580e9e491797/torch-2.0.1-cp311-none-macosx_10_9_x86_64.whl", hash = "sha256:ef654427d91600129864644e35deea761fb1fe131710180b952a6f2e2207075e", size = 143124494, upload-time = "2023-05-08T16:28:47.223Z" },
+ { url = "https://files.pythonhosted.org/packages/85/68/f901437d3e3ef6fe97adb1f372479626d994185b8fa06803f5bdf3bb90fd/torch-2.0.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:25aa43ca80dcdf32f13da04c503ec7afdf8e77e3a0183dd85cd3e53b2842e527", size = 55831241, upload-time = "2023-05-08T17:31:49.294Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.10.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "cuda-bindings", version = "12.9.4", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "filelock", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "fsspec", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "jinja2", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-cupti-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-nvrtc-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cuda-runtime-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cudnn-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cufft-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cufile-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-curand-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusolver-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusparselt-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nccl-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvshmem-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvtx-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "(python_full_version >= '3.12' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "sympy", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "triton", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/30/bfebdd8ec77db9a79775121789992d6b3b75ee5494971294d7b4b7c999bc/torch-2.10.0-2-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:2b980edd8d7c0a68c4e951ee1856334a43193f98730d97408fbd148c1a933313", size = 79411457, upload-time = "2026-02-10T21:44:59.189Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" },
+ { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" },
+ { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },
+ { url = "https://files.pythonhosted.org/packages/76/bb/d820f90e69cda6c8169b32a0c6a3ab7b17bf7990b8f2c680077c24a3c14c/torch-2.10.0-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:35e407430795c8d3edb07a1d711c41cc1f9eaddc8b2f1cc0a165a6767a8fb73d", size = 79411450, upload-time = "2026-01-21T16:25:30.692Z" },
+ { url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" },
+ { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
+ { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
+ { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
+ { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" },
+ { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" },
+ { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" },
+ { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "cuda-bindings", version = "13.2.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "filelock", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "fsspec", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "jinja2", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version < '3.11' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.11' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version >= '3.11' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.11' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cublas", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cudnn-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-cusparselt-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nccl-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "nvidia-nvshmem-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "setuptools", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "sympy", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "triton", version = "3.7.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "typing-extensions", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/b7/53fe0436586716ab7aecff41e26b9302d57c85ded481fd83a2cd741e6b4e/torch-2.12.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:1834bd984f8a2f4f16bdfbeecca9146184b220aa46276bf5756735b5dae12812", size = 87981887, upload-time = "2026-05-13T14:55:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/34/60/d930eac44c30de06ed16f6d1ba4e785e1632532b50d8f0bf9bf699a4d0c7/torch-2.12.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d4d029801cb7b6df858804a2a21b00cc2aa0bf0ee5d2ab18d343c9e9e5681f35", size = 426355000, upload-time = "2026-05-13T14:54:31.944Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0c/c76b6a087820bab55705b94dfc074e520de9ae91f5ef90da2ecbf2a3ef12/torch-2.12.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d47e7dee68ac4cd7a068b26bcd6b989935427709fae1c8f7bd0019978f829e15", size = 532144998, upload-time = "2026-05-13T14:56:05.523Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/64/8a0d036e166a6aa85ee09bef072f3655d1ba5d5486a68d1b03b6813c01b3/torch-2.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:cf9839790285dd472e7a16aafcb4a4e6bf58ec1b494045044b0eefb0eb4bd1f2", size = 122949877, upload-time = "2026-05-13T14:55:46.841Z" },
+ { url = "https://files.pythonhosted.org/packages/18/62/131124fb95df03811b8260d1d43dcc5ee85ea1a344b964613d7efe77fb08/torch-2.12.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:10802fd383bbfed646212e765a72c37d2185205d4f26eb197a254e8ac7ddcb25", size = 87990344, upload-time = "2026-05-13T14:55:42.154Z" },
+ { url = "https://files.pythonhosted.org/packages/12/9c/dda0dbd547dc549839824135f223792fd0e725f28ed0715dda366b7acaa2/torch-2.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c12592630aef72feaf18bd3f197ef587bbfa21131b31c38b23ab2e55fce92e36", size = 426362932, upload-time = "2026-05-13T14:54:15.295Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/d2/a7dd5a3f9bdaa7842124e8e2359202b317c48d47d2fc5816fafdf2049adb/torch-2.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:415c1b8d0412f67551c8e89a2daca0fb3e56694af0281ba155eaa9da481f58b4", size = 532170085, upload-time = "2026-05-13T14:55:20.788Z" },
+ { url = "https://files.pythonhosted.org/packages/12/1b/a61ce2004f9ab0ea8964a6e6168133a127795667639e2ff4f8f2bdb16a65/torch-2.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd37188ea325042cb1f6cafa56822b11ada2520c04791a52629b0af25bdfbfd9", size = 122953128, upload-time = "2026-05-13T14:54:52.744Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/bb/285d643f254731294c9b595a007eac39db4600a98682d7bca688f42ca164/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", size = 88010197, upload-time = "2026-05-13T14:55:35.414Z" },
+ { url = "https://files.pythonhosted.org/packages/79/81/76debf1db1343bd929bbb5d74c89fb437c2ed88eb144712557e7bd3eea45/torch-2.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8fbef9f108a863e7722a73740998967e3b074742a834fc5be3a535a2befa7057", size = 426376751, upload-time = "2026-05-13T14:55:03.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f0/80026028b603c4650ff270fc3785bdef4bd6738765a9cc5a0f5a637d65a2/torch-2.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4b4f64c2c2b11f7510d93dd6412b87025ff6eddd6bb61c3b5a3d892ea20c4756", size = 532261691, upload-time = "2026-05-13T14:52:54.453Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/c2/64b06cbb7830fb3cd9be13e1158b31a3f36b68e6a209105ee3c9d9480be0/torch-2.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:8b958caff4a14d3a3b0b2dfc6a378f64dda9728a9dad28c08a0db9ce4dafb549", size = 122988114, upload-time = "2026-05-13T14:54:42.153Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ca/01896c80ba921676aa45886b2c5b8d774912de2a1f719de48169c6f755cd/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", size = 88009511, upload-time = "2026-05-13T14:54:47.411Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/04/52bdaf4787eab6ac7d7f5851dff934e4def0bc8ead9c8fd2b69b3e529699/torch-2.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:864392c73b7654f4d2b3ae712f607937d0dbb1101c4555fbb41848106b297f39", size = 426383231, upload-time = "2026-05-13T14:53:32.129Z" },
+ { url = "https://files.pythonhosted.org/packages/49/8a/94bdecd13f5aaa90d45920b89789d9fe7c6f4af8c3cdd7ce01fcb59908fc/torch-2.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5d6b560dfa7d56291c07d615c3bb73e8d9943d9b6d87f76cd0d9d570c4797fa6", size = 532269288, upload-time = "2026-05-13T14:53:49.423Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/2f/bdbaaa267de519ef1b73054bf590d8c93c37a266c9a4e24a01bd38b6918f/torch-2.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:3fee918902090ade827643e758e98363278815de583c75d111fdd665ebffde9f", size = 122987706, upload-time = "2026-05-13T14:54:00.335Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ad/e95e822f3538171e22640a7fbe839a1fdb666600bf6487025de2ff03b11a/torch-2.12.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:10ee1448a9f304d3b987eb4656f664ba6e4d7b410ca7a5a7c642199777a2cf88", size = 88319556, upload-time = "2026-05-13T14:54:05.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/07/055d06d985b445d67422d25b033c11cf55bbb81785d4c4e68e28bca5820e/torch-2.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af68dbf403439cae9ceaeaaf92f8352b460787dcd27b92aa05c40dd4a19c0f1e", size = 426397656, upload-time = "2026-05-13T14:52:38.84Z" },
+ { url = "https://files.pythonhosted.org/packages/43/94/b0b4fdc3014122e0a7302fb90086d352aa48f2576f0b252561ebb38c01a8/torch-2.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a6a2eebb237d3b1d9ad3b378e86d9b9e0782afdea8b1e0eba6a13646b9b49c07", size = 532183124, upload-time = "2026-05-13T14:53:16.178Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/c8/052405e6ad05d3237bfe5a4df78f917773956f8e17813a2d44c059068b74/torch-2.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2140e373e9a51a3e22ef62e8d14366d0b470d18f0adf19fdc757368077133a34", size = 123232462, upload-time = "2026-05-13T14:52:27.26Z" },
+ { url = "https://files.pythonhosted.org/packages/67/dc/ac069f8d6e8be701535921141055293b0d4819d3d7f224a4612cf157c7f9/torch-2.12.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7dfae4a519197dfa050e98d8e36378a0fb5899625a875c2b54445005a2e404e", size = 88027282, upload-time = "2026-05-13T14:53:05.258Z" },
+ { url = "https://files.pythonhosted.org/packages/33/c3/1c1eb00e34555b536dddf792676026a988d710ed36981aa00499b36b0620/torch-2.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:891c769072637c74e9a5a77a3bc782894696d8ffec83b938df8536dee7f0ba78", size = 426386961, upload-time = "2026-05-13T14:51:28.406Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/d4/7e730dba0c7032a4154dc9056b76cf9625515e030e269cfbf8098fcfee7d/torch-2.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e2ad3eb85d39c3cab62dfa93ed5a73516e6a53c6713cb97d004004fe089f0f1f", size = 532272265, upload-time = "2026-05-13T14:51:59.308Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b4/92c80d1bbfee1c0036c06d1d2155a3065bd2423134c83bf8a47e65cd6b9b/torch-2.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:c66696857e987efb8bc1777a37357ec4f60ab5e8af6250b83d6034437fa2d8f3", size = 122987138, upload-time = "2026-05-13T14:51:45.942Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/78/2e12b37ce50a19a037d7bc62d652a5a8f27385a7b05859d6bc9204f20cfe/torch-2.12.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b4556715c8572758625d62b6e0ae3b1f76c440221913a6fb5e100f321fb4fb02", size = 88320100, upload-time = "2026-05-13T14:51:39.955Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5e/83c450ec7b0bb40a7b74611c1b5440f9260e33c54c90d556fd4a1f0fd955/torch-2.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a43ac605a5e13116c72b64c359644cce0229f213dde48d2ae0ae5eb5becf7feb", size = 426391871, upload-time = "2026-05-13T14:52:14.989Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e9/1a0b575d98d0afedd8f157d23fa3d2759421483660448e60d0a4b10b6daa/torch-2.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a7512adfdd7f6732e40de1c620831e3c75b39b98cef60b11d0c5f0a76473ec5", size = 532192241, upload-time = "2026-05-13T14:51:07.795Z" },
+ { url = "https://files.pythonhosted.org/packages/88/21/afadd25ecd81b3cea1e11c73cf1ab41a983a50271548c3ec7ec3b9efc3e9/torch-2.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5f96b63f8287f66a005dd1b5a6abba2920f11156c5e5c4d815f3e2050fd1aa16", size = 123231092, upload-time = "2026-05-13T14:51:18.854Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.15.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pillow", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "requests", marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e7/3b43cce519d7236bbbdc31f468b43ae2084ff7db8cb162764311028d32a1/torchvision-0.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7754088774e810c5672b142a45dcf20b1bd986a5a7da90f8660c43dc43fb850c", size = 1501860, upload-time = "2023-05-08T17:32:32.803Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/bf/4cd5133120e6cbcc2fa5c38c92f2f44a7486a9d2ae851e3d5a7e83f396d5/torchvision-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37eb138e13f6212537a3009ac218695483a635c404b6cc1d8e0d0d978026a86d", size = 1410848, upload-time = "2023-05-08T17:34:05.161Z" },
+ { url = "https://files.pythonhosted.org/packages/87/0f/88f023bf6176d9af0f85feedf4be129f9cf2748801c4d9c690739a10c100/torchvision-0.15.2-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:54143f7cc0797d199b98a53b7d21c3f97615762d4dd17ad45a41c7e80d880e73", size = 6032483, upload-time = "2023-05-08T17:33:04.697Z" },
+ { url = "https://files.pythonhosted.org/packages/16/5e/51c5fde550161edcfa3e131c51a8b4261775ebb2b118b3560116fa9f7a73/torchvision-0.15.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:1eefebf5fbd01a95fe8f003d623d941601c94b5cec547b420da89cb369d9cf96", size = 1249097, upload-time = "2023-05-08T17:33:43.726Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1d/cb1e7f25b6dda4e672ed8a3e7fbd073ec39e2ba6c378c3071ef2cd6100e1/torchvision-0.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:96fae30c5ca8423f4b9790df0f0d929748e32718d88709b7b567d2f630c042e3", size = 1195746, upload-time = "2023-05-08T17:33:33.397Z" },
+ { url = "https://files.pythonhosted.org/packages/69/40/2f3b2392ce7c4b856a5964803c4bc0bf0d5fc75ff7f6cc64cc2058c3e700/torchvision-0.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f35f6bd5bcc4568e6522e4137fa60fcc72f4fa3e615321c26cd87e855acd398", size = 1501857, upload-time = "2023-05-08T17:33:20.364Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/6d/d713159642b36c42f5b6871330241070797ec89d3f8855eeb91c8baddddd/torchvision-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:757505a0ab2be7096cb9d2bf4723202c971cceddb72c7952a7e877f773de0f8a", size = 1410853, upload-time = "2023-05-08T17:33:08.26Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/62/b6ec55347600b02b0a2a6596e673c69424aea7360c48343653866e66aa0d/torchvision-0.15.2-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:012ad25cfd9019ff9b0714a168727e3845029be1af82296ff1e1482931fa4b80", size = 6032450, upload-time = "2023-05-08T17:33:48.437Z" },
+ { url = "https://files.pythonhosted.org/packages/66/e0/cd847d4d22be88a71d5d65f5809342e7ea7ded62230e7bde7420a2105e51/torchvision-0.15.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:b02a7ffeaa61448737f39a4210b8ee60234bda0515a0c0d8562f884454105b0f", size = 1249109, upload-time = "2023-05-08T17:33:28.559Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/26/a1e128500fb661d3ee7d99b97fb45d3b83e57091278c9babec859da7b87f/torchvision-0.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:10be76ceded48329d0a0355ac33da131ee3993ff6c125e4a02ab34b5baa2472c", size = 1195759, upload-time = "2023-05-08T17:33:11.008Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.25.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pillow", marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.10.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-tf-cu12' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/ae/cbf727421eb73f1cf907fbe5788326a08f111b3f6b6ddca15426b53fec9a/torchvision-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a95c47abb817d4e90ea1a8e57bd0d728e3e6b533b3495ae77d84d883c4d11f56", size = 1874919, upload-time = "2026-01-21T16:27:47.617Z" },
+ { url = "https://files.pythonhosted.org/packages/64/68/dc7a224f606d53ea09f9a85196a3921ec3a801b0b1d17e84c73392f0c029/torchvision-0.25.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:acc339aba4a858192998c2b91f635827e40d9c469d9cf1455bafdda6e4c28ea4", size = 2343220, upload-time = "2026-01-21T16:27:44.26Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/fa/8cce5ca7ffd4da95193232493703d20aa06303f37b119fd23a65df4f239a/torchvision-0.25.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0d9a3f925a081dd2ebb0b791249b687c2ef2c2717d027946654607494b9b64b6", size = 8068106, upload-time = "2026-01-21T16:27:37.805Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/b9/a53bcf8f78f2cd89215e9ded70041765d50ef13bf301f9884ec6041a9421/torchvision-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:b57430fbe9e9b697418a395041bb615124d9c007710a2712fda6e35fb310f264", size = 3697295, upload-time = "2026-01-21T16:27:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/be/c704bceaf11c4f6b19d64337a34a877fcdfe3bd68160a8c9ae9bea4a35a3/torchvision-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db74a551946b75d19f9996c419a799ffdf6a223ecf17c656f90da011f1d75b20", size = 1874923, upload-time = "2026-01-21T16:27:46.574Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/e9/f143cd71232430de1f547ceab840f68c55e127d72558b1061a71d0b193cd/torchvision-0.25.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f49964f96644dbac2506dffe1a0a7ec0f2bf8cf7a588c3319fed26e6329ffdf3", size = 2344808, upload-time = "2026-01-21T16:27:43.191Z" },
+ { url = "https://files.pythonhosted.org/packages/43/ae/ad5d6165797de234c9658752acb4fce65b78a6a18d82efdf8367c940d8da/torchvision-0.25.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:153c0d2cbc34b7cf2da19d73450f24ba36d2b75ec9211b9962b5022fb9e4ecee", size = 8070752, upload-time = "2026-01-21T16:27:33.748Z" },
+ { url = "https://files.pythonhosted.org/packages/23/19/55b28aecdc7f38df57b8eb55eb0b14a62b470ed8efeb22cdc74224df1d6a/torchvision-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:ea580ffd6094cc01914ad32f8c8118174f18974629af905cea08cb6d5d48c7b7", size = 4038722, upload-time = "2026-01-21T16:27:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/56/3a/6ea0d73f49a9bef38a1b3a92e8dd455cea58470985d25635beab93841748/torchvision-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2abe430c90b1d5e552680037d68da4eb80a5852ebb1c811b2b89d299b10573b", size = 1874920, upload-time = "2026-01-21T16:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/51/f8/c0e1ef27c66e15406fece94930e7d6feee4cb6374bbc02d945a630d6426e/torchvision-0.25.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:b75deafa2dfea3e2c2a525559b04783515e3463f6e830cb71de0fb7ea36fe233", size = 2344556, upload-time = "2026-01-21T16:27:40.125Z" },
+ { url = "https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248", size = 8072351, upload-time = "2026-01-21T16:27:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/16/8f650c2e288977cf0f8f85184b90ee56ed170a4919347fc74ee99286ed6f/torchvision-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:f9c55ae8d673ab493325d1267cbd285bb94d56f99626c00ac4644de32a59ede3", size = 4303059, upload-time = "2026-01-21T16:27:11.08Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5b/1562a04a6a5a4cf8cf40016a0cdeda91ede75d6962cff7f809a85ae966a5/torchvision-0.25.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:24e11199e4d84ba9c5ee7825ebdf1cd37ce8deec225117f10243cae984ced3ec", size = 1874918, upload-time = "2026-01-21T16:27:39.02Z" },
+ { url = "https://files.pythonhosted.org/packages/36/b1/3d6c42f62c272ce34fcce609bb8939bdf873dab5f1b798fd4e880255f129/torchvision-0.25.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f271136d2d2c0b7a24c5671795c6e4fd8da4e0ea98aeb1041f62bc04c4370ef", size = 2309106, upload-time = "2026-01-21T16:27:30.624Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/60/59bb9c8b67cce356daeed4cb96a717caa4f69c9822f72e223a0eae7a9bd9/torchvision-0.25.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:855c0dc6d37f462482da7531c6788518baedca1e0847f3df42a911713acdfe52", size = 8071522, upload-time = "2026-01-21T16:27:29.392Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a5/9a9b1de0720f884ea50dbf9acb22cbe5312e51d7b8c4ac6ba9b51efd9bba/torchvision-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:cef0196be31be421f6f462d1e9da1101be7332d91984caa6f8022e6c78a5877f", size = 4321911, upload-time = "2026-01-21T16:27:35.195Z" },
+ { url = "https://files.pythonhosted.org/packages/52/99/dca81ed21ebaeff2b67cc9f815a20fdaa418b69f5f9ea4c6ed71721470db/torchvision-0.25.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a8f8061284395ce31bcd460f2169013382ccf411148ceb2ee38e718e9860f5a7", size = 1896209, upload-time = "2026-01-21T16:27:32.159Z" },
+ { url = "https://files.pythonhosted.org/packages/28/cc/2103149761fdb4eaed58a53e8437b2d716d48f05174fab1d9fcf1e2a2244/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:146d02c9876858420adf41f3189fe90e3d6a409cbfa65454c09f25fb33bf7266", size = 2310735, upload-time = "2026-01-21T16:27:22.327Z" },
+ { url = "https://files.pythonhosted.org/packages/76/ad/f4c985ad52ddd3b22711c588501be1b330adaeaf6850317f66751711b78c/torchvision-0.25.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c4d395cb2c4a2712f6eb93a34476cdf7aae74bb6ea2ea1917f858e96344b00aa", size = 8089557, upload-time = "2026-01-21T16:27:27.666Z" },
+ { url = "https://files.pythonhosted.org/packages/63/cc/0ea68b5802e5e3c31f44b307e74947bad5a38cc655231d845534ed50ddb8/torchvision-0.25.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5e6b449e9fa7d642142c0e27c41e5a43b508d57ed8e79b7c0a0c28652da8678c", size = 4344260, upload-time = "2026-01-21T16:27:17.018Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1f/fa839532660e2602b7e704d65010787c5bb296258b44fa8b9c1cd6175e7d/torchvision-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:620a236288d594dcec7634c754484542dc0a5c1b0e0b83a34bda5e91e9b7c3a1", size = 1896193, upload-time = "2026-01-21T16:27:24.785Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ed/d51889da7ceaf5ff7a0574fb28f9b6b223df19667265395891f81b364ab3/torchvision-0.25.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b5e7f50002a8145a98c5694a018e738c50e2972608310c7e88e1bd4c058f6ce", size = 2309331, upload-time = "2026-01-21T16:27:19.97Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a5/f93fcffaddd8f12f9e812256830ec9c9ca65abbf1bc369379f9c364d1ff4/torchvision-0.25.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:632db02300e83793812eee4f61ae6a2686dab10b4cfd628b620dc47747aa9d03", size = 8088713, upload-time = "2026-01-21T16:27:15.281Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/eb/d0096eed5690d962853213f2ee00d91478dfcb586b62dbbb449fb8abc3a6/torchvision-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:d1abd5ed030c708f5dbf4812ad5f6fbe9384b63c40d6bd79f8df41a4a759a917", size = 4325058, upload-time = "2026-01-21T16:27:26.165Z" },
+ { url = "https://files.pythonhosted.org/packages/97/36/96374a4c7ab50dea9787ce987815614ccfe988a42e10ac1a2e3e5b60319a/torchvision-0.25.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad9a8a5877782944d99186e4502a614770fe906626d76e9cd32446a0ac3075f2", size = 1896207, upload-time = "2026-01-21T16:27:23.383Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e2/7abb10a867db79b226b41da419b63b69c0bd5b82438c4a4ed50e084c552f/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:40a122c3cf4d14b651f095e0f672b688dde78632783fc5cd3d4d5e4f6a828563", size = 2310741, upload-time = "2026-01-21T16:27:18.712Z" },
+ { url = "https://files.pythonhosted.org/packages/08/e6/0927784e6ffc340b6676befde1c60260bd51641c9c574b9298d791a9cda4/torchvision-0.25.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:846890161b825b38aa85fc37fb3ba5eea74e7091ff28bab378287111483b6443", size = 8089772, upload-time = "2026-01-21T16:27:14.048Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/37/e7ca4ec820d434c0f23f824eb29f0676a0c3e7a118f1514f5b949c3356da/torchvision-0.25.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f07f01d27375ad89d72aa2b3f2180f07da95dd9d2e4c758e015c0acb2da72977", size = 4425879, upload-time = "2026-01-21T16:27:12.579Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.27.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+dependencies = [
+ { name = "numpy", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "pillow", marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+ { name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-deeplabcut-apple-mchips' or extra == 'extra-10-deeplabcut-fmpose3d' or extra == 'extra-10-deeplabcut-tf' or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/15/2df874db140bbfe42f377e05e2dd38f2b9dc88414a6607eecc42073b2baa/torchvision-0.27.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0822b58d2c5d325cd0c7152b744acbd15f898c07572e2cfb70b075a865a4f6f9", size = 1758817, upload-time = "2026-05-13T14:57:20.113Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/32/10b1ff4087d35b7af7bd85ccb85fbc2573c6f1c2008cf8abfcaf605a10fc/torchvision-0.27.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c9f44e35e6ec01caedacce9e941a5bf21fe424403321efac2507a201273653c5", size = 7830083, upload-time = "2026-05-13T14:57:18.336Z" },
+ { url = "https://files.pythonhosted.org/packages/57/20/97dca91770235028ba7e9c598ca1fc48c297f1843af8102430f2adcd4335/torchvision-0.27.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:419c98a9275b27660cdce6d09080fd5974d1ec1d4a225f71439ebacb3b0c4e64", size = 7573816, upload-time = "2026-05-13T14:57:12.327Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a5/66fbf7f21f292d095a153ee142806646813e2055a69efe5854c28e7c3fb9/torchvision-0.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:2664d06acd64d328aa7689b0d0c81ee31e240e9977d8768816b4be7c66c03211", size = 3435489, upload-time = "2026-05-13T14:57:13.716Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d6/a7e71e981042d5c573e2e61891b9023b190c88adb75b18bed8594371250c/torchvision-0.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:df0c166b6bdf7c47f88e81e8b43bc085451d5c50d0c5d1691bc474c1227d6fed", size = 1758812, upload-time = "2026-05-13T14:57:16.662Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f9/f542fb7e4476603fb237ebdc64369a7d11f18eb5a129aa2559cbdb710aee/torchvision-0.27.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9bb9251f64b854124efed95d02953a89f7e2726c3ca662d7ea0151129157297f", size = 7831148, upload-time = "2026-05-13T14:57:08.37Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/61/7aa7cc2c9e8750027f6fb9ae3a7393ef43860bcdfe3966e2f71fee800e31/torchvision-0.27.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f44453f107c296d5446a79f7ac59733ad8bf5ddfa04c53805dfbae298a42a798", size = 7575519, upload-time = "2026-05-13T14:56:50.552Z" },
+ { url = "https://files.pythonhosted.org/packages/19/aa/929b358b1a643849b81ec95569938044cc37dc65ab10c84eb6d82fe1bfbb/torchvision-0.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:b4aacff70ea4b7377f996f9048989c850d221fef33658ddbcae42aa5bd4ca11a", size = 3749475, upload-time = "2026-05-13T14:57:11.007Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c8/5cd91932f7f3671b0743dc4ae1a4c16b1d0b45bf4087976277d325bda718/torchvision-0.27.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1a6dd742a150645126df9e0b2e449874c1d635897c773b322c2e067e98382dfe", size = 1758824, upload-time = "2026-05-13T14:57:15.227Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/36/7fb7d19477b3d93283b52fea11fa8ee30ab9064a08c97b4a6b91445e26cb/torchvision-0.27.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65772ff3ec4f4f5d680e30019835555dd239e7fefee4b0a846375fe1cb1592ef", size = 7831034, upload-time = "2026-05-13T14:57:06.483Z" },
+ { url = "https://files.pythonhosted.org/packages/62/43/dfd894c3f8b01b5b33fde990f0159c1926ebc7b6e2c4193e2efb7da3c4cb/torchvision-0.27.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7a9966a088d06b4cf6c610e03be62de469efa6f2cd2e7c7eed8e925ed6af59ac", size = 7579774, upload-time = "2026-05-13T14:56:59.337Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0c/722e989f9cf026e97ef7cb24a9bb1859e099f72d247ae35388fb89729f73/torchvision-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c037709072ca9b19750c0cbe9e8bb6f91c9a1be1befa26df33e281deccbd8c7", size = 4021073, upload-time = "2026-05-13T14:57:00.848Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ae/36547812e6e047c1d80bcacd1b17a340612b08a6e876e0aabf3d0b9228b0/torchvision-0.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:41d6dae73e1af09fa82ded597ae57f2a2314285acde54b25890a8f8e51b999d7", size = 1758826, upload-time = "2026-05-13T14:57:05.262Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/30/32c4ea842738728a14e3df8c576c62dedcf5ae5cb6a5c984c6429ebe7524/torchvision-0.27.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:70f071c6f74b60d5fe8851636d8d4cd5f4fa29d57fd9348a87a6f17b990b95ba", size = 7789501, upload-time = "2026-05-13T14:56:57.786Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/24/4d0d48684251bd0673f87d633d5d88ab00227983b00591156eed2f86c8d5/torchvision-0.27.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:aaafa6962c9d91f42503de1957d6fa349907d028c06f335bd95da7a5bc57147d", size = 7579868, upload-time = "2026-05-13T14:56:41.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/da/e6edd051d2ba25adf23b120fa97f458dff888d098c51e84724f17d2d1470/torchvision-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:aee384a2782c89517c4ab9061d2720ba59fd2ffe5ef89d0a149cc2d43abdf521", size = 4092700, upload-time = "2026-05-13T14:57:09.729Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/23/95dfa40431360f42ca949bf861434bed51164adfa8fb9801e05bf3194f50/torchvision-0.27.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:c5121f1b9ab09a7f73e837871deb8321551f7eaeb19d87aa00de9191968eae44", size = 1845008, upload-time = "2026-05-13T14:57:03.768Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b9/9dbdf76b2b49a75ba8088df6f7c755bdb520afb6c6dbac0102b46cde5e99/torchvision-0.27.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1c01f0d1091ae22b9dfc082b0a0fe5faaf053686a29b4fb082ba7691375c73cf", size = 7791430, upload-time = "2026-05-13T14:56:56.206Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/6a/e4a16cf2f3310c2ea7760dc5d9054496844391e0f4c1fae87fefac2f3d9e/torchvision-0.27.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dadea3c5ecfd05bbb2a3312ab0374f213c58bf6459cb059122e2f4dfe13d10ed", size = 7668441, upload-time = "2026-05-13T14:57:02.127Z" },
+ { url = "https://files.pythonhosted.org/packages/00/70/01b6461117a6a94b5af3f8ee166bb0f045056f3cf187750c110dabfdfffa/torchvision-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a49e55055a39a8506fe7e59850522cab004efb2c3839f6057658889c1d69c815", size = 4141602, upload-time = "2026-05-13T14:56:53.449Z" },
+ { url = "https://files.pythonhosted.org/packages/92/22/c0633677b3b3f3e69554a21ac087bf705f829c40cd5e3783507b8c006681/torchvision-0.27.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:c1fac0fc2a7adf29481fc1938a0e7845c57ba1147a986784109c4d98f434ea8c", size = 1758818, upload-time = "2026-05-13T14:56:54.988Z" },
+ { url = "https://files.pythonhosted.org/packages/48/e8/55f9d9667b56dae470e69e31beac9b00d458ea393feec1aae95cc4f3f1c9/torchvision-0.27.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:cbf89764fc76f3f17fbf80c12d5a89c691e91cb9d82c38412aaf0568655ffb19", size = 7789667, upload-time = "2026-05-13T14:56:48.858Z" },
+ { url = "https://files.pythonhosted.org/packages/00/bc/6f8681daf3bbc4c315bb0005110f99d28e3ecd675bf9c8f2c0d393fbac7a/torchvision-0.27.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:91f61b9865423037c327eb56afa207cc72de874e458c361840db9dcf5ce0c0eb", size = 7579848, upload-time = "2026-05-13T14:56:38.209Z" },
+ { url = "https://files.pythonhosted.org/packages/19/6c/8d8020e6bd1e46c53e487c9c4e9457a07f2ee28931028fb5d71e2da40adc/torchvision-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:5bb82fc3c55daf1788621e504310b0a286f1069627a8742f692aebb075ef25a7", size = 4119284, upload-time = "2026-05-13T14:56:46.625Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/7e/e78c48662a8d551606efdbe11c6b9c1d6d2391b92cd0e4591b9e6a2412b8/torchvision-0.27.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2c4099a15150143b9b034730b404a56d572efe0b79489b4c765d929cb4eac7f3", size = 1758828, upload-time = "2026-05-13T14:56:52.293Z" },
+ { url = "https://files.pythonhosted.org/packages/21/dd/d03ee9f9ee7bf11a8c7c776fb8e7fd6102f59c013791a2a4e5175bd6cba7/torchvision-0.27.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b4c6bb0a670dcba017b3643e21902c9b8a1cc1c127d602f1488fa29ec3c6e865", size = 7790618, upload-time = "2026-05-13T14:56:44.721Z" },
+ { url = "https://files.pythonhosted.org/packages/39/08/4002336a74742be70728603ec1769feb2b55e0d19c532c9ec9f92008de76/torchvision-0.27.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1c2db4bde82bc48ebff73436a6adf34d4f809448268a70d9a1285f5c8f92313d", size = 7580217, upload-time = "2026-05-13T14:56:43.274Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/cb/4dd4783eb3565f526ba6e64b6f6ca26c00eacc924cdfe60455db9d91b84b/torchvision-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:72bf547e58ddb948689734eed6f4b6a2031f979dba4fb08e3690688b392e929f", size = 4226392, upload-time = "2026-05-13T14:56:40.235Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
+ { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
+ { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
+ { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "traitlets"
+version = "5.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" },
+]
+
+[[package]]
+name = "triton"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+]
+dependencies = [
+ { name = "cmake", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "filelock", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "lit", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "torch", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/31/ff6be541195daf77aa5c72303b2354661a69e717967d44d91eb4f3fdce32/triton-2.0.0-1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38806ee9663f4b0f7cd64790e96c579374089e58f49aac4a6608121aa55e2505", size = 63268585, upload-time = "2023-03-20T17:32:17.169Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/cd/4aa0179919306f9c2e3e5308f269d20c094b2a4e2963b656e9405172763f/triton-2.0.0-1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:226941c7b8595219ddef59a1fdb821e8c744289a132415ddd584facedeb475b1", size = 63278135, upload-time = "2023-03-20T17:32:25.22Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/ba/b1b04f4b291a3205d95ebd24465de0e5bf010a2df27a4e58a9b5f039d8f2/triton-3.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c723cfb12f6842a0ae94ac307dba7e7a44741d720a40cf0e270ed4a4e3be781", size = 175972180, upload-time = "2026-01-20T16:15:53.664Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" },
+ { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" },
+ { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" },
+ { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.7.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3e/97/dcd1f2a0f8336691bff74abc59b2ed9c69a0c0f8f65cd77109c49e05f068/triton-3.7.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223ac302091491436c248a34ee1e6c47a1026486579103c906ffd805be50cb89", size = 188367104, upload-time = "2026-05-07T19:04:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c0/c2ac4fd2d8809b7579d4a820a0f9e5de62a9bc8a757ed4b3abf4f7ee964a/triton-3.7.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c631b65668d4951213b948a413c0564184305b77bb45cc9d686d3e1ecc4701a3", size = 201313191, upload-time = "2026-05-07T18:45:58.444Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/c1/5d842314bb6c78442cc60437928781701c6050b8d479bc2a1aed691d37ca/triton-3.7.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9e71fc392675fac364e0ecf4ef3f76f85b7f5433a16f4c3c5fe5f05a52c85fe", size = 188480277, upload-time = "2026-05-07T19:05:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/13/31/8315ea5f8dd18e60970b3022e3a8b93fd37e0b784fbbef86e10c8e6e5ca1/triton-3.7.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22bacffce443f54593dd20f05294d5a40622e0ea9ab632816f87154504356221", size = 201415942, upload-time = "2026-05-07T18:46:06.479Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/13/ec05adfcd87311d532ba61e3af143e8be59fcd26675884c4682841406a20/triton-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4bf49b00a7a377a68a6da603a876e797614e6455a80e9021669c476a953ad9a", size = 188505104, upload-time = "2026-05-07T19:05:09.843Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7b/468a576e35beef1426e0828e28e9ba9e65f5474d496f16ee126c15646324/triton-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f111161d49bf903c0eaedde3962353a3d841c08a836839b7cc1025b8426efcf", size = 201457567, upload-time = "2026-05-07T18:46:13.505Z" },
+ { url = "https://files.pythonhosted.org/packages/01/e1/a59a583de59b8f62c495d67c80ee3ea97d09e91ac80c4c6e76456ed8d8ac/triton-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abdf6beaa89b1bcfb9a43cd990536ce66091a997841a4814b260b7bee4c88c3c", size = 188503209, upload-time = "2026-05-07T19:05:17.935Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b1/b7507bb9815d403927c8dd51d4158ed2e11751a92dbc118a044f247b6848/triton-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a35d7afe3f3f058e7ec49fcce09794049e0ffc5c59019ac25ec3413741b8c4e7", size = 201453566, upload-time = "2026-05-07T18:46:20.427Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/8f/0bea7a6a0c989315c9135a1d7fb37e41905cfb3a17cbc1f10044ebd4cc3a/triton-3.7.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc1d61c172d257db80ddf42595131fb196ad2e9bdd751e90fe2ef13531734e8b", size = 188612899, upload-time = "2026-05-07T19:05:24.955Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/02/d96f57828d0912aec733b9bc7e0e7dbfd2c6f079a8fa433ac25cb93d1a30/triton-3.7.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70fb9bbdc9f400afc54bbf6eb2670af28829a6ae3996863317964783141daf56", size = 201553816, upload-time = "2026-05-07T18:46:27.49Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fb/82a802dac4689f2a2fb2e69302e6a138eecc3e175bbe976ba3cfc717683a/triton-3.7.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a44a8476d0d3571eac4e4d1048e1ff75aad81a09ff4602ccfc56c6dea1672e", size = 188507879, upload-time = "2026-05-07T19:05:32.209Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/af/9904ec6d3c93d9b24e5ec360445bbdf758b7f00bfbeedb89cb0eb64eb8bb/triton-3.7.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b85e72968a9d8bba5ddb24e9b64aaabaf48affb042f2755cb7cfa92b7531ce", size = 201460637, upload-time = "2026-05-07T18:46:34.749Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/f9/4835a8ea746b88727d8899f4e3ccce4f9cacb38abfc3bb0a638266c53111/triton-3.7.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a160de426fd99f92b0baf509045360afbd3bfaa0b4a5171dde800ec9f09684", size = 188608706, upload-time = "2026-05-07T19:05:39.218Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "uc-micro-py"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.35.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
+]
+
+[[package]]
+name = "vispy"
+version = "0.15.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "freetype-py" },
+ { name = "hsluv" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1b/2e/ef2697f963111cf1bc83568bbe55f262b7c7c8a72948a6e802a7c236f2c1/vispy-0.15.2.tar.gz", hash = "sha256:d52d10c0697f48990555cea2a2bad3f9f5a772391856fda364ea4bbc69fd075c", size = 2513383, upload-time = "2025-05-19T13:26:41.015Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/2b/a483bf80575e047173d55f51115c38f9c43962cfd3247a861ce033913bee/vispy-0.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6bc8a49f4e0b27e19be0da318877666d733e1afc7231407e635f948a8aabb095", size = 1478710, upload-time = "2025-05-19T13:26:00.997Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/bc/8c9a3cb402037d6eda521492c04dddf6a00f600c85b650b33342573fe82f/vispy-0.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89a5d51cc980fed81f16373d515086d5b16da66868df8c1f76e71d4dfc17062c", size = 1472024, upload-time = "2025-05-19T13:26:03.055Z" },
+ { url = "https://files.pythonhosted.org/packages/58/67/46111a528d63ad5308e4a547484c75e1c9982ec6a3709732ba0bcd343a7a/vispy-0.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7cbce48e14e2eeac491688be33b4d422d198515c67a1a87021622790309107e", size = 1845072, upload-time = "2025-05-19T13:26:04.226Z" },
+ { url = "https://files.pythonhosted.org/packages/56/91/5c9d739410427c603fd0aa9e6a29b6c3e51edcc7f83f14bf243974808396/vispy-0.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8b0b6b55a781b6add58f946b95c368bfa203e7915fda234f4993c4db03d3c0", size = 1844568, upload-time = "2025-05-19T13:26:05.511Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8e/f07f9d7a341b5058a99e0700f7cab7d222b159154a392b335f0e59cbf636/vispy-0.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:0f2639656fd53ee4cbedf3e3caed0cef646f90e7ca4ec777ba18ad247234d8c9", size = 1467701, upload-time = "2025-05-19T13:26:07.193Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e7/3590e46cec1d51a8bba0b2d5442df698aec41ff5757e9e29e04aea3cbe12/vispy-0.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4f7adab04c0a90ca0a1155f09ecc425ca44bee713e6b2d4970d85fc010b05ee", size = 1478729, upload-time = "2025-05-19T13:26:08.951Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/71/5871fe8d2f612502e5e148224426fb263431870a2f5fa5d2205fb9f2606a/vispy-0.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:725ab77b11bb3a4de5145a25b6e06f4555b0b7b1821baf7ffdd7f674f725dab2", size = 1472015, upload-time = "2025-05-19T13:26:10.246Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d8/067de34eac9aecf71a3291c54a738161caa9cbf30d62314b0500abff4117/vispy-0.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d107b07365bc1bdfa28fe6f8df05004cd73bfd6269230bef26b3862f2016fa64", size = 1872447, upload-time = "2025-05-19T13:26:11.844Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1e/af88bdb15b20a690b3d5409fc89fbdbb79095a593abb8a37094a42428760/vispy-0.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c67fb06df462b63ba95c77eb00a3d5b7d53e90f69987b0e216ab67ad7d6d00e", size = 1876232, upload-time = "2025-05-19T13:26:13.094Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4d/236c77644db2427f33330fd6372bac416262fd3de6771c555b969f67bd4f/vispy-0.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:46f8ca742958e19135cfa0f76ef1707666837ae6a9559a3fa3c2a3186967299e", size = 1467601, upload-time = "2025-05-19T13:26:15.199Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/7e/31102425ea26bafab27bb5a675b499c57d29e72b1cc25fe3ae8facdd374e/vispy-0.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c8cbc22bc789f238423abadfcc3ba7589b85729be6b179b321720b511012fbee", size = 1478919, upload-time = "2025-05-19T13:26:16.962Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/63/a601b8f1e8e418d3821d7b4a465c2eb6695ab8eccadfce886b99ffdfe92a/vispy-0.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf995b86dcc1eab265fa2bf9a8a5ca5a225b86c3ed4ac63900f0c5e90d5e8100", size = 1471915, upload-time = "2025-05-19T13:26:18.441Z" },
+ { url = "https://files.pythonhosted.org/packages/79/1f/ca0deb4a876148c0fa515bf03d03e1dfad4553be8e6f2ab51433c074310b/vispy-0.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0aa64fcab1cd1730a10a1c9188e9cef8aefe6d099a46f2f87e2458bcef719918", size = 1866848, upload-time = "2025-05-19T13:26:19.6Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a4/dc6f335e54877f5d26ad4e4aac8f49b2d6e7719dee3e08f363d882c8aba4/vispy-0.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d38228cedd23876cb2359ff568d523a97284d225259d450d5e5e2129e98eb1", size = 1870433, upload-time = "2025-05-19T13:26:20.794Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/73/5ffa34d7300c35e8423d51789d594d35eaa7256d210f9909afa9fdd0aa37/vispy-0.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:a84531c1a89f8b9d3992eb0d0ab74f84884f49d1f46a8be3755c809d7507cb01", size = 1468068, upload-time = "2025-05-19T13:26:22.073Z" },
+ { url = "https://files.pythonhosted.org/packages/85/9a/6664b7f0d28e0008a28111bae847a1fa3eedd409458bf01ca45a6e99da6f/vispy-0.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ac46aab46e208b919dcf0bb26869bcd083a0a1a4927bcaf41631ba38c247639", size = 1477880, upload-time = "2025-05-19T13:26:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/6f/b9b36f841c5ff7320764f64822e79df3fea8a2c92270cda7f3a634d9a031/vispy-0.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e197a012503850a77d47177964d572edab964b2a8ea0f9d998c35b81325256c", size = 1471035, upload-time = "2025-05-19T13:26:24.554Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/af/b892a3c9b4be29755ce4d1fc17ecb8249446bdd0e17bb5b2a9cb39bcf0f4/vispy-0.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d9eff397ecdbaf0052baae0563caa9e276272d6dc78fbaab3f5e51dbf5f7f92", size = 1860412, upload-time = "2025-05-19T13:26:26.212Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/13/8997d96bdc0a81f331dfd41b368935d79e8ea2917840266567e6dc40d684/vispy-0.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13ee0cb95bbff6d7b235e1a8f96e620eee15ccb6d5ad74902427ab7e56dc8ab", size = 1865860, upload-time = "2025-05-19T13:26:27.928Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/37/abb30db1853b69aed4c32813cf312f301ec3641b8846193be9a6d892d607/vispy-0.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:f5955821e9b452980490706648a702da8cb2b82e762b6d6589e5872118301b84", size = 1467882, upload-time = "2025-05-19T13:26:29.608Z" },
+]
+
+[[package]]
+name = "wandb"
+version = "0.27.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "gitpython" },
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "protobuf", version = "4.25.9", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or extra == 'extra-10-deeplabcut-tf' or extra == 'extra-10-deeplabcut-tf-cu11' or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "5.29.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.13' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version < '3.13' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version < '3.13' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version < '3.13' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.13' and extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (python_full_version >= '3.13' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "protobuf", version = "6.33.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf') or (python_full_version < '3.13' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-tf' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "sentry-sdk" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/31/fe53d06b75ef0a7f2f0ee5931a89f7aedc27d233840b1839616860fed256/wandb-0.27.0.tar.gz", hash = "sha256:579e75300173059f9334e1f513a79ef15f6d9ea5c74e20d695633648cdd02031", size = 41090732, upload-time = "2026-05-14T03:44:08.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/5e/2c199e70e636ecfd217cde0bc7469f4511e1d03d0685eb92bfdfce391430/wandb-0.27.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:c156be4851485f3c4160cb6eb2e8991b4cdeffbccefc5636d33cf5e254847365", size = 24886476, upload-time = "2026-05-14T03:43:27.569Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/cd/a617c871cd304a9804e56a7ec2ec2c65685bf0091a2b9f91910175a149e2/wandb-0.27.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:20179f38afb0158859a4141d29ac650d3fdbd0cf801a74ce25565c934f03776c", size = 26045779, upload-time = "2026-05-14T03:43:31.999Z" },
+ { url = "https://files.pythonhosted.org/packages/10/0a/d3f159a201530b84b72ca5f98c68d1f351c2d9a1864558ed76c811407fae/wandb-0.27.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:626497d7975fa898d0a4a239da7a510483495ca3514510dbe75004a25963af4d", size = 25480764, upload-time = "2026-05-14T03:43:35.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/6a/8721fcdf71d42639191040a77a585d2982402b1754700cb2ecfc2ca1470a/wandb-0.27.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f772da7005cc26a2a32b729a16982a583dc68b3d493df6a09d0aa5c5ca5a2060", size = 27256204, upload-time = "2026-05-14T03:43:39.765Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5e/279d167ba79fb7a8a43401c9f25efd0f6663ee9bd1eaf5a8578530198888/wandb-0.27.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:63acfc5b994e4a90e4a2fbdee6d45e664da3dd865bb1419942c8995c06c41cf1", size = 25647469, upload-time = "2026-05-14T03:43:44.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/51/a69ac59300e3c813939d0764348959ed2a21e14c668cb1cebcb04010da6a/wandb-0.27.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:17aae6e4a88cd05c00ea8f546220918e3ebb6f8c1c36b70ef04a5ac75f0d7160", size = 27599005, upload-time = "2026-05-14T03:43:50.926Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/40/bf510c8758727df020f83b717ebc1fcc1739ed7f6ae1796ebef60bf6f592/wandb-0.27.0-py3-none-win32.whl", hash = "sha256:0bd5659417e386bf6538b5e2ffe6885774c6197f0e4853bfed517d5b0db457f1", size = 25036164, upload-time = "2026-05-14T03:43:54.839Z" },
+ { url = "https://files.pythonhosted.org/packages/54/ff/69f88e7d90c22b79bcb911143c13e59742ee192080b21015ff83a5a1f60a/wandb-0.27.0-py3-none-win_amd64.whl", hash = "sha256:89d584b73166eecee96fb446f18d0e45b1aa45aba6a3696296f3f06d7454516b", size = 25036170, upload-time = "2026-05-14T03:43:59.227Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/38/f7efd7a87297a55c7e9a331a1dbb5b19e54aeacc11fe6f43f8636a73987c/wandb-0.27.0-py3-none-win_arm64.whl", hash = "sha256:a6c129c311edf210a2b4f2f4acc557eff522628125f5f28ed27df19c16c07079", size = 22972710, upload-time = "2026-05-14T03:44:03.275Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe", marker = "(sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'darwin' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform == 'darwin' and extra == 'extra-10-deeplabcut-apple-mchips') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (extra != 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+]
+
+[[package]]
+name = "wheel"
+version = "0.47.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "1.14.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/49/925324e2eaa0c83c45b7a1eb31004af4ad7b37fdc1d9e897ea7d551215da/wrapt-1.14.2.tar.gz", hash = "sha256:19d33781d891c99b5a35f810656d2fe01765b35ccfb6d395007d2edb1058d98f", size = 50701, upload-time = "2025-08-12T06:59:02.334Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/3f/12f70730cf9237e07aa5887bdb3e3695fd68e7e0d6dd66b5dd7a8794724a/wrapt-1.14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:734de7e0182d15f03eb4f4fce0ff7f07a072119a19cdcc97c40aee96d10fe851", size = 35836, upload-time = "2025-08-12T06:58:20.535Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/95/5e261ed720bdddf9bbfea2b9bbd30bd03e298b02267333a231592a0cef7b/wrapt-1.14.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:851ded3662733bab093297a0adb844c71e7754b8c144f76bb569ac6544a7c27a", size = 76907, upload-time = "2025-08-12T06:58:39.06Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/fcf75886924473ebc11ded6f589f9f715bd605a787201e8b9b1f34b958a7/wrapt-1.14.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f7b9a0364edef5a3033307cde69f7c3775a74eb83caeff8955e72ec3e0963fe", size = 77665, upload-time = "2025-08-12T06:58:29.329Z" },
+ { url = "https://files.pythonhosted.org/packages/16/de/e9205650d0c8401c4e2cc2a01edc89ce3fabda219e2efabcc71d92d02a70/wrapt-1.14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3066203bf6e35a040c848143636e6fc2aa4ad6fb2b027a956f462083a3b635ea", size = 76401, upload-time = "2025-08-12T06:58:30.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/e6/e3b79ba90e7b3a7c1915dbecb84aa491dc0a16238216dae5137bb0d85689/wrapt-1.14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:072684f6264ca83695d1c7ad601e61b17953351b0950790cd50c327402006492", size = 76360, upload-time = "2025-08-12T06:58:40.019Z" },
+ { url = "https://files.pythonhosted.org/packages/35/07/fe53594e194b133b29b0808c40f1ad92773186c314dbb5b1bf5bb9a4e34a/wrapt-1.14.2-cp310-cp310-win32.whl", hash = "sha256:26c36d922c70de7ae7e933d0f9a10b4017d007d505f60fe02efa8e260a6c7af4", size = 33681, upload-time = "2025-08-12T06:58:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/12/68/134031baba6e1ba7dbdf2a217e99feb7177a2ad860b19f3ba319d40e0baf/wrapt-1.14.2-cp310-cp310-win_amd64.whl", hash = "sha256:ead0c5373df2c551347c7f71903a6403efb391506e1e349704ec21fe797e59bf", size = 35811, upload-time = "2025-08-12T06:58:51.696Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ea/7ab441022d98da4cd4ff6d2ec6cbd8e4bf69085f261bdf72971a43bd8a45/wrapt-1.14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3913516545546d3ff82ffe5bb4a7eab29dc7f1c71cf7329a1494659dc2a60b7b", size = 35832, upload-time = "2025-08-12T06:58:22.674Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/26/953f79a4233603958234358820434d973bd859d3831b19fde23078e09771/wrapt-1.14.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:122015da6e0a1d09e1f9fbb8c756b9a8802e6cb01138f5256c121602bc7c7399", size = 77384, upload-time = "2025-08-12T06:58:41.195Z" },
+ { url = "https://files.pythonhosted.org/packages/53/7f/4057df32059ea4782cf34b7a9dc08c6b29c4e29ecccfa58b32a12eb77582/wrapt-1.14.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:257a53f69737f340d0ba6e4d5554b56a579ca884de6eada15a7126baae8dacc0", size = 78135, upload-time = "2025-08-12T06:58:31.259Z" },
+ { url = "https://files.pythonhosted.org/packages/37/80/d688dfde8c9d75b41c6f01a71e6f64a77861b6ad67e04d622d78060d638c/wrapt-1.14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e60ca4aa80a85a0bc24cdf0f971d736f6d0957bdbc93874339027cfb0f286c99", size = 76933, upload-time = "2025-08-12T06:58:32.586Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b0/2b5f8b11c463f75acb6ca710a2ac2e40549f6b5d68bc85c135fc49a7ef19/wrapt-1.14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f23bfecdc0e139f15b195ca3601e71a1388dc058bdbbd160ba9d0f4a17b4a12d", size = 76873, upload-time = "2025-08-12T06:58:42.64Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d4/003f22d726ca8a0bc2170cad7651dad732f3d3a6e4f69d1f13d8244a93e9/wrapt-1.14.2-cp311-cp311-win32.whl", hash = "sha256:cb5aee915d67441bd07acbce9e4cab1d01a110b4e5f02932e80e5ef4c9411ae2", size = 33675, upload-time = "2025-08-12T06:58:54.885Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/61/8eb200c6cf899fedc677a7a01b2f189ad58f1d154d0ef84dab30800b9f01/wrapt-1.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:e9773691347205f7d11e7c4395561633285c8d1d691ebfc73ffd64f9072aa22e", size = 35811, upload-time = "2025-08-12T06:58:53.921Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/c7/7376998449689cf2adbdbeacad47084410d00f3ae04cf73e6127cf52b950/wrapt-1.14.2-py3-none-any.whl", hash = "sha256:82eea3b559f51f22aefc530b747b87406811ef00cb0c4734f48cb139c748db63", size = 21577, upload-time = "2025-08-12T06:59:01.408Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'linux'",
+ "python_full_version >= '3.13' and sys_platform == 'darwin'",
+ "python_full_version == '3.12.*' and sys_platform == 'darwin'",
+ "python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "(python_full_version == '3.12.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version == '3.11.*' and sys_platform == 'darwin'",
+ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.11' and sys_platform == 'darwin'",
+ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "(python_full_version < '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" },
+ { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" },
+ { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" },
+ { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
+ { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
+ { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
+ { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
+ { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
+ { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
+ { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
+ { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
+ { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
+ { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
+ { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
+ { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
+ { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
+ { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
+]
+
+[[package]]
+name = "yacs"
+version = "0.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/3e/4a45cb0738da6565f134c01d82ba291c746551b5bc82e781ec876eb20909/yacs-0.1.8.tar.gz", hash = "sha256:efc4c732942b3103bea904ee89af98bcd27d01f0ac12d8d4d369f1e7a2914384", size = 11100, upload-time = "2020-08-10T16:37:47.755Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/4f/fe9a4d472aa867878ce3bb7efb16654c5d63672b86dc0e6e953a67018433/yacs-0.1.8-py3-none-any.whl", hash = "sha256:99f893e30497a4b66842821bac316386f7bd5c4f47ad35c9073ef089aa33af32", size = 14747, upload-time = "2020-08-10T16:37:46.4Z" },
+]
+
+[[package]]
+name = "zipp"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
+]
|