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
new file mode 100644
index 0000000000..b46cf0e61f
--- /dev/null
+++ b/.codespellrc
@@ -0,0 +1,5 @@
+[codespell]
+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,assertin,bu,td,ctd,wither
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index cb4f71f7b9..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:
- - jeylau
+ - 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/actions/setup-dev-docs/action.yaml b/.github/actions/setup-dev-docs/action.yaml
new file mode 100644
index 0000000000..bbcbdc0d5d
--- /dev/null
+++ b/.github/actions/setup-dev-docs/action.yaml
@@ -0,0 +1,35 @@
+name: Setup dev-docs environment
+description: Install Python, system deps, and DeepLabCut dev-docs dependencies.
+
+inputs:
+ python-version:
+ description: Python version to use.
+ required: false
+ default: "3.10"
+
+ install-system-deps:
+ description: Install system dependencies required by mkdocs-material social plugin.
+ required: false
+ default: "true"
+
+runs:
+ using: composite
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install system dependencies for mkdocs-material social plugin
+ if: ${{ inputs.install-system-deps == 'true' }}
+ shell: bash
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y libcairo2-dev libfreetype6-dev libffi-dev \
+ libjpeg-dev libpng-dev libz-dev
+
+ - name: Install dev-docs dependencies
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install ".[dev-docs]"
diff --git a/.github/workflows/build-dev-docs.yml b/.github/workflows/build-dev-docs.yml
new file mode 100644
index 0000000000..125c68fc2b
--- /dev/null
+++ b/.github/workflows/build-dev-docs.yml
@@ -0,0 +1,33 @@
+name: Docs / Build dev-docs
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: Python version used to build the dev docs.
+ required: false
+ default: "3.10"
+ type: string
+
+ config-file:
+ description: MkDocs config file.
+ required: false
+ default: "dev-docs/mkdocs.yml"
+ type: string
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up dev-docs environment
+ uses: ./.github/actions/setup-dev-docs
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Build dev-docs
+ run: mkdocs build -v -f ${{ inputs.config-file }}
diff --git a/.github/workflows/build-main-docs.yml b/.github/workflows/build-main-docs.yml
new file mode 100644
index 0000000000..e74ee9a0d6
--- /dev/null
+++ b/.github/workflows/build-main-docs.yml
@@ -0,0 +1,49 @@
+name: Docs / Build main docs (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
new file mode 100644
index 0000000000..cac97bac77
--- /dev/null
+++ b/.github/workflows/codespell.yml
@@ -0,0 +1,22 @@
+---
+name: Code / Codespell
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+ branches: [ main ]
+
+jobs:
+ codespell:
+ name: Check for spelling errors
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ - name: Annotate locations with typos
+ uses: codespell-project/codespell-problem-matcher@v1
+ - name: Codespell
+ uses: codespell-project/actions-codespell@v2
diff --git a/.github/workflows/deploy-dev-docs-mike.yml b/.github/workflows/deploy-dev-docs-mike.yml
new file mode 100644
index 0000000000..7a396c38d9
--- /dev/null
+++ b/.github/workflows/deploy-dev-docs-mike.yml
@@ -0,0 +1,129 @@
+name: Docs / Deploy dev-docs with Mike
+
+on:
+ workflow_call:
+ inputs:
+ action:
+ description: "Mike action to perform: deploy or delete."
+ required: true
+ type: string
+
+ version_label:
+ description: "Version label to deploy/delete, e.g. main or 3.0."
+ required: true
+ type: string
+
+ aliases:
+ description: "Space-separated aliases to assign on deploy, e.g. latest-release."
+ required: false
+ default: ""
+ type: string
+
+ default_label:
+ description: "If non-empty, run mike set-default with this label or alias after deploy."
+ required: false
+ default: ""
+ type: string
+
+ git_tag:
+ description: "Optional git tag whose deeplabcut package should be checked out before deploying."
+ required: false
+ default: ""
+ type: string
+
+ python-version:
+ description: Python version used for dev docs.
+ required: false
+ default: "3.10"
+ type: string
+
+ config-file:
+ description: MkDocs config file.
+ required: false
+ default: "dev-docs/mkdocs.yml"
+ type: string
+
+ deploy-prefix:
+ description: Mike deploy prefix.
+ required: false
+ default: "dev"
+ type: string
+
+jobs:
+ mike:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Validate inputs
+ shell: bash
+ run: |
+ case "${{ inputs.action }}" in
+ deploy|delete)
+ ;;
+ *)
+ echo "::error::Unsupported action '${{ inputs.action }}'. Expected 'deploy' or 'delete'."
+ exit 1
+ ;;
+ esac
+
+ if [ "${{ inputs.action }}" = "deploy" ] && [ -z "${{ inputs.git_tag }}" ] && [ "${{ inputs.version_label }}" != "main" ]; then
+ echo "::error::git_tag is required when deploying non-'main' versions."
+ exit 1
+ fi
+
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Check out tagged package source
+ if: inputs.git_tag != ''
+ shell: bash
+ run: |
+ git checkout "${{ inputs.git_tag }}" -- deeplabcut
+
+ - name: Set up dev-docs environment
+ uses: ./.github/actions/setup-dev-docs
+ with:
+ python-version: ${{ inputs.python-version }}
+ install-system-deps: ${{ inputs.action != 'delete' }}
+
+ - name: Configure git for Mike
+ shell: bash
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Deploy dev-docs version
+ if: inputs.action == 'deploy'
+ shell: bash
+ run: |
+ if [ -n "${{ inputs.aliases }}" ]; then
+ mike deploy --push \
+ --config-file "${{ inputs.config-file }}" \
+ --deploy-prefix "${{ inputs.deploy-prefix }}" \
+ --update-aliases \
+ "${{ inputs.version_label }}" ${{ inputs.aliases }}
+ else
+ mike deploy --push \
+ --config-file "${{ inputs.config-file }}" \
+ --deploy-prefix "${{ inputs.deploy-prefix }}" \
+ "${{ inputs.version_label }}"
+ fi
+
+ if [ -n "${{ inputs.default_label }}" ]; then
+ mike set-default --push \
+ --config-file "${{ inputs.config-file }}" \
+ --deploy-prefix "${{ inputs.deploy-prefix }}" \
+ "${{ inputs.default_label }}"
+ fi
+
+ - name: Delete dev-docs version
+ if: inputs.action == 'delete'
+ shell: bash
+ run: |
+ mike delete --push \
+ --config-file "${{ inputs.config-file }}" \
+ --deploy-prefix "${{ inputs.deploy-prefix }}" \
+ "${{ inputs.version_label }}"
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 0000000000..96f78318bb
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,54 @@
+name: Docs / Deploy docs (main + dev-docs main)
+
+on:
+ push:
+ branches: [ main ]
+
+permissions:
+ contents: write
+
+concurrency:
+ group: gh-pages-deploy
+ cancel-in-progress: false
+
+jobs:
+ build-main-docs:
+ uses: ./.github/workflows/build-main-docs.yml
+ with:
+ python-version: "3.10"
+ build_dir: "./_build/html"
+ upload_artifact: true
+ secrets: inherit
+
+ deploy-main-docs:
+ needs: build-main-docs
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download built Jupyter Book artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: built-book
+ path: site
+
+ - name: Deploy main docs to gh-pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: site
+ keep_files: true
+
+ deploy-dev-docs-main:
+ needs: deploy-main-docs
+ uses: ./.github/workflows/deploy-dev-docs-mike.yml
+ with:
+ action: deploy
+ version_label: main
+ aliases: ""
+ default_label: ""
+ python-version: "3.10"
+ config-file: dev-docs/mkdocs.yml
+ deploy-prefix: dev
+ secrets: inherit
diff --git a/.github/workflows/docs_and_notebooks_checks.yml b/.github/workflows/docs_and_notebooks_checks.yml
new file mode 100644
index 0000000000..8248f7a53e
--- /dev/null
+++ b/.github/workflows/docs_and_notebooks_checks.yml
@@ -0,0 +1,105 @@
+name: Docs / Docs & notebooks freshness and formatting checks
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ staleness:
+ name: Docs and notebooks scan (changed docs 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: Collect changed .md/.ipynb files
+ id: changed_docs
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p tmp/docs_nb_checks
+
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ base="${{ github.event.pull_request.base.sha }}"
+ head="${{ github.event.pull_request.head.sha }}"
+ else
+ base="${{ github.event.before }}"
+ head="${{ github.sha }}"
+ fi
+
+ git diff --name-only --diff-filter=ACMR "$base" "$head" \
+ | { grep -iE '\.(md|ipynb)$' || true; } \
+ | { grep -E '^(docs/|tools/|examples/COLAB/|examples/JUPYTER/)' || true; } \
+ | sort -u > tmp/docs_nb_checks/changed_docs.txt
+
+ count=$(wc -l < tmp/docs_nb_checks/changed_docs.txt | tr -d ' ')
+ echo "count=$count" >> "$GITHUB_OUTPUT"
+
+ echo "Changed docs files:"
+ if [[ "$count" -eq 0 ]]; then
+ echo "(none)"
+ else
+ sed 's/^/- /' tmp/docs_nb_checks/changed_docs.txt
+ fi
+
+ - name: Run staleness report (read-only)
+ if: steps.changed_docs.outputs.count != '0'
+ shell: bash
+ run: |
+ set -euo pipefail
+ mapfile -t targets < tmp/docs_nb_checks/changed_docs.txt
+
+ python tools/docs_and_notebooks_check.py \
+ --config tools/docs_and_notebooks_report_config.yml \
+ --out-dir tmp/docs_nb_checks \
+ report \
+ --targets "${targets[@]}"
+
+ - name: Run staleness policy check (optional gate)
+ if: steps.changed_docs.outputs.count != '0'
+ continue-on-error: true
+ shell: bash
+ run: |
+ set -euo pipefail
+ mapfile -t targets < tmp/docs_nb_checks/changed_docs.txt
+
+ 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 \
+ --targets "${targets[@]}"
+
+ - name: No changed docs to scan
+ if: steps.changed_docs.outputs.count == '0'
+ run: echo "No changed .md or .ipynb files found in the repo. Skipping scan."
+
+ - name: Upload staleness artifacts
+ if: steps.changed_docs.outputs.count != '0'
+ uses: actions/upload-artifact@v4
+ with:
+ name: staleness-report
+ path: |
+ tmp/docs_nb_checks/*.json
+ tmp/docs_nb_checks/*.md
+ tmp/docs_nb_checks/changed_docs.txt
+ if-no-files-found: error
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
new file mode 100644
index 0000000000..3e32f6c5da
--- /dev/null
+++ b/.github/workflows/format.yml
@@ -0,0 +1,119 @@
+name: Code / 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..a203558542
--- /dev/null
+++ b/.github/workflows/intelligent-testing.yml
@@ -0,0 +1,176 @@
+name: Code / 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: Main docs build
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_docs == 'true'
+ uses: ./.github/workflows/build-main-docs.yml
+ with:
+ python-version: "3.10"
+ build_dir: "./_build/html"
+ upload_artifact: false
+ secrets: inherit
+
+ dev-docs:
+ name: Dev docs build
+ needs: intelligent-test-selection
+ if: needs.intelligent-test-selection.outputs.run_docs == 'true'
+ uses: ./.github/workflows/build-dev-docs.yml
+ with:
+ python-version: "3.10"
+ 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..f612f54668
--- /dev/null
+++ b/.github/workflows/lockfile-check.yml
@@ -0,0 +1,37 @@
+name: Code / 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/manage-dev-docs.yml b/.github/workflows/manage-dev-docs.yml
new file mode 100644
index 0000000000..fada72d0df
--- /dev/null
+++ b/.github/workflows/manage-dev-docs.yml
@@ -0,0 +1,61 @@
+name: Docs / Manage dev-docs versions
+
+on:
+ workflow_dispatch:
+ inputs:
+ action:
+ description: "Action to perform"
+ required: true
+ type: choice
+ options:
+ - deploy-version
+ - delete-version
+
+ version_label:
+ description: "Version label to deploy or delete, e.g. 3.0"
+ required: true
+ type: string
+
+ git_tag:
+ description: "Git tag to check out for deploy-version, e.g. v3.0.0rc14. Ignored for delete-version."
+ required: false
+ type: string
+
+ mark_latest_release:
+ description: "Alias this version as latest-release and set /dev/ default to latest-release."
+ required: false
+ type: boolean
+ default: false
+
+permissions:
+ contents: write
+
+concurrency:
+ group: gh-pages-deploy
+ cancel-in-progress: false
+
+jobs:
+ deploy-version:
+ if: inputs.action == 'deploy-version'
+ uses: ./.github/workflows/deploy-dev-docs-mike.yml
+ with:
+ action: deploy
+ version_label: ${{ inputs.version_label }}
+ git_tag: ${{ inputs.git_tag }}
+ aliases: ${{ inputs.mark_latest_release && 'latest-release' || '' }}
+ default_label: ${{ inputs.mark_latest_release && 'latest-release' || '' }}
+ python-version: "3.10"
+ config-file: dev-docs/mkdocs.yml
+ deploy-prefix: dev
+ secrets: inherit
+
+ delete-version:
+ if: inputs.action == 'delete-version'
+ uses: ./.github/workflows/deploy-dev-docs-mike.yml
+ with:
+ action: delete
+ version_label: ${{ inputs.version_label }}
+ python-version: "3.10"
+ config-file: dev-docs/mkdocs.yml
+ deploy-prefix: dev
+ secrets: inherit
diff --git a/.github/workflows/publish-book.yml b/.github/workflows/publish-book.yml
deleted file mode 100644
index c4160dd9fc..0000000000
--- a/.github/workflows/publish-book.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: publish-book
-
-on:
- push:
- branches:
- - master
-
-jobs:
- deploy-book:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
-
- - name: Set up Python 3.8
- uses: actions/setup-python@v2
- with:
- python-version: 3.8
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- python -m pip install -e .[docs]
- pip install jupyter-book
-
- - name: Build the book
- run: |
- jupyter-book build .
-
- - name: GitHub Pages action
- uses: peaceiris/actions-gh-pages@v3.7.3
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
- publish_dir: ./_build/html
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 53d3c726c0..db2a30a1f4 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -1,61 +1,250 @@
name: Python package
on:
- push:
- branches: [ master ]
- pull_request:
- branches: [ master ]
+ 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-11, windows-latest]
- python-version: [3.7, 3.8, 3.9] #3.9 only failing for tables on macos and windows; mwm 6302021
- include:
- - os: ubuntu-latest
- path: ~/.cache/pip
- - os: macos-11
- path: ~/Library/Caches/pip
- - os: windows-latest
- path: ~\AppData\Local\pip\Cache
+ matrix: ${{ fromJson(inputs.matrix_json) }}
steps:
- name: Checkout code
- uses: actions/checkout@v2
+ 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@v2
+ - 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 0e2874c29f..7656027ecc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,13 @@
+# Docker specific
+logs/
#Jupyter book build directory
_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
@@ -16,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]
@@ -113,7 +122,10 @@ ENV/
# Spyder project settings
.spyderproject
.spyproject
+
+# IDEs configurations
.vscode/*
+.idea/*
# Rope project settings
.ropeproject
@@ -123,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..21f085c90f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,128 @@
+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]
+
+ # docs/ and dev-docs/ use different MkDocs/Markdown dialects, so each tree gets its
+ # own mdformat hook with the matching plugin (see https://github.com/hukkin/mdformat/issues/546).
+ - repo: https://github.com/hukkin/mdformat
+ rev: 1.0.0
+ hooks:
+ - id: mdformat
+ name: mdformat (docs â myst)
+ additional_dependencies:
+ - mdformat-myst
+ files: ^docs/.*\.md$
+ stages: [pre-commit]
+
+ - id: mdformat
+ name: mdformat (dev-docs â mkdocs)
+ additional_dependencies:
+ - mdformat-mkdocs==5.2.1
+ # Autoref targets may not resolve in the formatter env; without this flag,
+ # mdformat-mkdocs can rewrite links incorrectly (see KyleKing/mdformat-mkdocs#80).
+ args: [--ignore-missing-references]
+ files: ^dev-docs/.*\.md$
+ stages: [pre-commit]
+
+ # 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)$
+ exclude: ^tools/docs_audits/
+ 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 e149d24204..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
@@ -69,7 +69,7 @@ A. Mathis, T. Biasi, S. Schneider, M. YĂŒksekgönĂŒl, B. Rogers, M. Bethge, M. M
############################################################################################################
DeepLabCut 2.1 - 2.2 additions:
-A Mathis, alexander.mathis@epfl.ch | https://github.com/DeepLabCut/DeepLabCut
+A Mathis, alexander.mathis@epfl.ch | https://github.com/AlexEMG
J Lauer, jessy@deeplabcut.org | https://github.com/jeylau
M Mathis, mackenzie@post.harvard.edu | https://github.com/MMathisLab
M Zhou, https://github.com/zhoumu53
@@ -82,6 +82,58 @@ 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,
-D Soberanes, G Feng, VN Murthy, G Lauder, C Dulac, M Mathis, A Mathis
+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:
+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).
+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.
+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.
+M.W.M. and A.M. co-supervised the project.
+
+############################################################################################################
+
+DeepLabCut 2.2 - 3.0 additions:
+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 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., 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 99eb468d03..79f60b0674 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,73 +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.
+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.
-**DeepLabCut is an open-source tool and has benefited from suggestions and edits by many individuals:**
+## Tests
-- the [authors](/AUTHORS)
-- [code contributors](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors)
+Pull requests are validated in CI, and contributors are encouraged to run tests locally using:
+```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
new file mode 100644
index 0000000000..d2ced97e0f
--- /dev/null
+++ b/NOTICE.yml
@@ -0,0 +1,115 @@
+# Main repository license
+- header: |
+ 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
+ include:
+ - 'deeplabcut/**/*.py'
+ - 'tests/**/*.py'
+ - 'examples/**/*.py'
+ - 'docs/**/*.py'
+ #- 'conda-environments/**/*.yaml'
+
+# 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)
+ © A. & M.W. Mathis Labs
+ https://github.com/DeepLabCut/DeepLabCut
+
+ Please see AUTHORS for contributors.
+ https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
+
+ Adapted from DeeperCut by Eldar Insafutdinov
+ https://github.com/eldar/pose-tensorflow
+
+ Licensed under GNU Lesser General Public License v3.0
+ include:
+ # This filelist was generated by running
+ # find deeplabcut/pose_estimation_tensorflow -iname '*.py' | xargs grep 'Eldar Insafutdinov'
+ # from the repo base directory.
+ - deeplabcut/pose_estimation_tensorflow/config.py
+ - deeplabcut/pose_estimation_tensorflow/datasets/factory.py
+ - deeplabcut/pose_estimation_tensorflow/vis_dataset.py
+ - deeplabcut/pose_estimation_tensorflow/core/train.py
+ - deeplabcut/pose_estimation_tensorflow/core/predict.py
+ - deeplabcut/pose_estimation_tensorflow/core/test.py
+ - deeplabcut/pose_estimation_tensorflow/default_config.py
+ - deeplabcut/pose_estimation_tensorflow/util/visualize.py
+ - deeplabcut/pose_estimation_tensorflow/util/__init__.py
+ - deeplabcut/pose_estimation_tensorflow/util/logging.py
+ - deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
+ - deeplabcut/pose_estimation_tensorflow/__init__.py
+ exclude: []
+
+# Tensorflow licenses
+- header: |
+ Copyright 2019 The TensorFlow Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ include:
+ - deeplabcut/pose_estimation_tensorflow/backbones/*.py
+ - deeplabcut/pose_estimation_tensorflow/nnets/utils.py
+
+- header: |
+ Copyright 2018 The TensorFlow Authors. All Rights Reserved.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ include:
+ - deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
+ - deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
+ - deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
+
+# TIMM license
+- header: |
+ Copyright 2019 Ross Wightman
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Hacked together by / Copyright 2020 Ross Wightman
+ https://github.com/rwightman/pytorch-image-models/blob/main/timm/scheduler/scheduler_factory.py
+ 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 100d7aaa73..3c2ca193cc 100644
--- a/README.md
+++ b/README.md
@@ -1,200 +1,243 @@
-
-
-[](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)
-
- www.deeplabcut.org
-
-
+
-
-
+
-
-
-DeepLabCut is a toolbox for markerless pose estimation of animals performing various tasks. [Read a short development and application summary below](https://github.com/DeepLabCut/DeepLabCut#why-use-deeplabcut). As long as you can see (label) what you want to track, you can use this toolbox, as it is animal and object agnostic.
+
+
+
+[đDocumentation](https://deeplabcut.github.io/DeepLabCut/README.html) |
+[đ ïž Installation](https://deeplabcut.github.io/DeepLabCut/docs/installation.html) |
+[đ Home Page](https://www.deeplabcut.org) |
+[đżđŽđđđ Model Zoo](http://www.mackenziemathislab.org/deeplabcut/) |
+[đš News](https://deeplabcut.github.io/DeepLabCut/README.html#news-and-in-the-news) |
+[đȘČ Reporting Issues](https://github.com/DeepLabCut/DeepLabCut/issues)
+
+
+[đ«¶ Getting Assistance](https://deeplabcut.github.io/DeepLabCut/README.html#be-part-of-the-dlc-community) |
+[â DeepLabCut Online Course](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials/blob/master/DLCcourse.md) |
+[đ Publications](https://deeplabcut.github.io/DeepLabCut/README.html#references) |
+[đ©đŸâđ»đšâđ» DeepLabCut AI Residency](https://www.deeplabcutairesidency.org/)
+
+
+
+[](https://pepy.tech/project/deeplabcut)
+[](https://pepy.tech/project/deeplabcut)
+[](https://badge.fury.io/py/deeplabcut)
+[](https://www.gnu.org/licenses/lgpl-3.0)
+
+[](https://github.com/DeepLabCut/DeepLabCut)
+[](http://isitmaintained.com/project/deeplabcut/deeplabcut "Average time to resolve an issue")
+[](http://isitmaintained.com/project/deeplabcut/deeplabcut "Percentage of issues still open")
+[](https://forum.image.sc/tag/deeplabcut)
+[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+[](https://x.com/DeepLabCut)
+[](CONTRIBUTING.md)
+[](https://czi.co/EOSS)
+
+
+
+# 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).
-**Latest updates:**
+# [Installation](https://deeplabcut.github.io/DeepLabCut/docs/installation.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 [preprint, Lauer et al 2021](https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1), and the [new docs]( https://deeplabcut.github.io/DeepLabCut) on how to use it!
+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).
-:purple_heart: We have a **real-time** package available! http://DLClive.deeplabcut.org
+## Quick start
-# [Installation: how to install DeepLabCut](docs/installation.md)
+Developers Stable Release: very quick start (Python 3.10+ required) to install
+DeepLabCut with the PyTorch engine
-Very quick start: `pip install "deeplabcut[gui]"` that includes all GUI functions, or `pip install deeplabcut` (headless version).
-* 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).
+- [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):
-# [Documentation: The DeepLabCut Process](https://deeplabcut.github.io/DeepLabCut)
+```bash
+pip install --pre "deeplabcut[gui]"
+```
+or `pip install --pre "deeplabcut"` (headless
+version with PyTorch)!
-*New: check out the docs in JupyterBook! https://deeplabcut.github.io/DeepLabCut (or on [github](docs/README.md)).
+To use the TensorFlow (TF) engine: you'll need to run `pip install "deeplabcut[gui,tf]"` or `pip install "deeplabcut[tf]"` (headless version with TF). Alternatively, we also offer more targeted optional TensorFlow installs for specific CUDA setups, e.g. `deeplabcut[tf-cu11]` or `deeplabcut[tf-cu12]`. Please refer to our [installation instructions](https://deeplabcut.github.io/DeepLabCut/docs/installation.html) for more detailed information on Python version, CUDA compatibility, etc.
+We aim to **deprecate the tensorflow backend** in version 3.2 (release date TBD).
-An overview of the pipeline and workflow for project management. For a step-by-step user guide, please also read 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
+
+# Documentation: The DeepLabCut Process
+
+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).
+
+
+
-# [DEMO the code](/examples)
+# [Code demo](examples/README.md)
+
+đ Pose tracking of single animals demo [](https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb)
-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, walking you through a demo dataset to test your installation, and another to run DeepLabCut from the start on your own data.
+We also show 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 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.
+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.
+
+## Pretrained Models
+
+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 dataset](https://doi.org/10.5281/zenodo.10619172) with *AP-10K* held out for out-of-domain testing and the [SuperAnimal-TopViewMouse dataset](https://doi.org/10.5281/zenodo.13757509) with *DLC-openfield* held out for out-of-distribution testing (see [Ye et al. 2024](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
+
+### Development and Applications
+
+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 updated 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.
-
+
**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.
+### Ecosystem
+
+**DeepLabCut** is part of 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:
+### 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!
+DeepLabCut 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.
+đ€© This is an actively developed package and we welcome community development and involvement:
-## Community Support, Developers, & Help:
+[](https://github.com/DeepLabCut/DeepLabCut/graphs/contributors)
-- 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
+# Get Assistance & be part of the DLC Communityâš
-- For quick discussions amongst users, please see here: [](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+| đ Platform | đŻ Goal | â±ïž Estimated Response Time | đą Support Squad |
+|------------------------------------------------------------|-----------------------------------------------------------------------------|---------------------------|----------------------------------------|
+| GitHub - [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 - [Contributing](https://github.com/DeepLabCut/DeepLabCut/blob/master/CONTRIBUTING.md) | To contribute your expertise and experienceđđŻ | 2-5 days | DLC Core Dev Team |
+| [](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 |
+| [](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 |
+
+
-- If you want to contribute to the code, please read our guide [here!](CONTRIBUTING.md)
-- The project [road map](docs/roadmap.md). Get in touch with us if you want to help!
+## References \& Citations
-## References:
+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!
-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 in beta mode, please contact us; if you use the 2.2rc1+, please cite [Lauer et al. 2021](https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1).
+## License
-DOIs (#ProTip, for helping you find citations for software, check out [CiteAs.org](http://citeas.org/)!):
+This project is primarily 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.
-- 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)
+SuperAnimal models are provided for research use only (non-commercial use).
+## Major Versions
-Please check out the following references for more details:
+**For all versions, please see [here](https://github.com/DeepLabCut/DeepLabCut/releases).**
- @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}}
+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!
- @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{Lauer2021MultianimalPE,
- title = {Multi-animal pose estimation and tracking with DeepLabCut},
- author = {Jessy Lauer and Mu Zhou and Shaokai Ye and William Menegas and Tanmay Nath and Mohammed Mostafizur Rahman and V. Di Santo and Daniel Soberanes and Guoping Feng and V. Murthy and G. Lauder and C. Dulac and M. Mathis and Alexander Mathis},
- journal = {bioRxiv},
- year = {2021}
- url = {https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1}}
+VERSION 2.3: Model Zoo SuperAnimals, and a whole new GUI experience.
- @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:
+VERSION 2.2: Multi-animal pose estimation, identification, and tracking with DeepLabCut is supported (as well as single-animal projects).
- @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}}
+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.
- @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}}
+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
-Other open-access pre-prints related to our work on DeepLabCut:
+# News
- @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}
- }
+## Major releases
-## License:
+đ We released a major update, moving from 2.x --> 3.x with the backend change to PyTorch
-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.
+đ The DeepLabCut Model Zoo launches SuperAnimals, see more [here](https://deeplabcut.github.io/DeepLabCut/docs/ModelZoo.html).
-## Versions:
+đ **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!
-VERSION 2.2: Multi-animal pose estimation and tracking with DeepLabCut.
+đ We support multi-animal re-identification, see [Lauer et al 2022](https://www.nature.com/articles/s41592-022-01443-0).
-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)).
+đ We have a **real-time** package available! [DLC-live on GitHub](https://github.com/DeepLabCut/DeepLabCut-live) and [DLC-live-GUI](https://github.com/DeepLabCut/DeepLabCut-live-GUI)
-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):
+## In the news
+- 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)
+- May 2023: The Model Zoo is now fully integrated into the DeepLabCut GUI, making it easier than ever to access a variety of pre-trained models. Check out the accompanying paper: [SuperAnimal pretrained pose estimation models for behavioral analysis by Ye et al.](https://arxiv.org/abs/2203.07436)
+- December 2022: DeepLabCut hits 450,000 downloads and 2.3 is the new stable release
+- August 2022: DeepLabCut hit 400,000 downloads
- 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.
@@ -204,7 +247,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)
@@ -218,3 +261,9 @@ 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 following support and funding 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 83f4b11801..b721b3aff9 100644
--- a/_config.yml
+++ b/_config.yml
@@ -4,8 +4,21 @@ logo: docs/images/logo.png
only_build_toc_files: true
sphinx:
+ config:
+ 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/**"
+ napoleon_google_docstring: true
+ napoleon_numpy_docstring: false
extra_extensions:
- - numpydoc
+ - sphinx.ext.napoleon
+ - sphinxcontrib.mermaid
execute:
execute_notebooks: "off"
@@ -20,7 +33,7 @@ html:
repository:
url: https://github.com/DeepLabCut/DeepLabCut
path_to_book: docs
- branch: master
+ branch: main
launch_buttons:
colab_url: "https://colab.research.google.com/github/DeepLabCut/DeepLabCut/blob/master/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb"
diff --git a/_toc.yml b/_toc.yml
index 698a956da0..b1844553dc 100644
--- a/_toc.yml
+++ b/_toc.yml
@@ -1,33 +1,126 @@
format: jb-book
-root: docs/intro
+root: README
+
parts:
-- caption: Main Documentation
- chapters:
- - file: docs/README
- - file: docs/UseOverviewGuide
- - file: docs/installation
- - file: docs/docker
- - file: docs/standardDeepLabCut_UserGuide
- - file: docs/maDLC_UserGuide
- - file: docs/PROJECT_GUI
- - file: docs/Overviewof3D
-- caption: Tutorials
- chapters:
- - file: docs/tutorial
- - file: docs/convert_maDLC
- - file: docs/HelperFunctions
-- caption: Recipes
- chapters:
- - file: docs/recipes/installTips
- - file: docs/recipes/io
- - file: docs/recipes/nn
- - file: docs/recipes/post
- - file: docs/recipes/BatchProcessing
- - file: docs/recipes/TechHardware
- - file: docs/recipes/DLCMethods
- - file: docs/recipes/OpenVINO
-- caption: Mission & Contribute
- chapters:
- - file: docs/MISSION_AND_VALUES
- - file: docs/roadmap
- - file: docs/Governance
+ - caption: Getting started
+ chapters:
+ - file: docs/UseOverviewGuide
+ - file: docs/installation
+ sections:
+ # - file: docs/recipes/installTips
+ - file: docs/docker
+ # - file: docs/quick-start/index
+ # sections:
+
+ - caption: Main workflows overview
+ chapters:
+ - file: docs/main-workflows/user-guide
+ sections:
+ - file: docs/quick-start/single_animal_quick_guide
+ - file: docs/quick-start/tutorial_maDLC
+ - file: docs/main-workflows/multi-animal-tracking
+ # - file: docs/standardDeepLabCut_UserGuide
+ # - file: docs/maDLC_UserGuide
+ - file: docs/Overviewof3D
+
+ - caption: GUI workflow
+ chapters:
+ - file: docs/gui/PROJECT_GUI
+ sections:
+ - 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
+ - file: docs/gui/napari_GUI
+ sections:
+ - file: docs/gui/napari/basic_usage
+ - file: docs/gui/napari/advanced_usage
+ - file: docs/gui/napari/tracking/basic_usage
+
+ - caption: Notebooks & Demos
+ chapters:
+ - file: docs/notebooks/your_data
+ sections:
+ - file: examples/COLAB/COLAB_YOURDATA_SuperAnimal
+ - file: examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis
+ - file: examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis
+ - file: docs/notebooks/main_demos
+ sections:
+ - 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
+ - file: docs/notebooks/extra
+ sections:
+ - 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: DeepLabCut 3.0 - PyTorch guides
+ chapters:
+ - file: docs/pytorch/index
+ sections:
+ - file: docs/pytorch/user_guide.md
+ - file: docs/pytorch/pytorch_config.md
+ - file: docs/pytorch/architectures.md
+ - file: docs/pytorch/Benchmarking_shuffle_guide
+
+ - caption: Advanced, Performance & Live
+ chapters:
+ - file: docs/ModelZoo
+ sections:
+ - file: docs/recipes/UsingModelZooPupil
+ - 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
+ - file: docs/benchmark
+ - file: docs/recipes/TechHardware
+
+ - caption: Development
+ chapters:
+ - title: Developer Documentation
+ url: https://deeplabcut.github.io/DeepLabCut/dev/
+
+ - caption: Additional guides (Recipes)
+ chapters:
+ - file: docs/recipes/index
+ sections:
+ - file: docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook
+ - file: docs/HelperFunctions
+ - 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/course
+ - file: docs/dlc-utils/index
+ sections:
+ - file: docs/dlc-utils/XROMM/usage
+
+ - caption: Project & Community
+ chapters:
+ - file: docs/MISSION_AND_VALUES
+ - file: docs/roadmap
+ - file: docs/Governance
+ - file: CONTRIBUTING
+ - 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 d4172e18ea..4848f9a099 100644
--- a/conda-environments/DEEPLABCUT.yaml
+++ b/conda-environments/DEEPLABCUT.yaml
@@ -1,15 +1,21 @@
# DEEPLABCUT.yaml
-#DeepLabCut2.0 Toolbox (deeplabcut.org)
-#© A. & M. Mathis Labs
+#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
+#
+# 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
@@ -18,11 +24,14 @@ channels:
- conda-forge
- defaults
dependencies:
- - python=3.8
+ - python=3.10
- pip
- ipython
- jupyter
- - nb_conda
- ffmpeg
+ - pyside6
- pip:
- - "deeplabcut[gui]"
+ - 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 4f8ed45f49..0000000000
--- a/conda-environments/DEEPLABCUT_M1.yaml
+++ /dev/null
@@ -1,37 +0,0 @@
-# DEEPLABCUT_M1.yaml
-
-#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
-#
-# DeepLabCut M1 environment instructions:
-# For the M1 chip, please first download the Rosetta wheel for tensorflow 2.4.1:
-# https://drive.google.com/file/d/17pSwfoNuyf3YR8vCaVggHeI-pMQ3xL7l/view?usp=sharing
-#
-# Then, run:
-#
-# conda env create -f DEEPLABCUT_M1.yaml
-#
-# Next, activate the environment, and in the terminal cd to Downloads, and then run:
-# pip install tensorflow-2.4.1-py3-none-any.whl --no-dependencies --force-reinstall
-#
-# Next, launch DLC with pythonw -m deeplabcut
-
-name: DEEPLABCUT_M1
-channels:
- - conda-forge
- - defaults
-dependencies:
- - python=3.7
- - pip
- - ipython
- - jupyter
- - nb_conda
- - python.app
- - ffmpeg
- - pip:
- - "deeplabcut[gui]"
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 3db1e1da28..df55aec39b 100644
--- a/deeplabcut/__init__.py
+++ b/deeplabcut/__init__.py
@@ -1,128 +1,277 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-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 logging
import os
+from importlib import import_module
+from typing import Any
-# Suppress tensorflow warning messages
-import tensorflow as tf
+logger = logging.getLogger(__name__)
-tf.compat.v1.logging.set_verbosity(tf.compat.v1.logging.ERROR)
-DEBUG = True and "DEBUG" in os.environ and os.environ["DEBUG"]
-from deeplabcut import DEBUG
-from deeplabcut.version import __version__, VERSION
+# DEBUG="", "0", "false", "no" -> False
+DEBUG = os.environ.get("DEBUG", "").strip().lower() not in {"", "0", "false", "no"}
-print(f"Loading DLC {VERSION}...")
+from .version import VERSION, __version__
-try:
- from deeplabcut import generate_training_dataset
- from deeplabcut import refine_training_dataset
+if DEBUG:
+ logger.debug("Loading DLC %s", VERSION)
- from deeplabcut.gui import select_crop_parameters
- from deeplabcut.gui.launch_script import launch_dlc
- from deeplabcut.gui.label_frames import label_frames
- from deeplabcut.gui.refine_labels import refine_labels
- from deeplabcut.gui.tracklet_toolbox import refine_tracklets
+# -----------------------------------------------------------------------------
+# Always-available public API
+# -----------------------------------------------------------------------------
- from deeplabcut.utils.skeleton 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 (
+# 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,
+ create_training_dataset_from_existing_split,
+ create_training_model_comparison,
+ dropannotationfileentriesduetodeletedimages,
+ dropduplicatesinannotatinfiles,
+ dropimagesduetolackofannotation,
+ dropunlabeledframes,
extract_frames,
mergeandsplit,
)
-from deeplabcut.generate_training_dataset import (
- create_training_model_comparison,
- create_multianimaltraining_dataset,
+from .modelzoo.video_inference import video_inference_superanimal
+from .pose_estimation_3d import (
+ calibrate_cameras,
+ check_undistortion,
+ create_labeled_video_3d,
+ triangulate,
)
-from deeplabcut.generate_training_dataset import (
- dropannotationfileentriesduetodeletedimages,
- comparevideolistsanddatafolders,
- dropimagesduetolackofannotation,
- adddatasetstovideolistandviceversa,
- dropduplicatesinannotatinfiles,
- dropunlabeledframes
+from .post_processing import analyzeskeleton, filterpredictions
+from .refine_training_dataset import (
+ extract_outlier_frames,
+ find_outliers_in_raw_data,
+ merge_datasets,
)
-from deeplabcut.utils import (
- create_labeled_video,
- create_video_with_all_detections,
- plot_trajectories,
- auxiliaryfunctions,
- convert2_maDLC,
- convertcsv2h5,
+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,
-)
+# -----------------------------------------------------------------------------
+# 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,
-)
-from deeplabcut.post_processing import filterpredictions, analyzeskeleton
+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
+)
diff --git a/deeplabcut/__main__.py b/deeplabcut/__main__.py
index 16afdb0426..ea7e3ca1c6 100644
--- a/deeplabcut/__main__.py
+++ b/deeplabcut/__main__.py
@@ -1,26 +1,36 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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 importlib import import_module
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-try:
- import wx
- 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...")
- import deeplabcut
+ lite = False
+ except ModuleNotFoundError:
+ lite = True
- deeplabcut.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
new file mode 100644
index 0000000000..ab863dec97
--- /dev/null
+++ b/deeplabcut/benchmark/__init__.py
@@ -0,0 +1,123 @@
+#
+# 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 json
+import os
+from collections.abc import Container
+from pathlib import Path
+from typing import Literal
+
+from deeplabcut.benchmark.base import Benchmark, Result, ResultCollection
+
+DATA_ROOT = Path.cwd() / "data"
+CACHE = Path.cwd() / ".results"
+
+__registry = []
+
+
+def register(cls):
+ """Add a benchmark to the list of evaluations to run.
+
+ Apply this function as a decorator to a class. Note that the
+ class needs to be a subclass of the ``benchmark.base.Benchmark``
+ base class.
+
+ In most situations, it will be a subclass of one of the pre-defined
+ benchmarks in ``benchmark.benchmarks``.
+
+ Throws:
+ ``ValueError`` if the decorator is applied to a class that is
+ not a subclass of ``benchmark.base.Benchmark``.
+ """
+ if not issubclass(cls, Benchmark):
+ raise ValueError(f"Can only register subclasses of {type(Benchmark)}, but got {cls}.")
+ __registry.append(cls)
+
+
+def evaluate(
+ include_benchmarks: Container[str] = None,
+ results: ResultCollection = None,
+ on_error="return",
+) -> ResultCollection:
+ """Run evaluation for all benchmarks and methods.
+
+ Note that in order for your custom benchmark to be included during
+ evaluation, the following conditions need to be met:
+
+ - The benchmark subclassed one of the benchmark definitions in
+ in ``benchmark.benchmarks``
+ - The benchmark is registered by applying the ``@benchmark.register``
+ decorator to the class
+ - The benchmark was imported. This is done automatically for all
+ benchmarks that are defined in submodules or subpackages of the
+ ``benchmark.submissions`` module. For all other locations, make
+ sure to manually import the packages **before** calling the
+ ``evaluate()`` function.
+
+ Args:
+ include_benchmarks:
+ If ``None``, run all benchmarks that were discovered. If a container
+ is passed, only include methods that were defined on benchmarks with
+ the specified names. E.g., ``include_benchmarks = ["trimouse"]`` would
+ only evaluate methods of the trimouse benchmark dataset.
+ on_error:
+ see documentation in ``benchmark.base.Benchmark.evaluate()``
+
+ Returns:
+ A collection of all results, which can be printed or exported to
+ ``pd.DataFrame`` or ``json`` file formats.
+ """
+ if results is None:
+ results = ResultCollection()
+ for benchmark_cls in __registry:
+ if include_benchmarks is not None:
+ if benchmark_cls.name not in include_benchmarks:
+ continue
+ benchmark = benchmark_cls()
+ for name in benchmark.names():
+ 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)
+ results.add(result)
+ return results
+
+
+def get_filepath(basename: str):
+ return DATA_ROOT / basename
+
+
+def savecache(results: ResultCollection):
+ with Path(CACHE).open("w") as fh:
+ json.dump(results.todicts(), fh, indent=2)
+
+
+def loadcache(cache=CACHE, on_missing: Literal["raise", "ignore"] = "ignore") -> ResultCollection:
+ if not Path(cache).exists():
+ if on_missing == "raise":
+ raise FileNotFoundError(cache)
+ return ResultCollection()
+ with Path(cache).open() as fh:
+ try:
+ data = json.load(fh)
+ except json.decoder.JSONDecodeError as e:
+ if on_missing == "raise":
+ raise e
+ return ResultCollection()
+ return ResultCollection.fromdicts(data)
diff --git a/deeplabcut/benchmark/__main__.py b/deeplabcut/benchmark/__main__.py
new file mode 100644
index 0000000000..28aa6776fb
--- /dev/null
+++ b/deeplabcut/benchmark/__main__.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+from deeplabcut.benchmark.cli import main
+
+if __name__ == "__main__":
+ main()
diff --git a/deeplabcut/benchmark/base.py b/deeplabcut/benchmark/base.py
new file mode 100644
index 0000000000..3c9193eb75
--- /dev/null
+++ b/deeplabcut/benchmark/base.py
@@ -0,0 +1,225 @@
+#
+# 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
+#
+
+"""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.
+Right now, the metrics to compute and report for each of the multi-animal benchmarks is the
+root mean-squared-error (RMSE) and the mean average precision (mAP).
+
+Note for contributors: If you decide to contribute a benchmark which does not fit
+into this evaluation framework, please feel free to extend the base classes
+(e.g. to support additional metrics).
+"""
+
+import abc
+import dataclasses
+import warnings
+from collections.abc import Iterable
+
+import pandas as pd
+
+import deeplabcut.benchmark.metrics
+from deeplabcut import __version__
+
+
+class BenchmarkEvaluationError(RuntimeError):
+ pass
+
+
+class Benchmark(abc.ABC):
+ """Abstract benchmark baseclass.
+
+ All benchmarks should subclass this class.
+ """
+
+ @abc.abstractmethod
+ 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.
+ """
+ raise NotImplementedError()
+
+ @abc.abstractmethod
+ def get_predictions(self):
+ """Return predictions for all images in the benchmark."""
+ raise NotImplementedError()
+
+ def __init__(self):
+ 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 to define the {key} property.")
+
+ def compute_pose_rmse(self, results_objects):
+ return deeplabcut.benchmark.metrics.calc_rmse_from_obj(
+ results_objects, h5_file=self.ground_truth, metadata_file=self.metadata
+ )
+
+ def compute_pose_map(self, results_objects):
+ return deeplabcut.benchmark.metrics.calc_map_from_obj(
+ results_objects, h5_file=self.ground_truth, metadata_file=self.metadata
+ )
+
+ 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()}")
+ 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:
+ if on_error == "ignore":
+ # ignore the exception and continue with the next evaluation, without
+ # yielding a result value.
+ return
+ elif on_error == "return":
+ # return the result value, with NaN as the result for all metrics that
+ # could not be computed due to the error.
+ pass
+ elif on_error == "raise":
+ # raise the error and stop evaluation
+ 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")
+ mean_avg_precision: float = float("nan")
+ benchmark_version: str = __version__
+
+ _export_mapping = dict(
+ code="code",
+ benchmark_name="benchmark",
+ method_name="method",
+ benchmark_version="version",
+ root_mean_squared_error="RMSE",
+ mean_avg_precision="mAP",
+ )
+
+ _primary_key = ("benchmark_name", "method_name", "benchmark_version")
+
+ @property
+ 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."""
+ return tuple(self._export_mapping.get(k) for k in self._primary_key)
+
+ def __str__(self):
+ return (
+ f"{self.method_name}, {self.benchmark_name}: "
+ f"{self.mean_avg_precision} mAP, "
+ f"{self.root_mean_squared_error} RMSE"
+ )
+
+ @classmethod
+ def fromdict(cls, data: dict):
+ """Construct result object from dictionary."""
+ kwargs = {attr: data[key] for attr, key in cls._export_mapping.items()}
+ return cls(**kwargs)
+
+ def todict(self) -> dict:
+ """Export result object to dictionary, with less verbose key names."""
+ return {key: getattr(self, attr) for attr, key in self._export_mapping.items()}
+
+
+class ResultCollection:
+ def __init__(self, *results):
+ self.results = {result.primary_key: result for result in results}
+
+ @property
+ 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)
+ )
+
+ def add(self, result: Result):
+ """Add a result to the collection."""
+ if result.primary_key in self.results:
+ raise ValueError(
+ "An entry for {result.primary_key} does already "
+ "exist in this collection. Did you try to add the "
+ "same result twice?"
+ )
+ if len(self) > 0:
+ if result.primary_key_names != self.primary_key_names:
+ raise ValueError("Incompatible result format.")
+ self.results[result.primary_key] = result
+
+ @classmethod
+ def fromdicts(cls, data: Iterable[dict]):
+ return cls(*[Result.fromdict(entry) for entry in data])
+
+ def todicts(self):
+ return [result.todict() for result in self.results.values()]
+
+ def __len__(self):
+ return len(self.results)
+
+ def __contains__(self, other: Result):
+ if not isinstance(other, Result):
+ 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):
+ if not isinstance(other, ResultCollection):
+ return False
+ return other.results == self.results
diff --git a/deeplabcut/benchmark/benchmarks.py b/deeplabcut/benchmark/benchmarks.py
new file mode 100644
index 0000000000..0701714a4a
--- /dev/null
+++ b/deeplabcut/benchmark/benchmarks.py
@@ -0,0 +1,214 @@
+#
+# 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
+#
+
+"""Definition for official DeepLabCut benchmark tasks.
+
+See benchmark.deeplabcut.org for a current leaderboard with models and metrics
+for each of these benchmarks. Submissions can be done by opening a PR in the
+benchmark reporistory:
+
+https://github.com/DeepLabCut/benchmark
+"""
+
+import deeplabcut.benchmark.base
+
+
+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.
+ """
+
+ name = "trimouse"
+ keypoints = (
+ "snout",
+ "leftear",
+ "rightear",
+ "shoulder",
+ "spine1",
+ "spine2",
+ "spine3",
+ "spine4",
+ "tailbase",
+ "tail1",
+ "tail2",
+ "tailend",
+ )
+ ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Daniel.h5")
+ 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.
+ """
+
+ name = "parenting"
+ keypoints = (
+ "end1",
+ "interm1",
+ "interm2",
+ "interm3",
+ "end2",
+ "snout",
+ "leftear",
+ "rightear",
+ "shoulder",
+ "spine1",
+ "spine2",
+ "spine3",
+ "spine4",
+ "tailbase",
+ "tail1",
+ "tail2",
+ "tailend",
+ )
+
+ ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Mostafizur.h5")
+ metadata = deeplabcut.benchmark.get_filepath("Documentation_data-CrackingParenting_70shuffle1.pickle")
+ num_animals = 2
+
+ def compute_pose_map(self, results_objects):
+ return deeplabcut.benchmark.metrics.calc_map_from_obj(
+ results_objects,
+ h5_file=self.ground_truth,
+ metadata_file=self.metadata,
+ oks_sigma=0.15,
+ margin=10,
+ 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.
+ """
+
+ name = "marmosets"
+ keypoints = (
+ "Front",
+ "Right",
+ "Middle",
+ "Left",
+ "FL1",
+ "BL1",
+ "FR1",
+ "BR1",
+ "BL2",
+ "BR2",
+ "FL2",
+ "FR2",
+ "Body1",
+ "Body2",
+ "Body3",
+ )
+ ground_truth = deeplabcut.benchmark.get_filepath("CollectedData_Mackenzie.h5")
+ 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.
+ """
+
+ 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")
+ num_animals = 14
+
+ def compute_pose_rmse(self, results_objects):
+ return deeplabcut.benchmark.metrics.calc_rmse_from_obj(
+ results_objects,
+ h5_file=self.ground_truth,
+ metadata_file=self.metadata,
+ drop_kpts=[4, 5],
+ )
+
+ def compute_pose_map(self, results_objects):
+ return deeplabcut.benchmark.metrics.calc_map_from_obj(
+ results_objects,
+ h5_file=self.ground_truth,
+ metadata_file=self.metadata,
+ drop_kpts=[4, 5],
+ )
diff --git a/deeplabcut/benchmark/cli.py b/deeplabcut/benchmark/cli.py
new file mode 100644
index 0000000000..c79146ef2f
--- /dev/null
+++ b/deeplabcut/benchmark/cli.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+"""Command line interface for DeepLabCut deeplabcut.benchmark."""
+
+import argparse
+
+import deeplabcut.benchmark
+
+
+def _parse_args():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--include", nargs="+", default=None, required=False)
+ parser.add_argument(
+ "--onerror",
+ default="return",
+ required=False,
+ choices=("ignore", "return", "raise"),
+ )
+ parser.add_argument("--nocache", action="store_true")
+ return parser.parse_args()
+
+
+def main():
+ """Main CLI entry point for generating deeplabcut.benchmark results."""
+ args = _parse_args()
+ if not args.nocache:
+ results = deeplabcut.benchmark.loadcache()
+ else:
+ results = None
+ results = deeplabcut.benchmark.evaluate(
+ include_benchmarks=args.include,
+ results=results,
+ on_error=args.onerror,
+ )
+ if not args.nocache:
+ deeplabcut.benchmark.savecache(results)
+ try:
+ print(results.toframe())
+ except StopIteration:
+ pass
diff --git a/deeplabcut/benchmark/metrics.py b/deeplabcut/benchmark/metrics.py
new file mode 100644
index 0000000000..7faf598c89
--- /dev/null
+++ b/deeplabcut/benchmark/metrics.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
+"""Evaluation metrics for the DeepLabCut benchmark."""
+
+import pickle
+from collections import defaultdict
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+import deeplabcut.benchmark.utils
+from deeplabcut.core import crossvalutils, inferenceutils
+from deeplabcut.utils.conversioncode import guarantee_multiindex_rows
+
+
+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"))
+ except KeyError:
+ n_unique = 0
+ guarantee_multiindex_rows(df)
+ file_paths = [Path(*row) for row in df.index.to_list()]
+ temp = (
+ df.stack("individuals", dropna=False)
+ .reindex(animals, level="individuals")
+ .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, strict=False)),
+ "metadata": meta,
+ }
+
+
+def _get_unique_level_values(header, level):
+ return header.get_level_values(level).unique().to_list()
+
+
+def calc_prediction_errors(preds, gt):
+ kpts_gt = gt["metadata"]["keypoints"]
+ kpts_pred = preds["metadata"]["keypoints"]
+ map_ = {kpts_gt.index(kpt): i for i, kpt in enumerate(kpts_pred)}
+ annot = gt["annotations"]
+
+ map_images = _map(list(preds["predictions"]), list(annot))
+
+ errors = np.full(
+ (
+ len(preds["predictions"]),
+ len(gt["metadata"]["animals"]),
+ len(kpts_gt),
+ 2, # Hold distance to GT and confidence
+ ),
+ np.nan,
+ )
+ for n, (path, preds_) in enumerate(preds["predictions"].items()):
+ if not preds_:
+ continue
+ xy_gt = annot[map_images[path]].swapaxes(0, 1)
+ xy_pred = preds_["coordinates"][0]
+ conf_pred = preds_["confidence"]
+ for i, xy_gt_ in enumerate(xy_gt):
+ visible = np.flatnonzero(np.all(~np.isnan(xy_gt_), axis=1))
+ xy_pred_ = xy_pred[map_[i]]
+ 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 = crossvalutils.find_closest_neighbors(xy_gt_[visible], xy_pred_, k=3)
+ found = neighbors != -1
+ if ~np.any(found):
+ continue
+ min_dists = np.linalg.norm(
+ xy_gt_[visible][found] - xy_pred_[neighbors[found]],
+ axis=1,
+ )
+ conf_pred_ = conf_pred[map_[i]]
+ errors[n, visible[found], i, 0] = min_dists
+ errors[n, visible[found], i, 1] = conf_pred_[neighbors[found], 0]
+ return errors
+
+
+def _map(paths: list[Path], subpaths: list[Path]) -> dict[Path, 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 = {}
+ paths_ = paths.copy()
+ subpaths_ = subpaths.copy()
+ while paths_:
+ path = paths_.pop()
+ for s in subpaths_:
+ if path.parts[-len(s.parts) :] == s.parts:
+ lookup[path] = s
+ subpaths_.remove(s)
+ break
+ return lookup
+
+
+def conv_obj_to_assemblies(eval_results_obj, keypoint_names):
+ """Convert predictions to deeplabcut assemblies."""
+ assemblies = {}
+ for image_path, results in eval_results_obj.items():
+ lst = []
+ for dict_ in results:
+ ass = inferenceutils.Assembly(len(keypoint_names))
+ for i, kpt in enumerate(keypoint_names):
+ xy = dict_["pose"][kpt]
+ if ~np.isnan(xy).all():
+ joint = inferenceutils.Joint(pos=(xy), label=i)
+ ass.add_joint(joint)
+ # TODO(jeylau) add affinity.setter to Assembly
+ ass._affinity = dict_["score"]
+ ass._links = [None]
+ if len(ass):
+ lst.append(ass)
+ assemblies[image_path] = lst
+ return assemblies
+
+
+def calc_map_from_obj(
+ eval_results_obj,
+ h5_file,
+ metadata_file,
+ oks_sigma=0.1,
+ margin=0,
+ symmetric_kpts=None,
+ drop_kpts=None,
+):
+ """Calculate mean average precision (mAP) based on predictions."""
+ eval_results = {Path(k): v for k, v in eval_results_obj.items()}
+ df = pd.read_hdf(h5_file)
+ try:
+ df.drop("single", level="individuals", axis=1, inplace=True)
+ except KeyError:
+ pass
+ n_animals = len(df.columns.get_level_values("individuals").unique())
+ kpts = list(df.columns.get_level_values("bodyparts").unique())
+
+ 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)
+ 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_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:
+ temp = {}
+ for k, v in assemblies_gt_test.items():
+ lst = []
+ for a in v:
+ arr = np.delete(a.data[:, :3], drop_kpts, axis=0)
+ a = inferenceutils.Assembly.from_array(arr)
+ lst.append(a)
+ temp[k] = lst
+ assemblies_gt_test = temp
+ for ind in sorted(drop_kpts, reverse=True):
+ kpts.pop(ind)
+
+ assemblies_pred = conv_obj_to_assemblies(eval_results, kpts)
+ with deeplabcut.benchmark.utils.DisableOutput():
+ oks = inferenceutils.evaluate_assembly(
+ assemblies_pred,
+ assemblies_gt_test,
+ oks_sigma,
+ margin=margin,
+ symmetric_kpts=symmetric_kpts,
+ greedy_matching=True,
+ )
+ return oks["mAP"]
+
+
+def calc_rmse_from_obj(
+ eval_results_obj,
+ h5_file,
+ metadata_file,
+ drop_kpts=None,
+):
+ """Calc prediction errors for submissions."""
+ 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)
+
+ test_objects = {Path(k): v for k, v in eval_results_obj.items() if Path(k) in gt["annotations"]}
+ 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
+ for image, assemblies in assemblies_pred.items():
+ if assemblies:
+ arr = np.stack([a.data for a in assemblies]).swapaxes(0, 1)
+ data = [xy[~np.isnan(xy).any(axis=1)] for xy in arr[..., :2]]
+ temp = {
+ "coordinates": tuple([data]),
+ "confidence": list(np.expand_dims(arr[..., 2], axis=2)),
+ }
+ preds["predictions"][image] = temp
+ 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[Path]:
+ """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]
+ return [Path(img_path) if isinstance(img_path, str) else Path(*img_path) for img_path in df_test.index]
+
+
+def _load_test_indices(shuffle_metadata_path: str) -> list[int]:
+ """Returns the indices of test images in the training dataset dataframe."""
+ with Path(shuffle_metadata_path).open("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..1454648d6f
--- /dev/null
+++ b/deeplabcut/benchmark/mot.py
@@ -0,0 +1,200 @@
+#
+# 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.
+
+ Args:
+ bboxes (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 `bboxes`
+ 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.
+
+ Args:
+ 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].
+
+ Note:
+ - 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.
+
+ Args:
+ 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, 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: 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].
+
+ Note:
+ - 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
new file mode 100644
index 0000000000..fc5492ade4
--- /dev/null
+++ b/deeplabcut/benchmark/utils.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/master/AUTHORS
+#
+# 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.
+"""
+
+import importlib
+import os
+import pkgutil
+import sys
+from pathlib import Path
+
+
+class RedirectStdStreams:
+ """Context manager for redirecting stdout and stderr
+ Reference:
+ https://stackoverflow.com/a/6796752
+ CC BY-SA 3.0, https://stackoverflow.com/users/46690/rob-cowie
+ """
+
+ def __init__(self, stdout=None, stderr=None):
+ self._stdout = stdout or sys.stdout
+ self._stderr = stderr or sys.stderr
+
+ def __enter__(self):
+ self.old_stdout, self.old_stderr = sys.stdout, sys.stderr
+ self.old_stdout.flush()
+ self.old_stderr.flush()
+ sys.stdout, sys.stderr = self._stdout, self._stderr
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self._stdout.flush()
+ self._stderr.flush()
+ sys.stdout = self.old_stdout
+ sys.stderr = self.old_stderr
+
+
+class DisableOutput(RedirectStdStreams):
+ def __init__(self):
+ devnull = Path(os.devnull).open("w")
+ super().__init__(stdout=devnull, stderr=devnull)
+
+
+def import_submodules(package, recursive=True):
+ """Import all submodules of a module, recursively, including subpackages.
+
+ :param package: package (name or actual module)
+ :type package: str | module
+ :rtype: dict[str, types.ModuleType]
+
+ Reference:
+ https://stackoverflow.com/a/25562415
+ CC BY-SA 3.0, https://stackoverflow.com/users/712522/mr-b
+ """
+ if isinstance(package, str):
+ package = importlib.import_module(package)
+ results = {}
+ 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:
+ results.update(import_submodules(full_name))
+ return results
diff --git a/deeplabcut/cli.py b/deeplabcut/cli.py
index f0169c708b..298cf1c987 100644
--- a/deeplabcut/cli.py
+++ b/deeplabcut/cli.py
@@ -1,12 +1,14 @@
-"""
-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
-"""
+#
+# 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
import click
@@ -24,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")
@@ -47,39 +49,50 @@ 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
-
- Options \n
- ---------- \n
- project : string \n
- \tString containing the name of the project.\n
- experimenter : string \n
- \tString containing the name of the experimenter. \n
- 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
- 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
-
- 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/
-
- 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
-
- 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
-
+ """Create a new project directory, sub-directories and a basic configuration file.
+
+ Delegates to ``deeplabcut.create_new_project``.
+
+ The configuration file is loaded with default values. Change its parameters to your
+ projects need.
+
+ Args:
+ project (string): String containing the name of the project.
+ experimenter (string): String containing the name of the experimenter.
+ videos (list): 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.
+ copy_videos (bool, optional): If True, symlink videos into project/videos directory.
+ The default is ``True``; if provided it must be either ``True`` or ``False``.
+
+ Examples:
+ To create the project in the current working directory without symbolic links:
+ ```bash
+ 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 the current working directory with symbolic links:
+ ```bash
+ 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:
+
+ ```bash
+ 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)
@@ -93,32 +106,31 @@ 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
-
- Options\n
- ----------\n
- config : string\n
- String containing the full path of the config file in the project.
+ """Add new videos to the config file at any stage of the project.
- videos : list \n
- A list of string containing the full paths of the videos to include in the project.
+ Delegates to ``deeplabcut.add_new_videos``.
- 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``
+ Args:
+ config (string): String containing the full path of the config file in the project.
+ videos (list): A list of string containing the full paths of the videos to include in the project.
+ copy_videos (bool, optional): If True, symlinks of the videos are copied to
+ project/videos. Default ``True``; must be ``True`` or ``False``.
- Examples\n
- --------\n
- >>> python3 dlc.py add_new_videos /home/project/reaching-task-Tanmay-2018-08-23/config.yaml /data/videos/mouse5.avi
+ Examples:
+ To add a new video to the project:
+ ```bash
+ 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")
@@ -137,63 +149,95 @@ 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
-
- CONFIG : string \n
- Full path of the config.yaml file as a string. \n \n \n
- MODE : string \n \n
- String containing the mode of extraction. It must be either ``automatic`` or ``manual``. \n
-
- Examples \n
- -------- \n
- 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
- >>> 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
- -------- \n
+ """Extracts frames from the videos in the config.yaml file.
+
+ Delegates to ``deeplabcut.extract_frames``.
+
+ Only the videos in the config.yaml will be used to select the frames. Use the
+ function ``add_new_videos`` at any stage of the project to add new videos to the
+ config file and extract their frames.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ mode (string): Mode of extraction. Must be either ``automatic`` or ``manual``.
+ algo (string, optional): For automatic extraction, the algorithm to use:
+ ``kmeans`` or ``uniform``. Defaults to ``uniform``.
+ crop (bool, optional): If True, crop frames according to config.yaml parameters.
+ Defaults to False.
+ Examples:
+ For selecting frames automatically with 'kmeans' and do not want to crop the frames:
+
+ ```bash
+ python3 dlc.py extract_frames /analysis/project/reaching-task/config.yaml \\
+ automatic --algo kmeans
+ ```
+
+ For selecting frames automatically with 'uniform' and want to crop the frames based on
+ the ``crop`` parameters in config.yaml:
+
+ ```bash
+ python3 dlc.py extract_frames /analysis/project/reaching-task/config.yaml \\
+ automatic --crop
+ ```
+
+ To select frames manually:
+
+ ```bash
+ python3 dlc.py extract_frames /analysis/project/reaching-task/config.yaml manual
+ ```
+
+ 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.
"""
- 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
- Example\n
- --------\n
- python3 dlc.py label_frames /analysis/project/reaching-task/config.yaml
+ """Manually label/annotate the extracted frames.
+
+ Delegates to ``deeplabcut.label_frames``.
+
+ Update the list of body parts you want to localize in the config.yaml file first.
+
+ Args:
+ config (string): Full path of the config.yaml file.
+
+ Examples:
+ To launch the frame labeling GUI:
+ ```bash
+ 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
+ """Check if labels were stored correctly by plotting annotations and inspect them
+ visually.
+
+ Delegates to ``deeplabcut.check_labels``.
+ If some are wrong, then use the refine_labels to correct the labels.
"""
- from deeplabcut.generate_training_dataset import labelFrames
+ 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,23 +249,42 @@ 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
- 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
- --------\n
- To create a training dataset with only 1 shuffle
- python3 dlc.py create_training_dataset /analysis/project/reaching-task/config.yaml
-
- To create a training dataset with only 2 shuffles
- python3 dlc.py create_training_dataset /analysis/project/reaching-task/config.yaml num_shuffles 2
+ """Combine frame and label information into an array. Create training and test sets.
+
+ Delegates to ``deeplabcut.create_training_dataset``.
+
+ Update parameters TrainFraction and iteration in config.yaml. Also update
+ parameters for pose_config.yaml as wanted.
+
+ Args:
+ config (string): Full path of the config.yaml file in the train directory of a
+ project.
+ num_shuffles (int, optional): Number of shuffles of training dataset to create.
+ Defaults to 1.
+
+ Examples:
+ To create a training dataset with only 1 shuffle:
+
+ ```bash
+ python3 dlc.py create_training_dataset \\
+ /analysis/project/reaching-task/config.yaml
+ ```
+
+ To create a training dataset with only 2 shuffles:
+
+ ```bash
+ 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(
@@ -233,20 +296,28 @@ def create_training_dataset(_, *args, **kwargs):
)
@click.pass_context
def train_network(_, *args, **kwargs):
- """Train a trained Feature detector with a specific training data set.\n
- Provide path to the pose_config file.
- CONFIG: Full path of the config.yaml file in the train directory of a project.\n
+ """Train a trained Feature detector with a specific training data set.
+
+ Delegates to ``deeplabcut.train_network``.
- e.g. run the script like this:
- python3 dlc.py step7_train /home/project/reaching/config.yaml
+ Args:
+ config (string): Full path of the config.yaml file in the train directory of a
+ project.
+ shuffle (int, optional): Shuffle index of the training dataset. Defaults to 1.
+ Examples:
+ To train the network with the default settings:
+
+ ```bash
+ python3 dlc.py step7_train /home/project/reaching/config.yaml
+ ```
"""
from deeplabcut.pose_estimation_tensorflow import training
training.train_network(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.option(
@@ -256,26 +327,32 @@ 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
- CONFIG: Full path of the "pose_config.yaml" file in the train directory of a project.\n
+ """Evaluates a trained Feature detector model.
- Example\n
- ----------
- Evalaute the network
- python3 dlc.py evaluate_network /home/project/reaching/config.yaml
+ Delegates to ``deeplabcut.evaluate_network``.
+ Args:
+ config (string): Full path of the config.yaml file in the train directory of a
+ project.
+ shuffle (list, optional): Shuffle index of the training dataset. Defaults to [1].
+ plotting (bool, optional): Make evaluation plots. Defaults to False.
+
+ Examples:
+ Evalaute the network:
+
+ ```bash
+ 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 +368,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",
)
@@ -304,15 +381,26 @@ def evaluate_network(_, config, **kwargs):
)
@click.pass_context
def analyze_videos(_, *args, **kwargs):
-
- """Makes prediction.\n
- CONFIG: Full path of the "config.yaml" file in the train directory of a project.\n
- VIDEOS: Full path to video.\n
-
- Example\n
- ----------
-
- python3 dlc.py analyze_videos /home/project/reaching/config.yaml /home/project/reaching/newVideo/1.avi
+ """Makes prediction on videos using a trained network.
+
+ Delegates to ``deeplabcut.analyze_videos``.
+
+ Args:
+ config (string): Full path of the config.yaml file in the train directory of a
+ project.
+ videos (list): Full path(s) to video(s).
+ shuffle (int, optional): Shuffle index of the training dataset. Defaults to 1.
+ video_extensions (string, optional): Video extension when the input is a directory.
+ Defaults to ``.avi``.
+ save_as_csv (bool, optional): Also save predictions as a CSV file. Defaults to
+ False.
+
+ Examples:
+ To analyze a video:
+ ```bash
+ python3 dlc.py analyze_videos /home/project/reaching/config.yaml \\
+ /home/project/reaching/newVideo/1.avi
+ ```
"""
from deeplabcut.pose_estimation_tensorflow import predict_videos
@@ -323,7 +411,7 @@ def analyze_videos(_, *args, **kwargs):
# predict.predict_video(config, video,**kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@@ -334,17 +422,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,8 +445,9 @@ def analyze_videos(_, *args, **kwargs):
"comparisonbodyparts",
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.",
+ 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.",
)
@click.option(
"-e",
@@ -361,15 +455,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",
@@ -377,15 +474,15 @@ def analyze_videos(_, *args, **kwargs):
"ARdegree",
default=7,
help="For outlieralgorithm `fitting`: Autoregressive degree of Sarimax model degree. \
- See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html",
+ See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html",
)
@click.option(
"-mad",
"--ma_degree",
"MAdegree",
default=1,
- help="Int value. For outlieralgorithm `fitting`: MovingAvarage degree of Sarimax model degree.\
- See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html",
+ 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(
"-a",
@@ -399,61 +496,103 @@ 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.
-
- Another crucial parameter in config.yaml is how many frames to extract 'numframes2extract'.
-
- CONFIG : string \n
- Full path of the config.yaml file as a string. \n
- VIDEO : Full path of the video to extract the frame from. Make sure that this video is already analyzed.
-
-
- 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
- --------\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
- --------\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
- --------\n
-
+ """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.
+
+ Delegates to ``deeplabcut.extract_outlier_frames``.
+
+ Another crucial parameter in config.yaml is how many frames to extract
+ 'numframes2extract'.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ video (string): Full path of the video to extract frames from. Make sure that
+ this video is already analyzed.
+ outlieralgorithm (string, optional): Algorithm used to detect outliers.
+ Defaults to ``fitting``.
+ comparisonbodyparts (string, optional): Body parts used for comparison.
+ Defaults to ``all``.
+ epsilon (float, optional): Meaning depends on outlier algorithm. Defaults to 20.
+ p_bound (float, optional): Likelihood threshold for ``uncertain`` algorithm.
+ Defaults to 0.01.
+ ARdegree (int, optional): Autoregressive degree for ``fitting`` algorithm.
+ Defaults to 7.
+ MAdegree (int, optional): Moving average degree for ``fitting`` algorithm.
+ Defaults to 1.
+ alpha (float, optional): Significance level for SARIMAX outlier detection.
+ Defaults to 0.01.
+ extractionalgorithm (string, optional): Algorithm for selecting outlier frames.
+ Defaults to ``uniform``.
+
+ Examples:
+ For extracting the frames with default settings:
+
+ ```bash
+ python3 dlc.py extract_outlier_frames \\
+ /analysis/project/reaching-task/config.yaml \\
+ /analysis/project/video/reachinvideo1.avi
+ ```
+
+ For extracting the frames with kmeans:
+
+ ```bash
+ python3 dlc.py extract_outlier_frames \\
+ /analysis/project/reaching-task/config.yaml \\
+ /analysis/project/video/reachinvideo1.avi \\
+ --extractionalgorithm 'kmeans'
+ ```
+
+ For extracting the frames with kmeans and epsilon = 5 pixels:
+
+ ```bash
+ python3 dlc.py extract_outlier_frames \\
+ /analysis/project/reaching-task/config.yaml \\
+ /analysis/project/video/reachinvideo1.avi \\
+ --epsilon 5 --extractionalgorithm kmeans
+ ```
"""
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
-
- Examples \n
- --------\n
- >>> python3 dlc.py refine_labels /analysis/project/reaching-task/config.yaml \n
- --------\n
+ """Refines the labels of the outlier frames extracted from the analyzed videos.
+
+ Delegates to ``deeplabcut.refine_labels``.
+
+ Helps in augmenting the training dataset. Use the function ``analyze_videos`` to
+ analyze a video and extract the outlier frames using ``extract_outlier_frames``
+ before refining the labels.
+
+ Args:
+ config (string): Full path of the config.yaml file.
+
+ Examples:
+ To refine the labels:
+ ```bash
+ python3 dlc.py refine_labels /analysis/project/reaching-task/config.yaml
+ ```
"""
from deeplabcut.refine_training_dataset import outlier_frames
outlier_frames.refine_labels(config)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("config")
@click.argument("videos", nargs=-1)
@@ -467,10 +606,10 @@ 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``",
+ Only videos with this extension are analyzed. The default is ``.avi``",
)
@click.option(
"-s",
@@ -479,9 +618,9 @@ def refine_labels(_, config):
is_flag=True,
default=False,
help="If true creates each frame individual and then combines into a video. \
- This variant is relatively slow as it stores all individual frames. However, it \
- uses matplotlib to create the frames and is therefore much more flexible \
- (one can set transparency of markers, crop, and easily customize.",
+ This variant is relatively slow as it stores all individual frames. However, it \
+ uses matplotlib to create the frames and is therefore much more flexible \
+ (one can set transparency of markers, crop, and easily customize.",
)
@click.option(
"-d",
@@ -490,19 +629,33 @@ def refine_labels(_, config):
is_flag=True,
default=False,
help="If true then the individual frames created during the video generation will be deleted.\
- Only the video will be left.",
+ Only the video will be left.",
)
@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.
+
+ Delegates to ``deeplabcut.create_labeled_video``.
+
+ Make sure the video is already analyzed by the function ``analyze_videos``.
+
+ Args:
+ config (string): Full path of the config.yaml file.
+ videos (list): Full path(s) to video(s).
+ shuffle (int, optional): Shuffle index of the training dataset. Defaults to 1.
+ video_extensions (string, optional): Video extension when the input is a directory.
+ Defaults to ``.avi``.
+ save_frames (bool, optional): Save individual frames before combining into video.
+ Defaults to False.
+ delete (bool, optional): Delete individual frames after video generation.
+ Defaults to False.
"""
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)
@@ -516,10 +669,10 @@ 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``",
+ Only videos with this extension are analyzed. The default is ``.avi``",
)
@click.option(
"-s",
@@ -531,21 +684,32 @@ def create_labeled_video(_, *args, **kwargs):
)
@click.pass_context
def plot_trajectories(_, *args, **kwargs):
- """
- 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
- --------\n
+ """Plots the trajectories of various bodyparts across the video.
+
+ Delegates to ``deeplabcut.plot_trajectories``.
+
+ Args:
+ config (string): Full path of the config.yaml file.
+ videos (list): Full path(s) to video(s).
+ shuffle (int, optional): Shuffle index of the training dataset. Defaults to 1.
+ video_extensions (string, optional): Video extension when the input is a directory.
+ Defaults to ``.avi``.
+ showfigures (bool, optional): Also display plots interactively. Defaults to False.
+
+ Examples:
+ For plotting trajectories:
+ ```bash
+ python3 dlc.py plot_trajectories
+ /analysis/project/reaching-task/config.yaml \\
+ /analysis/project/videos/reachingvideo1.avi
+ ```
"""
from deeplabcut.utils import plotting
plotting.plot_trajectories(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
@main.command(context_settings=CONTEXT_SETTINGS)
@click.argument("cfg-path", nargs=1, type=click.STRING)
@click.option(
@@ -607,45 +771,33 @@ def plot_trajectories(_, *args, **kwargs):
)
@click.pass_context
def export_model(_, *args, **kwargs):
+ """Export DLC models for the model zoo or for live inference.
+
+ Delegates to ``deeplabcut.export_model``.
+
+ Saves the pose configuration, snapshot files, and frozen graph of the model to a
+ directory named exported-models within the project directory.
+
+ Args:
+ cfg_path (string): Path to the DLC Project config.yaml file.
+ iteration (int, optional): The model iteration you wish to export.
+ If None, uses the iteration listed in the config file.
+ shuffle (int, optional): The shuffle of the model to export. Defaults to 1.
+ trainingsetindex (int, optional): Index of the training fraction for the model
+ to export. Defaults to 0.
+ snapshotindex (int, optional): The snapshot index for the weights you wish to
+ export. If None, uses the snapshotindex as defined in config.yaml.
+ Defaults to None.
+ TFGPUinference (bool, optional): Use the tensorflow inference model?
+ Defaults to True. For DeepLabCut-live, set TFGPUinference=False.
+ overwrite (bool, optional): If the model was already exported, whether to
+ overwrite. Defaults to False.
+ make_tar (bool, optional): Compress the exported directory to a tar file?
+ Defaults to True. Required for model zoo export, not for live inference.
"""
- 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
- -----------
-
- cfg_path : string\n
- \tpath to the DLC Project config.yaml file
-
- iteration : int, optional\n
- \tthe model iteration you wish to export.\n
- \tIf None, uses the iteration listed in the config file
-
- shuffle : int, optional\n
- \tthe shuffle of the model to export. default = 1
-
- trainingsetindex : int, optional\n
- \tthe index of the training fraction for the model you wish to export. default = 1
-
- snapshotindex : int, optional\n
- \tthe snapshot index for the weights you wish to export.\n
- \tIf None, uses the snapshotindex as defined in 'config.yaml'. Default = None
-
- TFGPUinference : bool, optional\n
- \tuse the tensorflow inference model? Default = True\n
- \tFor inference using DeepLabCut-live, it is recommended to set TFGPIinference=False
-
- overwrite : bool, optional\n
- \tif the model you wish to export has already been exported, whether to overwrite. default = False
-
- make_tar : bool, optional\n
- \tDo you want to compress the exported directory to a tar file? Default = True\n
- \tThis is necessary to export to the model zoo, but not for live inference.
- """
-
from deeplabcut import export_model
export_model(*args, **kwargs)
-###########################################################################################################################
+##########################################################################
diff --git a/deeplabcut/compat.py b/deeplabcut/compat.py
new file mode 100644
index 0000000000..51e8301208
--- /dev/null
+++ b/deeplabcut/compat.py
@@ -0,0 +1,1687 @@
+#
+# 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
+
+import deeplabcut.core.visualization as visualization
+from deeplabcut.core.config import read_config
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
+
+DEFAULT_ENGINE = Engine.PYTORCH
+
+
+def _coerce_video_paths(videos: list[str | Path]) -> list[Path]:
+ return [Path(v) for v in videos]
+
+
+def _coerce_optional_path(path: str | Path | None) -> Path | None:
+ return None if path is None else Path(path)
+
+
+def get_project_engine(cfg: dict) -> Engine:
+ """Get the project 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, ...]:
+ """Get the available aug methods.
+
+ 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ shuffle (int, optional): Integer value specifying the shuffle index to select for training. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ 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): 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! Defaults to None.
+ save_iters (optional): 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! Defaults to None.
+ max_iters (optional): 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! Defaults to None.
+ epochs (optional): 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.). Defaults to None.
+ save_epochs (optional): 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. Defaults to None.
+ allow_growth (bool, optional): 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. Defaults to True.
+ gputouse (optional): 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. Defaults to None.
+ autotune (bool, optional): Only for the TensorFlow engine. Property of TensorFlow, somehow faster if ``False``
+ (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). Defaults to False.
+ keepdeconvweights (bool, optional): 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. Defaults to True.
+ 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. Defaults to "".
+ superanimal_name (str, optional): 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. Defaults to "".
+ superanimal_transfer_learning (bool, optional): 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). Defaults to False.
+ engine (Engine, optional): 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.
+ Defaults to None.
+ device (str, optional): Only for the PyTorch engine. The device to run the training on (e.g. "cuda:0").
+ Defaults to None.
+ snapshot_path (str | Path, optional): Only for the PyTorch engine. The path to the pose model snapshot to
+ resume training from. Defaults to None.
+ detector_path (str | Path, optional): Only for the PyTorch engine. The path to the detector model snapshot to
+ resume training from. Defaults to None.
+ batch_size (int, optional): Only for the PyTorch engine. The batch size to use while training. Defaults to None.
+ detector_batch_size (int, optional): Only for the PyTorch engine. The batch size to use while training the
+ detector. Defaults to None.
+ detector_epochs (int, optional): Only for the PyTorch engine. The number of epochs to train the detector for.
+ Defaults to None.
+ detector_save_epochs (int, optional): Only for the PyTorch engine. The number of epochs between each detector
+ snapshot save. Defaults to None.
+ pose_threshold (float, optional): Only for the PyTorch engine. Used for memory-replay. Pseudo-predictions with
+ confidence lower than this threshold are discarded for memory-replay. Defaults to 0.1.
+ pytorch_cfg_updates (dict, optional): 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]}``. Defaults to None.
+
+ 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,
+ )
+ """
+ config = Path(config)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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: str | Path,
+ 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ 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): 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. Defaults to
+ None.
+
+ Returns:
+ tuple: trainposeconfigfile, testposeconfigfile, snapshotfolder.
+ """
+ config = Path(config)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ shuffles (Sequence[int], optional): List of integers specifying the shuffle indices of the training dataset.
+ Defaults to [1].
+ trainingsetindex (int or str, optional): Integer specifying which "TrainingsetFraction" to use. Note that
+ "TrainingFraction" is a list in config.yaml. This variable can also be set to "all". Defaults to 0.
+ plotting (bool or str, optional): 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. Defaults to False.
+ show_errors (bool, optional): Display train and test errors. Defaults to True.
+ comparison_bodyparts (str or list, optional): The average error will be computed for those body parts only. The
+ provided list has to be a subset of the defined body parts. Defaults to "all".
+ gputouse (int or None, optional): 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. Defaults to None.
+ rescale (bool, optional): 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! Defaults to False.
+ 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. Defaults to "".
+ per_keypoint_evaluation (bool): 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. Defaults to False.
+ snapshots_to_evaluate (List[str], optional): List of snapshot names to evaluate (e.g. ["snapshot-5000",
+ "snapshot-7500"]). Defaults to None.
+ pcutoff (float | list[float] | dict[str, float] | 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. Defaults to None.
+ engine (Engine, optional): 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. Defaults to
+ None.
+ 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.
+ """
+ config = Path(config)
+ if engine is None:
+ cfg = read_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 | Path,
+ 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]
+ )
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ shuffle (int): Shuffle index of the training dataset. Defaults to 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): The average error will be computed for those body parts only (has to
+ be a subset of the body parts). Defaults to "all".
+ snapshotindex (str or int, optional): The index of the snapshot to return the evaluation data for. This can be
+ an integer (e.g. 5000) or a string (e.g. "snapshot-5000").
+ If None, the snapshot index specified in the project configuration file will be used. Defaults to None.
+ rescale (bool): 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! Defaults to False.
+ engine (Engine, optional): 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. Defaults to
+ None.
+
+ Examples:
+ If you do not want to plot:
+
+ deeplabcut.return_evaluate_network_data("/analysis/project/reaching-task/config.yaml", shuffle=[1])
+
+ If you want to plot:
+
+ deeplabcut.return_evaluate_network_data(
+ "/analysis/project/reaching-task/config.yaml",
+ shuffle=[1],
+ plotting=True
+ )
+ """
+ config = Path(config)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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 | Path,
+ videos: list[str | Path],
+ 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 | Path | 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for
+ training the network. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml). Defaults to 0.
+ gputouse (int or None, optional): 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. Defaults to None.
+ save_as_csv (bool, optional): Saves the predictions in a .csv file. Defaults to False.
+ in_random_order (bool, optional): Whether or not to analyze videos in a random order.
+ This is only relevant when specifying a video directory in `videos`. Defaults to True.
+ destfolder (str | Path | None, optional): Destination folder for analysis data. If ``None``, uses the
+ video path. Pass this folder for subsequent analysis too. Defaults to None.
+ batch_size (int or None, optional): Currently not supported by the PyTorch engine. Batch size for inference;
+ overwrites ``pose_cfg.yaml`` if set. Defaults to None.
+ cropping (list or None, optional): 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. Defaults to None.
+ TFGPUinference (bool, optional): 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. Defaults to True.
+ 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. str:
+ """Creates a tracking dataset to train a ReID tracklet stitcher.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use. By default the first
+ (note that TrainingFraction is a list in config.yaml). Defaults to 0.
+ gputouse (int or None, optional): 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. Defaults to None.
+ TFGPUinference (bool, optional): 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. Defaults to True.
+ destfolder (str | Path | None, optional): 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): Directory containing the deeplabcut models to use when evaluating the network.
+ By default, the models are assumed to exist in the project folder. Defaults to "".
+ robust_nframes (bool, optional): 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. Defaults to False.
+ n_triplets (int): The number of triplets to extract for the dataset. Defaults to 1000.
+ engine (Engine, optional): 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. Defaults to
+ None.
+
+ Returns:
+ str: DLCScorer; the scorer used to analyze the videos.
+ """
+ config = Path(config)
+ videos = _coerce_video_paths(videos)
+ destfolder = _coerce_optional_path(destfolder)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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.
+
+ Args:
+ 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. If provided it must be either ``True`` or
+ ``False``. Defaults to 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): 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. Defaults to False.
+ pcutoff (float, optional): 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. Defaults to None.
+ bbox_pcutoff (float, optional): 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. Defaults to None.
+ plot_skeleton (bool): If a skeleton is defined in the project's config.yaml, whether to plot the skeleton
+ connecting the predicted bodyparts on the images. Defaults to False.
+ torch_kwargs: Any extra parameters to pass to the PyTorch API, such as ``ctd_conditions``.
+
+ Returns:
+ dict: 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(
+ read_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 | Path,
+ directory: str | Path,
+ 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ directory (str | Path): 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. Defaults to ``.png``.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network. Defaults to 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 | None, 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. If provided it must be either ``True`` or
+ ``False``. Defaults to 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.
+ ```bash
+ ffmpeg -i testvideo.avi "thumb%04d.png"
+ ```
+ """
+ config = Path(config)
+ directory = Path(directory)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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 | Path,
+ videos: list[str | Path],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ overwrite: bool = False,
+ destfolder: str | Path | 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!
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network. Defaults to 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 (str | Path | None, 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 (optional): 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. Defaults to None.
+ calibrate (bool, optional): 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. Defaults to False.
+ window_size (int, optional): 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. Defaults to 0.
+ identity_only (bool, optional): If True and animal identity was learned by the model, assembly and tracking rely
+ exclusively on identity prediction. Defaults to False.
+ 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): 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. Defaults to
+ None.
+
+ 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",
+ )
+ """
+ config = Path(config)
+ videos = _coerce_video_paths(videos)
+ destfolder = _coerce_optional_path(destfolder)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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: str | Path,
+ 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).
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ shuffle (int): Shuffle index of the training dataset. Defaults to 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): 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. Defaults to None.
+ device (str or None, 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"}. See
+ https://pytorch.org/docs/stable/notes/cuda.html for more information. Defaults to None.
+ rescale (bool): 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! Defaults to False.
+ engine (Engine, optional): 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. Defaults to
+ None.
+
+ 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])
+ """
+ config = Path(config)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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: str | Path,
+ 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 | Path | None = 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ shuffle (int): Shuffle index of the training dataset. Defaults to 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): The average error will be computed for those body parts only
+ (has to be a subset of the body parts). Defaults to "all".
+ 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): 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. Defaults to None.
+ device (str or None, 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"}. See
+ https://pytorch.org/docs/stable/notes/cuda.html for more information. Defaults to None.
+ Indices: For which images shall the scmap/locref and paf be computed? Give a list of images. Defaults to None.
+ 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): 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. Defaults to
+ None.
+
+ 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]
+ )
+ """
+ config = Path(config)
+ dest_folder = _coerce_optional_path(dest_folder)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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 | Path,
+ 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).
+
+ Args:
+ cfg_path (str | Path): Path to the DLC Project config.yaml file.
+ shuffle (int, optional): The shuffle of the model to export. Defaults to 1.
+ trainingsetindex (int, optional): The index of the training fraction for the model you wish to export. Defaults
+ to 1.
+ snapshotindex (int, optional): The snapshot index for the weights you wish to export. If None, uses the
+ snapshotindex as defined in 'config.yaml'. Defaults to 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? For inference using DeepLabCut-live, it is
+ recommended to set TFGPUinference=False. Defaults to True.
+ overwrite (bool, optional): If the model you wish to export has already been exported, whether to overwrite.
+ Defaults to False.
+ make_tar (bool, optional): Compress the exported directory to a tar file. This is necessary to export to the
+ model zoo, but not for live inference. Defaults to True.
+ 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): 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. Defaults to
+ None.
+
+ Examples:
+ Export the first stored snapshot for model trained with shuffle 3:
+
+ deeplabcut.export_model("/analysis/project/reaching-task/config.yaml", shuffle=3, snapshotindex=-1)
+ """
+ cfg_path = Path(cfg_path)
+ if engine is None:
+ engine = get_shuffle_engine(
+ read_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
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/__init__.py b/deeplabcut/core/config/__init__.py
new file mode 100644
index 0000000000..76115065bc
--- /dev/null
+++ b/deeplabcut/core/config/__init__.py
@@ -0,0 +1,44 @@
+from deeplabcut.core.config.base_config import DLCBaseConfig, DLCVersionedConfig
+from deeplabcut.core.config.project_config import ProjectConfig
+from deeplabcut.core.config.utils import (
+ create_config_template,
+ create_config_template_3d,
+ edit_config,
+ ensure_plain_config,
+ get_yaml_dumper,
+ get_yaml_loader,
+ normalize_for_serialization,
+ pretty_print,
+ read_config,
+ read_config_as_dict,
+ resolve_alias,
+ resolve_aliases_in_dict,
+ write_config,
+ write_config_3d,
+ write_config_3d_template,
+ write_project_config,
+)
+
+__all__ = [
+ "DLCBaseConfig",
+ "DLCVersionedConfig",
+ "ProjectConfig",
+ "create_config_template",
+ "create_config_template_3d",
+ "edit_config",
+ "pretty_print",
+ "read_config",
+ "read_config_as_dict",
+ "resolve_alias",
+ "resolve_aliases_in_dict",
+ "write_config",
+ "write_config_3d",
+ "write_config_3d_template",
+ "write_project_config",
+ "get_yaml_loader",
+ "get_yaml_dumper",
+ "versioning",
+ "validation",
+ "normalize_for_serialization",
+ "ensure_plain_config",
+]
diff --git a/deeplabcut/core/config/base_config.py b/deeplabcut/core/config/base_config.py
new file mode 100644
index 0000000000..13cbd62591
--- /dev/null
+++ b/deeplabcut/core/config/base_config.py
@@ -0,0 +1,433 @@
+from __future__ import annotations
+
+import functools
+import logging
+import sys
+from collections.abc import Callable, Iterator
+from pathlib import Path
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
+from pydantic_core import ArgsKwargs
+from ruamel.yaml.comments import CommentedMap
+from typing_extensions import Self
+
+from deeplabcut.core.config import versioning as versioning
+from deeplabcut.core.config.utils import (
+ normalize_for_serialization,
+ pretty_print,
+ read_config_as_dict,
+ resolve_alias,
+ resolve_aliases_in_dict,
+ write_config,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class DLCBaseConfig(BaseModel):
+ """Pydantic base for DeepLabCut configuration models.
+
+ This class is used to create configuration models for DeepLabCut.
+ It provides a base class for all configuration models that need YAML/dict I/O
+ and optional deprecated field names via ``json_schema_extra["aliases"]``.
+ (Use for all nested configs, e.g. pytorch ``DataConfig``, ``InferenceConfig``, etc.)
+
+ For project-level schema migration and dirty-field tracking, subclass
+ `DLCVersionedConfig` instead.
+
+ Features:
+
+ - Strict schema (`extra="forbid"`, `validate_assignment=True`).
+ - Load and save: `from_yaml`, `from_dict`, `from_any`, `to_yaml`, `to_dict`.
+ - Pretty-print via `print`.
+ - Hooks: `_post_yaml_load_updates`.
+ - Nested dot-notation via `select`.
+ - Dict-like access over declared fields (legacy compatibility).
+ - In-place bulk updates via `update`.
+ - Field aliases from `json_schema_extra`.
+ """
+
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+ # ------------------------------------------------------------------
+ # Validation (before pydantic field validation)
+ # ------------------------------------------------------------------
+
+ @model_validator(mode="before")
+ @classmethod
+ def resolve_aliases_before_validate(cls, data: Any) -> Any:
+ """Resolves aliases to their canonical names. (Normalizes ArgsKwargs
+ input to a dict for downstream validation.)
+
+ Args:
+ data: Raw validator input (`dict`, `ArgsKwargs`, or other).
+
+ Returns:
+ A dict with canonical field names when input is ArgsKwargs or dict;
+ otherwise `data` unchanged.
+ """
+ if isinstance(data, ArgsKwargs):
+ data: dict = cls._args_kwargs_to_dict(data)
+ if isinstance(data, dict):
+ return resolve_aliases_in_dict(data, cls._alias_map(), target=cls.__name__)
+ return data
+
+ # ------------------------------------------------------------------
+ # Construction
+ # ------------------------------------------------------------------
+
+ @classmethod
+ def from_dict(cls, cfg_dict: dict) -> Self:
+ return cls.model_validate(cfg_dict)
+
+ @classmethod
+ def from_any(
+ cls,
+ config: Self | dict | str | Path,
+ ) -> Self:
+ if isinstance(config, cls):
+ return config
+ elif isinstance(config, str | Path):
+ return cls.from_yaml(config)
+ elif isinstance(config, dict):
+ return cls.from_dict(config)
+ else:
+ raise TypeError(
+ "Failure to load configuration: Expected a config instance, "
+ f"dictionary, string, or Path. Got {type(config)}"
+ )
+
+ # Note @deruyter92 2026-06-15: the ignore_empty option is currently just used to support
+ # some top-level fields in v0 legacy configs that are often empty. Should be removed in v1.
+ @classmethod
+ def from_yaml(cls, yaml_path: str | Path, ignore_empty: bool = True) -> Self:
+ yaml_dict = read_config_as_dict(yaml_path)
+ if ignore_empty:
+ yaml_dict = {k: v for k, v in yaml_dict.items() if v is not None}
+ cfg = cls.from_dict(yaml_dict)
+ cfg._post_yaml_load_updates(yaml_path=Path(yaml_path))
+ return cfg
+
+ # ------------------------------------------------------------------
+ # Serialization
+ # ------------------------------------------------------------------
+
+ def to_commented_map(self) -> CommentedMap:
+ """Recursively convert the config to a CommentedMap with YAML comments."""
+ dumped = self.to_dict(normalize=True)
+ data = CommentedMap()
+ for name, info in type(self).model_fields.items():
+ extra = info.json_schema_extra
+ if isinstance(extra, dict) and (comment := extra.get("comment")):
+ data.yaml_set_comment_before_after_key(name, before=comment)
+ value = getattr(self, name)
+ if isinstance(value, DLCBaseConfig):
+ data[name] = value.to_commented_map()
+ else:
+ data[name] = dumped[name]
+ return data
+
+ def to_yaml(
+ self,
+ yaml_path: str | Path,
+ *,
+ overwrite: bool = True,
+ ) -> None:
+ write_config(yaml_path, self.to_commented_map(), overwrite=overwrite)
+
+ def to_dict(self, *, normalize: bool = False) -> dict:
+ if not normalize:
+ return self.model_dump()
+ return normalize_for_serialization(self.model_dump())
+
+ def print(
+ self,
+ indent: int = 0,
+ print_fn: Callable[[str], None] | None = None,
+ ) -> None:
+ pretty_print(config=self.to_dict(), indent=indent, print_fn=print_fn)
+
+ # ------------------------------------------------------------------
+ # Hooks (override in subclasses)
+ # ------------------------------------------------------------------
+
+ def _post_yaml_load_updates(self, *, yaml_path: Path) -> None:
+ pass
+
+ # ------------------------------------------------------------------
+ # Field aliases (deprecated names in json_schema_extra)
+ # ------------------------------------------------------------------
+
+ @classmethod
+ @functools.cache
+ def _alias_map(cls) -> dict[str, str]:
+ """Build a map of deprecated aliases to canonical field names.
+
+ Returns:
+ Dict mapping each alias in `json_schema_extra["aliases"]` to its
+ canonical field name.
+
+ Raises:
+ ValueError: If the same alias is declared on more than one field.
+ """
+ mapping: dict[str, str] = {}
+ for name, info in cls.model_fields.items():
+ extra = info.json_schema_extra
+ if not isinstance(extra, dict):
+ continue
+ for alias in extra.get("aliases", []):
+ if alias in mapping:
+ raise ValueError(f"Duplicate alias '{alias}' for fields '{mapping[alias]}' and '{name}'")
+ mapping[alias] = name
+ return mapping
+
+ def _resolve_alias(
+ self,
+ name: str,
+ *,
+ warn: bool = True,
+ stacklevel: int = 4,
+ ) -> str:
+ return resolve_alias(name, type(self)._alias_map(), warn=warn, stacklevel=stacklevel)
+
+ # ------------------------------------------------------------------
+ # Dict-like access (canonical field names only in keys()/iter)
+ # ------------------------------------------------------------------
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ name = self._resolve_alias(name)
+ super().__setattr__(name, value)
+
+ def __getattr__(self, name: str) -> Any:
+ # Only runs after normal lookup fails; try resolved alias or raise AttributeError via BaseModel.__getattr__.
+ if name in type(self)._alias_map():
+ return getattr(self, self._resolve_alias(name))
+ return super().__getattr__(name)
+
+ def __getitem__(self, key: str) -> Any:
+ key = self._resolve_alias(key)
+ try:
+ return getattr(self, key)
+ except AttributeError:
+ raise KeyError(key) from None
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ canonical = self._resolve_alias(key, warn=True)
+ if canonical not in self._field_names():
+ raise KeyError(f"'{type(self).__name__}' has no field '{key}'")
+ setattr(self, canonical, value)
+
+ def __contains__(self, key: object) -> bool:
+ if not isinstance(key, str):
+ return False
+ if key in self._field_names():
+ return True
+ return key in type(self)._alias_map()
+
+ def __iter__(self) -> Iterator[str]:
+ return iter(self._field_names())
+
+ def __len__(self) -> int:
+ return len(self._field_names())
+
+ def get(self, key: str, default: Any = None) -> Any:
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def update(
+ self,
+ updates: dict[str, Any] | None = None,
+ /,
+ **kwargs: Any,
+ ) -> Self:
+ if updates is not None and kwargs:
+ raise TypeError(f"{type(self).__name__}.update() accepts either a dict or keyword args, not both.")
+ overrides = updates if updates is not None else kwargs
+ # Resolve aliases together (checks for duplicate canonical field names)
+ resolved = resolve_aliases_in_dict(overrides, type(self)._alias_map(), target=type(self).__name__)
+ for name, value in resolved.items():
+ setattr(self, name, value)
+ return self
+
+ def keys(self) -> list[str]:
+ return self._field_names()
+
+ def values(self) -> list[Any]:
+ return [getattr(self, name) for name in self._field_names()]
+
+ def items(self) -> list[tuple[str, Any]]:
+ return [(name, getattr(self, name)) for name in self._field_names()]
+
+ def select(self, path: str, default: Any = None) -> Any:
+ """Select a value from the config using dot notation for nested keys."""
+ try:
+ return self._navigate_nested(fields=path.split("."))
+ except (AttributeError, KeyError, TypeError):
+ return default
+
+ def set_nested(self, path: str, value: Any) -> Self:
+ if "." not in path:
+ setattr(self, path, value)
+ return self
+ parts = path.split(".")
+ parent_fields, final_field = parts[:-1], parts[-1]
+ try:
+ parent = self._navigate_nested(parent_fields)
+ parent[final_field] = value
+ except (AttributeError, KeyError, TypeError) as e:
+ raise AttributeError(f"{type(self).__name__} has no '{path}'") from e
+ return self
+
+ def _navigate_nested(self, fields: list[str]) -> Any:
+ """Navigate a nested structure of fields. Raises AttributeError / KeyError if any field is not found."""
+ obj: Any = self
+ for field in fields:
+ obj = obj[field] if isinstance(obj, dict) else getattr(obj, field)
+ return obj
+
+ def _field_names(self) -> list[str]:
+ cls = type(self)
+ if not isinstance(self, BaseModel):
+ raise TypeError(f"{cls.__name__} must inherit from pydantic.BaseModel")
+ return list(cls.model_fields.keys())
+
+ @classmethod
+ def _args_kwargs_to_dict(cls, data: ArgsKwargs) -> dict:
+ """Map positional and keyword constructor args to a field-name dict."""
+ names = list(cls.model_fields.keys())
+ return dict(
+ zip(names, data.args or [], strict=False),
+ **(data.kwargs or {}),
+ )
+
+
+class DLCVersionedConfig(DLCBaseConfig):
+ """Top-level configs with schema migration and change tracking.
+
+ Subclass of `DLCBaseConfig` for project and pose YAML configs such as
+ `ProjectConfig` and `PoseConfig`.
+
+ Note:
+ Pydantic runs `migrate_before_validate` before the base
+ `resolve_aliases_before_validate` (child-first order): schema migration
+ on legacy keys, then alias resolution for the current model.
+
+ Additional behavior:
+
+ - `migrate_before_validate` upgrades raw dicts to `CURRENT_CONFIG_VERSION`.
+ - Tracks fields modified after load; `to_yaml` can log changes and mark clean.
+ - Patches `__setattr__` once per class to record dirty fields while delegating
+ alias warnings to the base `__setattr__`.
+ """
+
+ config_version: int = Field(
+ default=versioning.CURRENT_CONFIG_VERSION,
+ json_schema_extra={"comment": "Config schema version. Do not edit manually."},
+ )
+
+ _initialized: bool = PrivateAttr(default=False)
+ _dirty_fields: set[str] = PrivateAttr(default_factory=set)
+ _change_notes: dict[str, Any] = PrivateAttr(default_factory=dict)
+
+ # ------------------------------------------------------------------
+ # Version migration (before pydantic field validation)
+ # ------------------------------------------------------------------
+
+ @classmethod
+ def from_dict(cls, cfg_dict: dict) -> Self:
+ cfg_dict = versioning.migrate_config(
+ cfg_dict,
+ config_type=cls.__name__,
+ target_version=versioning.CURRENT_CONFIG_VERSION,
+ )
+ return super().from_dict(cfg_dict)
+
+ # ------------------------------------------------------------------
+ # Serialization
+ # ------------------------------------------------------------------
+
+ def to_yaml(
+ self,
+ yaml_path: str | Path,
+ *,
+ overwrite: bool = True,
+ log_changes: bool = True,
+ mark_clean: bool = True,
+ ) -> None:
+ super().to_yaml(yaml_path, overwrite=overwrite)
+ if log_changes:
+ self.log_changes()
+ if mark_clean:
+ self.mark_clean()
+
+ # ------------------------------------------------------------------
+ # Change tracking
+ # ------------------------------------------------------------------
+
+ def model_post_init(self, __context: Any) -> None:
+ super().model_post_init(__context)
+ self._initialized = True
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ name = self._resolve_alias(name)
+
+ # Private attributes (not a model field) skip tracking logic
+ if name not in type(self).model_fields or not self._initialized:
+ super().__setattr__(name, value)
+ return
+
+ # Get the coerced values before and after setting; log changes
+ old_value = getattr(self, name, None)
+ super().__setattr__(name, value)
+ new_value = getattr(self, name)
+ if old_value != new_value:
+ self._dirty_fields.add(name)
+
+ @property
+ def is_dirty(self) -> bool:
+ return bool(self._dirty_fields)
+
+ @property
+ def dirty_fields(self) -> frozenset[str]:
+ return frozenset(self._dirty_fields)
+
+ @property
+ def change_notes(self) -> list[str]:
+ return list(self._change_notes.values())
+
+ def record_change_note(
+ self,
+ field_name: str,
+ message: str,
+ *,
+ include_caller: bool = False,
+ _stack_depth: int = 1,
+ ) -> None:
+ field_name = self._resolve_alias(field_name)
+
+ if field_name not in type(self).model_fields:
+ raise KeyError(f"'{type(self).__name__}' has no field '{field_name}'")
+
+ if include_caller:
+ frame = sys._getframe(_stack_depth)
+ filename = frame.f_code.co_filename.rsplit("/", 1)[-1]
+ message = f"{message} [{filename}:{frame.f_lineno}]"
+
+ self._change_notes[field_name] = message
+
+ def log_changes(self) -> None:
+ if not self.is_dirty:
+ return
+ logger.info(f"Updates to {type(self).__name__}:")
+ for field_name in sorted(self._dirty_fields):
+ if field_name in self._change_notes:
+ logger.info(f" {self._change_notes[field_name]}")
+ else:
+ logger.info(f" {field_name} was modified")
+
+ def mark_clean(self) -> None:
+ self._dirty_fields.clear()
+ self._change_notes.clear()
diff --git a/deeplabcut/core/config/project_config.py b/deeplabcut/core/config/project_config.py
new file mode 100644
index 0000000000..40481e0af9
--- /dev/null
+++ b/deeplabcut/core/config/project_config.py
@@ -0,0 +1,275 @@
+#
+# 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
+#
+"""Project configuration classes for DeepLabCut pose estimation models."""
+
+from pathlib import Path
+from typing import Any, Literal
+
+from pydantic import Field, model_validator
+from typing_extensions import Self
+
+from deeplabcut.core.config.base_config import DLCVersionedConfig
+from deeplabcut.core.config.validation import (
+ BodypartPair,
+ Fraction,
+ NonNegativeInt,
+ StrictPositiveInt,
+ UniqueStrList,
+ less_than,
+ validate_crop_bounds,
+)
+
+
+class ProjectConfig(DLCVersionedConfig):
+ """Complete project configuration.
+
+ Mirrors the structure of the project config.yaml (and metadata in pose config).
+ Field names match the old dictionary keys for round-trip compatibility.
+
+ Attributes:
+ Task: Project task identifier (do not edit).
+ scorer: Scorer name (do not edit).
+ date: Project date (do not edit).
+ multianimalproject: Whether the project is multi-animal.
+ identity: Whether identity tracking is enabled (project config.yaml key).
+ project_path: Path to the DeepLabCut project.
+ pose_config_path: Path to the pose configuration file (metadata only).
+ engine: Default DeepLabCut engine (e.g. pytorch).
+ video_sets: Video set configuration.
+ bodyparts: List of body parts.
+ individuals: List of individual animal identities (multi-animal).
+ uniquebodyparts: List of unique body parts (multi-animal project key).
+ multianimalbodyparts: List of multi-animal body parts (multi-animal key).
+ start: Fraction of video to start extracting frames.
+ stop: Fraction of video to stop extracting frames.
+ numframes2pick: Number of frames to pick for labeling.
+ skeleton: Skeleton connectivity for plotting.
+ skeleton_color: Skeleton color for plotting.
+ pcutoff: Confidence cutoff for plotting.
+ dotsize: Dot size for visualization.
+ alphavalue: Alpha value for visualization.
+ colormap: Colormap for visualization.
+ TrainingFraction: Training fractions for dataset splits.
+ iteration: Training iteration.
+ default_net_type: Default network architecture.
+ default_augmenter: Default data augmenter.
+ default_track_method: Default tracking method.
+ snapshotindex: Snapshot index for evaluation.
+ detector_snapshotindex: Detector snapshot index.
+ batch_size: Training batch size.
+ detector_batch_size: Detector batch size.
+ cropping: Whether cropping is enabled for analysis.
+ x1: Cropping x1 coordinate.
+ x2: Cropping x2 coordinate.
+ y1: Cropping y1 coordinate.
+ y2: Cropping y2 coordinate.
+ corner2move2: Refinement corner configuration.
+ move2corner: Refinement move-to-corner flag.
+ SuperAnimalConversionTables: Conversion tables for SuperAnimal weights.
+ """
+
+ # Project definitions (do not edit)
+ Task: str = Field(default="", json_schema_extra={"comment": "Project definitions (do not edit)"})
+ scorer: str = ""
+ date: str = ""
+ multianimalproject: bool = False
+ identity: bool | None = Field(default=None, json_schema_extra={"aliases": ["with_identity"]})
+
+ # Project path
+ project_path: Path = Field(
+ default_factory=Path,
+ json_schema_extra={"comment": "\nProject path (change when moving around)"},
+ )
+ pose_config_path: Path | None = None
+
+ # Engine
+ engine: Literal["pytorch", "tensorflow"] = Field(
+ default="pytorch",
+ json_schema_extra={
+ "comment": "\nDefault DeepLabCut engine to use for shuffle creation (either pytorch or tensorflow)"
+ },
+ )
+
+ # Annotation dataset configuration (and individual video cropping parameters)
+ video_sets: dict[str, Any] = Field(
+ default_factory=dict,
+ json_schema_extra={"comment": "\nAnnotation data set configuration (and individual video cropping parameters)"},
+ )
+ # VV TODO @deruyter92 2026-01-30: following the old original config.yaml template for now. VV
+ # VV We should change this to a list[str] in the future. VV
+ bodyparts: UniqueStrList | Literal["MULTI!"] = Field(default_factory=list)
+
+ # TODO @deruyter92 2026-02-06: The current pipeline requires at least one individual defined in the
+ # default configuration. This will be removed in the future.
+ individuals: UniqueStrList = Field(default_factory=lambda: ["individual_1"])
+ uniquebodyparts: UniqueStrList = Field(default_factory=list, json_schema_extra={"aliases": ["unique_bodyparts"]})
+ multianimalbodyparts: UniqueStrList = Field(default_factory=list) # multi-animal project key
+
+ # Fraction of video to start/stop when extracting frames for labeling/refinement
+ start: Fraction = Field(
+ default=0.0,
+ json_schema_extra={
+ "comment": "\nFraction of video to start/stop when extracting frames for labeling/refinement"
+ },
+ )
+ stop: Fraction = 1.0
+ numframes2pick: NonNegativeInt = 20
+
+ # Plotting configuration
+ skeleton: list[BodypartPair] = Field(
+ default_factory=list,
+ json_schema_extra={"comment": "\nPlotting configuration"},
+ )
+ skeleton_color: str = "black"
+ pcutoff: Fraction = 0.6
+ dotsize: NonNegativeInt = 12
+ alphavalue: Fraction = 0.7
+ colormap: str = "rainbow"
+
+ # Training, evaluation and analysis configuration
+ TrainingFraction: list[Fraction] = Field(
+ default_factory=lambda: [0.95],
+ json_schema_extra={"comment": "\nTraining,Evaluation and Analysis configuration"},
+ )
+ iteration: NonNegativeInt | None = None
+ default_net_type: str = "resnet_50"
+ default_augmenter: str | None = None
+ default_track_method: str | None = None
+ snapshotindex: Literal["all"] | int = "all"
+ detector_snapshotindex: int = -1
+ batch_size: StrictPositiveInt = 8
+ detector_batch_size: StrictPositiveInt = 1
+
+ # Cropping parameters (for analysis and outlier frame detection)
+ cropping: bool = Field(
+ default=False,
+ json_schema_extra={"comment": "\nCropping Parameters (for analysis and outlier frame detection)"},
+ )
+ x1: NonNegativeInt | None = Field(
+ default=None,
+ json_schema_extra={"comment": "if cropping is true for analysis, then set the values here:"},
+ )
+ x2: NonNegativeInt | None = None
+ y1: NonNegativeInt | None = None
+ y2: NonNegativeInt | None = None
+
+ # Refinement configuration (parameters from annotation dataset configuration also relevant in this stage)
+ corner2move2: list[NonNegativeInt] | None = Field(
+ default=None,
+ json_schema_extra={
+ "comment": (
+ "\nRefinement configuration (parameters from annotation dataset "
+ "configuration also relevant in this stage)"
+ )
+ },
+ )
+ move2corner: bool | None = None
+
+ # Conversion tables to fine-tune SuperAnimal weights
+ SuperAnimalConversionTables: dict[str, Any] | None = Field(
+ default=None,
+ json_schema_extra={"comment": "\nConversion tables to fine-tune SuperAnimal weights"},
+ )
+
+ # TODO @deruyter92 2026-02-06: These parameters are no longer used in the new pipeline.
+ # We should remove them in config schema v1. They are needed now to support reading old configs.
+ resnet: int | None = Field(
+ default=None,
+ json_schema_extra={
+ "comment": "\nThese are very old parameters that are no longer used They are simply ignored."
+ },
+ )
+ croppedtraining: bool | None = None
+
+ @property
+ def bodyparts_list(self) -> list[str]:
+ # Animal-count agnostic; Always return a list (never "MULTI!", None, etc.)
+ if self.multianimalproject:
+ return list(self.multianimalbodyparts)
+ if self.bodyparts == "MULTI!":
+ raise ValueError("bodyparts must be a list of strings when multianimalproject is False, got 'MULTI!'")
+ return list(self.bodyparts)
+
+ @property
+ def config_yaml_path(self) -> Path:
+ return self.project_path / "config.yaml"
+
+ def validate_project_path(self, *, yaml_path: str | Path | None = None, write: bool = False) -> Self:
+ """Repair project_path from yaml location; optionally persist."""
+ path = Path(yaml_path) if yaml_path is not None else self.config_yaml_path
+ if not path.is_file():
+ raise FileNotFoundError(f"config.yaml not found: {path}")
+ self._post_yaml_load_updates(yaml_path=path)
+ if write and "project_path" in self.dirty_fields:
+ self.to_yaml(path, log_changes=True, mark_clean=True)
+ return self
+
+ @classmethod
+ def from_any(cls, config: Self | dict | str | Path, *, repair_path: bool = False) -> Self:
+ cfg = super().from_any(config)
+ if repair_path:
+ cfg.validate_project_path(yaml_path=config if isinstance(config, (str, Path)) else None, write=True)
+ return cfg
+
+ def _post_yaml_load_updates(self, *, yaml_path: Path) -> None:
+ """
+ Override method for post-yaml load updates. Called automatically by from_yaml().
+ These are logged but not written to disk -- call to_yaml() explicitly if needed.
+ """
+ project_path = yaml_path.parent
+ if project_path.absolute() != self.project_path.absolute():
+ old = self.project_path
+ self.project_path = project_path
+ self.record_change_note(
+ "project_path",
+ f"project_path updated: {old} -> {project_path} (resolved from YAML location when reading config.yaml)",
+ )
+
+ @model_validator(mode="before")
+ @classmethod
+ def normalize_legacy_empty_values(cls, data: Any) -> Any:
+ if not isinstance(data, dict):
+ return data
+ data = dict(data)
+
+ # Some old configs used empty strings for unset fields
+ for fieldname in ("skeleton", "TrainingFraction", "video_sets", "bodyparts"):
+ if data.get(fieldname) == "":
+ data.pop(fieldname)
+
+ # NOTE @deruyter92 2026-06-15: This should be removed in v1.
+ if data.get("multianimalproject") and not data.get("bodyparts"):
+ data["bodyparts"] = "MULTI!"
+
+ return data
+
+ @model_validator(mode="after")
+ def validate_start_before_stop(self) -> Self:
+ less_than(self.start, self.stop, name="start", threshold_name="stop")
+ return self
+
+ @model_validator(mode="after")
+ def validate_bodyparts_single_animal(self) -> Self:
+ if not self.multianimalproject and self.bodyparts == "MULTI!":
+ raise ValueError("bodyparts must be a list of strings when multianimalproject is False, got 'MULTI!'")
+
+ # TODO @deruyter92 2026-06-15: This sentinel should be removed in v1.
+ elif self.multianimalproject and self.bodyparts != "MULTI!":
+ raise ValueError(f"bodyparts must be 'MULTI!' when multianimalproject is True, got {self.bodyparts}")
+
+ return self
+
+ @model_validator(mode="after")
+ def validate_cropping_bounds(self) -> Self:
+ if self.cropping and any(value is None for value in (self.x1, self.x2, self.y1, self.y2)):
+ raise ValueError("When cropping is enabled, x1, x2, y1, and y2 must all be set")
+ validate_crop_bounds(x1=self.x1, x2=self.x2, y1=self.y1, y2=self.y2)
+ return self
diff --git a/deeplabcut/core/config/utils.py b/deeplabcut/core/config/utils.py
new file mode 100644
index 0000000000..cc0005bb76
--- /dev/null
+++ b/deeplabcut/core/config/utils.py
@@ -0,0 +1,428 @@
+#
+# 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
+#
+"""Centralized helpers for reading, writing, and creating configuration files (YAML)."""
+
+from __future__ import annotations
+
+import logging
+import warnings
+from collections import Counter
+from collections.abc import Callable, Mapping
+from enum import Enum
+from functools import wraps
+from pathlib import Path, PurePath
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from deeplabcut.core.config.project_config import ProjectConfig
+import numpy as np
+import ruamel.yaml.representer
+from pydantic import ValidationError
+from ruamel.yaml import YAML
+
+from deeplabcut.core.deprecation import deprecated
+
+logger = logging.getLogger(__name__)
+
+
+def get_yaml_loader() -> YAML:
+ """Get a ruamel.yaml YAML handler with safe mode."""
+ yaml = YAML(typ="safe", pure=True)
+ return yaml
+
+
+def get_yaml_dumper() -> YAML:
+ """Get a ruamel.yaml YAML handler with representers for Enum and Path objects."""
+ yaml = YAML(typ="rt", pure=True)
+
+ # 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
+ # See PR https://github.com/DeepLabCut/DeepLabCut/pull/3140 for more details.
+ yaml.width = 1_000_000
+
+ # Auto-serialize Path objects as strings
+ yaml.representer.add_multi_representer(PurePath, lambda r, p: r.represent_str(str(p)))
+ yaml.representer.add_multi_representer(Enum, lambda r, e: r.represent_str(e.value))
+ return 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
+
+ Raises:
+ FileNotFoundError: if the config file does not exist
+ """
+ if not Path(config_path).exists():
+ raise FileNotFoundError(f"Config {config_path} is not found. Please make sure that the file exists.")
+ with open(config_path) as f:
+ cfg = get_yaml_loader().load(f)
+ if cfg is None:
+ raise ValueError(f"Config {config_path} is empty or null.")
+ if not isinstance(cfg, dict):
+ raise ValueError(f"Config {config_path} must be a YAML mapping at the top level, got {type(cfg).__name__}.")
+ 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:
+ get_yaml_dumper().dump(config, file)
+
+
+def resolve_alias(
+ name: str,
+ alias_map: dict[str, str],
+ *,
+ warn: bool = True,
+ stacklevel: int = 3,
+) -> str:
+ """Resolve a config key to its canonical field name.
+ Args:
+ name: Raw key name (alias or canonical).
+ alias_map: ``{alias: canonical_name}`` for deprecated keys.
+ warn: If True, emit :class:`DLCDeprecationWarning` when ``name`` is an alias.
+ stacklevel: Passed to :func:`warnings.warn` for deprecation messages.
+ Returns:
+ Canonical field name, or ``name`` unchanged if it is not an alias.
+ """
+ canonical = alias_map.get(name, name)
+ if warn and name in alias_map:
+ from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+ warnings.warn(
+ f"'{name}' is deprecated, use '{canonical}' instead.",
+ DLCDeprecationWarning,
+ stacklevel=stacklevel,
+ )
+ return canonical
+
+
+def resolve_aliases_in_dict(
+ cfg_dict: dict,
+ alias_map: dict[str, str],
+ *,
+ target: str = "config",
+ warn: bool = True,
+ stacklevel: int = 3,
+) -> dict:
+ """Rename deprecated config keys to their canonical names.
+
+ Args:
+ cfg_dict: Raw configuration mapping (e.g. from YAML).
+ alias_map: ``{alias: canonical_name}`` for deprecated keys.
+ target: Config class name shown in errors.
+ stacklevel: Passed to :func:`warnings.warn` for deprecation messages.
+
+ Returns:
+ A new dict with alias keys replaced by canonical names. Unchanged if
+ ``alias_map`` is empty.
+
+ Raises:
+ TypeError: If multiple keys resolve to the same canonical field name
+ (e.g. an alias and its canonical name, or two aliases for one field).
+ """
+ if not alias_map:
+ return cfg_dict
+
+ def _raise_for_duplicates(raw_to_canonical: dict[str, str]):
+ counts = Counter(raw_to_canonical.values())
+ conflicts = [f"{raw} -> {canonical}" for raw, canonical in raw_to_canonical.items() if counts[canonical] > 1]
+ if conflicts:
+ raise TypeError(f"{target} received duplicate canonical field names: {', '.join(conflicts)}.")
+
+ raw_to_canonical = {raw: resolve_alias(raw, alias_map, warn=warn, stacklevel=stacklevel + 1) for raw in cfg_dict}
+ _raise_for_duplicates(raw_to_canonical)
+ return {raw_to_canonical[raw]: v for raw, v in cfg_dict.items()}
+
+
+def normalize_for_serialization(obj: Any) -> Any:
+ """Recursively normalize Paths to strings and Enums to values."""
+ if isinstance(obj, Path):
+ return str(obj)
+ if isinstance(obj, Enum):
+ return obj.value
+ if isinstance(obj, Mapping):
+ return type(obj)({k: normalize_for_serialization(v) for k, v in obj.items()})
+ if isinstance(obj, tuple):
+ return tuple(normalize_for_serialization(v) for v in obj)
+ if isinstance(obj, (list, set)):
+ return [normalize_for_serialization(v) for v in obj]
+ if isinstance(obj, np.ndarray):
+ return obj.tolist()
+ return obj
+
+
+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}")
+
+
+def ensure_plain_config(fn: Callable) -> Callable:
+ """Convert typed config arguments into plain Python objects.
+
+ Any positional or keyword argument that is a DLCBaseConfig is converted to
+ a plain ``dict`` before the decorated function is called.
+ """
+
+ def _to_plain(value, fn_name: str = "", var_name: str = ""):
+ # Lazy import to avoid circular imports during module initialization.
+ from deeplabcut.core.config.base_config import DLCBaseConfig
+
+ if isinstance(value, DLCBaseConfig):
+ logger.debug(
+ "converting %s (%s) to native dict in %s.",
+ var_name,
+ type(value).__name__,
+ fn_name,
+ )
+ return value.to_dict()
+ return value
+
+ @wraps(fn)
+ def wrapper(*args, **kwargs):
+ fn_name = fn.__qualname__
+ args = tuple(_to_plain(a, fn_name) for a in args)
+ kwargs = {k: _to_plain(v, fn_name=fn_name, var_name=k) for k, v in kwargs.items()}
+ return fn(*args, **kwargs)
+
+ return wrapper
+
+
+# -----------------------------------------------------------------------------
+# Project config (config.yaml with template and defaults)
+# -----------------------------------------------------------------------------
+
+
+@deprecated(replacement="deeplabcut.core.config.ProjectConfig", since="3.0.1")
+def create_config_template(multianimal: bool = False) -> tuple:
+ """
+ Creates a template for config.yaml file. This specific order is preserved while saving as yaml file.
+
+ Returns:
+ (cfg_file, ruamelFile) for further editing and dumping.
+ """
+ from deeplabcut.core.config.project_config import ProjectConfig
+
+ ruamelFile = get_yaml_dumper()
+
+ # TODO @deruyter92 2026-06-15: This sentinel should be removed in v1.
+ bodyparts = "MULTI!" if multianimal else []
+ cfg_file = ProjectConfig(multianimalproject=multianimal, bodyparts=bodyparts).to_dict()
+ return cfg_file, ruamelFile
+
+
+def create_config_template_3d() -> tuple:
+ """
+ Creates a template for config.yaml file for 3d project. This specific order is preserved while saving as yaml file.
+
+ Returns:
+ (cfg_file_3d, ruamelFile_3d) for further editing and dumping.
+ """
+ yaml_str = """\
+# Project definitions (do not edit)
+Task:
+scorer:
+date:
+\n
+# Project path (change when moving around)
+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
+# 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
+ """
+ ruamelFile_3d = get_yaml_dumper()
+ cfg_file_3d = ruamelFile_3d.load(yaml_str)
+ return cfg_file_3d, ruamelFile_3d
+
+
+def read_config(configname: str | Path, ignore_empty: bool = True) -> ProjectConfig:
+ """
+ Reads structured config file defining a project.
+
+ Applies default values and repairs (engine, detector_snapshotindex, project_path)
+ and writes back if needed.
+
+ Args:
+ configname: Path to the project configuration file (config.yaml).
+ ignore_empty: If True, empty/None values in the YAML are ignored and
+ dataclass defaults are used instead. If False, empty values represent None.
+ Defaults to True.
+
+ Returns:
+ The project configuration as a ProjectConfig instance (supports dict-like access).
+ """
+ from deeplabcut.core.config.project_config import ProjectConfig
+
+ path = Path(configname)
+ project_config = ProjectConfig.from_yaml(path, ignore_empty=ignore_empty)
+
+ # If necessary, ProjectConfig automatically updates its project path via _post_yaml_load_updates.
+ # if that is the case (marked as dirty), we write the config back to the file.
+ if "project_path" in project_config.dirty_fields:
+ # NOTE @deruyter92 2026-02-02: copied old behaviour of writing the config
+ # immediately back to the file after reading it. We should consider separating
+ # the writing and reading instead of having inplace edits during reading.
+ project_config.to_yaml(configname, log_changes=True, mark_clean=True)
+ return project_config
+
+
+def write_project_config(
+ configname: str | Path,
+ cfg: dict | ProjectConfig,
+) -> None:
+ """
+ Write structured project config file (config.yaml) preserving template order.
+
+ Args:
+ configname (str | Path): Path to the project configuration file (config.yaml).
+ cfg (dict | ProjectConfig): The project configuration to write (requires ProjectConfig schema).
+
+ Note:
+ Validates before writing when possible, unvalidated legacy dump on failure; This may not round-trip via
+ read_config for non-conformant legacy configurations.
+ """
+ from deeplabcut.core.config.base_config import DLCBaseConfig
+ from deeplabcut.core.config.project_config import ProjectConfig
+
+ try:
+ project_config: ProjectConfig = ProjectConfig.from_any(cfg)
+ project_config.to_yaml(configname)
+ return
+ except ValidationError as e:
+ logger.error(
+ "Invalid configuration! Validation error in project config file %s. Error: %s "
+ "Trying to write the file to disk anyway. Please verify the config file.",
+ cfg,
+ e,
+ )
+ if isinstance(cfg, DLCBaseConfig):
+ logger.error("Expected a ProjectConfig, got %s.", type(cfg).__name__)
+ cfg.to_yaml(configname)
+ return
+
+ warnings.warn("Reverting to legacy config file writing..", stacklevel=2)
+ with open(configname, "w") as cf:
+ 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 "skeleton" not in cfg.keys():
+ cfg_file["skeleton"] = []
+ cfg_file["skeleton_color"] = "black"
+ ruamelFile.dump(cfg_file, cf)
+
+
+def edit_config(configname: str | Path, edits: dict, output_name: str | Path = "") -> dict:
+ """
+ Convenience function to edit and save a config file from a dictionary.
+
+ Note:
+ Legacy helper without schema validation. Prefer manipulating and saving
+ the typed config instead (e.g. cfg.update(edits).to_yaml(cfg_path))
+
+ Parameters
+ ----------
+ configname : string
+ String containing the full path of the config file in the project.
+ edits : dict
+ Keyâvalue pairs to edit in config
+ output_name : string, optional (default='')
+ Overwrite the original config.yaml by default.
+ If passed in though, new filename of the edited config.
+
+ Examples
+ --------
+ config_path = 'my_stellar_lab/dlc/config.yaml'
+
+ edits = {'numframes2pick': 5,
+ 'trainingFraction': [0.5, 0.8],
+ 'skeleton': [['a', 'b'], ['b', 'c']]}
+
+ deeplabcut.core.config.edit_config(config_path, edits)
+ """
+ cfg = read_config_as_dict(configname)
+ for key, value in edits.items():
+ cfg[key] = value
+ if not output_name:
+ output_name = configname
+ try:
+ write_config(output_name, cfg)
+ except ruamel.yaml.representer.RepresenterError:
+ 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_config(output_name, cfg)
+ return cfg
+
+
+def write_config_3d(configname: str | Path, cfg: dict) -> None:
+ """Write structured 3D project config file."""
+ with open(configname, "w") as cf:
+ cfg_file, ruamelFile = create_config_template_3d()
+ for key in cfg.keys():
+ cfg_file[key] = cfg[key]
+ ruamelFile.dump(cfg_file, cf)
+
+
+def write_config_3d_template(projconfigfile: str | Path, cfg_file_3d: dict, ruamelFile_3d: YAML) -> None:
+ """Write 3D config from pre-built template and YAML instance."""
+ with open(projconfigfile, "w") as cf:
+ ruamelFile_3d.dump(cfg_file_3d, cf)
diff --git a/deeplabcut/core/config/validation.py b/deeplabcut/core/config/validation.py
new file mode 100644
index 0000000000..aa78845544
--- /dev/null
+++ b/deeplabcut/core/config/validation.py
@@ -0,0 +1,81 @@
+from collections.abc import Sequence
+from typing import Annotated, Any
+
+import numpy as np
+from numpy.typing import NDArray
+from pydantic import AfterValidator, BeforeValidator, Field, GetPydanticSchema, InstanceOf
+
+
+def _describe(value: float, name: str | None = None) -> str:
+ return f"{name} ({value})" if name else f"{value}"
+
+
+def greater_than(
+ value: float,
+ threshold: float,
+ name: str | None = None,
+ threshold_name: str | None = None,
+) -> None:
+ if value <= threshold:
+ raise ValueError(f"{_describe(value, name)} must be greater than {_describe(threshold, threshold_name)}")
+
+
+def less_than(
+ value: float,
+ threshold: float,
+ name: str,
+ threshold_name: str | None = None,
+) -> None:
+ if value >= threshold:
+ raise ValueError(f"{_describe(value, name)} must be less than {_describe(threshold, threshold_name)}")
+
+
+def unique_values(values: Sequence[Any]) -> Sequence[Any]:
+ if len(values) != len(set(values)):
+ raise ValueError("Values must be unique")
+ return values
+
+
+def validate_crop_bounds(
+ *,
+ x1: int | None,
+ x2: int | None,
+ y1: int | None,
+ y2: int | None,
+) -> None:
+ bounds = (x1, x2, y1, y2)
+ if any(value is None for value in bounds):
+ if any(value is not None for value in bounds):
+ raise ValueError("Crop bounds x1, x2, y1, and y2 must either all be set or all be omitted")
+ return
+
+ less_than(x1, x2, name="x1", threshold_name="x2")
+ less_than(y1, y2, name="y1", threshold_name="y2")
+
+
+def _coerce_ndarray(v):
+ if isinstance(v, np.ndarray):
+ return v
+ return np.asarray(v, dtype=int)
+
+
+def _bodypart_pair(values: Sequence[Any]) -> list[str]:
+ if len(values) != 2:
+ raise ValueError(f"Each bodypart pair must contain exactly two bodyparts, got {len(values)}")
+ return list(unique_values(values))
+
+
+Fraction = Annotated[float, Field(ge=0.0, le=1.0)]
+UniqueStrList = Annotated[list[str], AfterValidator(unique_values)]
+NonNegativeFloat = Annotated[float, Field(ge=0.0)]
+NonNegativeInt = Annotated[int, Field(ge=0)]
+StrictPositiveInt = Annotated[int, Field(ge=1)]
+NDArrayInt = Annotated[
+ NDArray,
+ BeforeValidator(_coerce_ndarray),
+ GetPydanticSchema(
+ lambda _s, h: h(InstanceOf[np.ndarray]),
+ lambda _s, h: h(InstanceOf[np.ndarray]),
+ ),
+]
+BodypartPair = Annotated[list[str], AfterValidator(_bodypart_pair)]
diff --git a/deeplabcut/core/config/versioning.py b/deeplabcut/core/config/versioning.py
new file mode 100644
index 0000000000..56094b123d
--- /dev/null
+++ b/deeplabcut/core/config/versioning.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/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Configuration migration system for handling version upgrades and downgrades.
+
+This module provides a versioned migration system that allows configurations
+to be upgraded from older versions to newer ones, or downgraded to older formats.
+Upgrade migrations are chained together, so any version can be upgraded to the
+latest by applying all intermediate migrations in sequence. Downgrade migrations
+can be registered for specific version pairs when backward compatibility is needed.
+"""
+
+import copy
+import logging
+from collections.abc import Callable
+from functools import wraps
+
+logger = logging.getLogger(__name__)
+
+# Current configuration schema version
+# Increment this when making breaking changes to the config structure
+CURRENT_CONFIG_VERSION = 0
+
+
+# Version registry: maps (config_type, from_version, to_version) -> migration function.
+# config_type is the class name of the DLCVersionedConfig subclass the migration applies to
+# (e.g. "ProjectConfig", "PoseConfig"). Every migration must declare its target type.
+_MIGRATIONS: dict[tuple[str, int, int], Callable[[dict], dict]] = {}
+
+
+def _diff_dicts(
+ before: dict,
+ after: dict,
+ path: str = "",
+ ignore_keys: set[str] | None = None,
+) -> list[str]:
+ """Recursively diff two dicts and return human-readable change descriptions.
+
+ Args:
+ before: The dict before the migration step.
+ after: The dict after the migration step.
+ path: Dot-separated key path for nested context (used in recursion).
+ ignore_keys: Top-level keys to skip (only applied at the root level).
+
+ Returns:
+ A list of strings, each describing a single added/removed/updated field.
+ """
+ ignore = ignore_keys or set()
+ before_keys = set(before.keys()) - ignore
+ after_keys = set(after.keys()) - ignore
+
+ changes: list[str] = []
+
+ for key in sorted(before_keys - after_keys):
+ full = f"{path}.{key}" if path else str(key)
+ changes.append(f"Removed field '{full}' (was: {before[key]!r})")
+
+ for key in sorted(after_keys - before_keys):
+ full = f"{path}.{key}" if path else str(key)
+ changes.append(f"Added field '{full}' = {after[key]!r}")
+
+ for key in sorted(before_keys & after_keys):
+ old_val, new_val = before[key], after[key]
+ if old_val == new_val:
+ continue
+ full = f"{path}.{key}" if path else str(key)
+ if isinstance(old_val, dict) and isinstance(new_val, dict):
+ changes.extend(_diff_dicts(old_val, new_val, path=full))
+ else:
+ changes.append(f"Updated field '{full}': {old_val!r} -> {new_val!r}")
+
+ return changes
+
+
+def _log_field_changes(before: dict, after: dict, from_v: int, to_v: int) -> None:
+ """Log which fields were added, removed, or changed during a migration step."""
+ step = f"v{from_v} -> v{to_v}"
+ changes = _diff_dicts(before, after, ignore_keys={"config_version"})
+
+ if changes:
+ for change in changes:
+ logger.debug(" [%s] %s", step, change)
+ else:
+ logger.debug(" [%s] No field changes", step)
+
+
+def register_migration(
+ from_version: int,
+ to_version: int,
+ config_type: str,
+):
+ """Decorator to register a migration function for a specific config type.
+
+ Every migration must be scoped to a concrete ``DLCVersionedConfig`` subclass.
+ This keeps ``ProjectConfig`` and ``PoseConfig`` migration chains fully independent.
+
+ Args:
+ from_version: The source version number (>= 0).
+ to_version: The target version number (>= 0, from_version ± 1 by convention).
+ config_type: Class name of the config this migration applies to (e.g.
+ ``"ProjectConfig"`` or ``"PoseConfig"``).
+
+ Raises:
+ ValueError: If version numbers are invalid or a migration for the same
+ (config_type, from_version, to_version) triple is already registered.
+
+ Example::
+
+ @register_migration(0, 1, config_type="ProjectConfig")
+ def migrate_project_v0_to_v1(config: dict) -> dict:
+ config["unique_bodyparts"] = config.pop("uniquebodyparts", [])
+ return config
+ """
+ if from_version < 0 or to_version < 0:
+ raise ValueError(f"Version numbers must be non-negative, got ({from_version}, {to_version})")
+ if from_version == to_version:
+ raise ValueError(f"from_version and to_version must differ, got ({from_version}, {to_version})")
+
+ def decorator(func: Callable[[dict], dict]) -> Callable[[dict], dict]:
+ key = (config_type, from_version, to_version)
+ if key in _MIGRATIONS:
+ raise ValueError(
+ f"Duplicate migration registered for {key}. "
+ f"Existing: {_MIGRATIONS[key].__wrapped__.__qualname__}, "
+ f"new: {func.__qualname__}"
+ )
+
+ @wraps(func)
+ def wrapper(config: dict) -> dict:
+ verbose = logger.isEnabledFor(logging.DEBUG)
+ if verbose:
+ before = copy.deepcopy(config)
+ result = func(config.copy()) # Don't mutate caller's dict
+ result["config_version"] = to_version
+ if verbose:
+ _log_field_changes(before, result, from_version, to_version)
+ return result
+
+ _MIGRATIONS[key] = wrapper
+ return wrapper
+
+ return decorator
+
+
+def get_config_version(config: dict) -> int:
+ """Extract the configuration version from a config dict.
+
+ Args:
+ config: Configuration dictionary
+
+ Returns:
+ Version number (0 for legacy/unversioned configs)
+ """
+ return config.get("config_version", 0)
+
+
+def migrate_config(
+ config: dict,
+ config_type: str,
+ target_version: int = CURRENT_CONFIG_VERSION,
+) -> dict:
+ """Migrate a configuration to the target version.
+
+ Applies all necessary migrations in sequence to upgrade the config
+ from its current version to the target version. Only migrations registered
+ for ``config_type`` are applied.
+
+ Args:
+ config: Configuration dictionary to migrate.
+ config_type: Class name of the config being migrated (e.g. ``"ProjectConfig"``).
+ Only migrations registered for this type are applied.
+ target_version: Target version to migrate to (default: current).
+
+ Returns:
+ Migrated configuration dictionary.
+
+ Raises:
+ ValueError: If migration chain is incomplete or target version is invalid.
+ """
+ current_version = get_config_version(config)
+
+ if current_version == target_version:
+ return config
+
+ if target_version > CURRENT_CONFIG_VERSION:
+ raise ValueError(f"Target version {target_version} exceeds current version {CURRENT_CONFIG_VERSION}")
+
+ direction = "upgrade" if target_version > current_version else "downgrade"
+ logger.info(
+ "Migrating %s from version %d to %d (%s)",
+ config_type,
+ current_version,
+ target_version,
+ direction,
+ )
+
+ # Chain migrations one step at a time (e.g. 0â1â2 or 3â2â1).
+ migrated = config
+ step = 1 if target_version > current_version else -1
+ for v in range(current_version, target_version, step):
+ next_v = v + step
+ key = (config_type, v, next_v)
+ if key not in _MIGRATIONS:
+ raise ValueError(
+ f"No migration registered for '{config_type}' v{v} -> v{next_v}. "
+ f"Available migrations: {list(_MIGRATIONS.keys())}"
+ )
+ logger.debug(
+ "Applying migration %s v%d -> v%d (%s)",
+ config_type,
+ v,
+ next_v,
+ _MIGRATIONS[key].__wrapped__.__qualname__,
+ )
+ try:
+ migrated = _MIGRATIONS[key](migrated)
+ except Exception as exc:
+ raise type(exc)(
+ f"Migration for '{config_type}' v{v} -> v{next_v} failed "
+ f"({_MIGRATIONS[key].__wrapped__.__qualname__}): {exc}"
+ ) from exc
+
+ logger.info("Migration complete: %s is now at version %d", config_type, target_version)
+ return migrated
+
+
+# ============================================================================
+# Migration Definitions
+# ============================================================================
+
+
+@register_migration(0, 1, config_type="ProjectConfig")
+def migrate_project_v0_to_v1(config: dict) -> dict:
+ """Migrate ProjectConfig from unversioned/legacy (v0) to v1."""
+ # Migration logic goes here, when migrating to v1.
+ return config
+
+
+@register_migration(1, 0, config_type="ProjectConfig")
+def migrate_project_v1_to_v0(config: dict) -> dict:
+ """Migrate ProjectConfig from v1 back to v0 (legacy format)."""
+ # Migration logic goes here, when migrating to v1.
+ return config
+
+
+@register_migration(0, 1, config_type="PoseConfig")
+def migrate_pose_v0_to_v1(config: dict) -> dict:
+ """Migrate PoseConfig from v0 to v1."""
+ # Migration logic goes here, when migrating to v1.
+ return config
+
+
+@register_migration(1, 0, config_type="PoseConfig")
+def migrate_pose_v1_to_v0(config: dict) -> dict:
+ """Migrate PoseConfig from v1 back to v0."""
+ # Migration logic goes here, when migrating to v1.
+ return config
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..ca1447c611
--- /dev/null
+++ b/deeplabcut/core/crossvalutils.py
@@ -0,0 +1,456 @@
+#
+# 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
+import shutil
+from collections import defaultdict
+from copy import deepcopy
+from pathlib import Path
+
+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):
+ p = Path(path)
+ ext = p.suffix
+ filename = p.name
+ return str(p.parent / (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 Path(full_data_file).open("rb") as file:
+ data = pickle.load(file)
+ with Path(metadata_file).open("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 = str(
+ Path(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 Path(output_name).open("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..a1f588708c
--- /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).absolute()) 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..8e531db54b
--- /dev/null
+++ b/deeplabcut/core/debug/debug_logger.py
@@ -0,0 +1,591 @@
+#
+# 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.
+
+ Args:
+ logger_name (str): Logger namespace to attach the recorder to.
+ capacity (int): Maximum number of captured records. By default, uses
+ LOG_QUEUE_MAXLEN.
+ handler_level (int): Minimum level stored by the recorder itself.
+ ensure_logger_level (int | None): 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
+
+ Returns:
+ InMemoryDebugRecorder: The attached recorder.
+ """
+ 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).absolute()) 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.
+
+ Args:
+ key (str): Label used in the output report.
+ command (str | None): Executable name or absolute path to resolve.
+ version_args (tuple[str, ...]): Arguments used to query the executable version.
+ Defaults to ("-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/deprecation.py b/deeplabcut/core/deprecation.py
new file mode 100644
index 0000000000..ed0bd0c7d1
--- /dev/null
+++ b/deeplabcut/core/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/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..b31bd85814
--- /dev/null
+++ b/deeplabcut/core/inferenceutils.py
@@ -0,0 +1,1268 @@
+#
+# 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 pathlib import Path
+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 Path(pickle_path).open("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 Path(output_name).open("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.
+ # TODO @deruyter92 2026-06-10 (#3362): pandas migration 3.0 requires us to add support for string dtype columns.
+ 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..2792e61873
--- /dev/null
+++ b/deeplabcut/core/metrics/api.py
@@ -0,0 +1,183 @@
+#
+# 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)
+
+ This yields the following output scores:
+
+ {
+ "rmse": 1.0,
+ "rmse_pcutoff": 0.0,
+ "mAP": 84.2,
+ "mAR": 74.5
+ }
+ """
+ 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..315162c131
--- /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..e0b094a788
--- /dev/null
+++ b/deeplabcut/core/trackingutils.py
@@ -0,0 +1,823 @@
+#
+# 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.
+
+ Args:
+ x (ndarray): 1D trajectory.
+ y (ndarray): 1D trajectory.
+
+ Returns:
+ ndarray: 1D array 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.
+
+ Args:
+ x (ndarray): 1D input of X coordinates.
+ y (ndarray): 1D input of Y coordinates.
+ sd (int): Size of the error ellipse in standard deviations.
+
+ Returns:
+ list: Ellipse center, semi-axes length, and 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.
+
+ Args:
+ coeffs (list): Fitting coefficients.
+
+ Returns:
+ list: Center (1D ndarray), semi-axes (1D ndarray), and 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], keepdims=False)[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], keepdims=False)[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(i)
+ 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`.
+
+ Args:
+ 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].
+
+ Note:
+ - 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..891d4510f4
--- /dev/null
+++ b/deeplabcut/core/weight_init.py
@@ -0,0 +1,182 @@
+#
+# 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 pathlib import Path
+
+import numpy as np
+from pydantic import field_validator, model_validator
+from typing_extensions import Self
+
+from deeplabcut.core.config import DLCBaseConfig
+from deeplabcut.core.config.validation import NDArrayInt
+
+
+class WeightInitialization(DLCBaseConfig):
+ """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 | None = None
+ detector_snapshot_path: Path | None = None
+ dataset: str | None = None
+ with_decoder: bool = False
+ memory_replay: bool = False
+ conversion_array: NDArrayInt | None = None
+ bodyparts: list[str] | None = None
+
+ @model_validator(mode="after")
+ def _validate_options(self) -> 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 len(self.conversion_array) != len(self.bodyparts):
+ raise ValueError(
+ f"There must be the same number of elements in the bodyparts list "
+ f"and conv. array; found {self.bodyparts}, {self.conversion_array}"
+ )
+ return self
+
+ @field_validator("snapshot_path", "detector_snapshot_path", mode="before")
+ @classmethod
+ def _coerce_null_path(cls, v):
+ if v is None or v == "None":
+ return None
+ return v
+
+ @classmethod
+ def from_dict(cls, data: dict) -> Self:
+ if "snapshot_path" not in data:
+ return cls.from_dict_legacy(data)
+ return cls.model_validate(data)
+
+ @classmethod
+ def from_dict_legacy(cls, data: dict) -> Self:
+ """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 cls(
+ 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/__init__.py b/deeplabcut/create_project/__init__.py
index 5e83e4fc20..d87e92227d 100644
--- a/deeplabcut/create_project/__init__.py
+++ b/deeplabcut/create_project/__init__.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
from deeplabcut.create_project.add import add_new_videos
from deeplabcut.create_project.demo_data import load_demo_data
from deeplabcut.create_project.modelzoo import (
diff --git a/deeplabcut/create_project/add.py b/deeplabcut/create_project/add.py
index acace2f255..6ca399576b 100644
--- a/deeplabcut/create_project/add.py
+++ b/deeplabcut/create_project/add.py
@@ -1,118 +1,141 @@
-"""
-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
-"""
-
-
-def add_new_videos(config, videos, copy_videos=False, coords=None, extract_frames=False):
+#
+# 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 os
+import shutil
+from pathlib import Path
+
+from deeplabcut.generate_training_dataset import frame_extraction
+from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import VideoReader
+
+
+def add_new_videos(
+ config: str | Path,
+ videos: list[str | Path],
+ copy_videos=False,
+ coords=None,
+ extract_frames=False,
+):
+ """Add new videos to the config file at any stage of the project.
+
+ Args:
+ config (string): String containing the full path of the config file in the project.
+ videos (list): A list of strings containing the full paths of the videos to include in the project.
+ copy_videos (bool, optional): If True, the videos will be copied to your
+ project/videos directory. If False, symlinks of the videos are copied
+ instead. Defaults to False.
+ coords (list, optional): A list containing the list of cropping coordinates of the video. Defaults to None.
+ extract_frames (bool, optional): If True, extract_frames will be run on the new
+ videos. Defaults to False.
+
+ 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"],
+ )
+
+ 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]],
+ )
+
+ 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]],
+ )
"""
- Add new videos to the config file at any stage of the project.
-
- Parameters
- ----------
- config : string
- String containing the full path of the config file in the project.
-
- videos : list
- A list of strings containing the full paths of the videos to include in the project.
-
- copy_videos : bool, optional
- If this is set to True, the symlink of the videos are copied to the project/videos directory. The default is
- ``False``; if provided it must be either ``True`` or ``False``.
-
- coords: list, optional
- A list containing the list of cropping coordinates of the video. The default is set to None.
-
- extract_frames: bool, optional
- if this is set to True extract_frames will be run on the 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'])
-
- 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]])
-
- 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]])
-
- """
- 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
+ config = Path(config).absolute()
# Read the config file
cfg = auxiliaryfunctions.read_config(config)
- video_path = Path(config).parents[0] / "videos"
- data_path = Path(config).parents[0] / "labeled-data"
- videos = [Path(vp) for vp in videos]
+ # deal with user passing a single video to add
+ if isinstance(videos, str):
+ videos = [videos]
- dirs = [data_path / Path(i.stem) for i in videos]
+ video_path = config.parent / "videos"
+ data_path = config.parent / "labeled-data"
+ videos = [Path(vp).absolute() for vp in videos]
+
+ dirs = [data_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:
print("Copying the videos")
shutil.copy(os.fspath(src), os.fspath(dst))
+
else:
- for src, dst in zip(videos, destinations):
+ # 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, strict=False):
if dst.exists():
- pass
- else:
- print("Creating the symbolic link of the video")
- src = str(src)
- dst = str(dst)
- os.symlink(src, dst)
+ print(f"Video {dst} already exists. Skipping...")
+ continue
+ try:
+ dst.symlink_to(src)
+ print(f"Created the symlink of {src} to {dst}")
+ except OSError:
+ try:
+ import subprocess
+
+ subprocess.check_call(f"mklink {os.fspath(dst)} {os.fspath(src)}", shell=True)
+ except (OSError, subprocess.CalledProcessError):
+ print("Symlink creation impossible (exFat architecture?): copying the video instead.")
+ shutil.copy(os.fspath(src), os.fspath(dst))
+ print(f"{src} copied to {dst}")
+ videos = destinations
if copy_videos:
videos = destinations # in this case the *new* location should be added to the config file
# adds the video list to the config.yaml file
for idx, video in enumerate(videos):
- try:
- # 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:
- video_path = os.readlink(video)
-
- vid = VideoReader(video_path)
+ video_key = Path(video).absolute()
+ vid = VideoReader(os.fspath(video_key))
if coords is not None:
c = coords[idx]
else:
c = vid.get_bbox()
- params = {video_path: {"crop": ", ".join(map(str, c))}}
+ params = {os.fspath(video_key): {"crop": ", ".join(map(str, c))}}
if "video_sets_original" not in cfg:
cfg["video_sets"].update(params)
else:
cfg["video_sets_original"].update(params)
- 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=[os.fspath(video) for video in videos],
)
+ 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."
- )
- auxiliaryfunctions.write_config(config, cfg)
+ 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 eeb7e316f5..22f428d948 100644
--- a/deeplabcut/create_project/demo_data.py
+++ b/deeplabcut/create_project/demo_data.py
@@ -1,67 +1,67 @@
-"""
-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
-"""
+#
+# 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 os
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
-
- Parameter
- ----------
- config : string
- Full path of the config.yaml file of the provided demo dataset as a string.
+def load_demo_data(
+ config: str | Path,
+ 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.
- createtrainingset : bool
- Boolean variable indicating if a training set shall be created.
-
- Example
- --------
- >>> deeplabcut.load_demo_data('config.yaml')
- --------
+ Args:
+ config (str | Path): Full path of the config.yaml file of the provided demo
+ dataset.
+ 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.
+
+ Examples:
+ deeplabcut.load_demo_data("config.yaml")
"""
- config = Path(config).resolve()
- config = str(config)
+ config = Path(config).absolute()
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.
+def transform_data(config: Path) -> None:
+ """This function adds the full path to labeling dataset.
+
It also adds the correct path to the video file in the config file.
"""
-
+ config = Path(config).absolute()
+ project_path = config.parent
cfg = auxiliaryfunctions.read_config(config)
- project_path = str(Path(config).parents[0])
-
cfg["project_path"] = project_path
- if "Reaching" in project_path:
- video_file = os.path.join(project_path, "videos", "reachingvideo1.avi")
- elif "openfield" in project_path:
- video_file = os.path.join(project_path, "videos", "m4s1.mp4")
+ if "Reaching" in project_path.parts:
+ video_file = project_path / "videos" / "reachingvideo1.avi"
+ elif "openfield" in project_path.parts:
+ video_file = project_path / "videos" / "m4s1.mp4"
else:
print("This is not an official demo dataset.")
+ return
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"][os.fspath(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 d0617d93c9..5db6a3e278 100644
--- a/deeplabcut/create_project/modelzoo.py
+++ b/deeplabcut/create_project/modelzoo.py
@@ -1,35 +1,52 @@
-"""
-DeepLabCut 2.1.8 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCu/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.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
+#
import os
+from collections.abc import Sequence
from pathlib import Path
import yaml
+from dlclibrary import get_available_detectors
+from dlclibrary.dlcmodelzoo.modelzoo_download import (
+ MODELOPTIONS,
+ download_huggingface_model,
+ get_available_datasets,
+ get_available_models,
+)
import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions, auxfun_models
-
-Modeloptions = [
- "full_human",
- "full_cat",
- "full_dog",
- "primate_face",
- "mouse_pupil_vclose",
- "horse_sideview",
- "full_macaque",
- "full_cheetah",
-] # just expand this list with new projects
+from deeplabcut.core.config import ProjectConfig, write_config
+from deeplabcut.core.deprecation import renamed_parameter
+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 import (
+ PoseMetadata,
+ make_pytorch_test_config,
+)
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import load_super_animal_config
+from deeplabcut.utils import auxiliaryfunctions
+
+Modeloptions = MODELOPTIONS # backwards compatibility for COLAB NOTEBOOK
def MakeTrain_pose_yaml(itemstochange, saveasconfigfile, defaultconfigfile):
- raw = open(defaultconfigfile).read()
+ raw = Path(defaultconfigfile).open().read()
docs = []
for raw_doc in raw.split("\n---"):
try:
@@ -40,8 +57,7 @@ def MakeTrain_pose_yaml(itemstochange, saveasconfigfile, defaultconfigfile):
for key in itemstochange.keys():
docs[0][key] = itemstochange[key]
docs[0]["max_input_size"] = 1500
- with open(saveasconfigfile, "w") as f:
- yaml.dump(docs[0], f)
+ write_config(saveasconfigfile, docs[0])
return docs[0]
@@ -62,18 +78,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', ..)
@@ -84,7 +100,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,
@@ -93,26 +111,161 @@ 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
+
+ Args:
+ project (string): String containing the name of the project.
+ experimenter (string): String containing the name of the experimenter.
+ model (string | None, optional): The model / dataset to use as basis for the
+ project. If None, the default model / dataset for the selected engine will
+ be used. Defaults to None.
+ 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. If None - the current working directory will be used. Defaults to
+ None.
+ copy_videos (bool, optional): 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 necessary when not running in Administrator mode.
+ The same applies whenever symlinks are disabled or unsupported.
+ Defaults to False.
+ 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, optional): Indicates if filtered pose data output should be
+ plotted rather than frame-by-frame predictions. Filtered version can be
+ calculated with deeplabcut.filterpredictions(). Defaults to True.
+ createlabeledvideo (bool, optional): Specifies if a labeled video needs to be
+ created. Defaults to True.
+ trainFraction (float | None, optional): Fraction that will be used in
+ dlc-model/trainingset folder name. If None - default value (0.95) from new
+ projects will be used. Defaults to None.
+ engine (Engine, optional): Engine on which the pretrained weights are based.
+ Defaults to Engine.PYTORCH.
+ multi_animal (bool, optional): Specifies if the project is single or
+ multi-animal. Implemented only for Pytorch-based models. Defaults to False.
+ individuals (list[str] | None, optional): Only if multianimal is True. Defines
+ the names of the individuals. Defaults to None.
+ net_name (str | None, optional): 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. Defaults to None.
+ detector_name (str | None, optional): 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. Defaults to
+ None.
+
+ Examples:
+ 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,
+ )
+
+ On Windows, paths should be formatted as ``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.
@@ -120,59 +273,285 @@ def create_pretrained_project(
http://modelzoo.deeplabcut.org
- Parameters
- ----------
- project : string
- String containing the name of the project.
+ Args:
+ project (string): String containing the name of the project.
+ experimenter (string): String containing the name of the experimenter.
+ dataset (string | None, optional): The superanimal dataset to use as basis for
+ the project. If not specified - superanimal_quadruped will be used by
+ default. Defaults to None.
+ 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. If None - the current working directory will be used. Defaults to
+ None.
+ copy_videos (bool, optional): 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!
+ Defaults to 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.
+ filtered (bool, optional): Indicates if filtered pose data output should be
+ plotted rather than frame-by-frame predictions. Filtered version can be
+ calculated with deeplabcut.filterpredictions(). Defaults to True.
+ create_labeled_video (bool, optional): Specifies if a labeled video needs to be
+ created. Defaults to True.
+ train_fraction (float | None, optional): Fraction that will be used in
+ dlc-model/trainingset folder name. If None - default value (0.95) from new
+ projects will be used. Defaults to None.
+ multi_animal (bool, optional): Specifies if the project is single or
+ multi-animal. Defaults to False.
+ individuals (list[str] | None, optional): Only if multianimal is True. Defines
+ the names of the individuals. Defaults to None.
+ net_name (str | None, optional): 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. Defaults to None.
+ detector_name (str | None, optional): 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. Defaults to
+ None.
+
+ Examples:
+ Linux/MacOs loading full_human model and analyzing video /homosapiens1.avi:
+
+ deeplabcut.create_pretrained_project_pytorch(
+ "humanstrokestudy", "Linus", ["/data/videos/homosapiens1.avi"], copy_videos=False
+ )
- experimenter : string
- String containing the name of the experimenter.
+ Loading full_cat model and analyzing video "felixfeliscatus3.avi":
- 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!
+ deeplabcut.create_pretrained_project_pytorch(
+ "humanstrokestudy", "Linus", ["/data/videos/felixfeliscatus3.avi"], model="full_cat", engine=Engine.TF
+ )
- videos : list
- A list of string containing the full paths of the videos to include in the project.
+ Windows:
+
+ deeplabcut.create_pretrained_project_pytorch(
+ "humanstrokestudy",
+ "Bill",
+ [r"C:\yourusername\rig-95\Videos\reachingvideo1.avi"],
+ r"C:\yourusername\analysis\project",
+ copy_videos=True,
+ )
- 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.
+ On Windows, paths should be formatted as ``r`"C:\"`` or ``"C:\\"`` (i.e. a double backslash).
+ """
+ # Check arguments
+ if not dataset:
+ dataset = "superanimal_quadruped"
- 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``.
+ if not net_name:
+ net_name = "hrnet_w32"
- 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
+ # Currently, all Pytorch Superanimal models are Top-Down.
+ if not detector_name:
+ detector_name = "fasterrcnn_resnet50_fpn_v2"
- 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
+ if dataset not in get_available_datasets():
+ raise ValueError(f"Invalid dataset '{dataset}'. Available datasets are: {get_available_datasets()}")
- trainFraction: By default value from *new* projects. (0.95)
- Fraction that will be used in dlc-model/trainingset folder name.
+ 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)}"
+ )
- 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)
+ 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)}"
+ )
- Loading full_cat model and analyzing video "felixfeliscatus3.avi"
- >>> deeplabcut.create_pretrained_project('humanstrokestudy','Linus',['/data/videos/felixfeliscatus3.avi'], model='full_cat')
+ # 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,
+ )
- 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 \ \ )
+ # 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
+ config = ProjectConfig.from_yaml(cfg_path)
+ config.update(cfg_edits)
+ config.to_yaml(cfg_path, log_changes=True, mark_clean=True)
+
+ # Create the shuffle train and test directories
+ 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["metadata"] = PoseMetadata.build(config, pose_config_path=train_cfg_path).to_dict()
+ pytorch_config["resume_training_from"] = str(train_dir / new_snapshot_name)
+ pytorch_config["detector"]["resume_training_from"] = str(train_dir / new_detector_name)
+ pytorch_config.to_yaml(train_cfg_path)
+
+ # 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 = 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
+
+ Args:
+ project (string): String containing the name of the project.
+ experimenter (string): String containing the name of the experimenter.
+ model (string | None, optional): The model / dataset to use as basis for the
+ project. If not specified - full_human will be used by default. Defaults to
+ None.
+ 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. If None - the current working directory will be used. Defaults to
+ None.
+ copy_videos (bool, optional): 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!
+ Defaults to False.
+ 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, optional): Indicates if filtered pose data output should be
+ plotted rather than frame-by-frame predictions. Filtered version can be
+ calculated with deeplabcut.filterpredictions(). Defaults to True.
+ createlabeledvideo (bool, optional): Specifies if a labeled video needs to be
+ created. Defaults to True.
+ trainFraction (float | None, optional): Fraction that will be used in
+ dlc-model/trainingset folder name. If None - default value (0.95) from new
+ projects will be used. Defaults to None.
+
+ Examples:
+ 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,
+ )
+
+ On Windows, paths should be formatted as ``r`"C:\"`` or ``"C:\\"`` (i.e. a double backslash).
"""
- if model in globals()["Modeloptions"]:
- cwd = os.getcwd()
+ if not model:
+ model = "full_human"
+
+ if model in MODELOPTIONS:
+ cwd = Path.cwd()
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]})
+ ProjectConfig.from_yaml(cfg).update(TrainingFraction=[trainFraction]).to_yaml(
+ cfg, log_changes=True, mark_clean=True
+ )
config = auxiliaryfunctions.read_config(cfg)
if model == "full_human":
@@ -215,31 +594,27 @@ def create_pretrained_project(
auxiliaryfunctions.write_config(cfg, config)
config = auxiliaryfunctions.read_config(cfg)
- train_dir = Path(
- os.path.join(
- config["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction=config["TrainingFraction"][0],
- shuffle=1,
- cfg=config,
- )
- ),
- "train",
+ train_dir = (
+ Path(config["project_path"])
+ / str(
+ auxiliaryfunctions.get_model_folder(
+ trainFraction=config["TrainingFraction"][0],
+ shuffle=1,
+ cfg=config,
+ )
)
+ / "train"
)
- test_dir = Path(
- os.path.join(
- config["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction=config["TrainingFraction"][0],
- shuffle=1,
- cfg=config,
- )
- ),
- "test",
+ test_dir = (
+ Path(config["project_path"])
+ / str(
+ auxiliaryfunctions.get_model_folder(
+ trainFraction=config["TrainingFraction"][0],
+ shuffle=1,
+ cfg=config,
+ )
)
+ / "test"
)
# Create the model directory
@@ -249,22 +624,15 @@ 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(Path(config["project_path"]) / Path(modelfoldername) / "train" / "pose_cfg.yaml")
+ path_test_config = str(Path(config["project_path"]) / Path(modelfoldername) / "test" / "pose_cfg.yaml")
# Download the weights and put then in appropriate directory
print("Downloading weights...")
- auxfun_models.DownloadModel(model, train_dir)
+ download_huggingface_model(model, train_dir)
pose_cfg = deeplabcut.auxiliaryfunctions.read_plainconfig(path_train_config)
+ pose_cfg["dataset_type"] = "imgaug"
print(path_train_config)
# Updating config file:
dict_ = {
@@ -273,17 +641,16 @@ def create_pretrained_project(
"bodyparts": pose_cfg["all_joints_names"],
"dotsize": 6,
}
- auxiliaryfunctions.edit_config(cfg, dict_)
+ ProjectConfig.from_yaml(cfg).update(dict_).to_yaml(cfg, log_changes=True, mark_clean=True)
- # downloading base encoder / not required unless on re-trains (but when a training set is created this happens anyway)
- # model_path, num_shuffles=auxfun_models.Check4weights(pose_cfg['net_type'], parent_path, num_shuffles= 1)
+ # 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 = [p.name for p in Path(train_dir).iterdir() if ".meta" in p.name][0].split(".meta")[0]
dict2change = {
- "init_weights": str(os.path.join(train_dir, snapshotname)),
+ "init_weights": str(Path(train_dir) / snapshotname),
"project_path": str(config["project_path"]),
}
@@ -303,23 +670,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 0ae359c16d..2f3400e427 100644
--- a/deeplabcut/create_project/new.py
+++ b/deeplabcut/create_project/new.py
@@ -1,99 +1,112 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
import os
import shutil
import warnings
+from collections.abc import Sequence
from pathlib import Path
+from typing import Literal
+
from deeplabcut import DEBUG
-from deeplabcut.utils.auxfun_videos import VideoReader
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.core.engine import Engine
+from deeplabcut.utils.auxfun_videos import VideoReader, collect_video_paths
+@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 | Path],
+ working_directory: str | Path | None = None,
+ copy_videos: bool = False,
+ video_extensions: str | Sequence[str] | None = None,
+ multianimal: bool = False,
+ individuals: list[str] | None = None,
+) -> Path | Literal["nothingcreated"]:
r"""Create the necessary folders and files for a new project.
Creating a new project involves creating the project directory, sub-directories and
a basic configuration file. The configuration file is loaded with the default
values. Change its parameters to your projects need.
- Parameters
- ----------
- project : string
- The name of the project.
-
- experimenter : string
- 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.
-
- working_directory : string, optional
- The directory where the project will be created. The default is the
- ``current working directory``.
-
- copy_videos : bool, optional, Default: False.
- If True, the videos are copied to the ``videos`` directory. If False, symlinks
- of the videos will be created in the ``project/videos`` directory.
-
- multianimal: bool, optional. Default: False.
- For creating a multi-animal project (introduced in DLC 2.2)
-
- Returns
- -------
- str
- Path to the new project configuration file.
-
- Examples
- --------
-
- Linux/MacOS:
-
- >>> deeplabcut.create_new_project(
- project='reaching-task',
- experimenter='Linus',
- videos=[
- '/data/videos/mouse1.avi',
- '/data/videos/mouse2.avi',
- '/data/videos/mouse3.avi'
- ],
- working_directory='/analysis/project/',
- )
- >>> deeplabcut.create_new_project(
- project='reaching-task',
- experimenter='Linus',
- videos=['/data/videos'],
- videotype='.mp4',
- )
-
- Windows:
-
- >>> deeplabcut.create_new_project(
- 'reaching-task',
- 'Bill',
- [r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],
- copy_videos=True,
- )
-
- Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
+ Args:
+ project (string): The name of the project.
+ experimenter (string): The name of the experimenter.
+ videos (list[str | Path]): A list of strings or paths representing the full
+ paths of the videos or video-directories to include in the project. If a
+ path represents a directory instead of a file, all videos of
+ ``video_extensions`` will be imported.
+ working_directory (str | Path | None, optional): The directory where the
+ project will be created. The default is the ``current working directory``.
+ copy_videos (bool, optional): If True, the videos are copied to the ``videos``
+ directory. If False, symlinks of the videos will be created in the
+ ``project/videos`` directory; in the event of a failure to create symbolic
+ links, videos will be moved instead. Defaults to False.
+ video_extensions (str | Sequence[str] | None, optional):
+ 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). Defaults to None.
+ multianimal (bool, optional): For creating a multi-animal project (introduced
+ in DLC 2.2). Defaults to False.
+ individuals (list[str] | None, optional): Relevant only if multianimal is True.
+ List of individuals to be used in the project configuration. If None,
+ defaults to ['individual1', 'individual2', 'individual3'].
+
+ Returns:
+ Path | Literal["nothingcreated"]: Path to the new project configuration file,
+ or ``"nothingcreated"`` if no valid videos were found.
+
+ Raises:
+ FileNotFoundError: If a non-existent path is passed to ``videos``.
+
+ Examples:
+ Linux/MacOS:
+
+ deeplabcut.create_new_project(
+ project='reaching-task',
+ experimenter='Linus',
+ videos=[
+ '/data/videos/mouse1.avi',
+ '/data/videos/mouse2.avi',
+ '/data/videos/mouse3.avi'
+ ],
+ working_directory='/analysis/project/',
+ )
+ deeplabcut.create_new_project(
+ project='reaching-task',
+ experimenter='Linus',
+ videos=['/data/videos'],
+ video_extensions='.mp4',
+ )
+
+ Windows:
+
+ deeplabcut.create_new_project(
+ 'reaching-task',
+ 'Bill',
+ [r'C:\yourusername\rig-95\Videos\reachingvideo1.avi'],
+ copy_videos=True,
+ )
+
+ On Windows, paths should be formatted as ``r`"C:\"`` or ``"C:\\"`` (i.e. a double backslash).
"""
from datetime import datetime as dt
+
from deeplabcut.utils import auxiliaryfunctions
months_3letter = {
@@ -116,118 +129,93 @@ def create_new_project(
day = date.day
d = str(month[0:3] + str(day))
date = dt.today().strftime("%Y-%m-%d")
- if working_directory == None:
+ if working_directory is None:
working_directory = "."
- wd = Path(working_directory).resolve()
- project_name = "{pn}-{exp}-{date}".format(pn=project, exp=experimenter, date=date)
+ wd = Path(working_directory).absolute()
+ 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))
- return os.path.join(str(project_path), "config.yaml")
+ print(f'Project "{project_path}" already exists!')
+ return (project_path / "config.yaml").absolute()
video_path = project_path / "videos"
data_path = project_path / "labeled-data"
shuffles_path = project_path / "training-datasets"
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.absolute() not in {d.absolute() 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 == True:
+ 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))
+ dst.symlink_to(src)
+ print(f"Created the symlink of {src} to {dst}")
except OSError:
try:
import subprocess
- subprocess.check_call("mklink %s %s" % (dst, src), shell=True)
- except OSError:
- print(
- "Symlink creation impossible (exFat architecture?): "
- "cutting/pasting the video instead."
- )
- shutil.move(os.fspath(src), os.fspath(dst))
- print("{} moved to {}".format(src, dst))
+ subprocess.check_call(f"mklink {os.fspath(dst)} {os.fspath(src)}", shell=True)
+ except (OSError, subprocess.CalledProcessError):
+ print("Symlink creation impossible (exFat architecture?): copying the video instead.")
+ shutil.copy(os.fspath(src), os.fspath(dst))
+ print(f"{src} copied to {dst}")
videos = destinations
- if copy_videos == True:
+ if copy_videos:
videos = destinations # in this case the *new* location should be added to the config file
# adds the video list to the config.yaml file
video_sets = {}
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)]
- rel_video_path = str(Path.resolve(Path(video)))
- except:
- rel_video_path = os.readlink(str(video))
+ video_key = Path(video).absolute()
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...")
- os.remove(video) # Removing the video or link from the project
+ vid = VideoReader(os.fspath(video_key))
+ video_sets[os.fspath(video_key)] = {"crop": ", ".join(map(str, vid.get_bbox()))}
+ except OSError:
+ warnings.warn("Cannot open the video file! Skipping to the next one...", stacklevel=2)
+ Path(video).unlink() # Removing the video or link from the project
if not len(video_sets):
# Silently sweep the files that were already written.
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"
@@ -236,7 +224,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!"
@@ -245,8 +233,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()
@@ -260,7 +255,7 @@ def create_new_project(
cfg_file["Task"] = project
cfg_file["scorer"] = experimenter
cfg_file["video_sets"] = video_sets
- cfg_file["project_path"] = str(project_path)
+ cfg_file["project_path"] = project_path
cfg_file["date"] = d
cfg_file["cropping"] = False
cfg_file["start"] = 0
@@ -269,13 +264,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"
@@ -284,13 +281,17 @@ def create_new_project(
cfg_file["alphavalue"] = 0.7 # for plots transparency of markers
cfg_file["colormap"] = "rainbow" # for plots type of colormap
- projconfigfile = os.path.join(str(project_path), "config.yaml")
+ projconfigfile = (project_path / "config.yaml").absolute()
# Write dictionary to yaml config file
auxiliaryfunctions.write_config(projconfigfile, cfg_file)
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 8d30c98d42..eb2919057e 100644
--- a/deeplabcut/create_project/new_3d.py
+++ b/deeplabcut/create_project/new_3d.py
@@ -1,49 +1,46 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-import os
from pathlib import Path
from deeplabcut import DEBUG
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
- ----------
- project : string
- String containing the name of the project.
+ Args:
+ project (string): String containing the name of the project.
+ experimenter (string): String containing the name of the experimenter.
+ num_cameras (int): 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.
- experimenter : string
- String containing the name of the experimenter.
+ Examples:
+ Linux/MacOs:
- num_cameras : int
- An integer value specifying the number of cameras.
+ deeplabcut.create_new_project_3d("reaching-task", "Linus", 2)
- 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.
+ Windows:
+ deeplabcut.create_new_project_3d("reaching-task", "Bill", 2)
- Example
- --------
- Linux/MacOs
- >>> deeplabcut.create_new_project_3d('reaching-task','Linus',2)
-
- Windows:
- >>> deeplabcut.create_new_project('reaching-task','Bill',2)
- Users must format paths with either: r'C:\ OR 'C:\\ <- i.e. a double backslash \ \ )
-
+ On Windows, paths should be formatted as ``r`"C:\"`` or ``"C:\\"`` (i.e. a double backslash).
"""
from datetime import datetime as dt
+
from deeplabcut.utils import auxiliaryfunctions
date = dt.today()
@@ -52,17 +49,15 @@ def create_new_project_3d(project, experimenter, num_cameras=2, working_director
d = str(month[0:3] + str(day))
date = dt.today().strftime("%Y-%m-%d")
- if working_directory == None:
+ if working_directory is None:
working_directory = "."
- wd = Path(working_directory).resolve()
- project_name = "{pn}-{exp}-{date}-{triangulate}".format(
- pn=project, exp=experimenter, date=date, triangulate="3d"
- )
+ wd = Path(working_directory).absolute()
+ 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"
@@ -79,7 +74,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()
@@ -87,7 +82,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
@@ -96,9 +94,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"] = [
@@ -111,26 +107,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")
+ projconfigfile = 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 528117c3e6..7729536aba 100644
--- a/deeplabcut/generate_training_dataset/__init__.py
+++ b/deeplabcut/generate_training_dataset/__init__.py
@@ -1,13 +1,20 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
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 75320278e6..7f7b22da6f 100755
--- a/deeplabcut/generate_training_dataset/frame_extraction.py
+++ b/deeplabcut/generate_training_dataset/frame_extraction.py
@@ -1,36 +1,33 @@
-"""
-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
-"""
-
-
-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.
-
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- videos : optional (default=None)
- List of videos whose cropping areas are to be defined. Note that full paths are required.
- By default, all videos in the config are successively loaded.
-
- Returns
- -------
- cfg : dict
- Updated project configuration
+#
+# 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
+
+
+def select_cropping_area(config: str | Path, 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.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ videos (list, optional): List of videos whose cropping areas are to be defined.
+ Full paths are required. By default, all videos in the config are loaded.
+ Defaults to None.
+
+ Returns:
+ 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:
@@ -62,7 +59,7 @@ def select_cropping_area(config, videos=None):
def extract_frames(
- config,
+ config: str | Path,
mode="automatic",
algo="kmeans",
crop=False,
@@ -94,205 +91,173 @@ def extract_frames(
https://www.nature.com/articles/s41596-019-0176-0 or the preprint:
https://www.biorxiv.org/content/biorxiv/early/2018/11/24/476531.full.pdf
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- mode : string. Either ``"automatic"``, ``"manual"`` or ``"match"``.
- String containing the mode of extraction. It must be either ``"automatic"`` or
- ``"manual"`` to extract the initial set of frames. It can also be ``"match"``
- to match frames between the cameras in preparation for the use of epipolar line
- during labeling; namely, extract from camera_1 first, then run this to extract
- the matched frames in camera_2.
-
- WARNING: if you use ``"match"``, and you previously extracted and labeled
- frames from the second camera, this will overwrite your data. This will require
- you to delete the ``collectdata(.h5/.csv)`` files before labeling. Use with
- caution!
-
- algo : string, Either ``"kmeans"`` or ``"uniform"``, Default: `"uniform"`.
- String specifying the algorithm to use for selecting the frames. Currently,
- deeplabcut supports either ``kmeans`` or ``uniform`` based selection. This flag
- is only required for ``automatic`` mode and the default is ``uniform``. For
- ``"uniform"``, frames are picked in temporally uniform way, ``"kmeans"``
- performs clustering on downsampled frames (see user guide for details).
-
- NOTE: Color information is discarded for ``"kmeans"``, thus e.g. for
- camouflaged octopus clustering one might want to change this.
-
- crop : bool or str, optional
- If ``True``, video frames are cropped according to the corresponding
- coordinates stored in the project configuration file. Alternatively, if
- cropping coordinates are not known yet, crop=``"GUI"`` triggers a user
- interface where the cropping area can be manually drawn and saved.
-
- 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
- some folders and want to extract data for new videos.
-
- cluster_resizewidth: int, default: 30
- For ``"k-means"`` one can change the width to which the images are downsampled
- (aspect ratio is fixed).
-
- cluster_step: int, default: 1
- By default each frame is used for clustering, but for long videos one could
- only use every nth frame (set using this parameter). This saves memory before
- clustering can start, however, reading the individual frames takes longer due
- to the skipping.
-
- cluster_color: bool, default: False
- If ``"False"`` then each downsampled image is treated as a grayscale vector
- (discarding color information). If ``"True"``, then the color channels are
- considered. This increases the computational complexity.
-
- opencv: bool, default: True
- Uses openCV for loading & extractiong (otherwise moviepy (legacy)).
-
- slider_width: int, default: 25
- Width of the video frames slider, in percent of window.
-
- config3d: string, optional
- Path to the project configuration file in the 3D project. This will be used to
- match frames extracted from all cameras present in the field 'camera_names' to
- the frames extracted from the camera given by the parameter 'extracted_cam'.
-
- extracted_cam: int, default: 0
- The index of the camera that already has extracted frames. This will match
- frame numbers to extract for all other cameras. This parameter is necessary if
- you wish to use epipolar lines in the labeling toolbox. Only use if
- ``mode='match'`` and ``config3d`` is provided.
-
- videos_list: list[str], Default: None
- A list of the string containing full paths to videos to extract frames for. If
- this is left as ``None`` all videos specified in the config file will have
- frames extracted. Otherwise one can select a subset by passing those paths.
-
- Returns
- -------
- None
-
- Notes
- -----
- Use the function ``add_new_videos`` at any stage of the project to add new videos
- to the config file and extract their frames.
-
- The following parameters for automatic extraction are used from the config file
-
- * ``numframes2pick``
- * ``start`` and ``stop``
-
- While selecting the frames manually, you do not need to specify the ``crop``
- parameter in the command. Rather, you will get a prompt in the graphic user
- interface to choose if you need to crop or not.
-
- Examples
- --------
- To extract frames automatically with 'kmeans' and then crop the frames
-
- >>> deeplabcut.extract_frames(
- config='/analysis/project/reaching-task/config.yaml',
- mode='automatic',
- algo='kmeans',
- crop=True,
- )
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ mode (string): Either ``"automatic"``, ``"manual"`` or ``"match"``.
+ String containing the mode of extraction. It must be either ``"automatic"`` or
+ ``"manual"`` to extract the initial set of frames. It can also be ``"match"``
+ to match frames between the cameras in preparation for the use of epipolar line
+ during labeling; namely, extract from camera_1 first, then run this to extract
+ the matched frames in camera_2.
+
+ WARNING: if you use ``"match"``, and you previously extracted and labeled
+ frames from the second camera, this will overwrite your data. This will require
+ you to delete the ``collectdata(.h5/.csv)`` files before labeling. Use with
+ caution!
+ algo (string): Either ``"kmeans"`` or ``"uniform"``. Defaults to ``"kmeans"``.
+ String specifying the algorithm to use for selecting the frames. Currently,
+ deeplabcut supports either ``kmeans`` or ``uniform`` based selection. This flag
+ is only required for ``automatic`` mode and the default is ``kmeans``. For
+ ``"uniform"``, frames are picked in temporally uniform way, ``"kmeans"``
+ performs clustering on downsampled frames (see user guide for details).
+
+ NOTE: Color information is discarded for ``"kmeans"``, thus e.g. for
+ camouflaged octopus clustering one might want to change this.
+ crop (bool or str, optional): If ``True``, video frames are cropped according to the corresponding
+ coordinates stored in the project configuration file. Alternatively, if
+ cropping coordinates are not known yet, crop=``"GUI"`` triggers a user
+ interface where the cropping area can be manually drawn and saved.
+ 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
+ some folders and want to extract data for new videos. Defaults to True.
+ cluster_resizewidth (int): For ``"k-means"`` one can change the width to which the images are downsampled
+ (aspect ratio is fixed). Defaults to 30.
+ cluster_step (int): By default each frame is used for clustering, but for long videos one could
+ only use every nth frame (set using this parameter). This saves memory before
+ clustering can start, however, reading the individual frames takes longer due
+ to the skipping. Defaults to 1.
+ cluster_color (bool): If ``"False"`` then each downsampled image is treated as a grayscale vector
+ (discarding color information). If ``"True"``, then the color channels are
+ considered. This increases the computational complexity. Defaults to False.
+ opencv (bool): Uses openCV for loading and extracting (otherwise moviepy (legacy)). Defaults to True.
+ slider_width (int): Width of the video frames slider, in percent of window. Defaults to 25.
+ config3d (string, optional): Path to the project configuration file in the 3D project. This will be used to
+ match frames extracted from all cameras present in the field 'camera_names' to
+ the frames extracted from the camera given by the parameter 'extracted_cam'.
+ extracted_cam (int): The index of the camera that already has extracted frames. This will match
+ frame numbers to extract for all other cameras. This parameter is necessary if
+ you wish to use epipolar lines in the labeling toolbox. Only use if
+ ``mode='match'`` and ``config3d`` is provided. Defaults to 0.
+ videos_list (list[str]): A list of the string containing full paths to videos to extract frames for. If
+ this is left as ``None`` all videos specified in the config file will have
+ frames extracted. Otherwise one can select a subset by passing those paths. Defaults to None.
+
+ Returns:
+ None
+
+ Note:
+ Use the function ``add_new_videos`` at any stage of the project to add new videos
+ to the config file and extract their frames.
+
+ The following parameters for automatic extraction are used from the config file
+
+ * ``numframes2pick``
+ * ``start`` and ``stop``
+
+ While selecting the frames manually, you do not need to specify the ``crop``
+ parameter in the command. Rather, you will get a prompt in the graphic user
+ interface to choose if you need to crop or not.
+
+ Examples:
+ To extract frames automatically with 'kmeans' and then crop the frames
+
+ deeplabcut.extract_frames(
+ config='/analysis/project/reaching-task/config.yaml',
+ mode='automatic',
+ algo='kmeans',
+ crop=True,
+ )
- To extract frames automatically with 'kmeans' and then defining the cropping area
- using a GUI
+ To extract frames automatically with 'kmeans' and then defining the cropping area
+ using a GUI
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml',
- 'automatic',
- 'kmeans',
- 'GUI',
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ 'automatic',
+ 'kmeans',
+ 'GUI',
+ )
- To consider the color information when extracting frames automatically with
- 'kmeans'
+ To consider the color information when extracting frames automatically with
+ 'kmeans':
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml',
- 'automatic',
- 'kmeans',
- cluster_color=True,
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ 'automatic',
+ 'kmeans',
+ cluster_color=True,
+ )
- To extract frames automatically with 'uniform' and then crop the frames
+ To extract frames automatically with 'uniform' and then crop the frames
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml',
- 'automatic',
- 'uniform',
- crop=True,
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ 'automatic',
+ 'uniform',
+ crop=True,
+ )
- To extract frames manually
+ To extract frames manually
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml', 'manual'
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml', 'manual'
+ )
- To extract frames manually, with a 60% wide frames slider
+ To extract frames manually, with a 60% wide frames slider
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml', 'manual', slider_width=60,
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml', 'manual', slider_width=60,
+ )
- To extract frames from a second camera that match the frames extracted from the
- first
+ To extract frames from a second camera that match the frames extracted from the
+ first
- >>> deeplabcut.extract_frames(
- '/analysis/project/reaching-task/config.yaml',
- mode='match',
- extracted_cam=0,
- )
+ deeplabcut.extract_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ mode='match',
+ extracted_cam=0,
+ )
"""
- 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)
+ cfg = auxiliaryfunctions.read_config(config_file)
+ print("Config file read successfully.")
+
+ if videos_list is None:
+ 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]
if mode == "manual":
- wd = Path(config).resolve().parents[0]
- os.chdir(str(wd))
- from deeplabcut.gui import frame_extraction_toolbox
+ from deeplabcut.gui.widgets import launch_napari
- frame_extraction_toolbox.show(config, slider_width)
+ _ = launch_napari(videos[0])
+ return
elif mode == "automatic":
- config_file = Path(config).resolve()
- cfg = auxiliaryfunctions.read_config(config_file)
- print("Config file read successfully.")
-
numframes2pick = cfg["numframes2pick"]
start = cfg["start"]
stop = cfg["stop"]
# 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."
- )
- if videos_list is None:
- videos = 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]
-
+ raise Exception("Perhaps consider extracting more, or a natural number of frames.")
+
if opencv:
- from deeplabcut.utils.auxfun_videos import VideoReader
+ from deeplabcut.utils.auxfun_videos import VideoWriter
else:
from moviepy.editor import VideoFileClip
@@ -316,9 +281,8 @@ def extract_frames(
or askuser == "oui"
or askuser == "ouais"
): # multilanguage support :)
-
if opencv:
- cap = VideoReader(video)
+ cap = VideoWriter(video)
nframes = len(cap)
else:
# Moviepy:
@@ -335,17 +299,12 @@ def extract_frames(
output_path = Path(config).parents[0] / "labeled-data" / fname.stem
if output_path.exists():
- if len(os.listdir(output_path)):
+ if any(output_path.iterdir()):
if userfeedback:
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":
@@ -355,26 +314,25 @@ def extract_frames(
except KeyError:
coords = cfg["video_sets_original"][video]["crop"].split(",")
- if crop and not opencv:
- clip = clip.crop(
- y1=int(coords[2]),
- y2=int(coords[3]),
- x1=int(coords[0]),
- x2=int(coords[1]),
- )
- elif not crop:
+ if crop:
+ if opencv:
+ cap.set_bbox(*map(int, coords))
+ else:
+ clip = clip.crop(
+ y1=int(coords[2]),
+ y2=int(coords[3]),
+ x1=int(coords[0]),
+ x2=int(coords[1]),
+ )
+ 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(
@@ -382,8 +340,6 @@ def extract_frames(
numframes2pick,
start,
stop,
- crop,
- coords,
step=cluster_step,
resizewidth=cluster_resizewidth,
color=cluster_color,
@@ -400,41 +356,26 @@ 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:
for index in frames2pick:
cap.set_to_frame(index) # extract a particular frame
- frame = cap.read_frame()
+ 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"
- )
- if crop:
- io.imsave(
- img_name,
- image[
- int(coords[2]) : int(coords[3]),
- int(coords[0]) : int(coords[1]),
- :,
- ],
- ) # y1 = int(coords[2]),y2 = int(coords[3]),x1 = int(coords[0]), x2 = int(coords[1]
- else:
- io.imsave(img_name, image)
+ img_name = str(output_path) + "/img" + str(index).zfill(indexlength) + ".png"
+ io.imsave(img_name, image)
is_valid.append(True)
else:
print("Frame", index, " not found!")
@@ -444,16 +385,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:
@@ -472,45 +409,44 @@ 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
- config_file = Path(config).resolve()
+ config_file = Path(config)
cfg = auxiliaryfunctions.read_config(config_file)
print("Config file read successfully.")
videos = sorted(cfg["video_sets"].keys())
+ if videos_list is not None: # filter video_list by the ones in the config file
+ 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/")
+ labels_path = project_path / "labeled-data"
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(labels_path.glob("*" + extCam_name + "*"))
# select crop method
crop_list = []
for video in videos:
- if extCam_name not in video:
+ if extCam_name in video:
if crop == "GUI":
cfg = select_cropping_area(config, [video])
print("in gui code")
@@ -526,33 +462,38 @@ def extract_frames(
elif not crop:
coords = None
crop_list.append(coords)
- print(crop_list)
- for coords, dirPath in zip(crop_list, label_dirs):
- extracted_images = glob.glob(os.path.join(dirPath, "*png"))
+ for coords, dirPath in zip(crop_list, label_dirs, strict=False):
+ extracted_images = list(dirPath.glob("*png"))
imgPattern = re.compile("[0-9]{1,10}")
for cam in cams:
- output_path = re.sub(extCam_name, cam, dirPath)
+ output_path = Path(re.sub(extCam_name, cam, str(dirPath)))
+
+ for p in output_path.iterdir():
+ if p.name.endswith(".png"):
+ p.unlink()
+
+ # Find the matching video from the config `video_sets`,
+ # as it may be stored elsewhere than in the `videos` directory.
+ video_name = output_path.name
+ vid = ""
+ for video in cfg["video_sets"]:
+ if video_name in video:
+ vid = video
+ break
+ if not vid:
+ raise ValueError(f"Video {video_name} not found...")
- for fname in os.listdir(output_path):
- if fname.endswith(".png"):
- os.remove(os.path.join(output_path, fname))
-
- vid = os.path.join(video_dir, os.path.basename(output_path)) + ".avi"
cap = cv2.VideoCapture(vid)
- print(
- "\n extracting matched frames from "
- + os.path.basename(output_path)
- + ".avi"
- )
+ print("\n extracting matched frames from " + video_name)
for img in extracted_images:
- imgNum = re.findall(imgPattern, os.path.basename(img))[0]
+ imgNum = re.findall(imgPattern, img.name)[0]
cap.set(1, int(imgNum))
ret, frame = cap.read()
if ret:
image = img_as_ubyte(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
- img_name = str(output_path) + "/img" + imgNum + ".png"
+ img_name = str(output_path / ("img" + imgNum + ".png"))
if crop:
io.imsave(
img_name,
@@ -564,12 +505,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..8e27d6463a
--- /dev/null
+++ b/deeplabcut/generate_training_dataset/metadata.py
@@ -0,0 +1,495 @@
+#
+# 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 deeplabcut.core.config import ProjectConfig, get_yaml_dumper, get_yaml_loader
+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:
+ RuntimeError: If the indices are not sorted in strictly increasing order.
+ """
+ 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: ProjectConfig | dict | Path | str,
+ trainset_path: Path,
+ ) -> ShuffleMetadata:
+ """Loads the data split for this shuffle.
+
+ Args:
+ cfg (ProjectConfig | dict | Path | str): The project configuration.
+ trainset_path: the path to the training dataset folder
+
+ Returns:
+ a new instance with the data split defined
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ _, doc_path = auxiliaryfunctions.get_data_and_metadata_filenames(
+ trainset_path, self.train_fraction, self.index, cfg
+ )
+ if not 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 doc_path.open("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: ProjectConfig
+ 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:
+ RuntimeError: If the shuffles are not sorted in ascending training fraction and index.
+ """
+ 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:
+ RuntimeError: If ``overwrite`` is False and a shuffle with the same index and
+ training fraction already exists.
+ """
+ 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 self.path(self.project_config).open("w") as file:
+ file.write("\n".join(self.file_header) + "\n")
+ get_yaml_dumper().dump(metadata, file)
+
+ @staticmethod
+ def load(
+ config: ProjectConfig | dict | Path | str,
+ load_splits: bool = False,
+ ) -> TrainingDatasetMetadata:
+ """Loads the metadata from disk.
+
+ Args:
+ config (ProjectConfig | dict | Path | str): The project configuration.
+ load_splits: whether to load the data split for each shuffle
+ """
+ cfg = ProjectConfig.from_any(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 = get_yaml_loader().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: ProjectConfig | dict | Path | str) -> 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 (ProjectConfig | dict | Path | str): The project configuration.
+ default_engine: the default engine to set for shuffles in the project
+
+ Returns:
+ the metadata for the existing shuffles in the project
+ """
+ cfg = ProjectConfig.from_any(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, exist_ok=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 doc_path.open("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: ProjectConfig | dict | Path | str) -> Path:
+ """
+ Args:
+ cfg (ProjectConfig | dict | Path | str): The project configuration.
+
+ Returns:
+ the path to the training dataset metadata file
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ meta_path = auxiliaryfunctions.get_training_set_folder(cfg) / "metadata.yaml"
+ return Path(cfg["project_path"]) / meta_path
+
+
+def update_metadata(
+ cfg: ProjectConfig | dict | Path | str,
+ 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 (ProjectConfig | dict | Path | str): The project configuration.
+ 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:
+ RuntimeError: If ``overwrite`` is ``False`` and a shuffle with the same index and
+ training fraction already exists.
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ 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: ProjectConfig | dict | Path | str,
+ trainingsetindex: int,
+ shuffle: int,
+ modelprefix: str = "",
+) -> Engine:
+ """Get the shuffle engine.
+
+ Args:
+ cfg (ProjectConfig | dict | Path | str): The project configuration.
+ 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
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ 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 14a45dad93..99fe05856b 100755
--- a/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py
+++ b/deeplabcut/generate_training_dataset/multiple_individuals_trainingsetmanipulation.py
@@ -1,15 +1,16 @@
-"""
-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
-"""
+#
+# 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 os
-import os.path
import re
import warnings
from itertools import combinations
@@ -18,21 +19,29 @@
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.config import ProjectConfig
+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,
)
+
def format_multianimal_training_data(
df,
train_inds,
@@ -47,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))
@@ -62,7 +69,7 @@ def format_multianimal_training_data(
array = np.round(array, decimals=n_decimals)
for i in tqdm(train_inds):
filename = filenames[i]
- img_shape = read_image_shape_fast(os.path.join(project_path, *filename))
+ img_shape = read_image_shape_fast(Path(project_path).joinpath(*filename))
joints = dict()
has_data = False
for n, xy in enumerate(array[i]):
@@ -94,11 +101,12 @@ def format_multianimal_training_data(
def create_multianimaltraining_dataset(
- config,
+ config: str | Path | ProjectConfig | dict,
num_shuffles=1,
Shuffles=None,
windows2linux=False,
net_type=None,
+ detector_type=None,
numdigits=2,
crop_size=(400, 400),
crop_sampling="hybrid",
@@ -107,79 +115,157 @@ 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.
- Imporant differences to standard:
+ Important differences to standard:
- stores coordinates with numdigits as many digits
- - creates
- Parameter
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- num_shuffles : int, optional
- Number of shuffles of training dataset to create, i.e. [1,2,3] for num_shuffles=3. Default is set to 1.
-
- Shuffles: list of shuffles.
- 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
-
- numdigits: int, optional
-
- crop_size: tuple of int, optional
- Dimensions (width, height) of the crops for data augmentation.
- Default is 400x400.
-
- crop_sampling: str, optional
- 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),
- or "hybrid" (alternating randomly between "uniform" and "density").
- Default is "hybrid".
-
- paf_graph: list of lists, or "config" optional (default=None)
- 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.
-
- "config" will use the skeleton defined in the config file.
-
- trainIndices: list of lists, optional (default=None)
- List of one or multiple lists containing train indexes.
- A list containing two lists of training indexes will produce two splits.
-
- testIndices: list of lists, optional (default=None)
- List of one or multiple lists containing test indexes.
-
- n_edges_threshold: int, optional (default=105)
- Number of edges above which the graph is automatically pruned.
-
- paf_graph_degree: int, optional (default=6)
- Degree of paf_graph when automatically pruning it (before training).
-
- 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])
-
- Windows:
- >>> deeplabcut.create_multianimaltraining_dataset(r'C:\\Users\\Ulf\\looming-task\\config.yaml',Shuffles=[3,17,5])
- --------
+
+ Args:
+ config (str | Path | ProjectConfig | dict): Full path of the config.yaml file.
+ Alternatively, a ProjectConfig object or a dictionary can be passed.
+ num_shuffles (int, optional): Number of shuffles of training dataset to create,
+ i.e. [1,2,3] for num_shuffles=3. Defaults to 1.
+ Shuffles (list of shuffles): Alternatively the user can also give a list of
+ shuffles (integers!).
+ net_type (string): 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): 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): Number of decimal digits for stored coordinates.
+ crop_size (tuple of int, optional): Only for the TensorFlow engine. Dimensions
+ (width, height) of the crops for data augmentation. Defaults to 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), "density" (weighing
+ preferentially dense regions of keypoints), or "hybrid" (alternating
+ randomly between "uniform" and "density"). Defaults to "hybrid".
+ paf_graph (list of lists, or "config", optional): 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. "config" will use the skeleton
+ defined in the config file. Defaults to None.
+ trainIndices (list of lists, optional): List of one or multiple lists containing
+ train indexes. A list containing two lists of training indexes will produce
+ two splits. Defaults to None.
+ testIndices (list of lists, optional): List of one or multiple lists containing
+ test indexes. Defaults to None.
+ n_edges_threshold (int, optional): Only for the TensorFlow engine. Number of
+ edges above which the graph is automatically pruned. Defaults to 105.
+ paf_graph_degree (int, optional): Only for the TensorFlow engine. Degree of
+ paf_graph when automatically pruning it (before training). Defaults to 6.
+ userfeedback (bool, optional): 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. Defaults to True.
+ weight_init (WeightInitialisation, optional): PyTorch engine only. Specify how
+ model weights should be initialized. The default mode uses transfer
+ learning from ImageNet weights. Defaults to None.
+ 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):
+ 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.
+ Defaults to None.
+
+ Examples:
+
+ 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],
+ )
+
+ Windows:
+
+ deeplabcut.create_multianimaltraining_dataset(
+ r"C:\\Users\\Ulf\\looming-task\\config.yaml",
+ Shuffles=[3, 17, 5],
+ )
"""
if windows2linux:
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):
@@ -187,18 +273,22 @@ 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:
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = ProjectConfig.from_any(config, repair_path=True)
scorer = cfg["scorer"]
project_path = cfg["project_path"]
# Create path for training sets & store data there
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
full_training_path = Path(project_path, trainingsetfolder)
- auxiliaryfunctions.attempttomakefolder(full_training_path, recursive=True)
+ 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:
@@ -207,17 +297,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"
@@ -229,37 +324,29 @@ 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;
# see Suppl. Fig S9c in Lauer et al., 2022.
if n_edges_orig >= n_edges_threshold:
partaffinityfield_graph = auxfun_multianimal.prune_paf_graph(
- partaffinityfield_graph, average_degree=paf_graph_degree,
+ partaffinityfield_graph,
+ average_degree=paf_graph_degree,
)
else:
if paf_graph == "config":
# 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)
@@ -268,15 +355,14 @@ 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, num_shuffles = auxfun_models.Check4weights(
- net_type, Path(dlcparent_path), num_shuffles
- )
+ defaultconfigfile = dlcparent_path / "pose_cfg.yaml"
- 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, 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:
@@ -287,19 +373,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)
@@ -308,6 +386,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)
@@ -331,24 +414,31 @@ def create_multianimaltraining_dataset(
(
datafilename,
metadatafilename,
- ) = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, trainFraction, shuffle, cfg
- )
+ ) = auxiliaryfunctions.get_data_and_metadata_filenames(trainingsetfolder, trainFraction, shuffle, cfg)
################################################################################
# Saving metadata and data file (Pickle file)
################################################################################
- auxiliaryfunctions.SaveMetadata(
- os.path.join(project_path, metadatafilename),
+ auxiliaryfunctions.save_metadata(
+ Path(project_path) / metadatafilename,
data,
trainIndices,
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"
+ datafilename = datafilename.with_suffix(".pickle")
import pickle
- with open(os.path.join(project_path, datafilename), "wb") as f:
+ with (Path(project_path) / datafilename).open("wb") as f:
# Pickle the 'labeled-data' dictionary using the highest protocol available.
pickle.dump(data, f, pickle.HIGHEST_PROTOCOL)
@@ -358,151 +448,160 @@ def create_multianimaltraining_dataset(
#################################################################################
modelfoldername = auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg
- )
- auxiliaryfunctions.attempttomakefolder(
- Path(config).parents[0] / modelfoldername, recursive=True
- )
- auxiliaryfunctions.attempttomakefolder(
- str(Path(config).parents[0] / modelfoldername / "train")
- )
- auxiliaryfunctions.attempttomakefolder(
- str(Path(config).parents[0] / modelfoldername / "test")
+ trainFraction,
+ shuffle,
+ cfg,
+ engine=engine,
)
-
- path_train_config = str(
- os.path.join(
- cfg["project_path"],
- Path(modelfoldername),
- "train",
- "pose_cfg.yaml",
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername, recursive=True)
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername / "train")
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername / "test")
+
+ path_train_config = str(Path(cfg["project_path"]) / modelfoldername / "train" / "pose_cfg.yaml")
+ path_test_config = str(Path(cfg["project_path"]) / modelfoldername / "test" / "pose_cfg.yaml")
+ path_inference_config = str(Path(cfg["project_path"]) / modelfoldername / "test" / "inference_cfg.yaml")
+
+ 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),
)
- )
- path_test_config = str(
- os.path.join(
- cfg["project_path"],
- Path(modelfoldername),
- "test",
- "pose_cfg.yaml",
+ 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,
)
- )
- path_inference_config = str(
- os.path.join(
- cfg["project_path"],
- Path(modelfoldername),
- "test",
- "inference_cfg.yaml",
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import (
+ make_super_animal_finetune_config,
)
- )
-
- 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,
- }
- 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"])
- + 1 * (len(cfg["uniquebodyparts"]) > 0),
- "withid": cfg.get("identity", False),
- }
- MakeInference_yaml(
- items2change, path_inference_config, defaultinference_configfile
+ default_inf_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
+# TODO @deruyter92 2026-06-05: This function seems to be unused dead code. Let's remove it.
def convert_cropped_to_standard_dataset(
config_path,
recreate_datasets=True,
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
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"]
@@ -513,11 +612,11 @@ def convert_cropped_to_standard_dataset(
if delete_crops:
print("Deleting crops...")
- data_path = os.path.join(project_path, "labeled-data")
+ data_path = Path(project_path) / "labeled-data"
for video in cfg["video_sets"]:
- _, filename, _ = trainingsetmanipulation._robust_path_split(video)
+ filename = Path(video).stem
if "_cropped" in video: # One can never be too safe...
- shutil.rmtree(os.path.join(data_path, filename), ignore_errors=True)
+ shutil.rmtree(data_path / filename, ignore_errors=True)
cfg["video_sets"] = videos_orig
write_config(config_path, cfg)
@@ -525,54 +624,44 @@ def convert_cropped_to_standard_dataset(
if not recreate_datasets:
return
- datasets_folder = os.path.join(
- project_path,
- auxiliaryfunctions.GetTrainingSetFolder(cfg),
- )
+ datasets_folder = Path(project_path) / auxiliaryfunctions.get_training_set_folder(cfg)
df_old = pd.read_hdf(
- os.path.join(datasets_folder, "CollectedData_" + cfg["scorer"] + ".h5"),
+ datasets_folder / ("CollectedData_" + cfg["scorer"] + ".h5"),
)
def strip_cropped_image_name(path):
- head, filename = os.path.split(path)
- head = head.replace("_cropped", "")
+ path_obj = Path(path)
+ head = str(path_obj.parent).replace("_cropped", "")
+ filename = path_obj.name
file, ext = filename.split(".")
file = file.split("c")[0]
- return os.path.join(head, file + "." + ext)
+ return str(Path(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 = []
test_idx = []
pickle_files = []
- for filename in os.listdir(datasets_folder):
- if filename.endswith("pickle"):
- pickle_file = os.path.join(datasets_folder, filename)
+ for p in datasets_folder.iterdir():
+ if p.name.endswith("pickle"):
+ pickle_file = p
pickle_files.append(pickle_file)
- if filename.startswith("Docu"):
- with open(pickle_file, "rb") as f:
+ if p.name.startswith("Docu"):
+ with p.open("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)
# Search a pose_config.yaml file to parse missing information
pose_config_path = ""
- for dirpath, _, filenames in os.walk(os.path.join(project_path, "dlc-models")):
+ for dirpath, _, filenames in os.walk(Path(project_path) / "dlc-models"):
for file in filenames:
if file.endswith("pose_cfg.yaml"):
- pose_config_path = os.path.join(dirpath, file)
+ pose_config_path = str(Path(dirpath) / file)
break
pose_cfg = read_plainconfig(pose_config_path)
net_type = pose_cfg["net_type"]
@@ -582,8 +671,8 @@ def strip_cropped_image_name(path):
# Clean the training-datasets folder prior to recreating the data pickles
shuffle_inds = set()
for file in pickle_files:
- os.remove(file)
- shuffle_inds.add(int(re.findall(r"shuffle(\d+)", file)[0]))
+ file.unlink()
+ shuffle_inds.add(int(re.findall(r"shuffle(\d+)", str(file))[0]))
create_multianimaltraining_dataset(
config_path,
trainIndices=train_idx,
diff --git a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
index b4658e9eda..a9bc73398b 100755
--- a/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
+++ b/deeplabcut/generate_training_dataset/trainingsetmanipulation.py
@@ -1,54 +1,51 @@
-"""
-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 math
+#
+# 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 logging
-import os
-import os.path
+import math
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.config import ProjectConfig, read_config, write_config
+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
-def comparevideolistsanddatafolders(config):
- """
- 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.
+def comparevideolistsanddatafolders(config: str | Path):
+ """Auxiliary function that compares the folders in labeled-data and the ones listed
+ under video_sets (in the config file).
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = 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 = [f.name for f in (Path(config).parent / "labeled-data").iterdir() if "_labeled" not in f.name]
print("Config file contains:", len(video_names))
print("Labeled-data contains:", len(alldatafolders))
@@ -62,32 +59,31 @@ def comparevideolistsanddatafolders(config):
print(vn, " is missing in config file!")
-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.
+def adddatasetstovideolistandviceversa(config: str | Path):
+ """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!
- Parameter
- ----------
- config : string
- String containing the full path of the config file in the project.
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = read_config(config)
videos = cfg["video_sets"]
video_names = [Path(i).stem for i in videos]
+ labeled_data_dir = Path(config).parent / "labeled-data"
alldatafolders = [
- fn
- for fn in os.listdir(Path(config).parent / "labeled-data")
- if "_labeled" not in fn and not fn.startswith(".")
+ f.name for f in labeled_data_dir.iterdir() if "_labeled" not in f.name and not f.name.startswith(".")
]
print("Config file contains:", len(video_names))
@@ -111,71 +107,60 @@ def adddatasetstovideolistandviceversa(config):
print(vn, " is missing in config file >> adding it!")
# Find the corresponding video file
found = False
- for file in os.listdir(os.path.join(cfg["project_path"], "videos")):
- if os.path.splitext(file)[0] == vn:
+ for file in (Path(cfg["project_path"]) / "videos").iterdir():
+ if file.stem == vn:
found = True
break
if found:
- video_path = os.path.join(cfg["project_path"], "videos", file)
+ video_path = str(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).
-
- Parameter
- ----------
- config : string
- String containing the full path of the config file in the project.
+def dropduplicatesinannotatinfiles(config: str | Path):
+ """Drop duplicate entries (of images) in annotation files (this should no longer
+ happen, but might be useful).
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = 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]
for folder in folders:
try:
- fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
+ fn = folder / ("CollectedData_" + cfg["scorer"] + ".h5")
DC = pd.read_hdf(fn)
numimages = len(DC.index)
DC = DC[~DC.index.duplicated(keep="first")]
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(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.
-
- Parameter
- ----------
- config : string
- String containing the full path of the config file in the project.
+def dropannotationfileentriesduetodeletedimages(config: str | Path):
+ """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.
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = 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]
for folder in folders:
- fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
+ fn = folder / ("CollectedData_" + cfg["scorer"] + ".h5")
try:
DC = pd.read_hdf(fn)
except FileNotFoundError:
@@ -183,57 +168,52 @@ def dropannotationfileentriesduetodeletedimages(config):
continue
dropped = False
for imagename in DC.index:
- if os.path.isfile(os.path.join(cfg["project_path"], *imagename)):
+ if Path(cfg["project_path"]).joinpath(*imagename).is_file():
pass
else:
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(folder / ("CollectedData_" + cfg["scorer"] + ".csv"))
-def dropimagesduetolackofannotation(config):
+def dropimagesduetolackofannotation(config: str | Path):
"""
Drop images from corresponding folder for not annotated images: /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.
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = 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]
for folder in folders:
- h5file = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
+ h5file = folder / ("CollectedData_" + cfg["scorer"] + ".h5")
try:
DC = pd.read_hdf(h5file)
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]
+ imagelist = [f.name for f in folder.iterdir() if ".png" in f.name]
print("Annotated images: ", len(annotatedimages), " In folder:", len(imagelist))
for imagename in imagelist:
if imagename in annotatedimages:
pass
else:
- fullpath = os.path.join(
- cfg["project_path"], "labeled-data", folder, imagename
- )
- if os.path.isfile(fullpath):
+ fullpath = folder / imagename
+ if fullpath.is_file():
print("Deleting", fullpath)
- os.remove(fullpath)
+ fullpath.unlink()
annotatedimages = [fn[-1] for fn in DC.index]
- imagelist = [fns for fns in os.listdir(str(folder)) if ".png" in fns]
+ imagelist = [f.name for f in folder.iterdir() if ".png" in f.name]
print(
"PROCESSED:",
folder,
@@ -243,46 +223,43 @@ def dropimagesduetolackofannotation(config):
len(imagelist),
)
-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.
- Parameter
- ----------
- config : string
- String containing the full path of the config file in the project.
+def dropunlabeledframes(config: str | Path):
+ """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.
+ Args:
+ config (string): String containing the full path of the config file in the project.
"""
- cfg = auxiliaryfunctions.read_config(config)
+ cfg = 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]
for folder in folders:
- h5file = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
+ h5file = folder / ("CollectedData_" + cfg["scorer"] + ".h5")
try:
DC = pd.read_hdf(h5file)
except FileNotFoundError:
- print("Skipping ",folder,"...")
+ print("Skipping ", folder, "...")
continue
before_len = len(DC.index)
- DC = DC.dropna(how='all') # drop rows where all values are missing(NaN)
+ DC = DC.dropna(how="all") # drop rows where all values are missing(NaN)
after_len = len(DC.index)
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(folder / ("CollectedData_" + cfg["scorer"] + ".csv"))
- print("Dropped ", dropped, "entries in ",folder)
+ print("Dropped ", dropped, "entries in ", folder)
print("Done.")
+
def check_labels(
- config,
- Labels=["+", ".", "x"],
+ config: str | Path,
+ Labels=None,
scale=1,
dpi=100,
draw_skeleton=True,
@@ -298,54 +275,39 @@ def check_labels(
Make sure that these labels are fine.
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- Labels: list, default='+'
- List of at least 3 matplotlib markers. The first one will be used to indicate
- the human ground truth location (Default: +)
-
- scale : float, default=1
- Change the relative size of the output images.
-
- dpi : int, optional, default=100
- Output resolution in dpi.
-
- draw_skeleton: bool, default=True
- Plot skeleton overlaid over body parts.
-
- visualizeindividuals: bool, default: True.
- For a multianimal project, if True, the different individuals have different
- colors (and all bodyparts the same). If False, the colors change over bodyparts
- rather than individuals.
-
- Returns
- -------
- None
-
- Examples
- --------
- >>> deeplabcut.check_labels('/analysis/project/reaching-task/config.yaml')
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ Labels (list, optional): List of at least 3 matplotlib markers. The first one
+ will be used to indicate the human ground truth location. Defaults to '+'.
+ scale (float, optional): Change the relative size of the output images.
+ Defaults to 1.
+ dpi (int, optional): Output resolution in dpi. Defaults to 100.
+ draw_skeleton (bool, optional): Plot skeleton overlaid over body parts.
+ Defaults to True.
+ visualizeindividuals (bool, optional): For a multianimal project, if True, the
+ different individuals have different colors (and all bodyparts the same).
+ If False, the colors change over bodyparts rather than individuals.
+ Defaults to True.
+
+ Returns:
+ None
+
+ Examples:
+ deeplabcut.check_labels("/analysis/project/reaching-task/config.yaml")
"""
-
from deeplabcut.utils import visualization
- cfg = auxiliaryfunctions.read_config(config)
+ if Labels is None:
+ Labels = ["+", ".", "x"]
+ cfg = read_config(config)
videos = cfg["video_sets"].keys()
- video_names = [_robust_path_split(video)[1] for video in videos]
+ video_names = [Path(video).stem 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 = [Path(cfg["project_path"]) / "labeled-data" / 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(folder / ("CollectedData_" + cfg["scorer"] + ".h5"))
conversioncode.guarantee_multiindex_rows(DataCombined)
if cfg.get("multianimalproject", False):
color_by = "individual" if visualizeindividuals else "bodypart"
@@ -365,9 +327,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):
@@ -377,7 +337,7 @@ def boxitintoacell(joints):
return outer
-def ParseYaml(configfile):
+def ParseYaml(configfile: str | Path):
raw = open(configfile).read()
docs = []
for raw_doc in raw.split("\n---"):
@@ -389,19 +349,25 @@ 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:
+ write_config(saveasconfigfile, docs[0])
return docs[0]
@@ -430,8 +396,7 @@ def MakeTest_pose_yaml(
dict_test["locref_smooth"] = locref_smooth
dict_test["scoremap_dir"] = "test"
- with open(saveasfile, "w") as f:
- yaml.dump(dict_test, f)
+ write_config(saveasfile, dict_test)
def MakeInference_yaml(itemstochange, saveasconfigfile, defaultconfigfile):
@@ -439,51 +404,113 @@ def MakeInference_yaml(itemstochange, saveasconfigfile, defaultconfigfile):
for key in itemstochange.keys():
docs[0][key] = itemstochange[key]
- with open(saveasconfigfile, "w") as f:
- yaml.dump(docs[0], f)
+ write_config(saveasconfigfile, docs[0])
return docs[0]
-def _robust_path_split(path):
- sep = "\\" if "\\" in path else "/"
- splits = path.rsplit(sep, 1)
- if len(splits) == 1:
- parent = "."
- file = splits[0]
- elif len(splits) == 2:
- parent, file = splits
- else:
- raise ("Unknown filepath split for path {}".format(path))
- filename, ext = os.path.splitext(file)
- return parent, filename, ext
+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.
-def merge_annotateddatasets(cfg, trainingsetfolder_full):
+ 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
+ """
+ filenames = []
+ filename_to_videos = {}
+ for video in videos:
+ filename = Path(video).stem
+ 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.
"""
- Merges all the h5 files for all labeled-datasets (from individual videos).
+ 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"))
+ data_path = Path(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 = 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..."
+ "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):
@@ -503,12 +530,20 @@ 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.to_hdf(filename + ".h5", key="df_with_missing", mode="w")
- AnnotationData.to_csv(filename + ".csv") # human readable.
+ 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 = trainingsetfolder_full / f"CollectedData_{cfg['scorer']}"
+ AnnotationData.to_hdf(str(filename) + ".h5", key="df_with_missing", mode="w")
+ AnnotationData.to_csv(str(filename) + ".csv") # human readable.
return AnnotationData
@@ -517,10 +552,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(
@@ -574,75 +611,85 @@ def pad_train_test_indices(train_inds, test_inds, train_fraction):
return train_inds, test_inds
-def mergeandsplit(config, trainindex=0, uniform=True):
- """
- This function allows additional control over "create_training_dataset".
+def mergeandsplit(config: str | Path | ProjectConfig | dict, trainindex=0, uniform=True):
+ """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
- ----------
- config : string
- 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.
-
- uniform: bool, optional
- Perform uniform split (disregarding folder structure in labeled data), or (if False) leave one folder out.
+ Args:
+ config (str | Path | ProjectConfig | dict): Full path of the config.yaml file.
+ Alternatively, a ProjectConfig object or a dictionary can be passed.
+ 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.
+ uniform (bool, optional): Perform uniform split (disregarding folder structure in
+ labeled data), or (if False) leave one folder out.
+
+ Examples:
+ 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. 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,
+ )
- Examples
- --------
- 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.
- 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):
- To freeze a (uniform) split (i.e. iid sampled from all the data):
- >>> trainIndices, testIndices=deeplabcut.mergeandsplit(config,trainindex=0,uniform=True)
+ 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)
+ cfg = ProjectConfig.from_any(config, repair_path=True)
scorer = cfg["scorer"]
project_path = cfg["project_path"]
# Create path for training sets & store data there
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(
- cfg
- ) # Path concatenation OS platform independent
- auxiliaryfunctions.attempttomakefolder(
- Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True
- )
- fn = os.path.join(project_path, trainingsetfolder, "CollectedData_" + cfg["scorer"])
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg) # Path concatenation OS platform independent
+ auxiliaryfunctions.attempt_to_make_folder(Path(project_path) / str(trainingsetfolder), recursive=True)
+ fn = str(Path(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)),
+ Path(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,
)
@@ -651,7 +698,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)
@@ -661,7 +708,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:
@@ -678,23 +725,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))
+ img_shape = read_image_shape_fast(Path(project_path).joinpath(*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(
@@ -704,115 +777,194 @@ 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
def create_training_dataset(
- config,
+ config: str | Path | ProjectConfig | dict,
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.
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.
- Parameters
- ----------
- config : string
- Full path of the ``config.yaml`` file as a string.
-
- num_shuffles : int, optional, default=1
- Number of shuffles of training dataset to create, i.e. ``[1,2,3]`` for
- ``num_shuffles=3``.
-
- Shuffles: list[int], optional
- Alternatively the user can also give a list of shuffles.
-
- userfeedback: bool, optional, default=False
- 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.
-
- trainIndices: list of lists, optional, default=None
- List of one or multiple lists containing train indexes.
- A list containing two lists of training indexes will produce two splits.
-
- testIndices: list of lists, optional, default=None
- 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``
-
- augmenter_type: string, optional, default=None
- Type of augmenter. Currently supported augmenters are
-
- * ``default``
- * ``scalecrop``
- * ``imgaug``
- * ``tensorpack``
- * ``deterministic``
-
- posecfg_template: string, optional, default=None
- 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``.
-
- Returns
- -------
- list(tuple) or None
- 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.
-
- Notes
- -----
- Use the function ``add_new_videos`` at any stage of the project to add more videos
- to the project.
-
- Examples
- --------
-
- Linux/MacOS
+ Args:
+ config (str | Path | ProjectConfig | dict): Full path of the ``config.yaml``
+ file. Alternatively, a ProjectConfig object or a dictionary can be passed.
+ num_shuffles (int, optional): Number of shuffles of training dataset to create,
+ i.e. ``[1,2,3]`` for ``num_shuffles=3``. Defaults to 1.
+ Shuffles (list[int], optional): Alternatively the user can also give a list of
+ shuffles.
+ userfeedback (bool, optional): 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. Defaults to True.
+ trainIndices (list of lists, optional): List of one or multiple lists containing
+ train indexes. A list containing two lists of training indexes will produce
+ two splits. Defaults to None.
+ testIndices (list of lists, optional): List of one or multiple lists containing
+ test indexes. Defaults to None.
+ net_type (list, optional): 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``
+ Defaults to None.
+
+ detector_type (string, optional): 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``
+ Defaults to None.
+
+ augmenter_type (string, optional): Type of augmenter. The options available
+ depend on which engine is used. Currently supported options are:
+ TensorFlow
+ * ``default``
+ * ``scalecrop``
+ * ``imgaug``
+ * ``tensorpack``
+ * ``deterministic``
+ PyTorch
+ * ``albumentations``
+ Defaults to None.
+
+ posecfg_template (string, optional): 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``. Defaults to None.
+
+ superanimal_name (string, optional): 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. Defaults to "".
+
+ weight_init (WeightInitialisation, optional): PyTorch engine only. Specify how
+ model weights should be initialized. The default mode uses transfer learning
+ from ImageNet weights. Defaults to None.
+
+ 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,
+ optional): 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. Valid for both evaluation and live
+ analyze.
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file. Evaluation-only â
+ not valid for ``analyze_images`` / ``analyze_videos``.
+ * 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. Defaults to None.
+
+ Returns:
+ list(tuple) or None: 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.
+
+ Note:
+ Use the function ``add_new_videos`` at any stage of the project to add more
+ videos to the project.
+
+ Examples:
+ Linux/MacOS:
+ deeplabcut.create_training_dataset(
+ '/analysis/project/reaching-task/config.yaml', num_shuffles=1,
+ )
- >>> deeplabcut.create_training_dataset(
- '/analysis/project/reaching-task/config.yaml', num_shuffles=1,
- )
+ deeplabcut.create_training_dataset(
+ '/analysis/project/reaching-task/config.yaml', Shuffles=[2], engine=deeplabcut.Engine.TF,
+ )
- Windows
+ Windows:
- >>> deeplabcut.create_training_dataset(
- 'C:\\Users\\Ulf\\looming-task\\config.yaml', Shuffles=[3,17,5],
- )
+ deeplabcut.create_training_dataset(
+ "C:\\Users\\Ulf\\looming-task\\config.yaml",
+ Shuffles=[3, 17, 5],
+ )
"""
import scipy.io as sio
@@ -821,42 +973,68 @@ 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)
+ cfg = ProjectConfig.from_any(config, repair_path=True)
+ cfg_path = config if isinstance(config, (str, Path)) else cfg.config_yaml_path
+
+ auxiliaryfunctions.get_deeplabcut_path()
+
+ if superanimal_name != "":
+ raise ValueError(
+ "Invalid argument superanimal_name. This functionality has been "
+ "removed. Please use modelzoo.build_weight_init() instead."
+ )
+
if posecfg_template:
- if not posecfg_template.endswith("pose_cfg.yaml"):
- raise ValueError(
- "posecfg_template argument must contain path to a pose_cfg.yaml file"
- )
+ posecfg_template = Path(posecfg_template)
+ if posecfg_template.name not in {"pose_cfg.yaml", "superquadruped.yaml", "supertopview.yaml"}:
+ raise ValueError("posecfg_template argument must contain path to a pose_cfg.yaml file")
else:
- print("Reloading pose_cfg parameters from " + posecfg_template +'\n')
+ print(f"Reloading pose_cfg parameters from {posecfg_template}\n")
from deeplabcut.utils.auxiliaryfunctions import read_plainconfig
- prior_cfg = read_plainconfig(posecfg_template)
+ prior_cfg = read_plainconfig(posecfg_template)
if cfg.get("multianimalproject", False):
from deeplabcut.generate_training_dataset.multiple_individuals_trainingsetmanipulation import (
create_multianimaltraining_dataset,
)
create_multianimaltraining_dataset(
- config, num_shuffles, Shuffles, net_type=net_type
+ cfg,
+ 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.GetTrainingSetFolder(
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(
cfg
) # Path concatenation OS platform independent
- auxiliaryfunctions.attempttomakefolder(
- Path(os.path.join(project_path, str(trainingsetfolder))), recursive=True
- )
+ auxiliaryfunctions.attempt_to_make_folder(Path(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)),
+ Path(project_path) / trainingsetfolder,
)
if Data is None:
return
@@ -865,57 +1043,73 @@ 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
- ):
+ 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
+ cfg.default_augmenter = augmenter_type
+ cfg.to_yaml(cfg_path, log_changes=True, mark_clean=True)
+ 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)
dlcparent_path = auxiliaryfunctions.get_deeplabcut_path()
if not posecfg_template:
- defaultconfigfile = os.path.join(dlcparent_path, "pose_cfg.yaml")
+ defaultconfigfile = dlcparent_path / "pose_cfg.yaml"
elif posecfg_template:
defaultconfigfile = posecfg_template
- model_path, num_shuffles = auxfun_models.Check4weights(
- net_type, Path(dlcparent_path), num_shuffles
- )
- 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, 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 = [
(
@@ -928,51 +1122,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(
- config,
+ trainposeconfigfile, _, _ = compat.return_train_network_path(
+ cfg_path,
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."
)
####################################################
@@ -982,134 +1165,257 @@ def create_training_dataset(
(
datafilename,
metadatafilename,
- ) = auxiliaryfunctions.GetDataandMetaDataFilenames(
- 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(str(Path(project_path) / datafilename), {"dataset": MatlabData})
################################################################################
# Saving metadata (Pickle file)
################################################################################
- auxiliaryfunctions.SaveMetadata(
- os.path.join(project_path, metadatafilename),
+ auxiliaryfunctions.save_metadata(
+ Path(project_path) / metadatafilename,
data,
trainIndices,
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.attempttomakefolder(
- Path(config).parents[0] / modelfoldername, recursive=True
- )
- auxiliaryfunctions.attempttomakefolder(
- str(Path(config).parents[0] / modelfoldername) + "/train"
- )
- auxiliaryfunctions.attempttomakefolder(
- str(Path(config).parents[0] / modelfoldername) + "/test"
+ trainFraction,
+ shuffle,
+ cfg,
+ engine=engine,
)
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername, recursive=True)
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername / "train")
+ auxiliaryfunctions.attempt_to_make_folder(cfg.project_path / modelfoldername / "test")
+
+ path_train_config = str(Path(cfg["project_path"]) / modelfoldername / "train" / engine.pose_cfg_name)
+ path_test_config = str(Path(cfg["project_path"]) / modelfoldername / "test" / "pose_cfg.yaml")
+ 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),
+ )
- path_train_config = str(
- os.path.join(
- cfg["project_path"],
- Path(modelfoldername),
- "train",
- "pose_cfg.yaml",
+ 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!"
)
- )
- path_test_config = str(
- os.path.join(
- cfg["project_path"],
- Path(modelfoldername),
- "test",
- "pose_cfg.yaml",
+ 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,
)
- )
- # 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
- )
- 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!"
- )
+ 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):
+def get_largestshuffle_index(config: str | Path):
"""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]:
+ """Get the existing shuffle indices.
+
+ 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 = 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,
+ config: str | Path | ProjectConfig | dict,
trainindex=0,
num_shuffles=1,
- net_types=["resnet_50"],
- augmenter_types=["imgaug"],
+ net_types=None,
+ augmenter_types=None,
userfeedback=False,
windows2linux=False,
):
@@ -1119,96 +1425,92 @@ def create_training_model_comparison(
indices. Therefore, this function is useful for benchmarking the performance of
different network and augmentation types on the same training/testdata.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- trainindex: int, optional, default=0
- 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.
-
- num_shuffles : int, optional, default=1
- Number of shuffles of training dataset to create,
- i.e. [1,2,3] for num_shuffles=3.
-
- net_types: list[str], optional, default=["resnet_50"]
- Currently supported networks 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"``
-
- augmenter_types: list[str], optional, default=["imgaug"]
- Currently supported augmenters are
-
- * ``"default"``
- * ``"imgaug"``
- * ``"tensorpack"``
- * ``"deterministic"``
-
- userfeedback: bool, optional, default=False
- If ``False``, then 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, then set this to True and you will be asked for each split.
-
- windows2linux
-
- ..deprecated::
- Has no effect since 2.2.0.4 and will be removed in 2.2.1.
-
- Returns
- -------
- shuffle_list: list
- List of indices corresponding to the trainingsplits/models that were created.
-
- Examples
- --------
- On Linux/MacOS
-
- >>> shuffle_list = deeplabcut.create_training_model_comparison(
- '/analysis/project/reaching-task/config.yaml',
- num_shuffles=1,
- net_types=['resnet_50','resnet_152'],
- augmenter_types=['tensorpack','deterministic'],
- )
+ Args:
+ config (str | Path | ProjectConfig | dict): Full path of the config.yaml file.
+ Alternatively, a ProjectConfig object or a dictionary can be passed.
+ 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. Defaults to 0.
+ num_shuffles (int, optional): Number of shuffles of training dataset to create,
+ i.e. [1,2,3] for num_shuffles=3. Defaults to 1.
+ net_types (list[str], optional): Currently supported networks 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"``
+
+ Defaults to ["resnet_50"].
+
+ augmenter_types (list[str], optional): Currently supported augmenters are
+
+ * ``"default"``
+ * ``"imgaug"``
+ * ``"tensorpack"``
+ * ``"deterministic"``
+
+ Defaults to ["imgaug"].
+
+ userfeedback (bool, optional): If ``False``, then 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, then set this to True and you will
+ be asked for each split. Defaults to False.
+ windows2linux: ..deprecated:: Has no effect since 2.2.0.4 and will be removed in
+ 2.2.1.
+
+ Returns:
+ shuffle_list (list): List of indices corresponding to the trainingsplits/models
+ that were created.
+
+ Examples:
+ On Linux/MacOS
+
+ shuffle_list = deeplabcut.create_training_model_comparison(
+ '/analysis/project/reaching-task/config.yaml',
+ num_shuffles=1,
+ net_types=['resnet_50','resnet_152'],
+ augmenter_types=['tensorpack','deterministic'],
+ )
- On Windows
+ On Windows
- >>> shuffle_list = deeplabcut.create_training_model_comparison(
- 'C:\\Users\\Ulf\\looming-task\\config.yaml',
- num_shuffles=1,
- net_types=['resnet_50','resnet_152'],
- augmenter_types=['tensorpack','deterministic'],
- )
+ shuffle_list = deeplabcut.create_training_model_comparison(
+ 'C:\\Users\\Ulf\\looming-task\\config.yaml',
+ num_shuffles=1,
+ net_types=['resnet_50','resnet_152'],
+ augmenter_types=['tensorpack','deterministic'],
+ )
- See ``examples/testscript_openfielddata_augmentationcomparison.py`` for an example
- of how to use ``shuffle_list``.
+ See ``examples/testscript_openfielddata_augmentationcomparison.py`` for an
+ example of how to use ``shuffle_list``.
"""
# read cfg file
- cfg = auxiliaryfunctions.read_config(config)
+ if augmenter_types is None:
+ augmenter_types = ["imgaug"]
+ if net_types is None:
+ net_types = ["resnet_50"]
+ cfg = ProjectConfig.from_any(config, repair_path=True)
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
- log_file_name = os.path.join(cfg["project_path"], "training_model_comparison.log")
+ log_file_name = str(Path(cfg["project_path"]) / "training_model_comparison.log")
logger = logging.getLogger("training_model_comparison")
if not logger.handlers:
logger = logging.getLogger("training_model_comparison")
@@ -1220,13 +1522,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(cfg, trainindex=trainindex, uniform=True)
for idx_net, net in enumerate(net_types):
for idx_aug, aug in enumerate(augmenter_types):
get_max_shuffle_idx = (
@@ -1250,7 +1554,7 @@ def create_training_model_comparison(
+ str(shuffle)
)
create_training_dataset(
- config,
+ cfg,
Shuffles=[get_max_shuffle_idx],
net_type=net,
trainIndices=[trainIndices],
@@ -1261,3 +1565,196 @@ def create_training_model_comparison(
logger.info(log_info)
return shuffle_list
+
+
+def create_training_dataset_from_existing_split(
+ config: str | Path | ProjectConfig | dict,
+ 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 (str | Path | ProjectConfig | dict):
+ Full path of the ``config.yaml`` file. Alternatively, a ProjectConfig object or a dictionary can be passed.
+
+ 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. Valid for both evaluation and live
+ analyze.
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file. Evaluation-only â
+ not valid for ``analyze_images`` / ``analyze_videos``.
+ * 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 = ProjectConfig.from_any(config, repair_path=True)
+ 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=cfg,
+ 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/__init__.py b/deeplabcut/gui/__init__.py
index af6907e9fc..e4a425ff56 100644
--- a/deeplabcut/gui/__init__.py
+++ b/deeplabcut/gui/__init__.py
@@ -1,17 +1,18 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-"""
import os
-from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path
+from pathlib import Path
+os.environ["QT_API"] = "pyside6"
+import qtpy # Necessary unused import to properly store the env variable
-DLC_PATH = get_deeplabcut_path()
-MEDIA_PATH = os.path.join(DLC_PATH, "gui", "media")
-LOGO_PATH = os.path.join(MEDIA_PATH, "logo.png")
+BASE_DIR = Path(__file__).parent
diff --git a/deeplabcut/gui/analyze_videos.py b/deeplabcut/gui/analyze_videos.py
deleted file mode 100644
index 3a028a4586..0000000000
--- a/deeplabcut/gui/analyze_videos.py
+++ /dev/null
@@ -1,610 +0,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 os
-import platform
-import pydoc
-import subprocess
-import sys
-import webbrowser
-
-import deeplabcut
-import wx
-from deeplabcut.utils import auxiliaryfunctions
-from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-from deeplabcut.gui import LOGO_PATH
-
-
-class Analyze_videos(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
- # variable initialization
- self.filelist = []
- self.picklelist = []
- self.bodyparts = []
- self.config = cfg
- self.cfg = auxiliaryfunctions.read_config(self.config)
- self.draw = False
- # design the panel
- self.sizer = wx.GridBagSizer(5, 10)
-
-
- text = wx.StaticText(self, label="DeepLabCut - Step 7. Analyze Videos")
-
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=10)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 10), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 8), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.vids = wx.StaticText(self, label="Choose the videos")
- self.sizer.Add(self.vids, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Select videos to analyze")
- self.sizer.Add(self.sel_vids, pos=(3, 1), flag=wx.TOP | wx.EXPAND, border=5)
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
-
- sb = wx.StaticBox(self, label="Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- self.hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox3 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox4 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox5 = wx.BoxSizer(wx.HORIZONTAL)
-
-#### BOX 1 ####
-
- shuffle_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffle_boxsizer = wx.StaticBoxSizer(shuffle_text, wx.VERTICAL)
- self.shuffle = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffle_boxsizer.Add(self.shuffle, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- videotype_text = wx.StaticBox(self, label="Specify the videotype")
- videotype_text_boxsizer = wx.StaticBoxSizer(videotype_text, wx.VERTICAL)
-
- self.videotype = wx.ComboBox(self, choices=("",) + SUPPORTED_VIDEOS, style=wx.CB_READONLY)
- self.videotype.SetValue("")
- videotype_text_boxsizer.Add(
- self.videotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1
- )
-
-
- boxsizer.Add(self.hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox1.Add(shuffle_boxsizer, 0, 0)
- self.hbox1.Add(videotype_text_boxsizer, 0, 0)
-
- if self.cfg.get("multianimalproject", False):
- ntracks_text = wx.StaticBox(self, label="Number of animals")
- ntracks_boxsizer = wx.StaticBoxSizer(ntracks_text, wx.VERTICAL)
- self.ntracks = wx.SpinCtrl(self, value=str(len(self.cfg["individuals"])), min=1, max=1000)
- ntracks_boxsizer.Add(self.ntracks, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
- self.hbox1.Add(ntracks_boxsizer, 1, 1)
-
-
-
-#### BOX 2 ####
-
- if self.cfg.get("multianimalproject", False):
-
- self.robust = wx.RadioBox(
- self,
- label="Use ffprobe to read video metadata (slow but robust)",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.robust.SetSelection(1)
-
-
- self.create_video_with_all_detections = wx.RadioBox(
- self,
- label="Create video for checking detections",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,)
- self.create_video_with_all_detections.SetSelection(0)
-
-
- boxsizer.Add(self.hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox2.Add(self.robust, 0, wx.EXPAND | wx.TOP | wx.BOTTOM,0)
- self.hbox2.Add(self.create_video_with_all_detections,0, wx.EXPAND | wx.TOP | wx.BOTTOM,0)
-
- self.calibrate = wx.RadioBox(
- self,
- label="Calibrate animal assembly?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,)
- self.calibrate.SetSelection(1)
-
-
- self.identity_toggle = wx.RadioBox(
- self,
- label="Assemble using animal identity?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,)
- self.identity_toggle.SetSelection(1)
-
- self.hbox2.Add(self.calibrate, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox2.Add(self.identity_toggle, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
-
-
-#### BOX 3 ####
-
- self.csv = wx.RadioBox(
- self,
- label="Want to save result(s) as csv?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.csv.SetSelection(1)
-
- self.nwb = wx.RadioBox(
- self,
- label="Want to save result(s) as nwb?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.nwb.SetSelection(1)
-
- self.filter = wx.RadioBox(
- self,
- label="Want to filter the predictions? (+ csv file)",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.filter.SetSelection(1)
-
- self.trajectory = wx.RadioBox(
- self,
- label="Want to plot the trajectories?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
-
- self.showfigs = wx.RadioBox(
- self,
- label="Want plots to pop up?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.trajectory.Bind(wx.EVT_RADIOBOX, self.chooseOption)
- self.trajectory.SetSelection(1)
-
- self.hbox3.Add(self.csv, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.nwb, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.filter, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.showfigs, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.trajectory, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
-
- else:
- self.dynamic = wx.RadioBox(
- self,
- label="Want to dynamically crop bodyparts?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.dynamic.SetSelection(1)
- self.hbox3.Add(self.dynamic, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
-
- self.csv = wx.RadioBox(
- self,
- label="Want to save result(s) as csv?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.csv.SetSelection(1)
-
- self.nwb = wx.RadioBox(
- self,
- label="Want to save result(s) as nwb?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.nwb.SetSelection(1)
-
- self.filter = wx.RadioBox(
- self,
- label="Want to filter the predictions?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.filter.SetSelection(1)
-
- self.trajectory = wx.RadioBox(
- self,
- label="Want to plot the trajectories?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
-
- self.showfigs = wx.RadioBox(
- self,
- label="Want plots to pop up?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.trajectory.Bind(wx.EVT_RADIOBOX, self.chooseOption)
- self.trajectory.SetSelection(0)
-
- self.hbox1.Add(self.csv, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox1.Add(self.nwb, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox1.Add(self.filter, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.showfigs, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
- self.hbox3.Add(self.trajectory, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
-
-
-
-
-
-
- boxsizer.Add(self.hbox3, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 0)
-
-
-
-
- config_file = auxiliaryfunctions.read_config(self.config)
- if config_file.get("multianimalproject", False):
- bodyparts = config_file["multianimalbodyparts"]
- else:
- bodyparts = config_file["bodyparts"]
- self.trajectory_to_plot = wx.CheckListBox(
- self, choices=bodyparts, style=0, name="Select the bodyparts"
- )
- self.trajectory_to_plot.Bind(wx.EVT_CHECKLISTBOX, self.getbp)
- self.trajectory_to_plot.SetCheckedItems(range(len(bodyparts)))
- self.trajectory_to_plot.Hide()
-
- self.draw_skeleton = wx.RadioBox(
- self,
- label="Include the skeleton in the video?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.draw_skeleton.Bind(wx.EVT_RADIOBOX, self.choose_draw_skeleton_options)
- self.draw_skeleton.SetSelection(1)
- self.draw_skeleton.Hide()
-
- self.trail_points_text = wx.StaticBox(
- self, label="Specify the number of trail points"
- )
- trail_pointsboxsizer = wx.StaticBoxSizer(self.trail_points_text, wx.VERTICAL)
- self.trail_points = wx.SpinCtrl(self, value="1")
- trail_pointsboxsizer.Add(
- self.trail_points, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
- self.trail_points_text.Hide()
- self.trail_points.Hide()
-
- self.hbox3.Add(self.trajectory_to_plot, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
- boxsizer.Add(self.hbox3, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.hbox4.Add(self.draw_skeleton, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox4.Add(trail_pointsboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- boxsizer.Add(self.hbox4, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- self.sizer.Add(
- boxsizer,
- pos=(5, 0),
- span=(1, 10),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(7, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Analyze Videos")
- self.sizer.Add(self.ok, pos=(7, 4), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.ok.Bind(wx.EVT_BUTTON, self.analyze_videos)
-
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.reset, pos=(7, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_analyze_videos)
-
- self.edit_config_file = wx.Button(self, label="Edit config.yaml")
- self.sizer.Add(self.edit_config_file, pos=(8, 4))
- self.edit_config_file.Bind(wx.EVT_BUTTON, self.edit_config)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def edit_config(self, event):
- """
- """
- if platform.system() == "Darwin":
- self.file_open_bool = subprocess.call(["open", self.config])
- self.file_open_bool = True
- else:
- self.file_open_bool = webbrowser.open(self.config)
- if self.file_open_bool:
- self.pose_cfg = auxiliaryfunctions.read_config(self.config)
- else:
- raise FileNotFoundError("File not found!")
-
- def edit_inf_config(self, event):
- # Read the infer config file
- cfg = auxiliaryfunctions.read_config(self.config)
- #trainFraction = cfg["TrainingFraction"][trainingsetindex]
- self.inf_cfg_path = os.path.join(
- cfg["project_path"],
- auxiliaryfunctions.get_model_folder(
- trainFraction, self.shuffle.GetValue(), cfg
- ),
- "test",
- "inference_cfg.yaml",
- )
- # let the user open the file with default text editor. Also make it mac compatible
- if sys.platform == "darwin":
- self.file_open_bool = subprocess.call(["open", self.inf_cfg_path])
- self.file_open_bool = True
- else:
- self.file_open_bool = webbrowser.open(self.inf_cfg_path)
- if self.file_open_bool:
- self.inf_cfg = auxiliaryfunctions.read_config(self.inf_cfg_path)
- else:
- raise FileNotFoundError("File not found!")
-
- def activate_change_wd(self, event):
- """
- Activates the option to change the working directory
- """
- self.change_wd = event.GetEventObject()
- if self.change_wd.GetValue():
- self.sel_wd.Enable(True)
- else:
- self.sel_wd.Enable(False)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.analyze_videos"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select videos to analyze", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids
- self.sel_vids.SetLabel("Total %s Videos selected" % len(self.filelist))
-
- def choose_draw_skeleton_options(self, event):
- if self.draw_skeleton.GetStringSelection() == "Yes":
- self.draw = True
- else:
- self.draw = False
-
- def analyze_videos(self, event):
-
- shuffle = self.shuffle.GetValue()
- videotype = self.videotype.GetValue(),
-
- if self.cfg.get("multianimalproject", False):
- print("DLC network loading and video analysis starting ... ")
- auto_track=True
- else:
- if self.csv.GetStringSelection() == "Yes":
- save_as_csv = True
- else:
- save_as_csv = False
- if self.dynamic.GetStringSelection() == "No":
- dynamic = (False, 0.5, 10)
- else:
- dynamic = (True, 0.5, 10)
- if self.filter.GetStringSelection() == "No":
- _filter = False
- else:
- _filter = True
-
- if self.cfg["cropping"] == "True":
- crop = self.cfg["x1"], self.cfg["x2"], self.cfg["y1"], self.cfg["y2"]
- else:
- crop = None
-
- if self.cfg.get("multianimalproject", False):
- if self.robust.GetStringSelection() == "No":
- robust = False
- else:
- robust = True
- scorername = deeplabcut.analyze_videos(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- gputouse=None,
- cropping=crop,
- robust_nframes=robust,
- auto_track=True,
- n_tracks=self.ntracks.GetValue(),
- calibrate=self.calibrate.GetStringSelection() == "Yes",
- identity_only=self.identity_toggle.GetStringSelection() == "Yes",
- )
-
- if self.create_video_with_all_detections.GetStringSelection() == "Yes":
- deeplabcut.create_video_with_all_detections(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- )
- if self.filter.GetStringSelection() == "Yes":
- deeplabcut.filterpredictions(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- )
-
- if self.csv.GetStringSelection() == "Yes":
- deeplabcut.analyze_videos_converth5_to_csv(self.filelist,listofvideos=True)
-
- if self.nwb.GetStringSelection() == "Yes":
- deeplabcut.analyze_videos_converth5_to_nwb(
- self.config,
- self.filelist,
- listofvideos=True,
- )
-
- if self.trajectory.GetStringSelection() == "Yes":
- if self.showfigs.GetStringSelection() == "No":
- showfig = False
- else:
- showfig = True
- deeplabcut.plot_trajectories(self.config,self.filelist,self.videotype.GetValue(),showfigures=showfig)
- else:
- scorername = deeplabcut.analyze_videos(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- gputouse=None,
- save_as_csv=save_as_csv,
- cropping=crop,
- dynamic=dynamic,
- )
- if _filter:
- deeplabcut.filterpredictions(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- filtertype="median",
- windowlength=5,
- save_as_csv=save_as_csv,
- )
-
- if self.trajectory.GetStringSelection() == "Yes":
- if self.showfigs.GetStringSelection() == "No":
- showfig = False
- else:
- showfig = True
- deeplabcut.plot_trajectories(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- displayedbodyparts=self.bodyparts,
- shuffle=shuffle,
- filtered=_filter,
- showfigures=showfig,
- )
-
- def reset_analyze_videos(self, event):
- """
- Reset to default
- """
- if self.cfg.get("multianimalproject", False):
- self.create_video_with_all_detections.SetSelection(1)
- else:
- self.csv.SetSelection(1)
- self.nwb.SetSelection(1)
- self.filter.SetSelection(1)
- self.trajectory.SetSelection(0)
- self.dynamic.SetSelection(1)
- # self.select_destfolder.SetPath("None")
- #self.config = []
- #self.sel_config.SetPath("")
- self.sel_vids.SetLabel("Select videos to analyze")
- self.filelist = []
- self.shuffle.SetValue(1)
- if self.draw_skeleton.IsShown():
- self.draw_skeleton.SetSelection(1)
- self.draw_skeleton.Hide()
- self.trail_points_text.Hide()
- self.trail_points.Hide()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def chooseOption(self, event):
- if self.trajectory.GetStringSelection() == "Yes":
- self.trajectory_to_plot.Show()
- self.getbp(event)
- if self.trajectory.GetStringSelection() == "No":
- self.trajectory_to_plot.Hide()
- self.bodyparts = []
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def getbp(self, event):
- self.bodyparts = list(self.trajectory_to_plot.GetCheckedStrings())
diff --git a/deeplabcut/gui/assets/icons/help.png b/deeplabcut/gui/assets/icons/help.png
new file mode 100644
index 0000000000..221ea6629a
Binary files /dev/null and b/deeplabcut/gui/assets/icons/help.png differ
diff --git a/deeplabcut/gui/assets/icons/help2.png b/deeplabcut/gui/assets/icons/help2.png
new file mode 100644
index 0000000000..43b7099ee9
Binary files /dev/null and b/deeplabcut/gui/assets/icons/help2.png differ
diff --git a/deeplabcut/gui/assets/icons/new_project.png b/deeplabcut/gui/assets/icons/new_project.png
new file mode 100644
index 0000000000..447f015de7
Binary files /dev/null and b/deeplabcut/gui/assets/icons/new_project.png differ
diff --git a/deeplabcut/gui/assets/icons/new_project2.png b/deeplabcut/gui/assets/icons/new_project2.png
new file mode 100644
index 0000000000..7e82b3dad5
Binary files /dev/null and b/deeplabcut/gui/assets/icons/new_project2.png differ
diff --git a/deeplabcut/gui/assets/icons/open.png b/deeplabcut/gui/assets/icons/open.png
new file mode 100644
index 0000000000..91e62d425e
Binary files /dev/null and b/deeplabcut/gui/assets/icons/open.png differ
diff --git a/deeplabcut/gui/assets/icons/open2.png b/deeplabcut/gui/assets/icons/open2.png
new file mode 100644
index 0000000000..2b61c16023
Binary files /dev/null and b/deeplabcut/gui/assets/icons/open2.png differ
diff --git a/deeplabcut/gui/assets/logo.png b/deeplabcut/gui/assets/logo.png
new file mode 100644
index 0000000000..ec77b4a720
Binary files /dev/null and b/deeplabcut/gui/assets/logo.png differ
diff --git a/deeplabcut/gui/assets/logo_transparent.png b/deeplabcut/gui/assets/logo_transparent.png
new file mode 100644
index 0000000000..45dd2a38ae
Binary files /dev/null and b/deeplabcut/gui/assets/logo_transparent.png differ
diff --git a/deeplabcut/gui/assets/style.qss b/deeplabcut/gui/assets/style.qss
new file mode 100644
index 0000000000..feaf66e3d4
--- /dev/null
+++ b/deeplabcut/gui/assets/style.qss
@@ -0,0 +1,31 @@
+ /*
+ Variables used
+ --------------
+
+ widgets height: 25px
+
+ */
+
+QPushButton{
+ height: 25px;
+ min-width: 100px;
+}
+
+QSpinBox{
+ height: 25px;
+ width: 100px
+}
+
+QDoubleSpinBox{
+ height: 25px;
+ width: 100px
+}
+
+QComboBox{
+ height: 25px;
+ min-width: 100px;
+}
+
+QLineEdit{
+ height: 25px;
+}
diff --git a/deeplabcut/gui/assets/welcome.png b/deeplabcut/gui/assets/welcome.png
new file mode 100644
index 0000000000..9afebe0f41
Binary files /dev/null and b/deeplabcut/gui/assets/welcome.png differ
diff --git a/deeplabcut/gui/auxfun_drag.py b/deeplabcut/gui/auxfun_drag.py
deleted file mode 100644
index cd68efedbb..0000000000
--- a/deeplabcut/gui/auxfun_drag.py
+++ /dev/null
@@ -1,173 +0,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
-"""
-
-"""
-Class to drag,annotate and remove the data points. Use left click to drag the data points
-around. Use middle click to remove any unwanted data points. Internally the corresponding data
-point is set to nan. When the user hovers the mouse over any data point, each data point is annotated with the labels and its corresponding likelihood. These annotations also move with the drag!
-"""
-
-
-import numpy as np
-import wx
-
-
-class DraggablePoint:
- lock = None # only one can be animated at a time
-
- def __init__(self, point, bodyParts, individual_names=None, likelihood=None):
- self.point = point
- self.bodyParts = bodyParts
- self.individual_names = individual_names
- self.likelihood = likelihood
- self.press = None
- self.background = None
- self.final_point = (0.0, 0.0)
- self.annot = self.point.axes.annotate(
- "",
- xy=(0, 0),
- xytext=(20, 20),
- textcoords="offset points",
- bbox=dict(boxstyle="round", fc="w"),
- arrowprops=dict(arrowstyle="->"),
- )
- self.annot.set_visible(False)
- self.coords = []
-
- 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
- )
-
- def on_press(self, event):
- """
- Define the event for the button press!
- """
- if event.inaxes != self.point.axes:
- return
- if DraggablePoint.lock is not None:
- return
- contains, attrd = self.point.contains(event)
- if not contains:
- return
- if event.button == 1:
- """
- 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
- axes = self.point.axes
- self.point.set_animated(True)
- canvas.draw()
- self.background = canvas.copy_from_bbox(self.point.axes.bbox)
- 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.
- """
- message = f"Do you want to remove the label {self.bodyParts}?"
- if self.likelihood is not None:
- message += " You cannot undo this step!"
- msg = wx.MessageBox(message, "Remove!", wx.YES_NO | wx.ICON_WARNING)
- if msg == 2:
- self.delete_data()
-
- def delete_data(self):
- self.press = None
- DraggablePoint.lock = None
- self.point.set_animated(False)
- self.background = None
- self.final_point = (np.nan, np.nan, self.individual_names, self.bodyParts)
- self.point.center = (np.nan, np.nan)
- self.coords.append(self.final_point)
- self.point.figure.canvas.draw()
-
- def on_motion(self, event):
- """
- During the drag!
- """
- if DraggablePoint.lock is not self:
- return
- if event.inaxes != self.point.axes:
- return
-
- if event.button == 1:
- self.point.center, xpress, ypress = self.press
- dx = event.xdata - xpress
- dy = event.ydata - ypress
- self.point.center = (self.point.center[0] + dx, self.point.center[1] + dy)
- canvas = self.point.figure.canvas
- axes = self.point.axes
- # restore the background region
- canvas.restore_region(self.background)
- axes.draw_artist(self.point)
- canvas.blit(axes.bbox)
-
- def on_release(self, event):
- "on release we reset the press data"
- if DraggablePoint.lock is not self:
- return
- if event.button == 1:
- self.press = None
- DraggablePoint.lock = None
- self.point.set_animated(False)
- self.background = None
- self.point.figure.canvas.draw()
- self.final_point = (
- self.point.center[0],
- self.point.center[1],
- self.individual_names,
- self.bodyParts,
- )
- self.coords.append(self.final_point)
-
- def on_hover(self, event):
- """
- Annotate the labels and likelihood when the user hovers over the data points.
- """
- vis = self.annot.get_visible()
-
- if event.inaxes == self.point.axes:
- contains, attrd = self.point.contains(event)
- if contains:
- self.annot.xy = (self.point.center[0], self.point.center[1])
- text = str(self.bodyParts)
- if self.individual_names is not None:
- text = f"{self.individual_names},{text}"
- if self.likelihood is not None:
- text += f",p={self.likelihood:.2f}"
- self.annot.set_text(text)
- self.annot.get_bbox_patch().set_alpha(0.4)
- self.annot.set_visible(True)
- self.point.figure.canvas.draw_idle()
- else:
- if vis:
- self.annot.set_visible(False)
-
- def disconnect(self):
- "disconnect all the stored connection ids"
- self.point.figure.canvas.mpl_disconnect(self.cidpress)
- self.point.figure.canvas.mpl_disconnect(self.cidrelease)
- self.point.figure.canvas.mpl_disconnect(self.cidmotion)
- self.point.figure.canvas.mpl_disconnect(self.cidhover)
diff --git a/deeplabcut/gui/components.py b/deeplabcut/gui/components.py
new file mode 100644
index 0000000000..06d1fedc58
--- /dev/null
+++ b/deeplabcut/gui/components.py
@@ -0,0 +1,794 @@
+#
+# 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
+
+from pathlib import Path
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt, Slot
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.gui_assets import icon_from_resource
+from deeplabcut.gui.widgets import ConfigEditor
+
+PathInput = str | Path
+Margins = tuple[int, int, int, int]
+
+
+def _create_label_widget(
+ text: str,
+ style: str = "",
+ margins: Margins = (20, 10, 0, 10),
+) -> QtWidgets.QLabel:
+ label = QtWidgets.QLabel(text)
+ label.setContentsMargins(*margins)
+ label.setStyleSheet(style)
+ return label
+
+
+def _create_horizontal_layout(
+ alignment: Qt.AlignmentFlag | None = None,
+ spacing: int = 20,
+ margins: Margins = (20, 0, 0, 0),
+) -> QtWidgets.QHBoxLayout:
+ layout = QtWidgets.QHBoxLayout()
+ layout.setAlignment(alignment if alignment is not None else Qt.AlignLeft | Qt.AlignTop)
+ layout.setSpacing(spacing)
+ layout.setContentsMargins(*margins)
+ return layout
+
+
+def _create_vertical_layout(
+ alignment: Qt.AlignmentFlag | None = None,
+ spacing: int = 20,
+ margins: Margins = (20, 0, 0, 0),
+) -> QtWidgets.QVBoxLayout:
+ layout = QtWidgets.QVBoxLayout()
+ layout.setAlignment(alignment if alignment is not None else Qt.AlignLeft | Qt.AlignTop)
+ layout.setSpacing(spacing)
+ layout.setContentsMargins(*margins)
+ return layout
+
+
+def _create_grid_layout(
+ alignment: Qt.AlignmentFlag | None = None,
+ spacing: int = 20,
+ margins: Margins | None = None,
+) -> QtWidgets.QGridLayout:
+ layout = QtWidgets.QGridLayout()
+ layout.setAlignment(alignment if alignment is not None else Qt.AlignLeft | Qt.AlignTop)
+ layout.setSpacing(spacing)
+
+ if margins is not None:
+ layout.setContentsMargins(*margins)
+
+ return layout
+
+
+def _dialog_directory(directory: PathInput | None) -> str:
+ """Convert an optional path to a QFileDialog-compatible string."""
+ if directory is None or directory == "":
+ return ""
+ return str(directory)
+
+
+def _get_open_file_name(
+ parent: QtWidgets.QWidget,
+ caption: str,
+ directory: PathInput | None,
+ file_filter: str,
+) -> tuple[str, str]:
+ """Open a binding-compatible single-file dialog."""
+ return QtWidgets.QFileDialog.getOpenFileName(
+ parent,
+ caption,
+ _dialog_directory(directory),
+ file_filter,
+ )
+
+
+def _get_open_file_names(
+ parent: QtWidgets.QWidget,
+ caption: str,
+ directory: PathInput | None,
+ file_filter: str,
+) -> tuple[list[str], str]:
+ """Open a binding-compatible multiple-file dialog."""
+ return QtWidgets.QFileDialog.getOpenFileNames(
+ parent,
+ caption,
+ _dialog_directory(directory),
+ file_filter,
+ )
+
+
+def set_combo_items(combo_box: QtWidgets.QComboBox, items: list[str], index: int = 0) -> None:
+ """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.
+ """
+
+ previous = combo_box.blockSignals(True)
+ try:
+ combo_box.clear()
+ combo_box.addItems(items)
+
+ if not items:
+ combo_box.setCurrentIndex(-1)
+ return
+
+ if combo_box.currentIndex() == index:
+ combo_box.setCurrentIndex(-1)
+ finally:
+ combo_box.blockSignals(previous)
+
+ combo_box.setCurrentIndex(index)
+
+
+class BodypartListWidget(QtWidgets.QListWidget):
+ def __init__(
+ self,
+ root: QtWidgets.QMainWindow,
+ parent: QtWidgets.QWidget,
+ # all_bodyparts: List
+ # NOTE: Is there a case where a specific list should
+ # have bodyparts other than the root? I don't think so.
+ ):
+ super().__init__()
+
+ self.root = root
+ self.parent = parent
+ self.selected_bodyparts = self.root.all_bodyparts
+
+ self.setEnabled(False)
+ self.setMaximumWidth(600)
+ self.setMaximumHeight(500)
+ self.hide()
+
+ self.addItems(self.root.all_bodyparts)
+ self.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
+
+ 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,
+ *,
+ hide_videotype: bool = False,
+ sync_videotype_with_selection: bool = False,
+ strict_videotype_filter: bool = False,
+ ):
+ super().__init__(parent)
+
+ self.root = root
+ self.parent = parent
+
+ # Optional safeties; defaults preserve current behavior
+ self.sync_videotype_with_selection = sync_videotype_with_selection
+ self.strict_videotype_filter = strict_videotype_filter
+
+ 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._normalize_videotype(self.root.video_type))
+ self.root.video_type_.connect(self._sync_videotype_from_root)
+ 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.update_videos)
+ self.root.video_files_.connect(self._update_video_selection)
+
+ # Number of selected videos text
+ self.selected_videos_text = QtWidgets.QLabel("")
+ self.selected_videos_text.setWordWrap(True)
+ self.selected_videos_text.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ QtWidgets.QSizePolicy.Policy.Preferred,
+ )
+
+ # Clear video selection
+ self.clear_videos = QtWidgets.QPushButton("Clear selection")
+ self.clear_videos.clicked.connect(self.clear_selected_videos)
+
+ 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)
+
+ self.setLayout(layout)
+
+ @property
+ def files(self):
+ return self.root.video_files
+
+ 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."""
+ return {Path(video).suffix.lower().lstrip(".") for video in self.files if Path(video).suffix}
+
+ @Slot(str)
+ def _sync_videotype_from_root(self, vtype: str) -> None:
+ normalized = self._normalize_videotype(vtype)
+
+ if normalized == self._normalize_videotype(self.videotype_widget.currentText()):
+ return
+
+ previous = self.videotype_widget.blockSignals(True)
+ try:
+ self.videotype_widget.setCurrentText(normalized)
+ finally:
+ self.videotype_widget.blockSignals(previous)
+
+ 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[Path]]:
+ """Return selected files grouped by suffix."""
+ groups: dict[str, list[Path]] = {}
+
+ for video in self.files:
+ path = Path(video)
+ suffix = path.suffix.lower()
+
+ if not keep_dot:
+ suffix = suffix.lstrip(".")
+
+ groups.setdefault(suffix, []).append(path)
+
+ 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) -> None:
+ 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
+
+ if self.videotype_widget.findText(normalized) == -1:
+ self.root.logger.warning(
+ f"Attempted to set unsupported videotype "
+ f"{normalized!r} silently; keeping current videotype "
+ f"{current!r}."
+ )
+ return
+
+ previous = self.videotype_widget.blockSignals(True)
+ try:
+ self.videotype_widget.setCurrentText(normalized)
+ finally:
+ self.videotype_widget.blockSignals(previous)
+
+ if self._normalize_videotype(self.root.video_type) != normalized:
+ self.root.video_type = normalized
+
+ @Slot(str)
+ def update_videotype(self, vtype: str) -> None:
+ normalized = self._normalize_videotype(vtype)
+ current = self._normalize_videotype(self.root.video_type)
+
+ if normalized == current:
+ return
+
+ self.clear_selected_videos()
+ self.root.video_type = normalized
+
+ def _update_video_selection(self, _videopaths) -> None:
+ n_videos = len(self.root.video_files)
+
+ if not n_videos:
+ self.selected_videos_text.setText("")
+ self.select_video_button.setText("Select videos")
+ return
+
+ suffixes = self.selected_suffixes
+
+ if len(suffixes) == 1:
+ suffix = next(iter(suffixes))
+ text = f"{n_videos} videos selected (.{suffix})"
+ elif len(suffixes) > 1:
+ counts = {suffix: len(files) for suffix, files in self.get_files_grouped_by_suffix().items()}
+ summary = ", ".join(f"{count} .{suffix}" for suffix, count in sorted(counts.items()))
+ text = f"{n_videos} videos selected ({summary}; will run in separate batches)"
+ else:
+ text = f"{n_videos} videos selected"
+
+ self.selected_videos_text.setText(text)
+ self.select_video_button.setText("Add more videos")
+
+ def update_videos(self):
+ video_filter = self._build_video_filter()
+
+ filenames, _ = _get_open_file_names(
+ self,
+ "Select video(s) to analyze",
+ self.root.project_folder,
+ video_filter,
+ )
+
+ if not filenames:
+ return
+
+ abs_files = [Path(filename).absolute() for filename in filenames]
+ self.root.add_video_files(abs_files)
+
+ if not self.sync_videotype_with_selection:
+ return
+
+ suffixes = {video.suffix.lower().lstrip(".") for video in abs_files if video.suffix}
+
+ if len(suffixes) == 1:
+ inferred = next(iter(suffixes))
+ self._set_videotype_silently(inferred)
+ self.root.logger.info(f"Inferred videotype {inferred!r} 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.clear_video_files()
+ self.root.logger.debug("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: Path | None = 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(self.selected_snapshot.name)
+ self.clear_snapshot_button.show()
+
+ def select_snapshot(self):
+ snapshot_types = ["*.pt", "*.PT"]
+ snapshot_filter = f"Snapshots ({' '.join(snapshot_types)})"
+
+ selected_snapshot, _ = _get_open_file_name(
+ self,
+ "Select snapshot to start training from",
+ self.root.models_folder,
+ snapshot_filter,
+ )
+
+ if selected_snapshot:
+ self.selected_snapshot = Path(selected_snapshot).absolute()
+
+ 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: Path | None = 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: Path | str, max_length: int = 30) -> str:
+ path_str = str(path)
+ if len(path_str) <= max_length:
+ return path_str
+ return "..." + path_str[-(max_length - 3) :]
+
+ self.selected_conditions_text.setText(
+ "" if self.selected_conditions is None else _shorten_path(self.selected_conditions)
+ )
+
+ def select_conditions(self):
+ def _is_model_bu(
+ selected_conditions: PathInput,
+ ) -> bool:
+ model_config_path = Path(selected_conditions).parent / "pytorch_config.yaml"
+ model_config = read_config_as_dict(model_config_path)
+ method = model_config.get("method")
+
+ return isinstance(method, str) and 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)})",
+ ]
+ )
+
+ selected_conditions, selected_filter = _get_open_file_name(
+ self,
+ ("Select conditions to use during inference (snapshot or predictions file)"),
+ self.root.project_folder,
+ conditions_filter,
+ )
+
+ if (
+ selected_conditions
+ and selected_filter.startswith(snapshots_label)
+ and not _is_model_bu(selected_conditions)
+ ):
+ msg = _create_message_box(
+ "Invalid conditions",
+ (
+ f"The selected snapshot "
+ f"({selected_conditions}) cannot be used "
+ "as conditions because it is not a "
+ "Bottom-Up model."
+ ),
+ )
+ msg.exec()
+ selected_conditions = None
+
+ self.selected_conditions = Path(selected_conditions).absolute() if selected_conditions else None
+
+ self._update_selected_conditions_display()
+
+
+class TrainingSetSpinBox(QtWidgets.QSpinBox):
+ def __init__(self, root, parent):
+ super().__init__(parent)
+
+ self.root = root
+ self.parent = parent
+
+ self.setMaximum(100)
+ self.setValue(self.root.trainingset_index)
+ self.valueChanged.connect(self.root.update_trainingset)
+
+
+class ShuffleSpinBox(QtWidgets.QSpinBox):
+ def __init__(self, root, parent):
+ super().__init__(parent)
+
+ self.root = root
+ self.parent = parent
+
+ 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) -> None:
+ if new_shuffle == self.value():
+ return
+
+ previous = self.blockSignals(True)
+ try:
+ self.setValue(new_shuffle)
+ finally:
+ self.blockSignals(previous)
+
+
+class DefaultTab(QtWidgets.QWidget):
+ def __init__(
+ self,
+ root: QtWidgets.QMainWindow,
+ parent: QtWidgets.QWidget | None = None,
+ h1_description: str = "",
+ ):
+ super().__init__(parent)
+
+ self.parent = parent
+ self.root = root
+ self.h1_description = h1_description
+
+ outer_layout = QtWidgets.QVBoxLayout(self)
+ outer_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.scroll_area = QtWidgets.QScrollArea(self)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
+
+ self.content_widget = QtWidgets.QWidget()
+ self.content_widget.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Expanding,
+ QtWidgets.QSizePolicy.Policy.Preferred,
+ )
+
+ self.main_layout = QtWidgets.QVBoxLayout(self.content_widget)
+ self.main_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+
+ self.scroll_area.setWidget(self.content_widget)
+ outer_layout.addWidget(self.scroll_area)
+
+ self._init_default_layout()
+
+ def _init_default_layout(self):
+ # Add tab header
+ self.main_layout.addWidget(_create_label_widget(self.h1_description, "font:bold;", (10, 10, 0, 10)))
+
+ # Add separating line
+ self.separator = QtWidgets.QFrame()
+ self.separator.setFrameShape(QtWidgets.QFrame.HLine)
+ self.separator.setFrameShadow(QtWidgets.QFrame.Raised)
+ self.separator.setLineWidth(0)
+ self.separator.setMidLineWidth(1)
+ policy = QtWidgets.QSizePolicy()
+ policy.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
+ policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding)
+ self.separator.setSizePolicy(policy)
+ self.main_layout.addWidget(self.separator)
+
+
+class EditYamlButton(QtWidgets.QPushButton):
+ def __init__(self, button_label: str, filepath: str, parent: QtWidgets.QWidget = None):
+ super().__init__(button_label, parent)
+ self.filepath = filepath
+ self.parent = parent
+ self._editor: ConfigEditor | None = None
+
+ self.clicked.connect(self.open_config)
+
+ def open_config(self):
+ self._editor = ConfigEditor(self.filepath)
+ self._editor.show()
+
+
+class BrowseFilesButton(QtWidgets.QPushButton):
+ def __init__(
+ self,
+ button_label: str,
+ filetype: str = None,
+ cwd: PathInput | None = None,
+ single_file: bool = False,
+ dialog_text: str = None,
+ file_text: str = None,
+ parent=None,
+ ):
+ super().__init__(button_label, parent)
+ self.filetype = filetype
+ self.single_file_only = single_file
+ self.cwd = cwd
+ self.parent = parent
+
+ self.dialog_text = dialog_text
+ self.file_text = file_text
+
+ self.files: set[Path] = set()
+
+ self.clicked.connect(self.browse_files)
+
+ def browse_files(self) -> None:
+ file_ext = "*"
+ if self.filetype:
+ file_ext = self.filetype.rsplit(".", 1)[-1]
+
+ dialog_text = self.dialog_text or f"Select .{file_ext} files"
+ file_text = self.file_text or f"Files (*.{file_ext})"
+
+ if self.single_file_only:
+ filepath, _ = _get_open_file_name(
+ self,
+ dialog_text,
+ self.cwd,
+ file_text,
+ )
+ if filepath:
+ self.files.add(Path(filepath).absolute())
+ return
+
+ filepaths, _ = _get_open_file_names(
+ self,
+ dialog_text,
+ self.cwd,
+ file_text,
+ )
+ self.files.update(Path(filepath).absolute() for filepath in filepaths)
+
+
+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)
+ icon = icon_from_resource("logo.png")
+ msg.setWindowIcon(icon)
+ 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)
+ icon = icon_from_resource("logo.png")
+ msg.setWindowIcon(icon)
+ 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/config_file_monitor.py b/deeplabcut/gui/config_file_monitor.py
new file mode 100644
index 0000000000..7bd2789f99
--- /dev/null
+++ b/deeplabcut/gui/config_file_monitor.py
@@ -0,0 +1,105 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Watch the active project configuration file for external edits."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from pathlib import Path
+
+from PySide6 import QtCore, QtWidgets
+
+
+class ConfigFileMonitor(QtCore.QObject):
+ """Offer an explicit reload when the active config file changes on disk."""
+
+ def __init__(
+ self,
+ status_bar: QtWidgets.QStatusBar,
+ on_reload: Callable[[], bool],
+ parent: QtCore.QObject | None = None,
+ debounce_ms: int = 300,
+ ) -> None:
+ super().__init__(parent)
+ self._path: str | None = None
+ self._loaded_signature: tuple[int, int] | None = None
+
+ self._watcher = QtCore.QFileSystemWatcher(self)
+ self._watcher.fileChanged.connect(self._on_file_changed)
+ self._watcher.directoryChanged.connect(self._on_file_changed)
+
+ self._debounce = QtCore.QTimer(self)
+ self._debounce.setSingleShot(True)
+ self._debounce.setInterval(debounce_ms)
+ self._debounce.timeout.connect(self._check_for_change)
+
+ self._reload_button = QtWidgets.QPushButton("Reload configuration")
+ self._reload_button.clicked.connect(on_reload)
+ self._reload_button.hide()
+ status_bar.addPermanentWidget(self._reload_button)
+ self._status_bar = status_bar
+
+ def set_path(self, path: str | None) -> None:
+ """Watch ``path`` and its parent directory, clearing any previous watches."""
+ watched_files = self._watcher.files()
+ if watched_files:
+ self._watcher.removePaths(watched_files)
+ watched_dirs = self._watcher.directories()
+ if watched_dirs:
+ self._watcher.removePaths(watched_dirs)
+
+ self._path = path
+ self._loaded_signature = None
+ self._debounce.stop()
+ self._reload_button.hide()
+
+ if path is not None:
+ config_path = Path(path)
+ if config_path.is_file():
+ self._watcher.addPath(path)
+ # Watch the parent directory so atomic file replacements
+ # (delete + rename) are also detected.
+ parent = str(config_path.parent)
+ if parent and Path(parent).is_dir():
+ self._watcher.addPath(parent)
+
+ def mark_current(self) -> None:
+ """Record that the in-memory config matches the file currently on disk."""
+ self._loaded_signature = self._signature()
+ if self._path is not None and Path(self._path).is_file():
+ if self._path not in self._watcher.files():
+ self._watcher.addPath(self._path)
+ self._reload_button.hide()
+
+ def _signature(self) -> tuple[int, int] | None:
+ if self._path is None:
+ return None
+ try:
+ stat = Path(self._path).stat()
+ except OSError:
+ return None
+ return stat.st_mtime_ns, stat.st_size
+
+ def _on_file_changed(self, _path: str) -> None:
+ self._debounce.start()
+
+ def _check_for_change(self) -> None:
+ if self._path is None or self._loaded_signature is None:
+ return
+
+ current = self._signature()
+ if current == self._loaded_signature:
+ return
+
+ # Some editors replace the file, which drops the watch path.
+ # Watching the parent directory helps detect such replacements.
+ if current is not None and self._path not in self._watcher.files():
+ self._watcher.addPath(self._path)
+
+ self._status_bar.showMessage("Project configuration changed on disk.")
+ self._reload_button.show()
diff --git a/deeplabcut/gui/create_new_project.py b/deeplabcut/gui/create_new_project.py
deleted file mode 100644
index a463362b53..0000000000
--- a/deeplabcut/gui/create_new_project.py
+++ /dev/null
@@ -1,480 +0,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 platform
-import pydoc
-import subprocess
-import sys
-import webbrowser
-
-import wx
-
-from deeplabcut.create_project import create_new_project, add_new_videos
-from deeplabcut.gui.analyze_videos import Analyze_videos
-from deeplabcut.gui.create_training_dataset import Create_training_dataset
-from deeplabcut.gui.create_videos import Create_Labeled_Videos
-from deeplabcut.gui.evaluate_network import Evaluate_network
-from deeplabcut.gui.extract_frames import Extract_frames
-from deeplabcut.gui.extract_outlier_frames import Extract_outlier_frames
-from deeplabcut.gui.label_frames import Label_frames
-from deeplabcut.gui.refine_tracklets import Refine_tracklets
-from deeplabcut.gui.train_network import Train_network
-from deeplabcut.gui.video_editing import Video_Editing
-from deeplabcut.gui.transformerID import TransformerID
-from deeplabcut.utils import auxiliaryfunctions
-
-
-class Create_new_project(wx.Panel):
- def __init__(self, parent, gui_size):
- self.gui_size = gui_size
- self.parent = parent
- h = gui_size[0]
- w = gui_size[1]
- wx.Panel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER, size=(h, w))
- # variable initialization
- self.filelist = []
- self.filelistnew = []
- self.dir = None
- self.copy = False
- self.cfg = None
- self.loaded = False
-
- # design the panel
- self.sizer = wx.GridBagSizer(10, 15)
-
- text1 = wx.StaticText(
- self, label="DeepLabCut - Step 1. Create New Project or Load a Project"
- )
- self.sizer.Add(text1, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
-
- # Add logo of DLC
- # icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(logo))
- # self.sizer.Add(icon, pos=(0,10), flag=wx.TOP|wx.RIGHT|wx.ALIGN_RIGHT,border=10)
-
- line = wx.StaticLine(self)
- self.sizer.Add(
- line, pos=(1, 0), span=(1, 15), flag=wx.EXPAND | wx.BOTTOM, border=5
- )
-
- # Add all the options
- self.proj = wx.RadioBox(
- self,
- label="Please choose an option:",
- choices=["Create new project", "Load existing project"],
- majorDimension=0,
- style=wx.RA_SPECIFY_COLS,
- )
- self.sizer.Add(self.proj, pos=(2, 0), span=(1, 15), flag=wx.LEFT, border=5)
- self.proj.Bind(wx.EVT_RADIOBOX, self.chooseOption)
-
- # line = wx.StaticLine(self)
- # self.sizer.Add(line, pos=(3, 0), span=(1, 8),flag=wx.EXPAND|wx.BOTTOM, border=10)
-
- self.proj_name = wx.StaticText(self, label="Name of the Project:")
- self.sizer.Add(self.proj_name, pos=(4, 0), flag=wx.LEFT, border=15)
-
- self.proj_name_txt_box = wx.TextCtrl(self)
- self.sizer.Add(
- self.proj_name_txt_box, pos=(4, 1), span=(1, 5), flag=wx.TOP | wx.EXPAND
- )
-
- self.exp = wx.StaticText(self, label="Name of the experimenter:")
- self.sizer.Add(self.exp, pos=(5, 0), flag=wx.LEFT | wx.TOP, border=15)
-
- self.exp_txt_box = wx.TextCtrl(self)
- self.sizer.Add(
- self.exp_txt_box, pos=(5, 1), span=(1, 5), flag=wx.TOP | wx.EXPAND, border=5
- )
-
- self.vids = wx.StaticText(self, label="Choose Videos:")
- self.sizer.Add(self.vids, pos=(6, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Load Videos")
- self.sizer.Add(
- self.sel_vids, pos=(6, 1), span=(1, 5), flag=wx.TOP | wx.EXPAND, border=10
- )
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
- #
- sb = wx.StaticBox(self, label="Optional Attributes")
- self.boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- hbox3 = wx.BoxSizer(wx.HORIZONTAL)
- hbox4 = wx.BoxSizer(wx.HORIZONTAL)
-
- self.change_workingdir = wx.CheckBox(
- self, label="Select the directory where project will be created"
- )
- hbox2.Add(self.change_workingdir)
- hbox2.AddSpacer(20)
- self.change_workingdir.Bind(wx.EVT_CHECKBOX, self.activate_change_wd)
- self.sel_wd = wx.Button(self, label="Browse")
- self.sel_wd.Enable(False)
- self.sel_wd.Bind(wx.EVT_BUTTON, self.select_working_dir)
- hbox2.Add(self.sel_wd, 0, wx.ALL, -1)
- self.boxsizer.Add(hbox2)
-
- self.copy_choice = wx.CheckBox(self, label="Copy the videos")
- self.copy_choice.Bind(wx.EVT_CHECKBOX, self.activate_copy_videos)
- hbox3.Add(self.copy_choice)
- hbox3.AddSpacer(155)
- self.boxsizer.Add(hbox3)
-
- self.multi_choice = wx.CheckBox(self, label="Is it a multi-animal project?")
- # self.multi_choice.Bind(wx.EVT_CHECKBOX,self.activate_copy_videos)
- hbox4.Add(self.multi_choice)
- self.boxsizer.Add(hbox4)
- self.sizer.Add(
- self.boxsizer,
- pos=(7, 0),
- span=(1, 10),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(8, 0), flag=wx.LEFT | wx.EXPAND, border=15)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- self.sizer.Add(
- self.sel_config, pos=(8, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.Bind(wx.EVT_BUTTON, self.create_new_project)
- self.sel_config.SetPath("")
- # Hide the button as this is not the default option
- self.sel_config.Hide()
- self.cfg_text.Hide()
-
- self.sel_vids_new = wx.Button(self, label="Load New Videos")
- self.sizer.Add(self.sel_vids_new, pos=(9, 2), flag=wx.TOP | wx.EXPAND, border=5)
- self.sel_vids_new.Bind(wx.EVT_BUTTON, self.select_new_videos)
- self.sel_vids_new.Enable(False)
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(10, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Ok")
- self.sizer.Add(self.ok, pos=(9, 5))
- self.ok.Bind(wx.EVT_BUTTON, self.create_new_project)
-
- self.edit_config_file = wx.Button(self, label="Edit config file")
- self.sizer.Add(self.edit_config_file, pos=(10, 4))
- self.edit_config_file.Bind(wx.EVT_BUTTON, self.edit_config)
- self.edit_config_file.Enable(False)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(self.reset, pos=(10, 1), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.reset.Bind(wx.EVT_BUTTON, self.reset_project)
- self.sizer.AddGrowableCol(2)
-
- self.addvid = wx.Button(self, label="Add New Videos")
- self.sizer.Add(self.addvid, pos=(10, 2))
- self.addvid.Bind(wx.EVT_BUTTON, self.add_videos)
- self.addvid.Enable(False)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def select_new_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select new videos to load", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.addvids = dlg.GetPaths()
- self.filelistnew = self.filelistnew + self.addvids
- self.sel_vids_new.SetLabel(
- "Total %s Videos selected" % len(self.filelistnew)
- )
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.create_new_project"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def chooseOption(self, event):
- if self.proj.GetStringSelection() == "Load existing project":
-
- if self.loaded:
- self.sel_config.SetPath(self.cfg)
- self.proj_name.Enable(False)
- self.proj_name_txt_box.Enable(False)
- self.exp.Enable(False)
- # self.vids.Enable(False)
- self.exp_txt_box.Enable(False)
- self.sel_vids.Enable(False)
- self.sel_vids_new.Enable(False)
- self.change_workingdir.Enable(False)
- self.copy_choice.Enable(False)
- self.multi_choice.Enable(False)
- self.sel_config.Show()
- self.cfg_text.Show()
- self.addvid.Enable(False)
- # self.SetSizer(self.sizer)
- # self.sizer.Add(self.sizer, pos=(3, 0), span=(1, 8),flag=wx.EXPAND|wx.BOTTOM, border=15)
- self.sizer.Fit(self)
- else:
- self.proj_name.Enable(True)
- self.proj_name_txt_box.Enable(True)
- self.exp.Enable(True)
- self.exp_txt_box.Enable(True)
- self.sel_vids.Enable(True)
- self.sel_vids_new.Enable(False)
- self.change_workingdir.Enable(True)
- self.copy_choice.Enable(True)
- self.multi_choice.Enable(True)
- if self.sel_config.IsShown():
- self.sel_config.Hide()
- self.cfg_text.Hide()
- # self.ok.Enable(False)
- # else:
- # self.ok.Enable(True)
- self.addvid.Enable(False)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def edit_config(self, event):
- """
- """
- if self.cfg != "":
- # For mac compatibility
- if platform.system() == "Darwin":
- self.file_open_bool = subprocess.call(["open", self.cfg])
- self.file_open_bool = True
- else:
- self.file_open_bool = webbrowser.open(self.cfg)
-
- if self.file_open_bool:
- pass
- else:
- raise FileNotFoundError("File not found!")
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select videos to add to the project", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids
- self.sel_vids.SetLabel("Total %s Videos selected" % len(self.filelist))
-
- def activate_copy_videos(self, event):
- """
- Activates the option to copy videos
- """
- self.change_copy = event.GetEventObject()
- if self.change_copy.GetValue():
- self.copy = True
- else:
- self.copy = False
-
- def activate_change_wd(self, event):
- """
- Activates the option to change the working directory
- """
- self.change_wd = event.GetEventObject()
- if self.change_wd.GetValue():
- self.sel_wd.Enable(True)
- else:
- self.sel_wd.Enable(False)
-
- def select_working_dir(self, event):
- cwd = os.getcwd()
- dlg = wx.DirDialog(
- self,
- "Choose the directory where your project will be saved:",
- cwd,
- style=wx.DD_DEFAULT_STYLE,
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.dir = dlg.GetPath()
-
- def create_new_project(self, event):
- """
- Finally create the new project
- """
- if self.sel_config.IsShown():
- self.cfg = self.sel_config.GetPath()
- if self.cfg == "":
- wx.MessageBox(
- "Please choose the config.yaml file to load the project",
- "Error",
- wx.OK | wx.ICON_ERROR,
- )
- self.loaded = False
- else:
- wx.MessageBox("Project Loaded!", "Info", wx.OK | wx.ICON_INFORMATION)
- self.loaded = True
- self.sel_vids_new.Enable(True)
- self.addvid.Enable(True)
- self.edit_config_file.Enable(True)
- else:
- self.task = self.proj_name_txt_box.GetValue()
- self.scorer = self.exp_txt_box.GetValue()
-
- if self.task != "" and self.scorer != "" and self.filelist != []:
- self.cfg = create_new_project(
- self.task,
- self.scorer,
- self.filelist,
- self.dir,
- copy_videos=self.copy,
- multianimal=self.multi_choice.IsChecked(),
- )
- else:
- wx.MessageBox(
- "Some of the enteries are missing.\n\nMake sure that the task and experimenter name are specified and videos are selected!",
- "Error",
- wx.OK | wx.ICON_ERROR,
- )
- self.cfg = False
- if self.cfg:
- wx.MessageBox(
- "New Project Created", "Info", wx.OK | wx.ICON_INFORMATION
- )
- self.loaded = True
- self.edit_config_file.Enable(True)
-
- # Remove the pages in case the user goes back to the create new project and creates/load a new project
- if self.parent.GetPageCount() > 3:
- for i in range(2, self.parent.GetPageCount()):
- self.parent.RemovePage(2)
- self.parent.Layout()
-
- # Add all the other pages
- if self.loaded:
- self.edit_config_file.Enable(True)
- cfg = auxiliaryfunctions.read_config(self.cfg)
- if self.parent.GetPageCount() < 3:
- page3 = Extract_frames(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page3, "Extract Frames")
-
- page4 = Label_frames(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page4, "Label Data")
-
- page5 = Create_training_dataset(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page5, "Create Training Dataset")
-
- page6 = Train_network(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page6, "Train")
-
- page7 = Evaluate_network(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page7, "Evaluate")
-
- page8 = Analyze_videos(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page8, "Analyze videos")
-
- if cfg.get("multianimalproject", False):
- page9 = TransformerID(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page9, "OPT: Unsupervised ID")
-
-
- page10 = Create_Labeled_Videos(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page10, "Create videos")
-
- page11 = Extract_outlier_frames(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page11, "OPT: Extract/Refine Outliers")
-
- page12 = Video_Editing(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page12, "OPT: Video editor")
-
- #page10 = Refine_labels(self.parent, self.gui_size, self.cfg, page5)
- #self.parent.AddPage(page10, "OPT: Refine Labels")
-
- self.edit_config_file.Enable(True)
-
- if cfg.get("multianimalproject", False):
- page13 = Refine_tracklets(self.parent, self.gui_size, self.cfg)
- self.parent.AddPage(page13, "OPT: Refine tracklets")
-
-
-
- def add_videos(self, event):
- print("adding new videos to be able to label ...")
- self.cfg = self.sel_config.GetPath()
- if len(self.filelistnew) > 0:
- self.filelistnew = self.filelistnew + self.addvids
- add_new_videos(self.cfg, self.filelistnew)
- else:
- print("Please select videos to add first. Click 'Load New Videos'...")
-
- def reset_project(self, event):
- self.loaded = False
- if self.sel_config.IsShown():
- self.sel_config.SetPath("")
- self.proj.SetSelection(0)
- self.sel_config.Hide()
- self.cfg_text.Hide()
-
- self.sel_config.SetPath("")
- self.proj_name_txt_box.SetValue("")
- self.exp_txt_box.SetValue("")
- self.filelist = []
- self.sel_vids.SetLabel("Load Videos")
- self.dir = os.getcwd()
- self.edit_config_file.Enable(False)
- self.proj_name.Enable(True)
- self.proj_name_txt_box.Enable(True)
- self.multi_choice.Enable(True)
- self.exp.Enable(True)
- self.exp_txt_box.Enable(True)
- self.sel_vids.Enable(True)
- self.addvid.Enable(False)
- self.sel_vids_new.Enable(False)
- self.change_workingdir.Enable(True)
- self.copy_choice.Enable(True)
-
- try:
- self.change_wd.SetValue(False)
- except:
- pass
- try:
- self.change_copy.SetValue(False)
- except:
- pass
- # self.yes.Enable(False)
- # self.no.Enable(False)
- self.sel_wd.Enable(False)
diff --git a/deeplabcut/gui/create_training_dataset.py b/deeplabcut/gui/create_training_dataset.py
deleted file mode 100644
index 92d060f07c..0000000000
--- a/deeplabcut/gui/create_training_dataset.py
+++ /dev/null
@@ -1,377 +0,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 pydoc
-import sys
-
-import wx
-
-import deeplabcut
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils import auxiliaryfunctions
-
-
-class Create_training_dataset(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.method = "automatic"
- self.config = cfg
- self.cfg = auxiliaryfunctions.read_config(self.config)
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 4. Create training dataset")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- sb = wx.StaticBox(self, label="Optional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- self.hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox3 = wx.BoxSizer(wx.HORIZONTAL)
-
- config_file = auxiliaryfunctions.read_config(self.config)
-
- net_text = wx.StaticBox(self, label="Select the network")
- netboxsizer = wx.StaticBoxSizer(net_text, wx.VERTICAL)
- self.net_choice = wx.ComboBox(self, style=wx.CB_READONLY)
- options = [
- "dlcrnet_ms5",
- "resnet_50",
- "resnet_101",
- "mobilenet_v2_1.0",
- "efficientnet-b0",
- ]
- self.net_choice.Set(options)
-
- if self.cfg.get("multianimalproject", False):
- self.net_choice.SetValue("dlcrnet_ms5")
- else:
- self.net_choice.SetValue("resnet_50")
-
- netboxsizer.Add(self.net_choice, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- aug_text = wx.StaticBox(self, label="Select the augmentation method")
- augboxsizer = wx.StaticBoxSizer(aug_text, wx.VERTICAL)
- self.aug_choice = wx.ComboBox(self, style=wx.CB_READONLY)
- options = ["default", "tensorpack", "imgaug"]
- self.aug_choice.Set(options)
- self.aug_choice.SetValue("imgaug")
- augboxsizer.Add(self.aug_choice, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.hbox1.Add(netboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox1.Add(augboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- shuffle_text = wx.StaticBox(
- self, label="Set a specific shuffle index (1 network only)"
- )
- shuffle_text_boxsizer = wx.StaticBoxSizer(shuffle_text, wx.VERTICAL)
- self.shuffle = wx.SpinCtrl(self, value="1", min=1, max=100)
- shuffle_text_boxsizer.Add(self.shuffle, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- #trainingindex_box = wx.StaticBox(self, label="Specify the trainingset index")
- #trainingindex_boxsizer = wx.StaticBoxSizer(trainingindex_box, wx.VERTICAL)
- #self.trainingindex = wx.SpinCtrl(self, value="0", min=0, max=100)
- #trainingindex_boxsizer.Add(
- # self.trainingindex, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- #)
-
- self.userfeedback = wx.RadioBox(
- self,
- label="User feedback (to confirm overwrite train/test split)?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.userfeedback.SetSelection(1)
-
- self.hbox2.Add(shuffle_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- #self.hbox2.Add(trainingindex_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.hbox2.Add(self.userfeedback, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- if config_file.get("multianimalproject", False):
-
- self.model_comparison_choice = "No"
- else:
- self.model_comparison_choice = wx.RadioBox(
- self,
- label="Want to compare models?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.model_comparison_choice.Bind(wx.EVT_RADIOBOX, self.chooseOption)
- self.model_comparison_choice.SetSelection(1)
-
- self.shuffles_text = wx.StaticBox(
- self, label="Specify the number of shuffles"
- )
- self.shuffles_text_boxsizer = wx.StaticBoxSizer(
- self.shuffles_text, wx.VERTICAL
- )
- self.shuffles = wx.SpinCtrl(self, value="1", min=1, max=100)
- self.shuffles_text_boxsizer.Add(
- self.shuffles, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- networks = [
- "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-b3",
- "efficientnet-b6",
- ]
- augmentation_methods = ["default", "tensorpack", "imgaug"]
- self.network_box = wx.StaticBox(self, label="Select the networks")
- self.network_boxsizer = wx.StaticBoxSizer(self.network_box, wx.VERTICAL)
- self.networks_to_compare = wx.CheckListBox(
- self, choices=networks, style=0, name="Select the networks"
- )
- self.networks_to_compare.Bind(wx.EVT_CHECKLISTBOX, self.get_network_names)
- self.network_boxsizer.Add(
- self.networks_to_compare, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- self.augmentation_box = wx.StaticBox(
- self, label="Select the augmentation methods"
- )
- self.augmentation_boxsizer = wx.StaticBoxSizer(
- self.augmentation_box, wx.VERTICAL
- )
- self.augmentation_to_compare = wx.CheckListBox(
- self,
- choices=augmentation_methods,
- style=0,
- name="Select the augmentation methods",
- )
- self.augmentation_to_compare.Bind(
- wx.EVT_CHECKLISTBOX, self.get_augmentation_method_names
- )
- self.augmentation_boxsizer.Add(
- self.augmentation_to_compare, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- self.hbox3.Add(
- self.model_comparison_choice, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5
- )
- self.hbox3.Add(
- self.shuffles_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5
- )
- self.hbox3.Add(self.network_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox3.Add(
- self.augmentation_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5
- )
-
- self.shuffles_text.Hide()
- self.shuffles.Hide()
- self.network_box.Hide()
- self.networks_to_compare.Hide()
- self.augmentation_box.Hide()
- self.augmentation_to_compare.Hide()
-
- boxsizer.Add(self.hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(self.hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(self.hbox3, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.sizer.Add(
- boxsizer,
- pos=(3, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(4, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Ok")
- self.sizer.Add(self.ok, pos=(4, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.create_training_dataset)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.reset, pos=(4, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_create_training_dataset)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.create_training_dataset"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def chooseOption(self, event):
- if self.model_comparison_choice.GetStringSelection() == "Yes":
- self.network_box.Show()
- self.networks_to_compare.Show()
- self.augmentation_box.Show()
- self.augmentation_to_compare.Show()
- self.shuffles_text.Show()
- self.shuffles.Show()
- self.net_choice.Enable(False)
- self.aug_choice.Enable(False)
- self.shuffle.Enable(False)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- self.get_network_names(event)
- self.get_augmentation_method_names(event)
- else:
- self.net_choice.Enable(True)
- self.aug_choice.Enable(True)
- self.shuffle.Enable(True)
- self.shuffles_text.Hide()
- self.shuffles.Hide()
- self.network_box.Hide()
- self.networks_to_compare.Hide()
- self.augmentation_box.Hide()
- self.augmentation_to_compare.Hide()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def get_network_names(self, event):
- self.net_type = list(self.networks_to_compare.GetCheckedStrings())
-
- def get_augmentation_method_names(self, event):
- self.aug_type = list(self.augmentation_to_compare.GetCheckedStrings())
-
- def create_training_dataset(self, event):
- """
- """
- num_shuffles = self.shuffle.GetValue()
- config_file = auxiliaryfunctions.read_config(self.config)
- #trainindex = self.trainingindex.GetValue()
-
- if self.userfeedback.GetStringSelection() == "Yes":
- userfeedback = True
- else:
- userfeedback = False
-
- if config_file.get("multianimalproject", False):
- deeplabcut.create_multianimaltraining_dataset(
- self.config,
- num_shuffles,
- Shuffles=[self.shuffle.GetValue()],
- net_type=self.net_choice.GetValue(),
- )
- else:
- if self.model_comparison_choice.GetStringSelection() == "No":
- deeplabcut.create_training_dataset(
- self.config,
- num_shuffles,
- Shuffles=[self.shuffle.GetValue()],
- userfeedback=userfeedback,
- net_type=self.net_choice.GetValue(),
- augmenter_type=self.aug_choice.GetValue(),
- )
- if self.model_comparison_choice.GetStringSelection() == "Yes":
- deeplabcut.create_training_model_comparison(
- self.config,
- # trainindex=trainindex,
- num_shuffles=num_shuffles,
- userfeedback=userfeedback,
- net_types=self.net_type,
- augmenter_types=self.aug_type,
- )
-
- def reset_create_training_dataset(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- # self.shuffles.SetValue("1")
- self.net_choice.SetValue("resnet_50")
- self.aug_choice.SetValue("imgaug")
- self.model_comparison_choice.SetSelection(1)
- self.network_box.Hide()
- self.networks_to_compare.Hide()
- self.augmentation_box.Hide()
- self.augmentation_to_compare.Hide()
- self.shuffles_text.Hide()
- self.shuffles.Hide()
- self.net_choice.Enable(True)
- self.aug_choice.Enable(True)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- self.Layout()
diff --git a/deeplabcut/gui/create_videos.py b/deeplabcut/gui/create_videos.py
deleted file mode 100644
index 31bece38d2..0000000000
--- a/deeplabcut/gui/create_videos.py
+++ /dev/null
@@ -1,399 +0,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 os
-import pydoc
-import sys
-
-import wx
-
-import deeplabcut
-
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils import auxiliaryfunctions, skeleton, auxfun_multianimal
-from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-
-
-class Create_Labeled_Videos(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
- # variable initialization
- self.filelist = []
- self.config = cfg
- self.bodyparts = []
- self.draw = False
- self.slow = False
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Create Labeled Videos")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.vids = wx.StaticText(self, label="Choose the videos")
- self.sizer.Add(self.vids, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Select videos")
- self.sizer.Add(self.sel_vids, pos=(3, 1), flag=wx.TOP | wx.EXPAND, border=10)
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
-
- sb = wx.StaticBox(self, label="Additional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- hbox3 = wx.BoxSizer(wx.HORIZONTAL)
- hbox4 = wx.BoxSizer(wx.HORIZONTAL)
-
- videotype_text = wx.StaticBox(self, label="Specify the videotype")
- videotype_text_boxsizer = wx.StaticBoxSizer(videotype_text, wx.VERTICAL)
-
- self.videotype = wx.ComboBox(self, choices=("",) + SUPPORTED_VIDEOS, style=wx.CB_READONLY)
- self.videotype.SetValue("")
- videotype_text_boxsizer.Add(
- self.videotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1
- )
-
- shuffle_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffle_boxsizer = wx.StaticBoxSizer(shuffle_text, wx.VERTICAL)
- self.shuffle = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffle_boxsizer.Add(self.shuffle, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
-
- hbox1.Add(videotype_text_boxsizer, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(shuffle_boxsizer, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.sizer.Add(
- boxsizer,
- pos=(4, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=5,
- )
-
- self.cfg = auxiliaryfunctions.read_config(self.config)
- if self.cfg.get("multianimalproject", False):
- self.plot_idv = wx.RadioBox(
- self,
- label="Create video with animal ID colored?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.plot_idv.SetSelection(1)
- hbox3.Add(self.plot_idv, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
-
- self.draw_skeleton = wx.RadioBox(
- self,
- label="Include the skeleton in the video?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.draw_skeleton.Bind(wx.EVT_RADIOBOX, self.choose_draw_skeleton_options)
- self.draw_skeleton.SetSelection(1)
-
- self.filter = wx.RadioBox(
- self,
- label="Use filtered predictions?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.filter.SetSelection(1)
-
- self.video_slow = wx.RadioBox(
- self,
- label="Create a higher quality video? (slow)",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.video_slow.Bind(wx.EVT_RADIOBOX, self.choose_video_slow_options)
- self.video_slow.SetSelection(1)
-
- self.trail_points_text = wx.StaticBox(
- self, label="Specify the number of trail points"
- )
- trail_pointsboxsizer = wx.StaticBoxSizer(self.trail_points_text, wx.VERTICAL)
- self.trail_points = wx.SpinCtrl(self, value="0")
- trail_pointsboxsizer.Add(
- self.trail_points, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3
- )
-
- self.bodypart_choice = wx.RadioBox(
- self,
- label="Plot all bodyparts?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.bodypart_choice.Bind(wx.EVT_RADIOBOX, self.chooseOption)
-
- config_file = auxiliaryfunctions.read_config(self.config)
- if config_file.get("multianimalproject", False):
- bodyparts = config_file["multianimalbodyparts"]
- else:
- bodyparts = config_file["bodyparts"]
- self.bodyparts_to_compare = wx.CheckListBox(
- self, choices=bodyparts, style=0, name="Select the bodyparts"
- )
- self.bodyparts_to_compare.Bind(wx.EVT_CHECKLISTBOX, self.getbp)
- self.bodyparts_to_compare.Hide()
-
- hbox2.Add(self.draw_skeleton, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
- hbox2.Add(trail_pointsboxsizer, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
- hbox2.Add(self.video_slow, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
- boxsizer.Add(hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- hbox3.Add(self.filter, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
- hbox4.Add(self.bodypart_choice, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
- hbox4.Add(self.bodyparts_to_compare, 3, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
-
- if self.cfg.get("multianimalproject", False):
- self.trajectory = wx.RadioBox(
- self,
- label="Want to plot the trajectories?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
-
- self.trackerType = wx.RadioBox(
- self,
- label="Which tracker to use?",
- choices=["Default", "Transformer"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
-
- hbox4.Add(self.trajectory, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox4.Add(self.trackerType, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- boxsizer.Add(hbox3, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox4, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.build = wx.Button(self, label="Build skeleton")
- self.sizer.Add(self.build, pos=(5, 3), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.build.Bind(wx.EVT_BUTTON, self.build_skeleton)
- self.build.Enable(True)
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(5, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="RUN")
- self.sizer.Add(self.ok, pos=(5, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.create_videos)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.reset, pos=(5, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_create_videos)
-
- self.sizer.AddGrowableCol(3)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def build_skeleton(self, event):
- skeleton.SkeletonBuilder(self.config)
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(self, "Select videos", cwd, "", "*.*", wx.FD_MULTIPLE)
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids
- self.sel_vids.SetLabel("Total %s Videos selected" % len(self.filelist))
-
- def choose_draw_skeleton_options(self, event):
- if self.draw_skeleton.GetStringSelection() == "Yes":
- self.draw = True
- else:
- self.draw = False
-
- def plot_idv_options(self, event):
- if self.plot_idv.GetStringSelection() == "Yes":
- self.plot_idv = "individual"
- else:
- self.plot_idv = "bodypart"
-
- def choose_video_slow_options(self, event):
- if self.video_slow.GetStringSelection() == "Yes":
- self.slow = True
- else:
- self.slow = False
-
- def chooseOption(self, event):
- if self.bodypart_choice.GetStringSelection() == "No":
- self.bodyparts_to_compare.Show()
- self.getbp(event)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self) # this sets location.
- if self.bodypart_choice.GetStringSelection() == "Yes":
- self.bodyparts_to_compare.Hide()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- self.bodyparts = "all"
-
- def getbp(self, event):
- self.bodyparts = list(self.bodyparts_to_compare.GetCheckedStrings())
-
- def create_videos(self, event):
-
- shuffle = self.shuffle.GetValue()
- # self.filelist = self.filelist + self.vids
-
- if self.filter.GetStringSelection() == "No":
- filtered = False
- else:
- filtered = True
-
- if self.video_slow.GetStringSelection() == "Yes":
- self.slow = True
- else:
- self.slow = False
-
- if len(self.bodyparts) == 0:
- self.bodyparts = "all"
-
- config_file = auxiliaryfunctions.read_config(self.config)
- if config_file.get("multianimalproject", False):
- print(
- "Creating a video with the "
- + self.trackerType.GetStringSelection()
- + " tracker method!"
- )
- if self.trackerType.GetStringSelection() == "Default":
- track_method = auxfun_multianimal.get_track_method(config_file, track_method="")
- else:
- track_method = "transformer"
-
- if self.plot_idv.GetStringSelection() == "Yes":
- color_by = "individual"
- else:
- color_by = "bodypart"
-
- deeplabcut.create_labeled_video(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- save_frames=self.slow,
- draw_skeleton=self.draw,
- displayedbodyparts=self.bodyparts,
- trailpoints=self.trail_points.GetValue(),
- filtered=filtered,
- color_by=color_by,
- track_method=track_method,
- )
-
- if self.trajectory.GetStringSelection() == "Yes":
- deeplabcut.plot_trajectories(
- self.config,
- self.filelist,
- displayedbodyparts=self.bodyparts,
- videotype=self.videotype.GetValue(),
- shuffle=shuffle,
- filtered=filtered,
- showfigures=False,
- )
- else:
- deeplabcut.create_labeled_video(
- self.config,
- self.filelist,
- self.videotype.GetValue(),
- shuffle=shuffle,
- save_frames=self.slow,
- draw_skeleton=self.draw,
- displayedbodyparts=self.bodyparts,
- trailpoints=self.trail_points.GetValue(),
- filtered=filtered,
- )
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.create_labeled_video"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- os.remove("help.txt")
-
- def reset_create_videos(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.videotype.SetStringSelection("")
- self.sel_vids.SetLabel("Select videos")
- self.filelist = []
- self.shuffle.SetValue(1)
- if self.draw_skeleton.IsShown():
- self.draw_skeleton.SetSelection(1)
- # self.SetSizer(self.sizer)
- # self.sizer.Fit(self)
- self.bodyparts_to_compare.Hide()
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/config_errors.py b/deeplabcut/gui/dialogs/config_errors.py
new file mode 100644
index 0000000000..2b47c129af
--- /dev/null
+++ b/deeplabcut/gui/dialogs/config_errors.py
@@ -0,0 +1,132 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""User-facing formatting for project configuration errors."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from pydantic import ValidationError
+
+#: Exceptions that indicate a configuration could not be read or validated.
+CONFIG_LOAD_ERRORS = (
+ ValidationError,
+ FileNotFoundError,
+ PermissionError,
+ OSError,
+ TypeError,
+ ValueError,
+)
+
+_CUSTOM_MESSAGES: dict[str, str] = {
+ "extra_forbidden": "This setting is not supported by the installed DeepLabCut version.",
+ "missing": "This required setting is missing.",
+}
+
+
+@dataclass(frozen=True)
+class ConfigErrorReport:
+ """Description of a configuration error for presentation in the GUI."""
+
+ title: str
+ summary: str
+ details: str
+ technical_details: str
+
+
+def _format_location(location: tuple[Any, ...]) -> str:
+ """Format a Pydantic error location as a readable field path."""
+ if not location:
+ return "Configuration"
+
+ parts: list[str] = []
+
+ for item in location:
+ if isinstance(item, int):
+ if parts:
+ parts[-1] = f"{parts[-1]}[{item}]"
+ else:
+ parts.append(f"[{item}]")
+ else:
+ parts.append(str(item))
+
+ return ".".join(parts)
+
+
+def _format_input(
+ value: Any,
+ max_length: int = 120,
+) -> str:
+ """Format an invalid value without flooding the dialog."""
+ text = repr(value)
+
+ if len(text) <= max_length:
+ return text
+
+ return text[: max_length - 3] + "..."
+
+
+def format_config_error(
+ config_path: str | Path,
+ error: Exception,
+) -> ConfigErrorReport:
+ """Build a concise, user-facing report for a configuration error."""
+ path = Path(config_path)
+
+ if isinstance(error, ValidationError):
+ entries: list[str] = []
+
+ for detail in error.errors(include_url=False):
+ location = _format_location(tuple(detail.get("loc", ())))
+ error_type = detail.get("type")
+ message = _CUSTOM_MESSAGES.get(
+ error_type,
+ detail.get("msg", "Invalid value"),
+ )
+
+ entry = f"âą {location}: {message}"
+
+ if "input" in detail:
+ entry += f"\n Received: {_format_input(detail['input'])}"
+
+ entries.append(entry)
+
+ count = error.error_count()
+ noun = "problem" if count == 1 else "problems"
+
+ return ConfigErrorReport(
+ title="Invalid project configuration",
+ summary=(f"DeepLabCut found {count} {noun} in the project configuration."),
+ details=(f"Configuration file:\n{path}\n\n" + "\n\n".join(entries)),
+ technical_details=str(error),
+ )
+
+ if isinstance(error, FileNotFoundError):
+ return ConfigErrorReport(
+ title="Project configuration not found",
+ summary=("The selected project configuration does not exist."),
+ details=f"Configuration file:\n{path}",
+ technical_details=repr(error),
+ )
+
+ if isinstance(error, PermissionError):
+ return ConfigErrorReport(
+ title="Cannot read project configuration",
+ summary=("DeepLabCut does not have permission to read the selected configuration."),
+ details=f"Configuration file:\n{path}",
+ technical_details=repr(error),
+ )
+
+ return ConfigErrorReport(
+ title="Cannot load project configuration",
+ summary=("DeepLabCut could not read the selected project configuration."),
+ details=(f"Configuration file:\n{path}\n\n{error}"),
+ technical_details=repr(error),
+ )
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..bc90fec53d
--- /dev/null
+++ b/deeplabcut/gui/displays/selected_shuffle_display.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
+#
+"""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 PySide6.QtCore import Qt
+from PySide6.QtWidgets import QSizePolicy
+
+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(object)
+
+ 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:\n\n{pose_cfg_path}\n\nwas 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.setWordWrap(True)
+ self._label.setTextFormat(Qt.PlainText)
+ self._label.setSizePolicy(
+ QSizePolicy.Policy.Minimum,
+ QSizePolicy.Policy.Expanding,
+ )
+ self._label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard
+ )
+
+ self._label.setText(error)
+ self._label.setStyleSheet(f"margin: 0 0 {self._row_margin}px 0; color: orange;")
+ self.pose_cfg = None
+
+ def _read_pose_config(self, pose_cfg_path: Path) -> None:
+ pose_cfg = auxiliaryfunctions.read_plainconfig(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")
+
+ method = pose_cfg.get("method")
+ self._is_top_down = self._engine == Engine.PYTORCH and isinstance(method, str) and 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..084d912643
--- /dev/null
+++ b/deeplabcut/gui/displays/shuffle_metadata_viewer.py
@@ -0,0 +1,65 @@
+#
+# 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 pathlib import Path
+
+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 Path(metadata_path).open() 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
new file mode 100644
index 0000000000..fce5d15a52
--- /dev/null
+++ b/deeplabcut/gui/dlc_params.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
+#
+class DLCParams:
+ VIDEOTYPES = [
+ "",
+ "avi",
+ "mp4",
+ "mkv",
+ "mov",
+ ]
+
+ NNETS = [
+ "dlcrnet_ms5",
+ "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-b3",
+ "efficientnet-b6",
+ ]
+
+ FRAME_EXTRACTION_ALGORITHMS = ["kmeans", "uniform"]
+
+ OUTLIER_EXTRACTION_ALGORITHMS = ["jump", "fitting", "uncertain", "manual"]
+
+ TRACKERS = ["ellipse", "box", "skeleton"]
diff --git a/deeplabcut/gui/evaluate_network.py b/deeplabcut/gui/evaluate_network.py
deleted file mode 100644
index 031f4ed433..0000000000
--- a/deeplabcut/gui/evaluate_network.py
+++ /dev/null
@@ -1,289 +0,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 pydoc
-import subprocess
-import sys
-import webbrowser
-
-import wx
-
-import deeplabcut
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils import auxiliaryfunctions
-
-
-class Evaluate_network(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.config = cfg
- self.bodyparts = []
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 6. Evaluate Network")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- sb = wx.StaticBox(self, label="Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- self.hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- self.hbox3 = wx.BoxSizer(wx.HORIZONTAL)
-
- config_file = auxiliaryfunctions.read_config(self.config)
-
- shuffles_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffles_text_boxsizer = wx.StaticBoxSizer(shuffles_text, wx.VERTICAL)
- self.shuffles = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffles_text_boxsizer.Add(self.shuffles, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- #trainingset = wx.StaticBox(self, label="Specify the trainingset index")
- #trainingset_boxsizer = wx.StaticBoxSizer(trainingset, wx.VERTICAL)
- #self.trainingset = wx.SpinCtrl(self, value="0", min=0, max=100)
- #trainingset_boxsizer.Add(
- # self.trainingset, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- #)
-
- self.plot_choice = wx.RadioBox(
- self,
- label="Want to plot predictions (as in standard DLC projects)?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.plot_choice.SetSelection(0)
-
- self.plot_scoremaps = wx.RadioBox(
- self,
- label="Want to plot maps (ALL images): scoremaps, PAFs, locrefs?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.plot_scoremaps.SetSelection(1)
-
- self.bodypart_choice = wx.RadioBox(
- self,
- label="Compare all bodyparts?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.bodypart_choice.Bind(wx.EVT_RADIOBOX, self.chooseOption)
-
- if config_file.get("multianimalproject", False):
- bodyparts = config_file["multianimalbodyparts"]
- else:
- bodyparts = config_file["bodyparts"]
- self.bodyparts_to_compare = wx.CheckListBox(
- self, choices=bodyparts, style=0, name="Select the bodyparts"
- )
- self.bodyparts_to_compare.Bind(wx.EVT_CHECKLISTBOX, self.getbp)
- self.bodyparts_to_compare.Hide()
-
- self.hbox1.Add(shuffles_text_boxsizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- #self.hbox1.Add(trainingset_boxsizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox1.Add(self.plot_scoremaps, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.hbox2.Add(self.plot_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox2.Add(self.bodypart_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- self.hbox2.Add(self.bodyparts_to_compare, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- boxsizer.Add(self.hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(self.hbox2, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.sizer.Add(
- boxsizer,
- pos=(3, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(4, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="RUN: Evaluate Network")
- self.sizer.Add(self.ok, pos=(4, 3))
- self.ok.Bind(wx.EVT_BUTTON, self.evaluate_network)
-
- self.ok = wx.Button(self, label="Optional: Plot 3 test maps")
- self.sizer.Add(self.ok, pos=(5, 3))
- self.ok.Bind(wx.EVT_BUTTON, self.plot_maps)
-
- self.cancel = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.cancel, pos=(4, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.cancel.Bind(wx.EVT_BUTTON, self.cancel_evaluate_network)
-
- if config_file.get("multianimalproject", False):
- self.inf_cfg_text = wx.Button(self, label="Edit the inference_config.yaml")
- self.inf_cfg_text.Bind(wx.EVT_BUTTON, self.edit_inf_config)
- self.sizer.Add(
- self.inf_cfg_text,
- pos=(4, 2),
- span=(1, 1),
- flag=wx.BOTTOM | wx.RIGHT,
- border=10,
- )
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.evaluate_network"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def chooseOption(self, event):
- if self.bodypart_choice.GetStringSelection() == "No":
- self.bodyparts_to_compare.Show()
- self.getbp(event)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- if self.bodypart_choice.GetStringSelection() == "Yes":
- self.bodyparts_to_compare.Hide()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- self.bodyparts = "all"
-
- def getbp(self, event):
- self.bodyparts = list(self.bodyparts_to_compare.GetCheckedStrings())
-
- def select_config(self, event):
- self.config = self.sel_config.GetPath()
-
- def plot_maps(self, event):
- shuffle = self.shuffles.GetValue()
- # if self.plot_scoremaps.GetStringSelection() == "Yes":
- deeplabcut.extract_save_all_maps(
- self.config, shuffle=shuffle, Indices=[0, 1, 5]
- )
-
- def edit_inf_config(self, event):
- # Read the infer config file
- cfg = auxiliaryfunctions.read_config(self.config)
- #trainingsetindex = self.trainingset.GetValue()
- trainFraction = cfg["TrainingFraction"]
- self.inf_cfg_path = os.path.join(
- cfg["project_path"],
- auxiliaryfunctions.get_model_folder(
- trainFraction[-1], self.shuffles.GetValue(), cfg
- ),
- "test",
- "inference_cfg.yaml",
- )
- # let the user open the file with default text editor. Also make it mac compatible
- if sys.platform == "darwin":
- self.file_open_bool = subprocess.call(["open", self.inf_cfg_path])
- self.file_open_bool = True
- else:
- self.file_open_bool = webbrowser.open(self.inf_cfg_path)
- if self.file_open_bool:
- self.inf_cfg = auxiliaryfunctions.read_config(self.inf_cfg_path)
- else:
- raise FileNotFoundError("File not found!")
-
- def evaluate_network(self, event):
-
- # shuffle = self.shuffle.GetValue()
- #trainingsetindex = self.trainingset.GetValue()
-
- Shuffles = [self.shuffles.GetValue()]
- if self.plot_choice.GetStringSelection() == "Yes":
- plotting = True
- else:
- plotting = False
-
- if self.plot_scoremaps.GetStringSelection() == "Yes":
- for shuffle in Shuffles:
- deeplabcut.extract_save_all_maps(self.config, shuffle=shuffle)
-
- if len(self.bodyparts) == 0:
- self.bodyparts = "all"
- deeplabcut.evaluate_network(
- self.config,
- Shuffles=Shuffles,
- #trainingsetindex=trainingsetindex,
- plotting=plotting,
- show_errors=True,
- comparisonbodyparts=self.bodyparts,
- )
-
- def cancel_evaluate_network(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.plot_choice.SetSelection(1)
- self.bodypart_choice.SetSelection(0)
- self.shuffles.SetValue(1)
- if self.bodyparts_to_compare.IsShown():
- self.bodyparts_to_compare.Hide()
diff --git a/deeplabcut/gui/extract_frames.py b/deeplabcut/gui/extract_frames.py
deleted file mode 100644
index 4c1eb77817..0000000000
--- a/deeplabcut/gui/extract_frames.py
+++ /dev/null
@@ -1,262 +0,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 pydoc
-import sys
-
-import wx
-
-from deeplabcut.generate_training_dataset import extract_frames
-from deeplabcut.gui import LOGO_PATH
-
-
-class Extract_frames(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.method = "automatic"
- self.config = cfg
- # design the panel
- sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 2. Extract Frames")
- sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- sizer.Add(icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5)
-
- line1 = wx.StaticLine(self)
- sizer.Add(line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- sb = wx.StaticBox(self, label="Optional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
- hbox3 = wx.BoxSizer(wx.HORIZONTAL)
-
- self.method_choice = wx.RadioBox(
- self,
- label="Choose the extraction method",
- choices=["automatic", "manual"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.method_choice.Bind(wx.EVT_RADIOBOX, self.select_extract_method)
- hbox1.Add(self.method_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.crop_choice = wx.RadioBox(
- self,
- label="Want to crop the frames?",
- choices=["False", "True (read from config file)", "GUI"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- hbox1.Add(self.crop_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.feedback_choice = wx.RadioBox(
- self,
- label="Need user feedback?",
- choices=["No", "Yes"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- hbox1.Add(self.feedback_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.opencv_choice = wx.RadioBox(
- self,
- label="Want to use openCV?",
- choices=["No", "Yes"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.opencv_choice.SetSelection(1)
- hbox1.Add(self.opencv_choice, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- algo_text = wx.StaticBox(self, label="Select the algorithm")
- algoboxsizer = wx.StaticBoxSizer(algo_text, wx.VERTICAL)
- self.algo_choice = wx.ComboBox(self, style=wx.CB_READONLY)
- options = ["kmeans", "uniform"]
- self.algo_choice.Set(options)
- self.algo_choice.SetValue("kmeans")
- algoboxsizer.Add(self.algo_choice, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- cluster_step_text = wx.StaticBox(self, label="Specify the cluster step")
- cluster_stepboxsizer = wx.StaticBoxSizer(cluster_step_text, wx.VERTICAL)
- self.cluster_step = wx.SpinCtrl(self, value="1")
- cluster_stepboxsizer.Add(
- self.cluster_step, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- slider_width_text = wx.StaticBox(self, label="Specify the GUI slider width")
- slider_widthboxsizer = wx.StaticBoxSizer(slider_width_text, wx.VERTICAL)
- self.slider_width = wx.SpinCtrl(self, value="25")
- slider_widthboxsizer.Add(
- self.slider_width, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- hbox3.Add(algoboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox3.Add(cluster_stepboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox3.Add(slider_widthboxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox2, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox3, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- sizer.Add(
- boxsizer,
- pos=(3, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.help_button = wx.Button(self, label="Help")
- sizer.Add(self.help_button, pos=(4, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Ok")
- sizer.Add(self.ok, pos=(4, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.extract_frames)
-
- self.reset = wx.Button(self, label="Reset")
- sizer.Add(
- self.reset, pos=(4, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_extract_frames)
-
- sizer.AddGrowableCol(2)
-
- self.SetSizer(sizer)
- sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.extract_frames"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_extract_method(self, event):
- self.method = self.method_choice.GetStringSelection()
- if self.method == "manual":
- self.crop_choice.Enable(False)
- self.feedback_choice.Enable(False)
- self.opencv_choice.Enable(False)
- self.algo_choice.Enable(False)
- self.cluster_step.Enable(False)
- self.slider_width.Enable(False)
- else:
- self.crop_choice.Enable(True)
- self.feedback_choice.Enable(True)
- self.opencv_choice.Enable(True)
- self.algo_choice.Enable(True)
- self.cluster_step.Enable(True)
- self.slider_width.Enable(True)
-
- def extract_frames(self, event):
- mode = self.method
- algo = self.algo_choice.GetValue()
- if self.crop_choice.GetStringSelection() == "True (read from config file)":
- crop = True
- elif self.crop_choice.GetStringSelection() == "GUI":
- crop = "GUI"
- else:
- crop = False
-
- if self.feedback_choice.GetStringSelection() == "Yes":
- userfeedback = True
- else:
- userfeedback = False
-
- if self.opencv_choice.GetStringSelection() == "Yes":
- opencv = True
- else:
- opencv = False
-
- slider_width = self.slider_width.GetValue()
- extract_frames(
- self.config,
- mode,
- algo,
- crop=crop,
- userfeedback=userfeedback,
- cluster_step=self.cluster_step.GetValue(),
- cluster_resizewidth=30,
- cluster_color=False,
- opencv=opencv,
- slider_width=slider_width,
- )
-
- def reset_extract_frames(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.method_choice.SetStringSelection("automatic")
- self.crop_choice.Enable(True)
- self.feedback_choice.Enable(True)
- self.opencv_choice.Enable(True)
- self.algo_choice.Enable(True)
- self.cluster_step.Enable(True)
- self.slider_width.Enable(True)
- self.crop_choice.SetStringSelection("False")
- self.feedback_choice.SetStringSelection("No")
- self.opencv_choice.SetStringSelection("Yes")
- self.algo_choice.SetValue("kmeans")
- self.cluster_step.SetValue(1)
- self.slider_width.SetValue(25)
diff --git a/deeplabcut/gui/extract_outlier_frames.py b/deeplabcut/gui/extract_outlier_frames.py
deleted file mode 100644
index 70046c6bcb..0000000000
--- a/deeplabcut/gui/extract_outlier_frames.py
+++ /dev/null
@@ -1,233 +0,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 pydoc
-import sys
-
-import wx
-
-import deeplabcut
-from deeplabcut import utils
-from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-
-from deeplabcut.gui import LOGO_PATH
-
-
-class Extract_outlier_frames(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.config = cfg
- self.cfg = utils.read_config(cfg)
- self.filelist = []
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - OPTIONAL: Extract and Refine labels on Outlier Frames")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.vids = wx.StaticText(self, label="Choose the videos")
- self.sizer.Add(self.vids, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Select videos to analyze")
- self.sizer.Add(self.sel_vids, pos=(3, 1), flag=wx.TOP | wx.EXPAND, border=5)
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
-
- sb = wx.StaticBox(self, label="Optional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
-
- videotype_text = wx.StaticBox(self, label="Specify the videotype")
- videotype_text_boxsizer = wx.StaticBoxSizer(videotype_text, wx.VERTICAL)
- self.videotype = wx.ComboBox(self, choices=("",) + SUPPORTED_VIDEOS, style=wx.CB_READONLY)
- self.videotype.SetValue("")
- videotype_text_boxsizer.Add(
- self.videotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1
- )
-
- shuffles_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffles_text_boxsizer = wx.StaticBoxSizer(shuffles_text, wx.VERTICAL)
- self.shuffles = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffles_text_boxsizer.Add(self.shuffles, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- outlier_algo_text = wx.StaticBox(self, label="Specify the algorithm")
- outlier_algo_text_boxsizer = wx.StaticBoxSizer(outlier_algo_text, wx.VERTICAL)
- algotypes = ["jump", "fitting", "uncertain", "manual"]
- self.algotype = wx.ComboBox(self, choices=algotypes, style=wx.CB_READONLY)
- self.algotype.SetValue("jump")
- outlier_algo_text_boxsizer.Add(self.algotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- hbox1.Add(videotype_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(shuffles_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(outlier_algo_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.sizer.Add(
- boxsizer,
- pos=(4, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
-### LABELING ###
-
-
-
- self.ok = wx.Button(self, label="LAUNCH GUI")
- self.sizer.Add(self.ok, pos=(7, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.refine_labels)
-
- self.merge = wx.Button(self, label="Merge dataset")
- self.sizer.Add(self.merge, pos=(7, 3), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.merge.Bind(wx.EVT_BUTTON, self.merge_dataset)
- self.merge.Enable(False)
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(6, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="EXTRACT FRAMES")
- self.sizer.Add(self.ok, pos=(6, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.extract_outlier_frames)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.reset, pos=(6, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_extract_outlier_frames)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.extract_outlier_frames"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select videos to analyze", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids
- self.sel_vids.SetLabel("Total %s Videos selected" % len(self.filelist))
-
- def extract_outlier_frames(self, event):
- deeplabcut.extract_outlier_frames(
- config=self.config,
- videos=self.filelist,
- videotype=self.videotype.GetValue(),
- shuffle=self.shuffles.GetValue(),
- outlieralgorithm=self.algotype.GetValue(),
- )
-
- def reset_extract_outlier_frames(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.videotype.SetValue("")
- self.algotype.SetValue("jump")
- self.sel_vids.SetLabel("Select videos to analyze")
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def refine_labels(self, event):
- self.merge.Enable(True)
- deeplabcut.refine_labels(self.config)
-
- def merge_dataset(self, event):
- dlg = wx.MessageDialog(
- None,
- "1. Make sure that you have refined all the labels before merging the dataset.\n\n2. If you merge the dataset, you need to re-create the training dataset before you start the training.\n\n3. Are you ready to merge the dataset?",
- "Warning",
- wx.YES_NO | wx.ICON_WARNING,
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- notebook = self.GetParent()
- notebook.SetSelection(4)
- deeplabcut.merge_datasets(self.config, forceiterate=None)
-
- def reset_refine_labels(self, event):
- """
- Reset to default
- """
- self.config = []
- #self.sel_config.SetPath("")
- self.merge.Enable(False)
diff --git a/deeplabcut/gui/frame_extraction_toolbox.py b/deeplabcut/gui/frame_extraction_toolbox.py
deleted file mode 100644
index f951eba5b8..0000000000
--- a/deeplabcut/gui/frame_extraction_toolbox.py
+++ /dev/null
@@ -1,495 +0,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 argparse
-import os
-from pathlib import Path
-
-import cv2
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import wx
-from matplotlib.figure import Figure
-from matplotlib.widgets import RectangleSelector
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-from skimage import io
-from skimage.util import img_as_ubyte
-
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import auxiliaryfunctions
-
-
-# ###########################################################################
-# Class for GUI MainFrame
-# ###########################################################################
-class ImagePanel(BasePanel):
- def getColorIndices(self, img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- im = io.imread(img)
- norm = mcolors.Normalize(vmin=0, vmax=np.max(im))
- ticks = np.linspace(0, np.max(im), len(bodyparts))[::-1]
- return norm, ticks
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, slider_width=25):
- super(MainFrame, self).__init__(
- "DeepLabCut2.0 - Manual Frame Extraction", parent
- )
-
- ###################################################################################################################################################
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
- topSplitter = wx.SplitterWindow(self)
-
- self.image_panel = ImagePanel(topSplitter, config, self.gui_size)
- self.widget_panel = WidgetPanel(topSplitter)
-
- topSplitter.SplitHorizontally(
- self.image_panel, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
-
- self.load = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Load Video")
- widgetsizer.Add(self.load, 1, wx.ALL, 15)
- self.load.Bind(wx.EVT_BUTTON, self.browseDir)
-
- self.help = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Help")
- widgetsizer.Add(self.help, 1, wx.ALL, 15)
- self.help.Bind(wx.EVT_BUTTON, self.helpButton)
-
- self.grab = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Grab Frames")
- widgetsizer.Add(self.grab, 1, wx.ALL, 15)
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.grab.Enable(False)
-
- widgetsizer.AddStretchSpacer(5)
- size_x = round(self.gui_size[0] * (slider_width / 100), 0)
- self.slider = wx.Slider(
- self.widget_panel,
- id=wx.ID_ANY,
- value=0,
- minValue=0,
- maxValue=1,
- size=(size_x, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- widgetsizer.Add(self.slider, 1, wx.ALL, 5)
- self.slider.Hide()
-
- widgetsizer.AddStretchSpacer(5)
- self.start_frames_sizer = wx.BoxSizer(wx.VERTICAL)
- self.end_frames_sizer = wx.BoxSizer(wx.VERTICAL)
-
- self.start_frames_sizer.AddSpacer(15)
- self.startFrame = wx.SpinCtrl(
- self.widget_panel, value="0", size=(100, -1), min=0, max=120
- )
- self.startFrame.Bind(wx.EVT_SPINCTRL, self.updateSlider)
- self.startFrame.Enable(False)
- self.start_frames_sizer.Add(self.startFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- start_text = wx.StaticText(self.widget_panel, label="Start Frame Index")
- self.start_frames_sizer.Add(start_text, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.checkBox = wx.CheckBox(
- self.widget_panel, id=wx.ID_ANY, label="Range of frames"
- )
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activate_frame_range)
- self.start_frames_sizer.Add(self.checkBox, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- #
- self.end_frames_sizer.AddSpacer(15)
- self.endFrame = wx.SpinCtrl(
- self.widget_panel, value="1", size=(160, -1), min=1, max=120
- )
- self.endFrame.Enable(False)
- self.end_frames_sizer.Add(self.endFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- end_text = wx.StaticText(self.widget_panel, label="Number of Frames")
- self.end_frames_sizer.Add(end_text, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.updateFrame = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Update")
- self.end_frames_sizer.Add(self.updateFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.updateFrame.Bind(wx.EVT_BUTTON, self.updateSlider)
- self.updateFrame.Enable(False)
-
- widgetsizer.Add(self.start_frames_sizer, 1, wx.ALL, 0)
- widgetsizer.AddStretchSpacer(5)
- widgetsizer.Add(self.end_frames_sizer, 1, wx.ALL, 0)
- widgetsizer.AddStretchSpacer(15)
-
- self.quit = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Quit")
- widgetsizer.Add(self.quit, 1, wx.ALL, 15)
- self.quit.Bind(wx.EVT_BUTTON, self.quitButton)
- self.quit.Enable(True)
-
- # Hiding these widgets and show them once the video is loaded
- self.start_frames_sizer.ShowItems(show=False)
- self.end_frames_sizer.ShowItems(show=False)
-
- self.widget_panel.SetSizer(widgetsizer)
- self.widget_panel.SetSizerAndFit(widgetsizer)
- self.widget_panel.Layout()
-
- # Variables initialization
- self.numberFrames = 0
- self.currFrame = 0
- self.figure = Figure()
- self.axes = self.figure.add_subplot(111)
- self.drs = []
- self.cfg = auxiliaryfunctions.read_config(config)
- self.Task = self.cfg["Task"]
- self.start = self.cfg["start"]
- self.stop = self.cfg["stop"]
- self.date = self.cfg["date"]
- self.trainFraction = self.cfg["TrainingFraction"]
- self.trainFraction = self.trainFraction[0]
- self.videos = list(
- self.cfg.get("video_sets_original") or self.cfg["video_sets"]
- )
- self.bodyparts = self.cfg["bodyparts"]
- self.colormap = plt.get_cmap(self.cfg["colormap"])
- self.colormap = self.colormap.reversed()
- self.markerSize = self.cfg["dotsize"]
- self.alpha = self.cfg["alphavalue"]
- self.video_names = [Path(i).stem for i in self.videos]
- self.config_path = Path(config)
- self.extract_range_frame = False
- self.extract_from_analyse_video = False
-
- def quitButton(self, event):
- """
- Quits the GUI
- """
- self.statusbar.SetStatusText("")
- dlg = wx.MessageDialog(
- None, "Are you sure?", "Quit!", wx.YES_NO | wx.ICON_WARNING
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- print("Quitting for now!")
- self.Destroy()
-
- def updateSlider(self, event):
- self.slider.SetValue(self.startFrame.GetValue())
- self.currFrame = self.slider.GetValue()
- if self.extract_from_analyse_video:
- self.figure.delaxes(self.figure.axes[1])
- self.plot_labels()
- self.update()
-
- def activate_frame_range(self, event):
- """
- Activates the frame range boxes
- """
- self.checkSlider = event.GetEventObject()
- if self.checkSlider.GetValue():
- self.extract_range_frame = True
- self.startFrame.Enable(True)
- self.startFrame.SetValue(self.slider.GetValue())
- self.endFrame.Enable(True)
- self.updateFrame.Enable(True)
- self.grab.Enable(False)
- else:
- self.extract_range_frame = False
- self.startFrame.Enable(False)
- self.endFrame.Enable(False)
- self.updateFrame.Enable(False)
- self.grab.Enable(True)
-
- def line_select_callback(self, eclick, erelease):
- "eclick and erelease are the press and release events"
- self.new_x1, self.new_y1 = eclick.xdata, eclick.ydata
- self.new_x2, self.new_y2 = erelease.xdata, erelease.ydata
-
- def CheckCropping(self):
- """ Display frame at time "time" for video to check if cropping is fine.
- Select ROI of interest by adjusting values in myconfig.py
-
- USAGE for cropping:
- clip.crop(x1=None, y1=None, x2=None, y2=None, width=None, height=None, x_center=None, y_center=None)
-
- Returns a new clip in which just a rectangular subregion of the
- original clip is conserved. x1,y1 indicates the top left corner and
- x2,y2 is the lower right corner of the cropped region.
-
- All coordinates are in pixels. Float numbers are accepted.
- """
-
- videosource = self.video_source
- try:
- self.x1 = int(self.cfg["video_sets"][videosource]["crop"].split(",")[0])
- self.x2 = int(self.cfg["video_sets"][videosource]["crop"].split(",")[1])
- self.y1 = int(self.cfg["video_sets"][videosource]["crop"].split(",")[2])
- self.y2 = int(self.cfg["video_sets"][videosource]["crop"].split(",")[3])
- except KeyError:
- self.x1, self.x2, self.y1, self.y2 = map(
- int, self.cfg["video_sets_original"][videosource]["crop"].split(",")
- )
-
- if self.cropping:
- # Select ROI of interest by drawing a rectangle
- self.cid = RectangleSelector(
- self.axes,
- self.line_select_callback,
- drawtype="box",
- useblit=False,
- button=[1],
- minspanx=5,
- minspany=5,
- spancoords="pixels",
- interactive=True,
- )
- self.canvas.mpl_connect("key_press_event", self.cid)
-
- def OnSliderScroll(self, event):
- """
- Slider to scroll through the video
- """
- self.axes.clear()
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.currFrame = self.slider.GetValue()
- self.startFrame.SetValue(self.currFrame)
- self.update()
-
- def is_crop_ok(self, event):
- """
- Checks if the cropping is ok
- """
-
- self.grab.SetLabel("Grab Frames")
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.slider.Show()
- self.start_frames_sizer.ShowItems(show=True)
- self.end_frames_sizer.ShowItems(show=True)
- self.widget_panel.Layout()
- self.slider.SetMax(self.numberFrames)
- self.startFrame.SetMax(self.numberFrames - 1)
- self.endFrame.SetMax(self.numberFrames)
- self.x1 = int(self.new_x1)
- self.x2 = int(self.new_x2)
- self.y1 = int(self.new_y1)
- self.y2 = int(self.new_y2)
- self.canvas.mpl_disconnect(self.cid)
- self.axes.clear()
- self.currFrame = self.slider.GetValue()
- self.update()
- # Update the config.yaml file
- self.cfg["video_sets"][self.video_source] = {
- "crop": ", ".join(map(str, [self.x1, self.x2, self.y1, self.y2]))
- }
- auxiliaryfunctions.write_config(self.config_path, self.cfg)
-
- def browseDir(self, event):
- """
- Show the File Dialog and ask the user to select the video file
- """
-
- self.statusbar.SetStatusText("Looking for a video to start extraction..")
- dlg = wx.FileDialog(self, "SELECT A VIDEO", os.getcwd(), "", "*.*", wx.FD_OPEN)
- if dlg.ShowModal() == wx.ID_OK:
- self.video_source_original = dlg.GetPath()
- self.video_source = str(Path(self.video_source_original).resolve())
-
- self.load.Enable(False)
- else:
- pass
- dlg.Destroy()
- self.Close(True)
- dlg.Destroy()
- selectedvideo = Path(self.video_source)
-
- self.statusbar.SetStatusText(
- "Working on video: {}".format(os.path.split(str(selectedvideo))[-1])
- )
-
- if str(selectedvideo.stem) in self.video_names:
- self.grab.Enable(True)
- self.vid = cv2.VideoCapture(self.video_source)
- self.videoPath = os.path.dirname(self.video_source)
- self.filename = Path(self.video_source).name
- self.numberFrames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
- # Checks if the video is corrupt.
- if not self.vid.isOpened():
- msg = wx.MessageBox(
- "Invalid Video file!Do you want to retry?",
- "Error!",
- wx.YES_NO | wx.ICON_WARNING,
- )
- if msg == 2:
- self.load.Enable(True)
- MainFrame.browseDir(self, event)
- else:
- self.Destroy()
- self.slider.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
- self.update()
-
- cropMsg = wx.MessageBox(
- "Do you want to crop the frames?",
- "Want to crop?",
- wx.YES_NO | wx.ICON_INFORMATION,
- )
- if cropMsg == 2:
- self.cropping = True
- self.grab.SetLabel("Set cropping parameters")
- self.grab.Bind(wx.EVT_BUTTON, self.is_crop_ok)
- self.widget_panel.Layout()
- self.basefolder = "data-" + self.Task + "/"
- MainFrame.CheckCropping(self)
- else:
- self.cropping = False
- self.slider.Show()
- self.start_frames_sizer.ShowItems(show=True)
- self.end_frames_sizer.ShowItems(show=True)
- self.widget_panel.Layout()
- self.slider.SetMax(self.numberFrames - 1)
- self.startFrame.SetMax(self.numberFrames - 1)
- self.endFrame.SetMax(self.numberFrames - 1)
-
- else:
- wx.MessageBox(
- "Video file is not in config file. Use add function to add this video in the config file and retry!",
- "Error!",
- wx.OK | wx.ICON_WARNING,
- )
- self.Close(True)
-
- def update(self):
- """
- Updates the image with the current slider index
- """
- self.grab.Enable(True)
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.figure, self.axes, self.canvas = self.image_panel.getfigure()
- self.vid.set(1, self.currFrame)
- ret, frame = self.vid.read()
- if ret:
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- self.ax = self.axes.imshow(frame)
- self.axes.set_title(
- str(
- str(self.currFrame)
- + "/"
- + str(self.numberFrames - 1)
- + " "
- + self.filename
- )
- )
- self.figure.canvas.draw()
-
- def chooseFrame(self):
- ret, frame = self.vid.read()
- fname = Path(self.filename)
- output_path = self.config_path.parents[0] / "labeled-data" / fname.stem
-
- if output_path.exists():
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- frame = img_as_ubyte(frame)
- img_name = (
- str(output_path)
- + "/img"
- + str(self.currFrame).zfill(int(np.ceil(np.log10(self.numberFrames))))
- + ".png"
- )
- if self.cropping:
- crop_img = frame[self.y1 : self.y2, self.x1 : self.x2]
- cv2.imwrite(img_name, cv2.cvtColor(crop_img, cv2.COLOR_RGB2BGR))
- else:
- cv2.imwrite(img_name, cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))
- else:
- print(
- "%s path not found. Please make sure that the video was added to the config file using the function 'deeplabcut.add_new_videos'."
- % output_path
- )
-
- def grabFrame(self, event):
- """
- Extracts the frame and saves in the current directory
- """
- num_frames_extract = self.endFrame.GetValue()
- for i in range(self.currFrame, self.currFrame + num_frames_extract):
- self.currFrame = i
- self.vid.set(1, self.currFrame)
- self.chooseFrame()
- self.vid.set(1, self.currFrame)
- self.chooseFrame()
-
- def plot_labels(self):
- """
- Plots the labels of the analyzed video
- """
- self.vid.set(1, self.currFrame)
- ret, frame = self.vid.read()
- if ret:
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
- self.norm = mcolors.Normalize(vmin=np.min(frame), vmax=np.max(frame))
- self.colorIndex = np.linspace(
- np.min(frame), np.max(frame), len(self.bodyparts)
- )
- divider = make_axes_locatable(self.axes)
- cax = divider.append_axes("right", size="5%", pad=0.05)
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.bodyparts)
- for bpindex, bp in enumerate(self.bodyparts):
- color = self.colormap(self.norm(self.colorIndex[bpindex]))
- self.points = [
- self.Dataframe[self.scorer][bp]["x"].values[self.currFrame],
- self.Dataframe[self.scorer][bp]["y"].values[self.currFrame],
- 1.0,
- ]
- circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(circle[0])
- self.figure.canvas.draw()
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- wx.MessageBox(
- "1. Use the Load Video button to load a video. Use the slider to select a frame in the entire video. The number mentioned on the top of the slider represents the frame index. \n\n2. Click Grab Frames button to save the specific frame.\n\n3. In events where you need to extract a range of frames, then use the checkbox Range of frames to select the start frame index and number of frames to extract. Click the update button to see the start frame index. Click Grab Frames to select the range of frames. \n\n Click OK to continue",
- "Instructions to use!",
- wx.OK | wx.ICON_INFORMATION,
- )
-
-
-class MatplotPanel(wx.Panel):
- def __init__(self, parent, config):
- wx.Panel.__init__(self, parent, -1, size=(100, 100))
-
- self.figure = Figure()
- self.axes = self.figure.add_subplot(111)
-
-
-def show(config, slider_width=25):
- app = wx.App()
- frame = MainFrame(None, config, slider_width).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/gui_assets.py b/deeplabcut/gui/gui_assets.py
new file mode 100644
index 0000000000..85dcd603d3
--- /dev/null
+++ b/deeplabcut/gui/gui_assets.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from __future__ import annotations
+
+from importlib.resources import files
+from pathlib import Path
+
+from PySide6.QtGui import QIcon, QPixmap
+
+ASSETS_DIR = files("deeplabcut.gui").joinpath("assets")
+
+
+def resource_bytes(*parts: str) -> bytes:
+ """Read a resource bundled inside deeplabcut.gui."""
+ return ASSETS_DIR.joinpath(*parts).read_bytes()
+
+
+def resource_text(*parts: str, encoding: str = "utf-8") -> str:
+ """Read a text resource bundled inside deeplabcut.gui."""
+ return ASSETS_DIR.joinpath(*parts).read_text(encoding=encoding)
+
+
+def get_assets_dir() -> Path:
+ """Get the path to the assets directory."""
+ return Path(ASSETS_DIR)
+
+
+def get_style_qss() -> str:
+ """Get the contents of the style.qss file."""
+ return resource_text("style.qss")
+
+
+def pixmap_from_resource(*parts: str) -> QPixmap:
+ pixmap = QPixmap()
+ data = resource_bytes(*parts)
+
+ if not pixmap.loadFromData(data):
+ joined = "/".join(parts)
+ raise FileNotFoundError(f"Could not load GUI resource as QPixmap: {joined}")
+
+ return pixmap
+
+
+def icon_from_resource(*parts: str) -> QIcon:
+ return QIcon(pixmap_from_resource(*parts))
diff --git a/deeplabcut/gui/label_frames.py b/deeplabcut/gui/label_frames.py
deleted file mode 100644
index 0445ebe53c..0000000000
--- a/deeplabcut/gui/label_frames.py
+++ /dev/null
@@ -1,239 +0,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 pydoc
-import sys
-
-import wx
-
-from deeplabcut.generate_training_dataset import check_labels
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils import auxiliaryfunctions
-from pathlib import Path
-
-
-def label_frames(
- config,
- multiple_individualsGUI=False,
- imtypes=["*.png"],
- config3d=None,
- sourceCam=None,
- jump_unlabeled=False,
-):
- """Manually label/annotate the extracted frames.
-
- Update the list of body parts you want to localize in the config.yaml file first.
-
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- multiple_individualsGUI: bool, optional, default=False
- If ``True``, a user can label multiple individuals. Note for
- ``"multianimalproject=True"`` this is automatically used.
-
- imtypes: list[str], optional, default=["*.png"]
- Image types to look for in the folder. By default only png images are labeled.
-
- config3d: str or None, optional, default=None
- Full path of the config file in the 3D project. Include when epipolar lines
- would be helpful for labeling additional camera angles.
-
- sourceCam: str or None, optional, default=None
- The camera name from which to pull labeling data to generate epipolar lines.
- This must match the pattern in ``'camera_names'`` in the 3D config file.
- If no value is entered, data will be pulled from either cam1 or cam2.
-
- jump_unlabeled: bool, optional, default=False
- Aumatically jump to the next folder containing unlabeled images.
-
- Returns
- -------
- None
-
- Examples
- --------
- Standard use case
-
- >>> deeplabcut.label_frames('/myawesomeproject/reaching4thestars/config.yaml')
-
- To label multiple individuals (without having a multiple individuals project);
- otherwise this GUI is loaded automatically
-
- >>> deeplabcut.label_frames(
- '/analysis/project/reaching-task/config.yaml',
- multiple_individualsGUI=True,
- )
-
- To label non-default image types
-
- >>> label_frames(config, multiple=False, imtypes=['*.jpg','*.jpeg'])
-
- To label with epipolar lines projected from labels in another camera angle.
-
- >>> label_frames(
- config,
- config3d='/analysis/project/reaching-task/reaching-task-3d/config.yaml',
- sourceCam='cam1',
- )
- """
- startpath = os.getcwd()
- wd = Path(config).resolve().parents[0]
- os.chdir(str(wd))
- cfg = auxiliaryfunctions.read_config(config)
- if cfg.get("multianimalproject", False) or multiple_individualsGUI:
- from deeplabcut.gui import multiple_individuals_labeling_toolbox
-
- multiple_individuals_labeling_toolbox.show(config, config3d, sourceCam, jump_unlabeled)
- else:
- from deeplabcut.gui import labeling_toolbox
-
- labeling_toolbox.show(config, config3d, sourceCam, imtypes, jump_unlabeled)
-
- os.chdir(startpath)
-
-
-class Label_frames(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.method = "automatic"
- self.config = cfg
- # design the panel
- sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 3. Label Frames")
- sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- sizer.Add(icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5)
-
- line1 = wx.StaticLine(self)
- sizer.Add(line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_BUTTON, self.select_config)
-
- self.help_button = wx.Button(self, label="Help")
- sizer.Add(self.help_button, pos=(4, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.check = wx.Button(self, label="Check Labels!")
- sizer.Add(self.check, pos=(5, 4), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.check.Bind(wx.EVT_BUTTON, self.check_labelF)
- self.check.Enable(True)
-
- # self.build = wx.Button(self, label="Build skeleton")
- # sizer.Add(self.build, pos=(4, 3), flag=wx.BOTTOM | wx.RIGHT, border=10)
- # self.build.Bind(wx.EVT_BUTTON, self.build_skeleton)
- # self.build.Enable(True)
-
- self.cfg = auxiliaryfunctions.read_config(self.config)
- if self.cfg.get("multianimalproject", False):
-
- self.check = wx.Button(self, label="Check Labels Individuals")
- sizer.Add(self.check, pos=(5, 3), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.check.Bind(wx.EVT_BUTTON, self.check_labelInd)
- self.check.Enable(True)
-
- self.ok = wx.Button(self, label="Label Frames")
- sizer.Add(self.ok, pos=(4, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.label_frames)
-
- self.reset = wx.Button(self, label="Reset")
- sizer.Add(
- self.reset, pos=(4, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_label_frames)
-
- sizer.AddGrowableCol(2)
-
- self.SetSizer(sizer)
- sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.label_frames"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def check_labelF(self, event):
- dlg = wx.MessageDialog(
- None,
- "This will now plot the labeled frames after you have finished labeling!",
- )
- result = dlg.ShowModal()
- check_labels(self.config, visualizeindividuals=False)
-
- def check_labelInd(self, event):
- dlg = wx.MessageDialog(
- None,
- "This will now plot the labeled frames after you have finished labeling!",
- )
- result = dlg.ShowModal()
- check_labels(self.config, visualizeindividuals=True)
-
- # def build_skeleton(self, event):
- # skeleton.SkeletonBuilder(self.config)
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def label_frames(self, event):
- label_frames(self.config)
-
- def reset_label_frames(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
diff --git a/deeplabcut/gui/labeling_toolbox.py b/deeplabcut/gui/labeling_toolbox.py
deleted file mode 100755
index 3361998ff3..0000000000
--- a/deeplabcut/gui/labeling_toolbox.py
+++ /dev/null
@@ -1,979 +0,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 argparse
-import glob
-import os
-import os.path
-from pathlib import Path
-
-import cv2
-import re
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import wx
-import wx.lib.scrolledpanel as SP
-from matplotlib.backends.backend_wxagg import (
- NavigationToolbar2WxAgg as NavigationToolbar,
-)
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-
-from deeplabcut.gui import auxfun_drag
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import (
- auxiliaryfunctions,
- auxiliaryfunctions_3d,
- conversioncode,
-)
-
-
-class ImagePanel(BasePanel):
- def __init__(self, parent, config, config3d, sourceCam, gui_size, **kwargs):
- super(ImagePanel, self).__init__(parent, config, gui_size, **kwargs)
- self.config = config
- self.config3d = config3d
- self.sourceCam = sourceCam
- self.toolbar = None
-
- def retrieveData_and_computeEpLines(self, img, imNum):
-
- # load labeledPoints and fundamental Matrix
-
- if self.config3d is not None:
- cfg_3d = auxiliaryfunctions.read_config(self.config3d)
- cams = cfg_3d["camera_names"]
- path_camera_matrix = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)[2]
- path_stereo_file = os.path.join(path_camera_matrix, "stereo_params.pickle")
- stereo_file = auxiliaryfunctions.read_pickle(path_stereo_file)
-
- for cam in cams:
- if cam in img:
- labelCam = cam
- if self.sourceCam is None:
- sourceCam = [
- otherCam for otherCam in cams if cam not in otherCam
- ][
- 0
- ] # WHY?
- else:
- sourceCam = self.sourceCam
-
- sourceCamIdx = np.where(np.array(cams) == sourceCam)[0][0]
- labelCamIdx = np.where(np.array(cams) == labelCam)[0][0]
-
- if sourceCamIdx < labelCamIdx:
- camera_pair = cams[sourceCamIdx] + "-" + cams[labelCamIdx]
- sourceCam_numInPair = 1
- else:
- camera_pair = cams[labelCamIdx] + "-" + cams[sourceCamIdx]
- sourceCam_numInPair = 2
-
- fundMat = stereo_file[camera_pair]["F"]
- sourceCam_path = os.path.split(img.replace(labelCam, sourceCam))[0]
-
- cfg = auxiliaryfunctions.read_config(self.config)
- scorer = cfg["scorer"]
-
- try:
- dataFrame = pd.read_hdf(
- os.path.join(sourceCam_path, "CollectedData_" + scorer + ".h5")
- )
- dataFrame.sort_index(inplace=True)
- except IOError:
- print(
- "source camera images have not yet been labeled, or you have opened this folder in the wrong mode!"
- )
- return None, None, None
-
- # Find offset terms for drawing epipolar Lines
- # Get crop params for camera being labeled
- foundEvent = 0
- eventSearch = re.compile(os.path.split(os.path.split(img)[0])[1])
- cropPattern = re.compile("[0-9]{1,4}")
- with open(self.config, "rt") as config:
- for line in config:
- if foundEvent == 1:
- crop_labelCam = np.int32(re.findall(cropPattern, line))
- break
- if eventSearch.search(line) != None:
- foundEvent = 1
- # Get crop params for other camera
- foundEvent = 0
- eventSearch = re.compile(os.path.split(sourceCam_path)[1])
- cropPattern = re.compile("[0-9]{1,4}")
- with open(self.config, "rt") as config:
- for line in config:
- if foundEvent == 1:
- crop_sourceCam = np.int32(re.findall(cropPattern, line))
- break
- if eventSearch.search(line) != None:
- foundEvent = 1
-
- labelCam_offsets = [crop_labelCam[0], crop_labelCam[2]]
- sourceCam_offsets = [crop_sourceCam[0], crop_sourceCam[2]]
-
- sourceCam_pts = np.asarray(dataFrame, dtype=np.int32)
- sourceCam_pts = sourceCam_pts.reshape(
- (sourceCam_pts.shape[0], int(sourceCam_pts.shape[1] / 2), 2)
- )
- sourceCam_pts = np.moveaxis(sourceCam_pts, [0, 1, 2], [1, 0, 2])
- sourceCam_pts[..., 0] = sourceCam_pts[..., 0] + sourceCam_offsets[0]
- sourceCam_pts[..., 1] = sourceCam_pts[..., 1] + sourceCam_offsets[1]
-
- sourcePts = sourceCam_pts[:, imNum, :]
-
- epLines_source2label = cv2.computeCorrespondEpilines(
- sourcePts, int(sourceCam_numInPair), fundMat
- )
- epLines_source2label.reshape(-1, 3)
-
- return epLines_source2label, sourcePts, labelCam_offsets
-
- else:
- return None, None, None
-
- def drawEpLines(self, drawImage, lines, sourcePts, offsets, colorIndex, cmap, norm):
- height, width, depth = drawImage.shape
- for line, pt, cIdx in zip(lines, sourcePts, colorIndex):
- if pt[0] > -1000:
- coeffs = line[0]
- x0, y0 = map(int, [0 - offsets[0], -coeffs[2] / coeffs[1] - offsets[1]])
- x1, y1 = map(
- int,
- [
- width,
- -(coeffs[2] + coeffs[0] * (width + offsets[0])) / coeffs[1]
- - offsets[1],
- ],
- )
- color = cmap(norm(cIdx), bytes=True)[:-1]
- color = tuple([int(x) for x in color])
- drawImage = cv2.line(drawImage, (x0, y0), (x1, y1), color, 1)
-
- return drawImage
-
- def drawplot(self, img, img_name, itr, index, bodyparts, cmap, keep_view=False):
- xlim = self.axes.get_xlim()
- ylim = self.axes.get_ylim()
- self.axes.clear()
- # convert the image to RGB as you are showing the image with matplotlib
- im = cv2.imread(img)[..., ::-1]
- norm, colorIndex = getColorIndices(img, bodyparts)
- # draw epipolar lines
- epLines, sourcePts, offsets = self.retrieveData_and_computeEpLines(img, itr)
- if epLines is not None:
- im = self.drawEpLines(
- im.copy(), epLines, sourcePts, offsets, colorIndex, cmap, norm
- )
- ax = self.axes.imshow(im, cmap=cmap)
- self.orig_xlim = self.axes.get_xlim()
- self.orig_ylim = self.axes.get_ylim()
- divider = make_axes_locatable(self.axes)
- cax = divider.append_axes("right", size="5%", pad=0.05)
- cbar = self.figure.colorbar(
- plt.cm.ScalarMappable(cmap=cmap),
- cax=cax,
- ticks=np.linspace(0, 1, len(bodyparts)),
- )
- cbar.set_ticklabels(bodyparts[::-1])
- self.axes.set_title(str(str(itr) + "/" + str(len(index) - 1) + " " + img_name))
- if keep_view:
- self.axes.set_xlim(xlim)
- self.axes.set_ylim(ylim)
- if self.toolbar is None:
- self.toolbar = NavigationToolbar(self.canvas)
- return (self.figure, self.axes, self.canvas, self.toolbar)
-
-
-def getColorIndices(img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- im = cv2.imread(img)
- vmax = np.max(im)
- norm = mcolors.Normalize(vmin=0, vmax=vmax)
- ticks = np.linspace(0, vmax, len(bodyparts))[::-1]
- return norm, ticks
-
-
-class ScrollPanel(SP.ScrolledPanel):
- def __init__(self, parent):
- SP.ScrolledPanel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
- self.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def addRadioButtons(self, bodyparts, fileIndex, markersize):
- """
- Adds radio buttons for each bodypart on the right panel
- """
- self.choiceBox = wx.BoxSizer(wx.VERTICAL)
- choices = [l for l in bodyparts]
- self.fieldradiobox = wx.RadioBox(
- self,
- label="Select a bodypart to label",
- style=wx.RA_SPECIFY_ROWS,
- choices=choices,
- )
- self.slider = wx.Slider(
- self,
- -1,
- markersize,
- 1,
- markersize * 3,
- size=(250, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- self.slider.Enable(False)
- self.checkBox = wx.CheckBox(self, id=wx.ID_ANY, label="Adjust marker size.")
- self.choiceBox.Add(self.slider, 0, wx.ALL, 5)
- self.choiceBox.Add(self.checkBox, 0, wx.ALL, 5)
- self.choiceBox.Add(self.fieldradiobox, 0, wx.EXPAND | wx.ALL, 10)
- self.SetSizerAndFit(self.choiceBox)
- self.Layout()
- return (self.choiceBox, self.fieldradiobox, self.slider, self.checkBox)
-
- def clearBoxer(self):
- self.choiceBox.Clear(True)
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, imtypes, config3d, sourceCam, jump_unlabeled):
- super(MainFrame, self).__init__(
- "DeepLabCut2.0 - Labeling ToolBox", parent, imtypes
- )
- self.jump_unlabeled = jump_unlabeled
- self.statusbar.SetStatusText(
- "Looking for a folder to start labeling. Click 'Load frames' to begin."
- )
- self.Bind(wx.EVT_CHAR_HOOK, self.OnKeyPressed)
- ###################################################################################################################################################
-
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
-
- topSplitter = wx.SplitterWindow(self)
- vSplitter = wx.SplitterWindow(topSplitter)
-
- self.image_panel = ImagePanel(
- vSplitter, config, config3d, sourceCam, self.gui_size
- )
- self.choice_panel = ScrollPanel(vSplitter)
- vSplitter.SplitVertically(
- self.image_panel, self.choice_panel, sashPosition=self.gui_size[0] * 0.8
- )
- vSplitter.SetSashGravity(1)
- self.widget_panel = WidgetPanel(topSplitter)
- topSplitter.SplitHorizontally(
- vSplitter, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
- self.load = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Load frames")
- widgetsizer.Add(self.load, 1, wx.ALL, 15)
- self.load.Bind(wx.EVT_BUTTON, self.browseDir)
-
- self.prev = wx.Button(self.widget_panel, id=wx.ID_ANY, label="<= 1:
- curr_image = self.relativeimagenames[self.iter]
- prev_image = self.relativeimagenames[self.iter - 1]
- self.dataFrame.loc[curr_image] = self.dataFrame.loc[prev_image].values
- img_name = Path(self.index[self.iter]).name
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.bodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- def activateSlider(self, event):
- """
- Activates the slider to increase the markersize
- """
- self.checkSlider = event.GetEventObject()
- if self.checkSlider.GetValue():
- self.activate_slider = True
- self.slider.Enable(True)
- MainFrame.updateZoomPan(self)
- else:
- self.slider.Enable(False)
-
- def OnSliderScroll(self, event):
- """
- Adjust marker size for plotting the annotations
- """
- MainFrame.saveEachImage(self)
- MainFrame.updateZoomPan(self)
- self.buttonCounter = []
- self.markerSize = self.slider.GetValue()
- img_name = Path(self.index[self.iter]).name
- self.figure.delaxes(self.figure.axes[1])
- self.figure, self.axes, self.canvas, self.toolbar = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.bodyparts,
- self.colormap,
- keep_view=True,
- )
-
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- def quitButton(self, event):
- """
- Asks user for its inputs and then quits the GUI
- """
- self.statusbar.SetStatusText("Qutting now!")
-
- nextFilemsg = wx.MessageBox(
- "Do you want to label another data set?",
- "Repeat?",
- wx.YES_NO | wx.ICON_INFORMATION,
- )
- if nextFilemsg == 2:
- self.file = 1
- self.buttonCounter = []
- self.updatedCoords = []
- self.dataFrame = None
- self.bodyparts = []
- self.new_labels = self.new_labels
- self.axes.clear()
- self.figure.delaxes(self.figure.axes[1])
- self.choiceBox.Clear(True)
- MainFrame.updateZoomPan(self)
- MainFrame.browseDir(self, event)
- self.save.Enable(True)
- else:
- self.Destroy()
- print(
- "You can now check the labels, using 'check_labels' before proceeding. Then, you can use the function 'create_training_dataset' to create the training dataset."
- )
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- MainFrame.updateZoomPan(self)
- wx.MessageBox(
- "1. Select an individual and one of the body parts from the radio buttons to add a label (if necessary change config.yaml first to edit the label names). \n\n2. Right clicking on the image will add the selected label and the next available label will be selected from the radio button. \n The label will be marked as circle filled with a unique color (and individual ID a unique color on the rim).\n\n3. To change the marker size, mark the checkbox and move the slider, then uncheck the box. \n\n4. Hover your mouse over this newly added label to see its name. \n\n5. Use left click and drag to move the label position. \n\n6. Once you are happy with the position, right click to add the next available label. You can always reposition the old labels, if required. You can delete a label with the middle button mouse click (or click 'delete' key). \n\n7. Click Next/Previous to move to the next/previous image (or hot-key arrows left and right).\n User can also re-label a deleted point by going to a previous/next image then returning to the current image. \n NOTE: the user cannot add a label if the label is already present. \n \n8. You can click Cntrl+C to copy+paste labels from a previous image into the current image. \n\n9. When finished labeling all the images, click 'Save' to save all the labels as a .h5 file. \n\n10. Click OK to continue using the labeling GUI. For more tips and hotkeys: see docs!!",
- "User instructions",
- wx.OK | wx.ICON_INFORMATION,
- )
- self.statusbar.SetStatusText("Help")
-
- def onButtonRelease(self, event):
- if self.pan.GetValue():
- self.updateZoomPan()
- self.statusbar.SetStatusText("Pan Off")
-
- def onClick(self, event):
- """
- This function adds labels and auto advances to the next label.
- """
- x1 = event.xdata
- y1 = event.ydata
-
- if event.button == 3:
- if self.rdb.GetSelection() in self.buttonCounter:
- wx.MessageBox(
- "%s is already annotated. \n Select another body part to annotate."
- % (str(self.bodyparts[self.rdb.GetSelection()])),
- "Error!",
- wx.OK | wx.ICON_ERROR,
- )
- else:
- color = self.colormap(
- self.norm(self.colorIndex[self.rdb.GetSelection()])
- )
- circle = [
- patches.Circle(
- (x1, y1), radius=self.markerSize, fc=color, alpha=self.alpha
- )
- ]
- self.num.append(circle)
- self.axes.add_patch(circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- circle[0], self.bodyparts[self.rdb.GetSelection()]
- )
- self.dr.connect()
- self.buttonCounter.append(self.rdb.GetSelection())
- self.dr.coords = [
- [
- x1,
- y1,
- self.bodyparts[self.rdb.GetSelection()],
- self.rdb.GetSelection(),
- ]
- ]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- if self.rdb.GetSelection() < len(self.bodyparts) - 1:
- self.rdb.SetSelection(self.rdb.GetSelection() + 1)
- self.figure.canvas.draw()
-
- self.canvas.mpl_disconnect(self.onClick)
- self.canvas.mpl_disconnect(self.onButtonRelease)
-
- def nextLabel(self, event):
- """
- This function is to create a hotkey to skip down on the radio button panel.
- """
- if self.rdb.GetSelection() < len(self.bodyparts) - 1:
- self.rdb.SetSelection(self.rdb.GetSelection() + 1)
-
- def previousLabel(self, event):
- """
- This function is to create a hotkey to skip up on the radio button panel.
- """
- if self.rdb.GetSelection() > 0:
- self.rdb.SetSelection(self.rdb.GetSelection() - 1)
-
- def browseDir(self, event):
- """
- Show the DirDialog and ask the user to change the directory where machine labels are stored
- """
- if self.jump_unlabeled:
- self.dir = str(auxiliaryfunctions.find_next_unlabeled_folder(
- self.config_file
- ))
- else:
- self.statusbar.SetStatusText("Looking for a folder to start labeling...")
- cwd = os.path.join(os.getcwd(), "labeled-data")
- dlg = wx.DirDialog(
- self,
- "Choose the directory where your extracted frames are saved:",
- cwd,
- style=wx.DD_DEFAULT_STYLE,
- )
- if dlg.ShowModal() != wx.ID_OK:
- dlg.Destroy()
- self.Close(True)
- return
- self.dir = dlg.GetPath()
- dlg.Destroy()
-
- self.load.Enable(False)
- self.next.Enable(True)
- self.save.Enable(True)
-
- # Enabling the zoom, pan and home buttons
- self.zoom.Enable(True)
- self.home.Enable(True)
- self.pan.Enable(True)
- self.lock.Enable(True)
-
- # Reading config file and its variables
- self.cfg = auxiliaryfunctions.read_config(self.config_file)
- self.scorer = self.cfg["scorer"]
- self.bodyparts = self.cfg["bodyparts"]
- self.videos = self.cfg["video_sets"].keys()
- self.markerSize = self.cfg["dotsize"]
- self.alpha = self.cfg["alphavalue"]
- self.colormap = plt.get_cmap(self.cfg["colormap"])
- self.colormap = self.colormap.reversed()
- self.project_path = self.cfg["project_path"]
-
- imlist = []
- for imtype in self.imtypes:
- imlist.extend(
- [
- fn
- for fn in glob.glob(os.path.join(self.dir, imtype))
- if ("labeled.png" not in fn)
- ]
- )
-
- if len(imlist) == 0:
- print("No images found!!")
-
- self.index = np.sort(imlist)
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- relativeimagenames = [
- "labeled" + n.split("labeled")[1] for n in self.index
- ] # [n.split(self.project_path+'/')[1] for n in self.index]
- self.relativeimagenames = [tuple(name.split(os.path.sep))
- for name in relativeimagenames]
- # Reading the existing dataset,if already present
- try:
- self.dataFrame = pd.read_hdf(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".h5")
- )
- conversioncode.guarantee_multiindex_rows(self.dataFrame)
- self.dataFrame.sort_index(inplace=True)
- self.prev.Enable(True)
-
- # Finds the first empty row in the dataframe and sets the iteration to that index
- for idx, j in enumerate(self.dataFrame.index):
- values = self.dataFrame.loc(axis=0)[j].values
- if np.prod(np.isnan(values)) == 1:
- self.iter = idx
- break
- else:
- self.iter = 0
-
- except:
- a = np.empty((len(self.index), 2))
- a[:] = np.nan
- for bodypart in self.bodyparts:
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [bodypart], ["x", "y"]],
- names=["scorer", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(self.relativeimagenames)
- frame = pd.DataFrame(a, columns=cols, index=index)
- self.dataFrame = pd.concat([self.dataFrame, frame], axis=1)
- self.iter = 0
-
- # Reading the image name
- self.img = os.path.join(*self.dataFrame.index[self.iter])
- img_name = Path(self.img).name
- self.norm, self.colorIndex = getColorIndices(
- self.img, self.bodyparts
- )
-
- # Checking for new frames and adding them to the existing dataframe
- old_imgs = sorted(self.dataFrame.index)
- self.newimages = list(set(self.relativeimagenames) - set(old_imgs))
- if self.newimages:
- print("Found new frames..")
- # Create an empty dataframe with all the new images and then merge this to the existing dataframe.
- self.df = None
- a = np.empty((len(self.newimages), 2))
- a[:] = np.nan
- for bodypart in self.bodyparts:
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [bodypart], ["x", "y"]],
- names=["scorer", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(self.newimages)
- frame = pd.DataFrame(a, columns=cols, index=index)
- self.df = pd.concat([self.df, frame], axis=1)
- self.dataFrame = pd.concat([self.dataFrame, self.df], axis=0)
- # Sort it by the index values
- self.dataFrame.sort_index(inplace=True)
-
- # checks for unique bodyparts
- if len(self.bodyparts) != len(set(self.bodyparts)):
- print(
- "Error - bodyparts must have unique labels! Please choose unique bodyparts in config.yaml file and try again. Quitting for now!"
- )
- self.Close(True)
-
- # Extracting the list of new labels
- oldBodyParts = self.dataFrame.columns.get_level_values(1)
- _, idx = np.unique(oldBodyParts, return_index=True)
- oldbodyparts2plot = list(oldBodyParts[np.sort(idx)])
- self.new_bodyparts = [x for x in self.bodyparts if x not in oldbodyparts2plot]
- # Checking if user added a new label
- if not self.new_bodyparts: # i.e. no new label
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img, img_name, self.iter, self.index, self.bodyparts, self.colormap
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
-
- (
- self.choiceBox,
- self.rdb,
- self.slider,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.bodyparts, self.file, self.markerSize
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- self.canvas.mpl_connect("button_release_event", self.onButtonRelease)
- else:
- dlg = wx.MessageDialog(
- None,
- "New label found in the config file. Do you want to see all the other labels?",
- "New label found",
- wx.YES_NO | wx.ICON_WARNING,
- )
- result = dlg.ShowModal()
- if result == wx.ID_NO:
- self.bodyparts = self.new_bodyparts
- self.norm, self.colorIndex = getColorIndices(
- self.img, self.bodyparts
- )
- a = np.empty((len(self.index), 2))
- a[:] = np.nan
- for bodypart in self.new_bodyparts:
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [bodypart], ["x", "y"]],
- names=["scorer", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(self.relativeimagenames)
- frame = pd.DataFrame(a, columns=cols, index=index)
- self.dataFrame = pd.concat([self.dataFrame, frame], axis=1)
-
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img, img_name, self.iter, self.index, self.bodyparts, self.colormap
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
-
- (
- self.choiceBox,
- self.rdb,
- self.slider,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.bodyparts, self.file, self.markerSize
- )
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- self.canvas.mpl_connect("button_release_event", self.onButtonRelease)
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activateSlider)
- self.slider.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
-
- def nextImage(self, event):
- """
- Moves to next image
- """
- # Checks for the last image and disables the Next button
- if len(self.index) - self.iter == 1:
- self.next.Enable(False)
- return
- self.prev.Enable(True)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
-
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- self.rdb.SetSelection(0)
- self.file = 1
- # Refreshing the button counter
- self.buttonCounter = []
-
- MainFrame.saveEachImage(self)
- self.iter = self.iter + 1
-
- if len(self.index) >= self.iter:
- self.updatedCoords = MainFrame.getLabels(self, self.iter)
- self.img = self.index[self.iter]
- img_name = Path(self.index[self.iter]).name
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.bodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
-
- self.buttonCounter = MainFrame.plot(self, self.img)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- self.canvas.mpl_connect("button_release_event", self.onButtonRelease)
-
- def prevImage(self, event):
- """
- Checks the previous Image and enables user to move the annotations.
- """
- # Checks for the first image and disables the Previous button
- if self.iter == 0:
- self.prev.Enable(False)
- return
- else:
- self.next.Enable(True)
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- MainFrame.saveEachImage(self)
-
- self.buttonCounter = []
- self.iter = self.iter - 1
-
- self.rdb.SetSelection(0)
- self.img = self.index[self.iter]
- img_name = Path(self.index[self.iter]).name
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- self.figure, self.axes, self.canvas, self.toolbar = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.bodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
-
- self.buttonCounter = MainFrame.plot(self, self.img)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- self.canvas.mpl_connect("button_release_event", self.onButtonRelease)
- MainFrame.saveEachImage(self)
-
- def getLabels(self, img_index):
- """
- Returns a list of x and y labels of the corresponding image index
- """
- self.previous_image_points = []
- for bpindex, bp in enumerate(self.bodyparts):
- image_points = [
- [
- self.dataFrame[self.scorer][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][bp]["y"].values[self.iter],
- bp,
- bpindex,
- ]
- ]
- self.previous_image_points.append(image_points)
- return self.previous_image_points
-
- def plot(self, img):
- """
- Plots and call auxfun_drag class for moving and removing points.
- """
- self.drs = []
- self.updatedCoords = []
- for bpindex, bp in enumerate(self.bodyparts):
- color = self.colormap(self.norm(self.colorIndex[bpindex]))
- self.points = [
- self.dataFrame[self.scorer][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][bp]["y"].values[self.iter],
- ]
- circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(circle[0])
- self.dr = auxfun_drag.DraggablePoint(circle[0], self.bodyparts[bpindex])
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(self, self.iter)[bpindex]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- if not np.isnan(self.points)[0]:
- self.buttonCounter.append(bpindex)
- self.figure.canvas.draw()
-
- return self.buttonCounter
-
- def saveEachImage(self):
- """
- Saves data for each image
- """
- for idx, bp in enumerate(self.updatedCoords):
- self.dataFrame.loc[self.relativeimagenames[self.iter]][
- self.scorer, bp[0][-2], "x"
- ] = bp[-1][0]
- self.dataFrame.loc[self.relativeimagenames[self.iter]][
- self.scorer, bp[0][-2], "y"
- ] = bp[-1][1]
-
- def saveDataSet(self, event):
- """
- Saves the final dataframe
- """
- self.statusbar.SetStatusText("File saved")
- MainFrame.saveEachImage(self)
- MainFrame.updateZoomPan(self)
-
- # Windows compatible
- self.dataFrame.sort_index(inplace=True)
- self.dataFrame = self.dataFrame.reindex(
- self.cfg["bodyparts"],
- axis=1,
- level=self.dataFrame.columns.names.index("bodyparts"),
- )
- self.dataFrame.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".csv")
- )
- self.dataFrame.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".h5"),
- "df_with_missing",
- )
-
- def onChecked(self, event):
- self.cb = event.GetEventObject()
- if self.cb.GetValue():
- self.slider.Enable(True)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- self.canvas.mpl_connect("button_release_event", self.onButtonRelease)
- else:
- self.slider.Enable(False)
-
-
-def show(config, config3d, sourceCam, imtypes=["*.png"], jump_unlabeled=False):
- app = wx.App()
- frame = MainFrame(None, config, imtypes, config3d, sourceCam, jump_unlabeled).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config")
- parser.add_argument("config3d")
- parser.add_argument("sourceCam")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/launch_script.py b/deeplabcut/gui/launch_script.py
index 4b6ac99a5a..e2e70a7230 100644
--- a/deeplabcut/gui/launch_script.py
+++ b/deeplabcut/gui/launch_script.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,43 +18,57 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import os
-import wx
-import matplotlib as mpl
-
-from deeplabcut.gui.create_new_project import Create_new_project
-from deeplabcut.gui.welcome import Welcome
-from deeplabcut.gui.widgets import BaseFrame
-from deeplabcut.utils import auxiliaryfunctions
-
-
-class MainFrame(BaseFrame):
- def __init__(self):
- super(MainFrame, self).__init__("DeepLabCut")
- self.statusbar.SetStatusText("www.deeplabcut.org")
- dlcparent_path = auxiliaryfunctions.get_deeplabcut_path()
- media_path = os.path.join(dlcparent_path, "gui", "media")
- logo = os.path.join(media_path, "logo.png")
- self.SetIcon(wx.Icon(logo))
- # Here we create a panel and a notebook on the panel
- self.panel = wx.Panel(self)
- self.nb = wx.Notebook(self.panel)
- # create the page windows as children of the notebook and add the pages to the notebook with the label to show on the tab
- page1 = Welcome(self.nb, self.gui_size)
- self.nb.AddPage(page1, "Welcome")
-
- page2 = Create_new_project(self.nb, self.gui_size)
- self.nb.AddPage(page2, "Manage Project")
-
- self.sizer = wx.BoxSizer()
- self.sizer.Add(self.nb, 1, wx.EXPAND)
- self.panel.SetSizer(self.sizer)
+
+import logging
+import sys
+
+import PySide6.QtWidgets as QtWidgets
+import qdarkstyle
+from PySide6.QtCore import Qt
+
+from deeplabcut.gui.gui_assets import get_style_qss, icon_from_resource, pixmap_from_resource
+
+logger = logging.getLogger(__name__)
def launch_dlc():
- mpl.use("WxAgg")
+ app = QtWidgets.QApplication(sys.argv)
+ app.setWindowIcon(icon_from_resource("logo.png"))
+ screen_size = app.screens()[0].size()
+ pixmap = pixmap_from_resource("welcome.png").scaledToWidth(int(0.7 * screen_size.width()), Qt.SmoothTransformation)
+ splash = QtWidgets.QSplashScreen(pixmap)
+ splash.show()
+
+ app.setStyleSheet(get_style_qss()) # this gets overridden immediately?
+ try:
+ dark_stylesheet = qdarkstyle.load_stylesheet_pyside6()
+ except Exception as e:
+ logger.warning(f"Could not load qdarkstyle stylesheet for PySide6: {e}. Falling back to PySide2 stylesheet.")
+ dark_stylesheet = qdarkstyle.load_stylesheet_pyside2()
+ app.setStyleSheet(dark_stylesheet)
+
+ # Set up a logger and add an stdout handler.
+ # A single logger can have many handlers:
+ # https://docs.python.org/3/howto/logging.html#handler-basic
+ # TODO Dump to log file instead
+ # logger = logging.getLogger("GUI")
+ # logger.setLevel(logging.DEBUG)
+ # handler = logging.StreamHandler(stream=sys.stdout)
+ # handler.setLevel(logging.DEBUG)
+ # formatter = logging.Formatter(
+ # "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"
+ # )
+ # handler.setFormatter(formatter)
+ # logger.addHandler(handler)
+
+ from deeplabcut.gui.window import MainWindow
+
+ window = MainWindow(app)
+ window.receiver.start()
+ window.showMaximized()
+ splash.finish(window)
+ sys.exit(app.exec_())
+
- app = wx.App()
- app.locale = wx.Locale(wx.LANGUAGE_ENGLISH)
- frame = MainFrame().Show()
- app.MainLoop()
+if __name__ == "__main__":
+ launch_dlc()
diff --git a/deeplabcut/gui/load_project.py b/deeplabcut/gui/load_project.py
deleted file mode 100644
index d6fe698537..0000000000
--- a/deeplabcut/gui/load_project.py
+++ /dev/null
@@ -1,102 +0,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 sys
-
-import wx
-
-
-class Load_project(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.config = cfg
- # design the panel
- self.sizer = wx.GridBagSizer(10, 15)
-
- text = wx.StaticText(self, label="DeepLabCut Load project")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- # icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(logo))
- # self.sizer.Add(icon, pos=(0, 4), flag=wx.TOP|wx.RIGHT|wx.ALIGN_RIGHT,border=5)
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 15), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- if self.config is None:
- self.config = "Please select the config file"
-
- self.sel_config.SetPath(self.config)
-
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 15), flag=wx.TOP | wx.EXPAND, border=0
- )
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- button3 = wx.Button(self, label="Help")
- self.sizer.Add(button3, pos=(4, 0), flag=wx.LEFT, border=10)
-
- self.ok = wx.Button(self, label="Ok")
- self.sizer.Add(self.ok, pos=(4, 5))
- self.ok.Bind(wx.EVT_BUTTON, self.load_project)
-
- self.cancel = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.cancel, pos=(4, 3), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.cancel.Bind(wx.EVT_BUTTON, self.cancel_load_project)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def load_project(self, event):
- print(self.config)
-
- def cancel_load_project(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
diff --git a/deeplabcut/gui/media/__init__.py b/deeplabcut/gui/media/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/gui/media/__init__.py
+++ b/deeplabcut/gui/media/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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/multiple_individuals_labeling_toolbox.py b/deeplabcut/gui/multiple_individuals_labeling_toolbox.py
deleted file mode 100755
index ec493b1257..0000000000
--- a/deeplabcut/gui/multiple_individuals_labeling_toolbox.py
+++ /dev/null
@@ -1,1350 +0,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 argparse
-import glob
-import os
-import os.path
-from pathlib import Path
-
-import re
-import cv2
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import wx
-import wx.lib.scrolledpanel as SP
-from matplotlib.backends.backend_wxagg import (
- NavigationToolbar2WxAgg as NavigationToolbar,
-)
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-
-from deeplabcut.gui import auxfun_drag
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import (
- auxiliaryfunctions,
- auxfun_multianimal,
- auxiliaryfunctions_3d,
- conversioncode,
-)
-
-# ###########################################################################
-# Class for GUI MainFrame
-# ###########################################################################
-class ImagePanel(BasePanel):
- def __init__(self, parent, config, config3d, sourceCam, gui_size, **kwargs):
- super(ImagePanel, self).__init__(parent, config, gui_size, **kwargs)
- self.config = config
- self.cfg = auxiliaryfunctions.read_config(self.config)
- self.config3d = config3d
- self.sourceCam = sourceCam
- self.toolbar = None
-
- def retrieveData_and_computeEpLines(self, img, imNum):
-
- # load labeledPoints and fundamental Matrix
-
- if self.config3d is not None:
- cfg_3d = auxiliaryfunctions.read_config(self.config3d)
- cams = cfg_3d["camera_names"]
- path_camera_matrix = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)[2]
- path_stereo_file = os.path.join(path_camera_matrix, "stereo_params.pickle")
- stereo_file = auxiliaryfunctions.read_pickle(path_stereo_file)
-
- for cam in cams:
- if cam in img:
- labelCam = cam
- if self.sourceCam is None:
- sourceCam = [
- otherCam for otherCam in cams if cam not in otherCam
- ][0]
- else:
- sourceCam = self.sourceCam
-
- sourceCamIdx = np.where(np.array(cams) == sourceCam)[0][0]
- labelCamIdx = np.where(np.array(cams) == labelCam)[0][0]
- if sourceCamIdx < labelCamIdx:
- camera_pair = cams[sourceCamIdx] + "-" + cams[labelCamIdx]
- sourceCam_numInPair = 1
- else:
- camera_pair = cams[labelCamIdx] + "-" + cams[sourceCamIdx]
- sourceCam_numInPair = 2
-
- fundMat = stereo_file[camera_pair]["F"]
- sourceCam_path = os.path.split(img.replace(labelCam, sourceCam))[0]
-
- cfg = auxiliaryfunctions.read_config(self.config)
- scorer = cfg["scorer"]
-
- try:
- dataFrame = pd.read_hdf(
- os.path.join(sourceCam_path, "CollectedData_" + scorer + ".h5")
- )
- dataFrame.sort_index(inplace=True)
- except IOError:
- print(
- "source camera images have not yet been labeled, or you have opened this folder in the wrong mode!"
- )
- return None, None, None
-
- # Find offset terms for drawing epipolar Lines
- # Get crop params for camera being labeled
- foundEvent = 0
- eventSearch = re.compile(os.path.split(os.path.split(img)[0])[1])
- cropPattern = re.compile("[0-9]{1,4}")
- with open(self.config, "rt") as config:
- for line in config:
- if foundEvent == 1:
- crop_labelCam = np.int32(re.findall(cropPattern, line))
- break
- if eventSearch.search(line) != None:
- foundEvent = 1
- # Get crop params for other camera
- foundEvent = 0
- eventSearch = re.compile(os.path.split(sourceCam_path)[1])
- cropPattern = re.compile("[0-9]{1,4}")
- with open(self.config, "rt") as config:
- for line in config:
- if foundEvent == 1:
- crop_sourceCam = np.int32(re.findall(cropPattern, line))
- break
- if eventSearch.search(line) != None:
- foundEvent = 1
-
- labelCam_offsets = [crop_labelCam[0], crop_labelCam[2]]
- sourceCam_offsets = [crop_sourceCam[0], crop_sourceCam[2]]
-
- sourceCam_pts = np.asarray(dataFrame, dtype=np.int32)
- sourceCam_pts = sourceCam_pts.reshape(
- (sourceCam_pts.shape[0], int(sourceCam_pts.shape[1] / 2), 2)
- )
- sourceCam_pts = np.moveaxis(sourceCam_pts, [0, 1, 2], [1, 0, 2])
- sourceCam_pts[..., 0] = sourceCam_pts[..., 0] + sourceCam_offsets[0]
- sourceCam_pts[..., 1] = sourceCam_pts[..., 1] + sourceCam_offsets[1]
-
- sourcePts = sourceCam_pts[:, imNum, :]
-
- epLines_source2label = cv2.computeCorrespondEpilines(
- sourcePts, int(sourceCam_numInPair), fundMat
- )
- epLines_source2label.reshape(-1, 3)
-
- return epLines_source2label, sourcePts, labelCam_offsets
-
- else:
- return None, None, None
-
- def drawEpLines(self, drawImage, lines, sourcePts, offsets, colorIndex, cmap, norm):
- height, width, depth = drawImage.shape
- for line, pt, cIdx in zip(lines, sourcePts, colorIndex):
- if pt[0] > -1000:
- coeffs = line[0]
- x0, y0 = map(int, [0 - offsets[0], -coeffs[2] / coeffs[1] - offsets[1]])
- x1, y1 = map(
- int,
- [
- width,
- -(coeffs[2] + coeffs[0] * (width + offsets[0])) / coeffs[1]
- - offsets[1],
- ],
- )
- color = cmap(norm(cIdx), bytes=True)[:-1]
- color = tuple([int(x) for x in color])
- drawImage = cv2.line(drawImage, (x0, y0), (x1, y1), color, 1)
- return drawImage
-
- def drawplot(self, img, img_name, itr, index, bodyparts, cmap, keep_view=False):
- individuals = self.cfg["individuals"]
- xlim = self.axes.get_xlim()
- ylim = self.axes.get_ylim()
- self.axes.clear()
- # convert the image to RGB as you are showing the image with matplotlib
- im = cv2.imread(img)[..., ::-1]
- colorIndex = []
- for indiv in range(len(individuals)):
- colorIndex.extend(np.linspace(np.max(im), 0, len(bodyparts)))
- colorIndex = np.array(colorIndex)
- norm = mcolors.Normalize(vmin=0, vmax=np.max(im))
- # draw epipolar lines
- epLines, sourcePts, offsets = self.retrieveData_and_computeEpLines(img, itr)
- if epLines is not None:
- im = self.drawEpLines(
- im.copy(), epLines, sourcePts, offsets, colorIndex, cmap, norm
- )
- ax = self.axes.imshow(im, cmap=cmap)
- self.orig_xlim = self.axes.get_xlim()
- self.orig_ylim = self.axes.get_ylim()
- # divider = make_axes_locatable(self.axes)
- # colorIndex = np.linspace(np.min(im),np.max(im),len(bodyparts))
- # cax = divider.append_axes("right", size="5%", pad=0.05)
- # cbar = self.figure.colorbar(ax, cax=cax,spacing='proportional', ticks=colorIndex)
- # cbar.set_ticklabels(bodyparts[::-1])
- self.axes.set_title(str(str(itr) + "/" + str(len(index) - 1) + " " + img_name))
- # self.figure.canvas.draw()
- if keep_view:
- self.axes.set_xlim(xlim)
- self.axes.set_ylim(ylim)
- if self.toolbar is None:
- self.toolbar = NavigationToolbar(self.canvas)
- return (self.figure, self.axes, self.canvas, self.toolbar, ax)
-
- def addcolorbar(self, img, ax, itr, bodyparts, cmap):
- im = cv2.imread(img)
- divider = make_axes_locatable(self.axes)
- colorIndex = np.linspace(np.min(im), np.max(im), len(bodyparts))
- cax = divider.append_axes("right", size="5%", pad=0.05)
- cbar = self.figure.colorbar(
- ax, cax=cax, spacing="proportional", ticks=colorIndex
- )
- cbar.set_ticklabels(bodyparts[::-1])
- self.figure.canvas.draw()
- if self.toolbar is None:
- self.toolbar = NavigationToolbar(self.canvas)
- return (self.figure, self.axes, self.canvas, self.toolbar)
-
- def getColorIndices(self, img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- im = cv2.imread(img)
- norm = mcolors.Normalize(vmin=0, vmax=np.max(im))
- ticks = np.linspace(0, np.max(im), len(bodyparts))[::-1]
- return norm, ticks
-
-
-class ScrollPanel(SP.ScrolledPanel):
- def __init__(self, parent):
- SP.ScrolledPanel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
- self.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def addRadioButtons(self, bodyparts, individual_names, fileIndex, markersize):
- """
- Adds radio buttons for each bodypart on the right panel
- """
- self.choiceBox = wx.BoxSizer(wx.VERTICAL)
- choices = [l for l in bodyparts]
- self.fieldradiobox = wx.RadioBox(
- self,
- label="Select a bodypart to label",
- majorDimension=3,
- style=wx.RA_SPECIFY_COLS,
- choices=choices,
- )
- self.change_marker = wx.Slider(
- self,
- -1,
- markersize,
- 1,
- markersize * 3,
- size=(250, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- self.change_marker.Enable(False)
- names = [k for k in individual_names]
- self.individualradiobox = wx.RadioBox(
- self,
- label="Select an individual",
- majorDimension=3,
- style=wx.RA_SPECIFY_COLS,
- choices=names,
- )
-
- self.checkBox = wx.CheckBox(self, id=wx.ID_ANY, label="Adjust marker size")
- self.choiceBox.Add(self.change_marker, 0, wx.ALL, 5)
- self.choiceBox.Add(self.checkBox, 0, wx.ALL, 5)
- self.choiceBox.Add(self.individualradiobox, 0, wx.EXPAND | wx.ALL, 10)
-
- self.choiceBox.Add(self.fieldradiobox, 0, wx.EXPAND | wx.ALL, 10)
- self.SetSizerAndFit(self.choiceBox)
- self.Layout()
- return (
- self.choiceBox,
- self.individualradiobox,
- self.fieldradiobox,
- self.change_marker,
- self.checkBox,
- )
-
- def clearBoxer(self):
- self.choiceBox.Clear(True)
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, config3d, sourceCam, jump_unlabeled):
- super(MainFrame, self).__init__(
- "DeepLabCut 2.2 - Multiple Individuals Labeling", parent
- )
- self.jump_unlabeled = jump_unlabeled
- self.statusbar.SetStatusText(
- "Looking for a folder to start labeling. Click 'Load frames' to begin."
- )
- self.Bind(wx.EVT_CHAR_HOOK, self.OnKeyPressed)
-
- ###################################################################################################################################################
-
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
-
- topSplitter = wx.SplitterWindow(self)
- vSplitter = wx.SplitterWindow(topSplitter)
-
- self.image_panel = ImagePanel(
- vSplitter, config, config3d, sourceCam, self.gui_size
- )
- self.choice_panel = ScrollPanel(vSplitter)
-
- vSplitter.SplitVertically(
- self.image_panel, self.choice_panel, sashPosition=self.gui_size[0] * 0.8
- )
- vSplitter.SetSashGravity(1)
- self.widget_panel = WidgetPanel(topSplitter)
- topSplitter.SplitHorizontally(
- vSplitter, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
- self.load = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Load frames")
- widgetsizer.Add(self.load, 1, wx.ALL, 15)
- self.load.Bind(wx.EVT_BUTTON, self.browseDir)
-
- self.prev = wx.Button(self.widget_panel, id=wx.ID_ANY, label="<= 1:
- curr_individual = self.individualrdb.GetStringSelection()
- curr_image = self.relativeimagenames[self.iter]
- prev_image = self.relativeimagenames[self.iter - 1]
- idx = pd.IndexSlice
- self.dataFrame.loc[
- curr_image, idx[:, curr_individual]
- ] = self.dataFrame.loc[prev_image, idx[:, curr_individual]].values
- img_name = Path(self.index[self.iter]).name
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- if curr_individual == "single":
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.uniquebodyparts
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
- else:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.multibodyparts
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- def activateSlider(self, event):
- """
- Activates the slider to increase the markersize
- """
- self.checkSlider = event.GetEventObject()
- if self.checkSlider.GetValue():
- self.activate_slider = True
- self.change_marker_size.Enable(True)
- MainFrame.updateZoomPan(self)
- else:
- self.change_marker_size.Enable(False)
-
- def OnSliderScroll(self, event):
- """
- Adjust marker size for plotting the annotations
- """
- MainFrame.saveEachImage(self)
- MainFrame.updateZoomPan(self)
- self.updatedCoords = []
- self.markerSize = self.change_marker_size.GetValue()
- self.edgewidth = self.markerSize // 3
- img_name = Path(self.index[self.iter]).name
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=True,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- def quitButton(self, event):
- """
- Asks user for its inputs and then quits the GUI
- """
- # MainFrame.saveDataSet(self, event)
- self.statusbar.SetStatusText("Quitting now!")
-
- nextFilemsg = wx.MessageBox(
- "Do you want to label another data set?",
- "Repeat?",
- wx.YES_NO | wx.ICON_INFORMATION,
- )
- if nextFilemsg == 2:
- self.file = 1
- self.buttonCounter = {i: [] for i in self.individual_names}
- self.updatedCoords = []
- self.dataFrame = None
- self.multibodyparts = []
- self.new_labels = self.new_labels
- self.axes.clear()
- self.figure.delaxes(self.figure.axes[1])
- self.choiceBox.Clear(True)
- MainFrame.updateZoomPan(self)
- MainFrame.browseDir(self, event)
- else:
- self.Destroy()
- print(
- "You can now check the labels, using 'check_labels' before proceeding. Then, you can use the function 'create_training_dataset' to create the training dataset."
- )
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- MainFrame.updateZoomPan(self)
- wx.MessageBox(
- "1. Select an individual and one of the body parts from the radio buttons to add a label (if necessary change config.yaml first to edit the label names). \n\n2. Right clicking on the image will add the selected label and the next available label will be selected from the radio button. \n The label will be marked as circle filled with a unique color (and individual ID a unique color on the rim).\n\n3. To change the marker size, mark the checkbox and move the slider, then uncheck the box. \n\n4. Hover your mouse over this newly added label to see its name. \n\n5. Use left click and drag to move the label position. \n\n6. Once you are happy with the position, right click to add the next available label. You can always reposition the old labels, if required. You can delete a label with the middle button mouse click (or click 'delete' key). \n\n7. Click Next/Previous to move to the next/previous image (or hot-key arrows left and right).\n User can also re-label a deleted point by going to a previous/next image then returning to the current image. \n NOTE: the user cannot add a label if the label is already present. \n \n8. You can click Cntrl+C to copy+paste labels from a previous image into the current image. For maDLC, you do this for each individual. \n\n9. When finished labeling all the images, click 'Save' to save all the labels as a .h5 file. \n\n10. Click OK to continue using the labeling GUI. For more tips and hotkeys: see docs!!",
- "User instructions",
- wx.OK | wx.ICON_INFORMATION,
- )
- self.statusbar.SetStatusText("Help")
-
- def onButtonRelease(self, event):
- if self.pan.GetValue():
- self.updateZoomPan()
- self.statusbar.SetStatusText("Pan Off")
-
- def onClick(self, event):
- """
- This function adds labels and auto advances to the next label.
- """
- x1 = event.xdata
- y1 = event.ydata
- if event.button == 3:
- num_indiv = self.individualrdb.GetSelection()
- indiv = self.individual_names[num_indiv]
- idcolor = self.idmap(num_indiv)
- if self.individualrdb.GetStringSelection() == "single":
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.uniquebodyparts
- )
- if (
- self.uniquebodyparts[self.rdb.GetSelection()]
- in self.buttonCounter[indiv]
- ):
- wx.MessageBox(
- "%s is already annotated for %s. \n Select another body part to annotate."
- % (
- str(self.uniquebodyparts[self.rdb.GetSelection()]),
- str(
- self.individual_names[self.individualrdb.GetSelection()]
- ),
- ),
- "Error!",
- wx.OK | wx.ICON_ERROR,
- )
- else:
- color = self.colormap(
- self.norm(self.colorIndex[self.rdb.GetSelection()])
- )
- circle = [
- patches.Circle(
- (x1, y1),
- radius=self.markerSize,
- fc=color,
- ec=idcolor,
- lw=self.edgewidth,
- alpha=self.alpha,
- )
- ]
- self.num.append(circle)
- self.axes.add_patch(circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- circle[0],
- self.uniquebodyparts[self.rdb.GetSelection()],
- individual_names=indiv,
- )
- self.dr.connect()
- self.buttonCounter[indiv].append(
- self.uniquebodyparts[self.rdb.GetSelection()]
- )
- self.dr.coords = [
- [x1, y1, indiv, self.uniquebodyparts[self.rdb.GetSelection()]]
- ]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
-
- if self.rdb.GetSelection() < len(self.uniquebodyparts) - 1:
- self.rdb.SetSelection(self.rdb.GetSelection() + 1)
- else:
- self.rdb.SetSelection(0)
- if (
- self.individualrdb.GetSelection()
- < len(self.individual_names) - 1
- ):
- self.individualrdb.SetSelection(
- self.individualrdb.GetSelection() + 1
- )
- MainFrame.select_individual(self, event)
-
- else:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.multibodyparts
- )
- if (
- self.multibodyparts[self.rdb.GetSelection()]
- in self.buttonCounter[indiv]
- ):
- wx.MessageBox(
- "%s is already annotated for %s. \n Select another body part to annotate."
- % (
- str(self.multibodyparts[self.rdb.GetSelection()]),
- str(
- self.individual_names[self.individualrdb.GetSelection()]
- ),
- ),
- "Error!",
- wx.OK | wx.ICON_ERROR,
- )
- else:
- color = self.colormap(
- self.norm(self.colorIndex[self.rdb.GetSelection()])
- )
- circle = [
- patches.Circle(
- (x1, y1),
- radius=self.markerSize,
- fc=color,
- ec=idcolor,
- lw=self.edgewidth,
- alpha=self.alpha,
- )
- ]
- self.num.append(circle)
- self.axes.add_patch(circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- circle[0],
- self.multibodyparts[self.rdb.GetSelection()],
- individual_names=indiv,
- )
- self.dr.connect()
- self.buttonCounter[indiv].append(
- self.multibodyparts[self.rdb.GetSelection()]
- )
- self.dr.coords = [
- [x1, y1, indiv, self.multibodyparts[self.rdb.GetSelection()]]
- ]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
-
- if self.rdb.GetSelection() < len(self.multibodyparts) - 1:
- self.rdb.SetSelection(self.rdb.GetSelection() + 1)
- else:
- self.rdb.SetSelection(0)
- if (
- self.individualrdb.GetSelection()
- < len(self.individual_names) - 1
- ):
- self.individualrdb.SetSelection(
- self.individualrdb.GetSelection() + 1
- )
- MainFrame.select_individual(self, event)
- self.canvas.mpl_disconnect(self.onClick)
-
- def nextLabel(self, event):
- """
- This function is to create a hotkey to skip down on the radio button panel.
- """
- if self.rdb.GetSelection() < len(self.multibodyparts) - 1:
- self.rdb.SetSelection(self.rdb.GetSelection() + 1)
-
- def previousLabel(self, event):
- """
- This function is to create a hotkey to skip up on the radio button panel.
- """
- if self.rdb.GetSelection() > 0:
- self.rdb.SetSelection(self.rdb.GetSelection() - 1)
-
- def browseDir(self, event):
- """
- Show the DirDialog and ask the user to change the directory where machine labels are stored
- """
- if self.jump_unlabeled:
- self.dir = str(auxiliaryfunctions.find_next_unlabeled_folder(
- self.config_file
- ))
- else:
- self.statusbar.SetStatusText("Looking for a folder to start labeling...")
- cwd = os.path.join(os.getcwd(), "labeled-data")
- dlg = wx.DirDialog(
- self,
- "Choose the directory where your extracted frames are saved:",
- cwd,
- style=wx.DD_DEFAULT_STYLE,
- )
- if dlg.ShowModal() != wx.ID_OK:
- dlg.Destroy()
- self.Close(True)
- return
- self.dir = dlg.GetPath()
- dlg.Destroy()
-
- self.load.Enable(False)
- self.next.Enable(True)
- self.save.Enable(True)
-
- # Enabling the zoom, pan and home buttons
- self.zoom.Enable(True)
- self.home.Enable(True)
- self.pan.Enable(True)
- self.lock.Enable(True)
-
- # Reading config file and its variables
- self.cfg = auxiliaryfunctions.read_config(self.config_file)
- self.scorer = self.cfg["scorer"]
- (
- individuals,
- uniquebodyparts,
- multianimalbodyparts,
- ) = auxfun_multianimal.extractindividualsandbodyparts(self.cfg)
-
- self.multibodyparts = multianimalbodyparts
- # checks for unique bodyparts
- if len(self.multibodyparts) != len(set(self.multibodyparts)):
- print(
- "Error - bodyparts must have unique labels! Please choose unique bodyparts in config.yaml file and try again. Quitting for now!"
- )
- self.Close(True)
-
- self.uniquebodyparts = uniquebodyparts
- self.individual_names_ = individuals
- self.individual_names = self.individual_names_.copy()
-
- self.videos = self.cfg["video_sets"].keys()
- self.markerSize = self.cfg["dotsize"]
- self.edgewidth = self.markerSize // 3
- self.alpha = self.cfg["alphavalue"]
- self.colormap = plt.get_cmap(self.cfg["colormap"])
- self.colormap = self.colormap.reversed()
- self.idmap = plt.cm.get_cmap("Set1", len(individuals))
- self.project_path = self.cfg["project_path"]
-
- if not self.uniquebodyparts:
- self.are_unique_bodyparts_present = False
-
- self.buttonCounter = {i: [] for i in self.individual_names}
- self.index = np.sort(
- [
- fn
- for fn in glob.glob(os.path.join(self.dir, "*.png"))
- if ("labeled.png" not in fn)
- ]
- )
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- relativeimagenames = [
- "labeled" + n.split("labeled")[1] for n in self.index
- ] # [n.split(self.project_path+'/')[1] for n in self.index]
- self.relativeimagenames = [tuple(name.split(os.path.sep))
- for name in relativeimagenames]
- # Reading the existing dataset,if already present
- try:
- self.dataFrame = pd.read_hdf(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".h5")
- )
- conversioncode.guarantee_multiindex_rows(self.dataFrame)
- self.dataFrame.sort_index(inplace=True)
- self.prev.Enable(True)
- # Finds the first empty row in the dataframe and sets the iteration to that index
- self.iter = np.argmax(np.isnan(self.dataFrame.values).all(axis=1))
- except FileNotFoundError:
- # Create an empty data frame
- self.dataFrame = MainFrame.create_dataframe(
- self,
- self.dataFrame,
- self.relativeimagenames,
- self.individual_names,
- self.uniquebodyparts,
- self.multibodyparts,
- )
- self.iter = 0
-
- # Cache original bodyparts
- self._old_multi = (
- self.dataFrame.xs(self.individual_names[0], axis=1, level="individuals")
- .columns.get_level_values("bodyparts")
- .unique()
- .to_list()
- )
- self._old_unique = (
- self.dataFrame.loc[
- :, self.dataFrame.columns.get_level_values("individuals") == "single"
- ]
- .columns.get_level_values("bodyparts")
- .unique()
- .to_list()
- )
-
- # Reading the image name
- self.img = self.index[self.iter]
- img_name = Path(self.index[self.iter]).name
-
- # Checking for new frames and adding them to the existing dataframe
- old_imgs = sorted(self.dataFrame.index)
- self.newimages = list(set(self.relativeimagenames) - set(old_imgs))
- if self.newimages:
- print("Found new frames..")
- # Create an empty dataframe with all the new images and then merge this to the existing dataframe.
- self.df = MainFrame.create_dataframe(
- self,
- None,
- self.newimages,
- self.individual_names,
- self.uniquebodyparts,
- self.multibodyparts,
- )
- self.dataFrame = pd.concat([self.dataFrame, self.df], axis=0)
- self.dataFrame.sort_index(inplace=True)
- # Rearrange bodypart columns in config order
- bodyparts = self.multibodyparts + self.uniquebodyparts
- self.dataFrame.reindex(
- bodyparts, axis=1, level=self.dataFrame.columns.names.index("bodyparts")
- )
- # Test whether there are missing frames and superfluous data
- if len(old_imgs) > len(self.relativeimagenames):
- missing_frames = set(old_imgs).difference(self.relativeimagenames)
- self.dataFrame.drop(missing_frames, inplace=True)
-
- # Check whether new individuals or body parts were added
- old_animals = self.dataFrame.columns.get_level_values("individuals").unique()
- self.new_animals = [x for x in self.individual_names if x not in old_animals]
- self.new_multi = [x for x in self.multibodyparts if x not in self._old_multi]
- self.new_unique = [x for x in self.uniquebodyparts if x not in self._old_unique]
-
- if self.new_animals:
- dlg = wx.MessageDialog(
- None,
- "New individual found in the config file. Do you want to see all the other ones?",
- "New individual found",
- wx.YES_NO | wx.ICON_WARNING,
- )
- result = dlg.ShowModal()
- if result == wx.ID_NO:
- self.individual_names = self.new_animals
- self.dataFrame = MainFrame.create_dataframe(
- self,
- self.dataFrame,
- self.relativeimagenames,
- self.new_animals,
- self._old_unique,
- self._old_multi,
- )
-
- # Checking if user added a new label
- if not any([self.new_multi, self.new_unique]): # i.e. no new labels
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- else:
- # Found new labels in either multiple bodyparts or unique bodyparts
- dlg = wx.MessageDialog(
- None,
- "New label found in the config file. Do you want to see all the other labels?",
- "New label found",
- wx.YES_NO | wx.ICON_WARNING,
- )
- result = dlg.ShowModal()
- if result == wx.ID_NO:
- if self.new_multi:
- self.multibodyparts = self.new_multi
- if self.new_unique:
- self.uniquebodyparts = self.new_unique
-
- self.dataFrame = MainFrame.create_dataframe(
- self,
- self.dataFrame,
- self.relativeimagenames,
- self.dataFrame.columns.get_level_values("individuals").unique(),
- self.new_unique,
- self.new_multi,
- )
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
-
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
-
- if self.individual_names[0] == "single":
- (
- self.choiceBox,
- self.individualrdb,
- self.rdb,
- self.change_marker_size,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.uniquebodyparts, self.individual_names, self.file, self.markerSize
- )
- self.image_panel.addcolorbar(
- self.img,
- self.image_axis,
- self.iter,
- self.uniquebodyparts,
- self.colormap,
- )
- else:
- (
- self.choiceBox,
- self.individualrdb,
- self.rdb,
- self.change_marker_size,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.multibodyparts, self.individual_names, self.file, self.markerSize
- )
- self.image_panel.addcolorbar(
- self.img, self.image_axis, self.iter, self.multibodyparts, self.colormap
- )
- self.individualrdb.Bind(wx.EVT_RADIOBOX, self.select_individual)
- # check if single is selected when radio buttons are changed
- if self.individualrdb.GetStringSelection() == "single":
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.uniquebodyparts
- )
- else:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.multibodyparts
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
-
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activateSlider)
- self.change_marker_size.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
-
- def create_dataframe(
- self,
- dataFrame,
- relativeimagenames,
- individual_names,
- uniquebodyparts,
- multibodyparts,
- ):
- a = np.empty((len(relativeimagenames), 2))
- a[:] = np.nan
- for prfxindex, prefix in enumerate(individual_names):
- if uniquebodyparts is not None:
- if prefix == "single":
- for c, bp in enumerate(uniquebodyparts):
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [prefix], [bp], ["x", "y"]],
- names=["scorer", "individuals", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(relativeimagenames)
- frame = pd.DataFrame(a, columns=cols, index=index)
- dataFrame = pd.concat([dataFrame, frame], axis=1)
- else:
- for c, bp in enumerate(multibodyparts):
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [prefix], [bp], ["x", "y"]],
- names=["scorer", "individuals", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(relativeimagenames)
- frame = pd.DataFrame(a, columns=cols, index=index)
- dataFrame = pd.concat([dataFrame, frame], axis=1)
- else:
- for c, bp in enumerate(multibodyparts):
- cols = pd.MultiIndex.from_product(
- [[self.scorer], [prefix], [bp], ["x", "y"]],
- names=["scorer", "individuals", "bodyparts", "coords"],
- )
- index = pd.MultiIndex.from_tuples(relativeimagenames)
- frame = pd.DataFrame(a, columns=cols, index=index)
- dataFrame = pd.concat([dataFrame, frame], axis=1)
- dataFrame.sort_index(inplace=True)
- return dataFrame
-
- def select_individual(self, event):
- individualName = self.individualrdb.GetStringSelection()
- self.change_marker_size.Hide()
- self.change_marker_size.Destroy()
- if individualName == "single":
- self.checkBox.Hide()
- self.individualrdb.Hide()
- self.rdb.Hide()
- (
- self.choiceBox,
- self.individualrdb,
- self.rdb,
- self.change_marker_size,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.uniquebodyparts, self.individual_names, self.file, self.markerSize
- )
- self.individualrdb.SetStringSelection(individualName)
- self.individualrdb.Bind(wx.EVT_RADIOBOX, self.select_individual)
- self.figure.delaxes(self.figure.axes[1])
- self.image_panel.addcolorbar(
- self.img,
- self.image_axis,
- self.iter,
- self.uniquebodyparts,
- self.colormap,
- )
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activateSlider)
- self.change_marker_size.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
- else:
- self.checkBox.Hide()
- self.individualrdb.Hide()
- self.rdb.Hide()
- (
- self.choiceBox,
- self.individualrdb,
- self.rdb,
- self.change_marker_size,
- self.checkBox,
- ) = self.choice_panel.addRadioButtons(
- self.multibodyparts, self.individual_names, self.file, self.markerSize
- )
- self.individualrdb.SetStringSelection(individualName)
- self.change_marker_size.Show()
- self.checkBox.Show()
- self.individualrdb.Show()
- self.rdb.Show()
- self.individualrdb.Bind(wx.EVT_RADIOBOX, self.select_individual)
- self.figure.delaxes(self.figure.axes[1])
- self.image_panel.addcolorbar(
- self.img, self.image_axis, self.iter, self.multibodyparts, self.colormap
- )
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activateSlider)
- self.change_marker_size.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
-
- def nextImage(self, event):
- """
- Moves to next image
- """
- self.individualrdb.SetSelection(0)
- MainFrame.select_individual(self, event)
- # Checks for the last image and disables the Next button
- if len(self.index) - self.iter == 1:
- self.next.Enable(False)
- return
- self.prev.Enable(True)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
-
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- self.rdb.SetSelection(0)
- self.individualrdb.SetSelection(0)
- self.file = 1
- # Refreshing the button counters
- self.buttonCounter = {i: [] for i in self.individual_names}
- MainFrame.saveEachImage(self)
- self.iter = self.iter + 1
-
- if len(self.index) >= self.iter:
- self.updatedCoords = []
- self.img = self.index[self.iter]
- img_name = Path(self.index[self.iter]).name
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- if self.individualrdb.GetStringSelection() == "single":
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.uniquebodyparts
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
- else:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.multibodyparts
- )
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
-
- def prevImage(self, event):
- """
- Checks the previous Image and enables user to move the annotations.
- """
- self.individualrdb.SetSelection(0)
- MainFrame.select_individual(self, event)
- MainFrame.saveEachImage(self)
- # Checks for the first image and disables the Previous button
- if self.iter == 0:
- self.prev.Enable(False)
- return
- else:
- self.next.Enable(True)
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- self.buttonCounter = {i: [] for i in self.individual_names}
- self.iter = self.iter - 1
-
- self.rdb.SetSelection(0)
- self.individualrdb.SetSelection(0)
- self.updatedCoords = []
- self.img = self.index[self.iter]
- img_name = Path(self.index[self.iter]).name
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.image_axis,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.multibodyparts,
- self.colormap,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- self.buttonCounter = MainFrame.plot(self, self.img)
-
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
-
- def plot(self, img):
- """
- Plots and call auxfun_drag class for moving and removing points.
- """
- self.drs = []
- self.updatedCoords = []
- if len(self.individual_names) > 1:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.multibodyparts
- )
- for j, ind in enumerate(self.individual_names):
- idcolor = self.idmap(j)
- if ind not in self.dataFrame.columns.get_level_values(1):
- continue
- if ind == "single":
- for c, bp in enumerate(self.uniquebodyparts):
- image_points = [
- [
- self.dataFrame[self.scorer][ind][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][ind][bp]["y"].values[self.iter],
- ind,
- bp,
- ]
- ]
- self.points = [
- self.dataFrame[self.scorer][ind][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][ind][bp]["y"].values[self.iter],
- ]
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- self.img, self.uniquebodyparts
- )
- color = self.colormap(self.norm(self.colorIndex[c]))
- circle = patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- ec=idcolor,
- lw=self.edgewidth,
- alpha=self.alpha,
- )
- self.axes.add_patch(circle)
- self.dr = auxfun_drag.DraggablePoint(
- circle, self.uniquebodyparts[c], individual_names=ind
- )
- self.dr.connect()
- self.dr.coords = image_points
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- if not np.isnan(self.points)[0]:
- self.buttonCounter[ind].append(self.uniquebodyparts[c])
- else:
- for c, bp in enumerate(self.multibodyparts):
- image_points = [
- [
- self.dataFrame[self.scorer][ind][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][ind][bp]["y"].values[self.iter],
- ind,
- bp,
- ]
- ]
- self.points = [
- self.dataFrame[self.scorer][ind][bp]["x"].values[self.iter],
- self.dataFrame[self.scorer][ind][bp]["y"].values[self.iter],
- ]
- color = self.colormap(self.norm(self.colorIndex[c]))
- circle = patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- ec=idcolor,
- lw=self.edgewidth,
- alpha=self.alpha,
- )
- self.axes.add_patch(circle)
- self.dr = auxfun_drag.DraggablePoint(
- circle, self.multibodyparts[c], individual_names=ind
- )
- self.dr.connect()
- self.dr.coords = image_points
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- if not np.isnan(self.points)[0]:
- self.buttonCounter[ind].append(self.multibodyparts[c])
- MainFrame.saveEachImage(self)
- self.figure.canvas.draw()
- return self.buttonCounter
-
- def saveEachImage(self):
- """
- Saves data for each image
- """
-
- for idx, bp in enumerate(self.updatedCoords):
-
- x, y, ind = bp[-1][:3]
- bpt = bp[0][-1]
- self.dataFrame.loc[
- self.relativeimagenames[self.iter], (self.scorer, ind, bpt, "x")
- ] = x
- self.dataFrame.loc[
- self.relativeimagenames[self.iter], (self.scorer, ind, bpt, "y")
- ] = y
-
- def saveDataSet(self, event):
- """
- Saves the final dataframe
- """
- self.statusbar.SetStatusText("File saved")
- MainFrame.saveEachImage(self)
- MainFrame.updateZoomPan(self)
-
- # Windows compatible
- self.dataFrame.sort_index(inplace=True)
- # Discard data associated with bodyparts that are no longer in the config
- config_bpts = self.cfg["multianimalbodyparts"] + self.cfg["uniquebodyparts"]
- valid = [
- bp in config_bpts
- for bp in self.dataFrame.columns.get_level_values("bodyparts")
- ]
- self.dataFrame = self.dataFrame.loc[:, valid]
- # Re-organize the dataframe so the CSV looks consistent with the config
- self.dataFrame = self.dataFrame.reindex(
- columns=self.individual_names_, level="individuals"
- ).reindex(columns=config_bpts, level="bodyparts")
- self.dataFrame.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".csv")
- )
- self.dataFrame.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.scorer + ".h5"),
- "df_with_missing",
- )
-
- def onChecked(self, event):
- self.cb = event.GetEventObject()
- if self.cb.GetValue():
- self.change_marker_size.Enable(True)
- self.cidClick = self.canvas.mpl_connect("button_press_event", self.onClick)
- else:
- self.change_marker_size.Enable(False)
-
-
-def show(config, config3d, sourceCam, jump_unlabeled=False):
- app = wx.App()
- frame = MainFrame(None, config, config3d, sourceCam, jump_unlabeled).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config")
- parser.add_argument("config3d")
- parser.add_argument("sourceCam")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/multiple_individuals_refinement_toolbox.py b/deeplabcut/gui/multiple_individuals_refinement_toolbox.py
deleted file mode 100644
index 783bf3b38e..0000000000
--- a/deeplabcut/gui/multiple_individuals_refinement_toolbox.py
+++ /dev/null
@@ -1,1185 +0,0 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
-
-https://github.com/DeepLabCutDeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-
-import argparse
-import os
-import os.path
-import platform
-from pathlib import Path
-
-# from skimage import io
-import PIL
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import wx
-import wx.lib.scrolledpanel as SP
-from matplotlib.backends.backend_wxagg import (
- NavigationToolbar2WxAgg as NavigationToolbar,
-)
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-from skimage import io
-
-from deeplabcut.gui import auxfun_drag
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import (
- auxiliaryfunctions,
- visualization,
- conversioncode,
-)
-
-
-# ###########################################################################
-# Class for GUI MainFrame
-# ###########################################################################
-class ImagePanel(BasePanel):
- def drawplot(
- self, img, img_name, itr, index, threshold, cmap, preview, keep_view=False
- ):
- xlim = self.axes.get_xlim()
- ylim = self.axes.get_ylim()
- self.axes.clear()
- im = io.imread(img)
- self.ax = self.axes.imshow(im, cmap=cmap)
- self.orig_xlim = self.axes.get_xlim()
- self.orig_ylim = self.axes.get_ylim()
- if not preview:
- self.axes.set_title(
- str(
- str(itr)
- + "/"
- + str(len(index) - 1)
- + " "
- + str(Path(*index[itr]).stem)
- + " "
- + " Threshold chosen is: "
- + str("{0:.2f}".format(threshold))
- )
- )
- else:
- self.axes.set_title(
- str(
- str(itr)
- + "/"
- + str(len(index) - 1)
- + " "
- + str(Path(*index[itr]).stem)
- )
- )
- if keep_view:
- self.axes.set_xlim(xlim)
- self.axes.set_ylim(ylim)
- self.figure.canvas.draw()
- if not hasattr(self, "toolbar"):
- self.toolbar = NavigationToolbar(self.canvas)
- return (self.figure, self.axes, self.canvas, self.toolbar, self.ax)
-
- def getColorIndices(self, img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- im = io.imread(img)
- norm = mcolors.Normalize(vmin=np.min(im), vmax=np.max(im))
- ticks = np.linspace(np.min(im), np.max(im), len(bodyparts))[::-1]
- return norm, ticks
-
-
-class ScrollPanel(SP.ScrolledPanel):
- def __init__(self, parent):
- SP.ScrolledPanel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
- self.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def addCheckBoxSlider(self, bodyparts, fileIndex, markersize):
- """
- Adds checkbox and a slider
- """
- self.choiceBox = wx.BoxSizer(wx.VERTICAL)
-
- self.slider = wx.Slider(
- self,
- -1,
- markersize,
- 1,
- markersize * 3,
- size=(250, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- self.slider.Enable(False)
- self.checkBox = wx.CheckBox(self, id=wx.ID_ANY, label="Adjust marker size.")
- self.choiceBox.Add(self.slider, 0, wx.ALL, 5)
- self.choiceBox.Add(self.checkBox, 0, wx.ALL, 5)
- names = ["Color individuals", "Color bodyparts"]
- self.visualization_radiobox = wx.RadioBox(
- self,
- label="Select the visualization scheme",
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- choices=names,
- )
- self.choiceBox.Add(self.visualization_radiobox, 0, wx.EXPAND | wx.ALL, 10)
-
- self.SetSizerAndFit(self.choiceBox)
- self.Layout()
- return (self.choiceBox, self.slider, self.checkBox, self.visualization_radiobox)
-
- def clearBoxer(self):
- self.choiceBox.Clear(True)
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, jump_unlabeled):
- super(MainFrame, self).__init__("DeepLabCut - Refinement ToolBox", parent)
- self.Bind(wx.EVT_CHAR_HOOK, self.OnKeyPressed)
- self.jump_unlabeled = jump_unlabeled
- ###################################################################################################################################################
-
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
-
- topSplitter = wx.SplitterWindow(self)
- vSplitter = wx.SplitterWindow(topSplitter)
-
- self.image_panel = ImagePanel(vSplitter, config, self.gui_size)
- self.choice_panel = ScrollPanel(vSplitter)
- # self.choice_panel.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- # self.choice_panel.SetupScrolling(scroll_x=True, scrollToTop=False)
- vSplitter.SplitVertically(
- self.image_panel, self.choice_panel, sashPosition=self.gui_size[0] * 0.8
- )
- vSplitter.SetSashGravity(1)
- self.widget_panel = WidgetPanel(topSplitter)
- topSplitter.SplitHorizontally(
- vSplitter, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
- self.load = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Load labels")
- widgetsizer.Add(self.load, 1, wx.ALL, 15)
- self.load.Bind(wx.EVT_BUTTON, self.browseDir)
-
- self.prev = wx.Button(self.widget_panel, id=wx.ID_ANY, label="< self.iter:
- self.updatedCoords = []
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- # Plotting
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- if self.visualization_rdb.GetSelection() == 0:
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.ax,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- else:
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.ax,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- im = io.imread(self.img)
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- if np.max(im) == 0:
- msg = wx.MessageBox(
- "Invalid image. Click Yes to remove",
- "Error!",
- wx.YES_NO | wx.ICON_WARNING,
- )
- if msg == 2:
- self.Dataframe = self.Dataframe.drop(self.index[self.iter])
- self.index = list(self.Dataframe.iloc[:, 0].index)
- self.iter = self.iter - 1
-
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.ax,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- MainFrame.plot(self, self.img)
- else:
- self.next.Enable(False)
- MainFrame.saveEachImage(self)
-
- def prevImage(self, event):
- """
- Checks the previous Image and enables user to move the annotations.
- """
-
- MainFrame.saveEachImage(self)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
-
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- self.next.Enable(True)
- self.iter = self.iter - 1
-
- # Checks for the first image and disables the Previous button
- if self.iter == 0:
- self.prev.Enable(False)
-
- if self.iter >= 0:
- self.updatedCoords = []
- # Reading Image
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- # Plotting
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- if self.visualization_rdb.GetSelection() == 0:
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.ax,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- else:
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- self.ax,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- MainFrame.plot(self, self.img)
- else:
- self.prev.Enable(False)
- MainFrame.saveEachImage(self)
-
- def quitButton(self, event):
- """
- Quits the GUI
- """
- self.statusbar.SetStatusText("")
- dlg = wx.MessageDialog(
- None, "Are you sure?", "Quit!", wx.YES_NO | wx.ICON_WARNING
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- print(
- "Closing... The refined labels are stored in a subdirectory under labeled-data. Use the function 'merge_datasets' to augment the training dataset, and then re-train a network using create_training_dataset followed by train_network!"
- )
- self.Destroy()
- else:
- self.save.Enable(True)
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- self.statusbar.SetStatusText("Help")
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- wx.MessageBox(
- "1. Enter the likelihood threshold. \n\n2. All the data points above the threshold will be marked as circle filled with a unique color. All the data points below the threshold will be marked with a hollow circle. \n\n3. Enable the checkbox to adjust the marker size (you will not be able to zoom/pan/home until the next frame). \n\n4. Hover your mouse over data points to see the labels and their likelihood. \n\n5. LEFT click+drag to move the data points. \n\n6. MIDDLE click on any data point to remove it. Be careful, you cannot undo this step! \n Click once on the zoom button to zoom-in the image. The cursor will become cross, click and drag over a point to zoom in. \n Click on the zoom button again to disable the zooming function and recover the cursor. \n Use pan button to pan across the image while zoomed in. Use home button to go back to the full default view. \n\n7. When finished click 'Save' to save all the changes. \n\n8. Click OK to continue",
- "User instructions",
- wx.OK | wx.ICON_INFORMATION,
- )
-
- def onChecked(self, event):
- MainFrame.saveEachImage(self)
- self.cb = event.GetEventObject()
- if self.cb.GetValue():
- self.slider.Enable(True)
- else:
- self.slider.Enable(False)
-
- def force_outside_labels_Nans(self, index, ind, bodyparts):
- """
- Checks the dataframe for any labels outside the image and forces them Nans.
- """
- for bpindex, bp in enumerate(bodyparts):
- testCondition = (
- self.Dataframe.loc[index, (self.scorer, ind, bp, "x")] > self.width
- or self.Dataframe.loc[index, (self.scorer, ind, bp, "x")] < 0
- or self.Dataframe.loc[index, (self.scorer, ind, bp, "y")] > self.height
- or self.Dataframe.loc[index, (self.scorer, ind, bp, "y")] < 0
- )
- if testCondition:
- print("Found %s outside the image %s.Setting it to NaN" % (bp, index))
- self.Dataframe.loc[index, (self.scorer, ind, bp, "x")] = np.nan
- self.Dataframe.loc[index, (self.scorer, ind, bp, "y")] = np.nan
- return self.Dataframe
-
- def check_labels(self):
- print("Checking labels if they are outside the image")
- for i in self.Dataframe.index:
- image_name = os.path.join(self.project_path, *i)
- im = PIL.Image.open(image_name)
- self.width, self.height = im.size
- for ind in self.individual_names:
- if ind == "single":
- self.Dataframe = MainFrame.force_outside_labels_Nans(
- self, i, ind, self.uniquebodyparts
- )
- else:
- self.Dataframe = MainFrame.force_outside_labels_Nans(
- self, i, ind, self.multianimalbodyparts
- )
- return self.Dataframe
-
- def saveDataSet(self, event):
-
- MainFrame.saveEachImage(self)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- self.statusbar.SetStatusText("File saved")
-
- self.Dataframe = MainFrame.check_labels(self)
- # Overwrite machine label file
- self.Dataframe.to_hdf(self.dataname, key="df_with_missing", mode="w")
-
- self.Dataframe.columns.set_levels(
- [self.scorer.replace(self.scorer, self.humanscorer)], level=0, inplace=True
- )
- self.Dataframe = self.Dataframe.drop("likelihood", axis=1, level=3)
-
- if Path(self.dir, "CollectedData_" + self.humanscorer + ".h5").is_file():
- print(
- "A training dataset file is already found for this video. The refined machine labels are merged to this data!"
- )
- DataU1 = pd.read_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5")
- )
- # combine datasets Original Col. + corrected machinefiles:
- DataCombined = pd.concat([self.Dataframe, DataU1])
- # Now drop redundant ones keeping the first one [this will make sure that the refined machine file gets preference]
- DataCombined = DataCombined[~DataCombined.index.duplicated(keep="first")]
- """
- if len(self.droppedframes)>0: #i.e. frames were dropped/corrupt. also remove them from original file (if they exist!)
- for fn in self.droppedframes:
- try:
- DataCombined.drop(fn,inplace=True)
- except KeyError:
- pass
- """
- DataCombined.sort_index(inplace=True)
- DataCombined.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5"),
- key="df_with_missing",
- mode="w",
- )
- DataCombined.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".csv")
- )
- else:
- self.Dataframe.sort_index(inplace=True)
- self.Dataframe.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5"),
- key="df_with_missing",
- mode="w",
- )
- self.Dataframe.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".csv")
- )
- self.next.Enable(False)
- self.prev.Enable(False)
- self.slider.Enable(False)
- self.checkBox.Enable(False)
-
- nextFilemsg = wx.MessageBox(
- "File saved. Do you want to refine another file?",
- "Repeat?",
- wx.YES_NO | wx.ICON_INFORMATION,
- )
- if nextFilemsg == 2:
- self.file = 1
- self.axes.clear()
- self.figure.delaxes(self.figure.axes[1])
- self.choiceBox.Clear(True)
- MainFrame.updateZoomPan(self)
- self.load.Enable(True)
- MainFrame.browseDir(self, event)
-
- # ###########################################################################
- # Other functions
- # ###########################################################################
- def saveEachImage(self):
- """
- Updates the dataframe for the current image with the new datapoints
- """
-
- for bpindex, bp in enumerate(self.updatedCoords):
- self.Dataframe.loc[self.Dataframe.index[self.iter]][
- self.scorer, bp[0][-1], bp[0][-3], "x"
- ] = self.updatedCoords[bpindex][-1][0]
- self.Dataframe.loc[self.Dataframe.index[self.iter]][
- self.scorer, bp[0][-1], bp[0][-3], "y"
- ] = self.updatedCoords[bpindex][-1][1]
-
- def getLabels(self, img_index, ind, bodyparts):
- """
- Returns a list of x and y labels of the corresponding image index
- """
- self.previous_image_points = []
- for bpindex, bp in enumerate(bodyparts):
- image_points = [
- [
- self.Dataframe[self.scorer][ind][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["y"].values[self.iter],
- bp,
- bpindex,
- ind,
- ]
- ]
- self.previous_image_points.append(image_points)
- return self.previous_image_points
-
- def plot(self, im):
- """
- Plots and call auxfun_drag class for moving and removing points.
- """
- # small hack in case there are any 0 intensity images!
- img = io.imread(im)
- maxIntensity = np.max(img)
- if maxIntensity == 0:
- maxIntensity = np.max(img) + 255
-
- divider = make_axes_locatable(self.axes)
- cax = divider.append_axes("right", size="5%", pad=0.05)
- self.drs = []
-
- if (
- self.visualization_rdb.GetSelection() == 0
- ): # i.e. for color scheme for individuals
- self.Colorscheme = visualization.get_cmap(
- len(self.individual_names), self.cfg["colormap"]
- )
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- im, self.individual_names
- )
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.individual_names)
- else: # i.e. for color scheme for all bodyparts
- self.Colorscheme = visualization.get_cmap(
- len(self.all_bodyparts), self.cfg["colormap"]
- )
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- im, self.all_bodyparts
- )
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.all_bodyparts)
-
- for ci, ind in enumerate(self.individual_names):
- col_idx = (
- 0 # variable for iterating through the colorscheme for all bodyparts
- )
- image_points = []
- if ind == "single":
- if self.visualization_rdb.GetSelection() == 0:
- for c, bp in enumerate(self.uniquebodyparts):
- self.points = [
- self.Dataframe[self.scorer][ind][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["y"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["likelihood"].values[
- self.iter
- ],
- ]
- self.likelihood = self.points[2]
-
- # fix move to corner
- if self.move2corner:
- ny, nx = np.shape(img)[0], np.shape(img)[1]
- if self.points[0] > nx or self.points[0] < 0:
- print("fixing x for ", bp)
- self.points[0] = self.center[0]
- if self.points[1] > ny or self.points[1] < 0:
- print("fixing y for ", bp)
- self.points[1] = self.center[1]
-
- if self.likelihood < self.threshold:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- facecolor="None",
- edgecolor=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- ]
- else:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(self.circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- self.circle[0],
- bp,
- individual_names=ind,
- likelihood=self.likelihood,
- )
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(
- self, self.iter, ind, self.uniquebodyparts
- )[c]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- else:
- for c, bp in enumerate(self.uniquebodyparts):
- self.points = [
- self.Dataframe[self.scorer][ind][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["y"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["likelihood"].values[
- self.iter
- ],
- ]
- self.likelihood = self.points[2]
-
- # fix move to corner
- if self.move2corner:
- ny, nx = np.shape(img)[0], np.shape(img)[1]
- if self.points[0] > nx or self.points[0] < 0:
- print("fixing x for ", bp)
- self.points[0] = self.center[0]
- if self.points[1] > ny or self.points[1] < 0:
- print("fixing y for ", bp)
- self.points[1] = self.center[1]
-
- if self.likelihood < self.threshold:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc="None",
- edgecolor=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- ]
- else:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(self.circle[0])
- col_idx = col_idx + 1
- self.dr = auxfun_drag.DraggablePoint(
- self.circle[0],
- bp,
- individual_names=ind,
- likelihood=self.likelihood,
- )
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(
- self, self.iter, ind, self.uniquebodyparts
- )[c]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- else:
- if self.visualization_rdb.GetSelection() == 0:
- for c, bp in enumerate(self.multianimalbodyparts):
- self.points = [
- self.Dataframe[self.scorer][ind][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["y"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["likelihood"].values[
- self.iter
- ],
- ]
- self.likelihood = self.points[2]
-
- # fix move to corner
- if self.move2corner:
- ny, nx = np.shape(img)[0], np.shape(img)[1]
- if self.points[0] > nx or self.points[0] < 0:
- print("fixing x for ", bp)
- self.points[0] = self.center[0]
- if self.points[1] > ny or self.points[1] < 0:
- print("fixing y for ", bp)
- self.points[1] = self.center[1]
-
- if self.likelihood < self.threshold:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc="None",
- edgecolor=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- ]
- else:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(self.circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- self.circle[0],
- bp,
- individual_names=ind,
- likelihood=self.likelihood,
- )
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(
- self, self.iter, ind, self.multianimalbodyparts
- )[c]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- else:
- for c, bp in enumerate(self.multianimalbodyparts):
- self.points = [
- self.Dataframe[self.scorer][ind][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["y"].values[self.iter],
- self.Dataframe[self.scorer][ind][bp]["likelihood"].values[
- self.iter
- ],
- ]
- self.likelihood = self.points[2]
-
- # fix move to corner
- if self.move2corner:
- ny, nx = np.shape(img)[0], np.shape(img)[1]
- if self.points[0] > nx or self.points[0] < 0:
- print("fixing x for ", bp)
- self.points[0] = self.center[0]
- if self.points[1] > ny or self.points[1] < 0:
- print("fixing y for ", bp)
- self.points[1] = self.center[1]
-
- if self.likelihood < self.threshold:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc="None",
- edgecolor=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- ]
- else:
- self.circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(self.circle[0])
- col_idx = col_idx + 1
- self.dr = auxfun_drag.DraggablePoint(
- self.circle[0],
- bp,
- individual_names=ind,
- likelihood=self.likelihood,
- )
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(
- self, self.iter, ind, self.multianimalbodyparts
- )[c]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- self.figure.canvas.draw()
-
-
-def show(config, jump_unlabeled=False):
- app = wx.App()
- frame = MainFrame(None, config, jump_unlabeled).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/outlier_frame_extraction_toolbox.py b/deeplabcut/gui/outlier_frame_extraction_toolbox.py
deleted file mode 100644
index cc3c7f1ffe..0000000000
--- a/deeplabcut/gui/outlier_frame_extraction_toolbox.py
+++ /dev/null
@@ -1,596 +0,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 argparse
-import os
-from pathlib import Path
-
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import warnings
-import wx
-import wx.lib.scrolledpanel as SP
-from matplotlib.figure import Figure
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-from skimage import io
-from skimage.util import img_as_ubyte
-
-from deeplabcut.create_project import add
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import (
- auxiliaryfunctions,
- visualization,
- conversioncode,
-)
-from deeplabcut.utils.auxfun_videos import VideoWriter
-
-
-# ###########################################################################
-# Class for GUI MainFrame
-# ###########################################################################
-class ImagePanel(BasePanel):
- def getColorIndices(self, img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- norm = mcolors.Normalize(vmin=np.min(img), vmax=np.max(img))
- ticks = np.linspace(np.min(img), np.max(img), len(bodyparts))[::-1]
- return norm, ticks
-
-
-class ScrollPanel(SP.ScrolledPanel):
- def __init__(self, parent):
- SP.ScrolledPanel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
- self.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def addRadioButtons(self):
- """
- Adds radio buttons for each bodypart on the right panel
- """
- self.choiceBox = wx.BoxSizer(wx.VERTICAL)
- names = ["Color individuals", "Color bodyparts"]
- self.visualization_radiobox = wx.RadioBox(
- self,
- label="Select the visualization scheme",
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- choices=names,
- )
- self.choiceBox.Add(self.visualization_radiobox, 0, wx.EXPAND | wx.ALL, 10)
-
- self.SetSizerAndFit(self.choiceBox)
- self.Layout()
- return (self.choiceBox, self.visualization_radiobox)
-
-
-class MainFrame(BaseFrame):
- """Contains the main GUI and button boxes"""
-
- def __init__(
- self, parent, config, video, shuffle, Dataframe, savelabeled, multianimal
- ):
- super(MainFrame, self).__init__(
- "DeepLabCut2.0 - Manual Outlier Frame Extraction", parent
- )
-
- ###################################################################################################################################################
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
- # topSplitter = wx.SplitterWindow(self)
- #
- # self.image_panel = ImagePanel(topSplitter, config,video,shuffle,Dataframe,self.gui_size)
- # self.widget_panel = WidgetPanel(topSplitter)
- #
- # topSplitter.SplitHorizontally(self.image_panel, self.widget_panel,sashPosition=self.gui_size[1]*0.83)#0.9
- # topSplitter.SetSashGravity(1)
- # sizer = wx.BoxSizer(wx.VERTICAL)
- # sizer.Add(topSplitter, 1, wx.EXPAND)
- # self.SetSizer(sizer)
-
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
-
- topSplitter = wx.SplitterWindow(self)
- vSplitter = wx.SplitterWindow(topSplitter)
-
- self.image_panel = ImagePanel(vSplitter, config, self.gui_size)
- self.choice_panel = ScrollPanel(vSplitter)
-
- vSplitter.SplitVertically(
- self.image_panel, self.choice_panel, sashPosition=self.gui_size[0] * 0.8
- )
- vSplitter.SetSashGravity(1)
- self.widget_panel = WidgetPanel(topSplitter)
- topSplitter.SplitHorizontally(
- vSplitter, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
-
- self.load_button_sizer = wx.BoxSizer(wx.VERTICAL)
- self.help_button_sizer = wx.BoxSizer(wx.VERTICAL)
-
- self.help = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Help")
- self.help_button_sizer.Add(self.help, 1, wx.ALL, 15)
- # widgetsizer.Add(self.help , 1, wx.ALL, 15)
- self.help.Bind(wx.EVT_BUTTON, self.helpButton)
-
- widgetsizer.Add(self.help_button_sizer, 1, wx.ALL, 0)
-
- self.grab = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Grab Frames")
- widgetsizer.Add(self.grab, 1, wx.ALL, 15)
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.grab.Enable(True)
-
- widgetsizer.AddStretchSpacer(5)
- self.slider = wx.Slider(
- self.widget_panel,
- id=wx.ID_ANY,
- value=0,
- minValue=0,
- maxValue=1,
- size=(200, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- widgetsizer.Add(self.slider, 1, wx.ALL, 5)
- self.slider.Bind(wx.EVT_SLIDER, self.OnSliderScroll)
-
- widgetsizer.AddStretchSpacer(5)
- self.start_frames_sizer = wx.BoxSizer(wx.VERTICAL)
- self.end_frames_sizer = wx.BoxSizer(wx.VERTICAL)
-
- self.start_frames_sizer.AddSpacer(15)
- # self.startFrame = wx.SpinCtrl(self.widget_panel, value='0', size=(100, -1), min=0, max=120)
- self.startFrame = wx.SpinCtrl(
- self.widget_panel, value="0", size=(100, -1)
- ) # ,style=wx.SP_VERTICAL)
- self.startFrame.Enable(False)
- self.start_frames_sizer.Add(self.startFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- start_text = wx.StaticText(self.widget_panel, label="Start Frame Index")
- self.start_frames_sizer.Add(start_text, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.checkBox = wx.CheckBox(
- self.widget_panel, id=wx.ID_ANY, label="Range of frames"
- )
- self.checkBox.Bind(wx.EVT_CHECKBOX, self.activate_frame_range)
- self.start_frames_sizer.Add(self.checkBox, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- #
- self.end_frames_sizer.AddSpacer(15)
- self.endFrame = wx.SpinCtrl(
- self.widget_panel, value="1", size=(160, -1)
- ) # , min=1, max=120)
- self.endFrame.Enable(False)
- self.end_frames_sizer.Add(self.endFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- end_text = wx.StaticText(self.widget_panel, label="Number of Frames")
- self.end_frames_sizer.Add(end_text, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.updateFrame = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Update")
- self.end_frames_sizer.Add(self.updateFrame, 1, wx.EXPAND | wx.ALIGN_LEFT, 15)
- self.updateFrame.Bind(wx.EVT_BUTTON, self.updateSlider)
- self.updateFrame.Enable(False)
-
- widgetsizer.Add(self.start_frames_sizer, 1, wx.ALL, 0)
- widgetsizer.AddStretchSpacer(5)
- widgetsizer.Add(self.end_frames_sizer, 1, wx.ALL, 0)
- widgetsizer.AddStretchSpacer(15)
-
- self.quit = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Quit")
- widgetsizer.Add(self.quit, 1, wx.ALL, 15)
- self.quit.Bind(wx.EVT_BUTTON, self.quitButton)
- self.quit.Enable(True)
-
- self.widget_panel.SetSizer(widgetsizer)
- self.widget_panel.SetSizerAndFit(widgetsizer)
-
- # Variables initialization
- self.numberFrames = 0
- self.currFrame = 0
- self.figure = Figure()
- self.axes = self.figure.add_subplot(111)
- self.drs = []
- self.extract_range_frame = False
- self.firstFrame = 0
- self.Colorscheme = []
-
- # Read confing file
- self.cfg = auxiliaryfunctions.read_config(config)
- self.Task = self.cfg["Task"]
- self.start = self.cfg["start"]
- self.stop = self.cfg["stop"]
- self.date = self.cfg["date"]
- self.trainFraction = self.cfg["TrainingFraction"]
- self.trainFraction = self.trainFraction[0]
- self.videos = self.cfg["video_sets"].keys()
- self.bodyparts = self.cfg["bodyparts"]
- self.colormap = plt.get_cmap(self.cfg["colormap"])
- self.colormap = self.colormap.reversed()
- self.markerSize = self.cfg["dotsize"]
- self.alpha = self.cfg["alphavalue"]
- self.iterationindex = self.cfg["iteration"]
- self.cropping = self.cfg["cropping"]
- self.video_names = [Path(i).stem for i in self.videos]
- self.config_path = Path(config)
- self.video_source = Path(video).resolve()
- self.shuffle = shuffle
- self.Dataframe = Dataframe
- conversioncode.guarantee_multiindex_rows(self.Dataframe)
- self.savelabeled = savelabeled
- self.multianimal = multianimal
- if self.multianimal:
- from deeplabcut.utils import auxfun_multianimal
-
- (
- self.individual_names,
- self.uniquebodyparts,
- self.multianimalbodyparts,
- ) = auxfun_multianimal.extractindividualsandbodyparts(self.cfg)
- self.choiceBox, self.visualization_rdb = self.choice_panel.addRadioButtons()
- self.Colorscheme = visualization.get_cmap(
- len(self.individual_names), self.cfg["colormap"]
- )
- self.visualization_rdb.Bind(wx.EVT_RADIOBOX, self.clear_plot)
- # Read the video file
- self.vid = VideoWriter(str(self.video_source))
- if self.cropping:
- self.vid.set_bbox(
- self.cfg["x1"], self.cfg["x2"], self.cfg["y1"], self.cfg["y2"]
- )
- self.filename = Path(self.video_source).name
- self.numberFrames = len(self.vid)
- self.strwidth = int(np.ceil(np.log10(self.numberFrames)))
- # Set the values of slider and range of frames
- self.startFrame.SetMax(self.numberFrames - 1)
- self.slider.SetMax(self.numberFrames - 1)
- self.endFrame.SetMax(self.numberFrames - 1)
- self.startFrame.Bind(wx.EVT_SPINCTRL, self.updateSlider) # wx.EVT_SPIN
- # Set the status bar
- self.statusbar.SetStatusText("Working on video: {}".format(self.filename))
- # Adding the video file to the config file.
- if self.vid.name not in self.video_names:
- add.add_new_videos(self.config_path, [self.video_source])
-
- self.update()
- self.plot_labels()
- self.widget_panel.Layout()
-
- def quitButton(self, event):
- """
- Quits the GUI
- """
- self.statusbar.SetStatusText("")
- dlg = wx.MessageDialog(
- None, "Are you sure?", "Quit!", wx.YES_NO | wx.ICON_WARNING
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- print("Quitting for now!")
- self.Destroy()
-
- def updateSlider(self, event):
- self.slider.SetValue(self.startFrame.GetValue())
- self.startFrame.SetValue(self.slider.GetValue())
- self.axes.clear()
- self.figure.delaxes(self.figure.axes[1])
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.currFrame = self.slider.GetValue()
- self.update()
- self.plot_labels()
-
- def activate_frame_range(self, event):
- """
- Activates the frame range boxes
- """
- self.checkSlider = event.GetEventObject()
- if self.checkSlider.GetValue():
- self.extract_range_frame = True
- self.startFrame.Enable(True)
- self.startFrame.SetValue(self.slider.GetValue())
- self.endFrame.Enable(True)
- self.updateFrame.Enable(True)
- self.grab.Enable(False)
- else:
- self.extract_range_frame = False
- self.startFrame.Enable(False)
- self.endFrame.Enable(False)
- self.updateFrame.Enable(False)
- self.grab.Enable(True)
-
- def line_select_callback(self, eclick, erelease):
- "eclick and erelease are the press and release events"
- self.new_x1, self.new_y1 = eclick.xdata, eclick.ydata
- self.new_x2, self.new_y2 = erelease.xdata, erelease.ydata
-
- def OnSliderScroll(self, event):
- """
- Slider to scroll through the video
- """
- self.axes.clear()
- self.figure.delaxes(self.figure.axes[1])
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.currFrame = self.slider.GetValue()
- self.startFrame.SetValue(self.currFrame)
- self.update()
- self.plot_labels()
-
- def update(self):
- """
- Updates the image with the current slider index
- """
- self.grab.Enable(True)
- self.grab.Bind(wx.EVT_BUTTON, self.grabFrame)
- self.figure, self.axes, self.canvas = self.image_panel.getfigure()
- self.vid.set_to_frame(self.currFrame)
- frame = self.vid.read_frame(crop=self.cropping)
- if frame is not None:
- frame = img_as_ubyte(frame)
- self.ax = self.axes.imshow(frame, cmap=self.colormap)
- self.axes.set_title(
- str(
- str(self.currFrame)
- + "/"
- + str(self.numberFrames - 1)
- + " "
- + self.filename
- )
- )
- self.figure.canvas.draw()
- else:
- print("Invalid frame")
-
- def chooseFrame(self):
- frame = img_as_ubyte(self.vid.read_frame(crop=self.cropping))
- fname = Path(self.filename)
- output_path = self.config_path.parents[0] / "labeled-data" / fname.stem
- img_name = (
- str(output_path)
- + "/img"
- + str(self.currFrame).zfill(int(np.ceil(np.log10(self.numberFrames))))
- + ".png"
- )
- if os.path.exists(img_name):
- warnings.warn("The selected frame has already been extracted; please select another one.")
- return
-
- self.machinefile = os.path.join(
- str(output_path), "machinelabels-iter" + str(self.iterationindex) + ".h5"
- )
- name = str(fname.stem)
- DF = self.Dataframe.iloc[[self.currFrame]]
- DF.index = pd.MultiIndex.from_tuples([
- ("labeled-data", name, "img" + str(index).zfill(self.strwidth) + ".png")
- for index in DF.index
- ])
- labeled_img_name = (
- str(output_path)
- + "/img"
- + str(self.currFrame).zfill(int(np.ceil(np.log10(self.numberFrames))))
- + "labeled.png"
- )
-
- # Check for it output path and a machine label file exist
- if output_path.exists() and Path(self.machinefile).is_file():
- io.imsave(img_name, frame)
- if self.savelabeled:
- self.figure.savefig(labeled_img_name, bbox_inches="tight")
- Data = pd.read_hdf(self.machinefile)
- conversioncode.guarantee_multiindex_rows(Data)
- DataCombined = pd.concat([Data, DF])
- DataCombined = DataCombined[~DataCombined.index.duplicated(keep="first")]
- DataCombined.to_hdf(self.machinefile, key="df_with_missing", mode="w")
- DataCombined.to_csv(os.path.join(str(output_path), "machinelabels.csv"))
- # If machine label file does not exist then create one
- elif output_path.exists() and not (Path(self.machinefile).is_file()):
- if self.savelabeled:
- self.figure.savefig(labeled_img_name, bbox_inches="tight")
- io.imsave(img_name, frame)
- DF.to_hdf(self.machinefile, key="df_with_missing", mode="w")
- DF.to_csv(os.path.join(str(output_path), "machinelabels.csv"))
- else:
- print(
- "%s path not found. Please make sure that the video was added to the config file using the function 'deeplabcut.add_new_videos'.Quitting for now!"
- % output_path
- )
- self.Destroy()
-
- def grabFrame(self, event):
- """
- Extracts the frame and saves in the current directory
- """
-
- if self.extract_range_frame:
- num_frames_extract = self.endFrame.GetValue()
- for i in range(self.currFrame, self.currFrame + num_frames_extract):
- self.currFrame = i
- self.vid.set_to_frame(self.currFrame)
- self.chooseFrame()
- else:
- self.vid.set_to_frame(self.currFrame)
- self.chooseFrame()
-
- def clear_plot(self, event):
- self.figure.delaxes(self.figure.axes[1])
- [p.remove() for p in reversed(self.axes.patches)]
- self.plot_labels()
-
- def plot_labels(self):
- """
- Plots the labels of the analyzed video
- """
- self.vid.set_to_frame(self.currFrame)
- frame = self.vid.read_frame()
- if frame is not None:
- divider = make_axes_locatable(self.axes)
- cax = divider.append_axes("right", size="5%", pad=0.05)
- if self.multianimal:
- # take into account of all the bodyparts for the colorscheme. Sort the bodyparts to have same order as in the config file
- self.all_bodyparts = np.array(
- self.multianimalbodyparts + self.uniquebodyparts
- )
- _, return_idx = np.unique(self.all_bodyparts, return_index=True)
- self.all_bodyparts = list(self.all_bodyparts[np.sort(return_idx)])
-
- if (
- self.visualization_rdb.GetSelection() == 0
- ): # i.e. for color scheme for individuals
- self.Colorscheme = visualization.get_cmap(
- len(self.individual_names), self.cfg["colormap"]
- )
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- frame, self.individual_names
- )
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.individual_names)
- else: # i.e. for color scheme for all bodyparts
- self.Colorscheme = visualization.get_cmap(
- len(self.all_bodyparts), self.cfg["colormap"]
- )
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- frame, self.all_bodyparts
- )
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.all_bodyparts)
-
- for ci, ind in enumerate(self.individual_names):
- col_idx = 0 # variable for iterating through the colorscheme for all bodyparts
- image_points = []
- if ind == "single":
- if self.visualization_rdb.GetSelection() == 0:
- for c, bp in enumerate(self.uniquebodyparts):
- pts = self.Dataframe.xs(
- (ind, bp),
- level=("individuals", "bodyparts"),
- axis=1,
- ).values
- self.circle = patches.Circle(
- pts[self.currFrame, :2],
- radius=self.markerSize,
- fc=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- self.axes.add_patch(self.circle)
- else:
- for c, bp in enumerate(self.uniquebodyparts):
- pts = self.Dataframe.xs(
- (ind, bp),
- level=("individuals", "bodyparts"),
- axis=1,
- ).values
- self.circle = patches.Circle(
- pts[self.currFrame, :2],
- radius=self.markerSize,
- fc=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- self.axes.add_patch(self.circle)
- col_idx = col_idx + 1
- else:
- if self.visualization_rdb.GetSelection() == 0:
- for c, bp in enumerate(self.multianimalbodyparts):
- pts = self.Dataframe.xs(
- (ind, bp),
- level=("individuals", "bodyparts"),
- axis=1,
- ).values
- self.circle = patches.Circle(
- pts[self.currFrame, :2],
- radius=self.markerSize,
- fc=self.Colorscheme(ci),
- alpha=self.alpha,
- )
- self.axes.add_patch(self.circle)
- else:
- for c, bp in enumerate(self.multianimalbodyparts):
- pts = self.Dataframe.xs(
- (ind, bp),
- level=("individuals", "bodyparts"),
- axis=1,
- ).values
- self.circle = patches.Circle(
- pts[self.currFrame, :2],
- radius=self.markerSize,
- fc=self.Colorscheme(col_idx),
- alpha=self.alpha,
- )
- self.axes.add_patch(self.circle)
- col_idx = col_idx + 1
- self.figure.canvas.draw()
- else:
- self.norm, self.colorIndex = self.image_panel.getColorIndices(
- frame, self.bodyparts
- )
- cbar = self.figure.colorbar(
- self.ax, cax=cax, spacing="proportional", ticks=self.colorIndex
- )
- cbar.set_ticklabels(self.bodyparts)
- for bpindex, bp in enumerate(self.bodyparts):
- color = self.colormap(self.norm(self.colorIndex[bpindex]))
- self.points = [
- self.Dataframe.xs((bp, "x"), level=(-2, -1), axis=1).values[
- self.currFrame
- ],
- self.Dataframe.xs((bp, "y"), level=(-2, -1), axis=1).values[
- self.currFrame
- ],
- 1.0,
- ]
- circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- alpha=self.alpha,
- )
- ]
- self.axes.add_patch(circle[0])
- self.figure.canvas.draw()
- else:
- print("Invalid frame")
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- wx.MessageBox(
- "1. Use the slider to select a frame in the entire video. \n\n2. Click Grab Frames button to save the specific frame.\
- \n\n3. In the events where you need to extract a range of frames, then use the checkbox 'Range of frames' to select the starting frame index and the number of frames to extract.\
- \n Click the update button to see the frame. Click Grab Frames to select the range of frames. \n\n Click OK to continue",
- "Instructions to use!",
- wx.OK | wx.ICON_INFORMATION,
- )
-
-
-def show(config, video, shuffle, Dataframe, savelabeled, multianimal):
- app = wx.App()
- frame = MainFrame(
- None, config, video, shuffle, Dataframe, savelabeled, multianimal
- ).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument(
- "config", "video", "shuffle", "Dataframe", "savelabeled", "multianimal"
- )
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/refine_labels.py b/deeplabcut/gui/refine_labels.py
deleted file mode 100644
index 779b9460ff..0000000000
--- a/deeplabcut/gui/refine_labels.py
+++ /dev/null
@@ -1,179 +0,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 pydoc
-import sys
-
-import wx
-
-import deeplabcut
-
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils import auxiliaryfunctions
-from pathlib import Path
-
-
-def refine_labels(config, multianimal=False, jump_unlabeled=False):
- """Refines the labels of the outlier frames extracted from the analyzed videos.
-
- Helps in augmenting the training dataset. Use ``deeplabcut.analyze_videos`` to
- analyze a video and extracts the outlier frames using
- ``deeplabcut.extract_outlier_frames`` before refining the labels.
-
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- multianimal, bool, optional, default=False
- If ``True``, a user can label multiple individuals. Note for
- ``"multianimalproject=True"`` this is automatically used.
-
- jump_unlabeled: bool, optional, default=False
- Aumatically jump to the next folder containing unlabeled images.
-
- Examples
- --------
- >>> deeplabcut.refine_labels('/analysis/project/reaching-task/config.yaml')
- """
-
- startpath = os.getcwd()
- wd = Path(config).resolve().parents[0]
- os.chdir(str(wd))
- cfg = auxiliaryfunctions.read_config(config)
- if not multianimal and not cfg.get("multianimalproject", False):
- from deeplabcut.gui import refinement
-
- refinement.show(config, jump_unlabeled)
- else: # loading multianimal labeling GUI
- from deeplabcut.gui import multiple_individuals_refinement_toolbox
-
- multiple_individuals_refinement_toolbox.show(config, jump_unlabeled)
-
- os.chdir(startpath)
-
-
-class Refine_labels(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg, page):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.method = "automatic"
- self.config = cfg
- self.page = page
- # design the panel
- sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 9. Refine labels")
- sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- sizer.Add(icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5)
-
- line1 = wx.StaticLine(self)
- sizer.Add(line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.help_button = wx.Button(self, label="Help")
- sizer.Add(self.help_button, pos=(4, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="LAUNCH")
- sizer.Add(self.ok, pos=(4, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.refine_labels)
-
- self.merge = wx.Button(self, label="Merge dataset")
- sizer.Add(self.merge, pos=(4, 3), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.merge.Bind(wx.EVT_BUTTON, self.merge_dataset)
- self.merge.Enable(False)
-
- self.reset = wx.Button(self, label="Reset")
- sizer.Add(self.reset, pos=(4, 1), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.reset.Bind(wx.EVT_BUTTON, self.reset_refine_labels)
-
- sizer.AddGrowableCol(2)
-
- self.SetSizer(sizer)
- sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.refine_labels"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def refine_labels(self, event):
- self.merge.Enable(True)
- deeplabcut.refine_labels(self.config)
-
- def merge_dataset(self, event):
- dlg = wx.MessageDialog(
- None,
- "1. Make sure that you have refined all the labels before merging the dataset.\n\n2. If you merge the dataset, you need to re-create the training dataset before you start the training.\n\n3. Are you ready to merge the dataset?",
- "Warning",
- wx.YES_NO | wx.ICON_WARNING,
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- notebook = self.GetParent()
- notebook.SetSelection(4)
- deeplabcut.merge_datasets(self.config, forceiterate=None)
-
- def reset_refine_labels(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.merge.Enable(False)
diff --git a/deeplabcut/gui/refine_tracklets.py b/deeplabcut/gui/refine_tracklets.py
deleted file mode 100644
index 2efdbc3900..0000000000
--- a/deeplabcut/gui/refine_tracklets.py
+++ /dev/null
@@ -1,343 +0,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 pydoc
-import sys
-import subprocess
-
-import wx
-
-import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions
-from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-
-from deeplabcut.gui import LOGO_PATH
-from pathlib import Path
-
-
-class Refine_tracklets(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
- self.config = cfg
- self.cfg = auxiliaryfunctions.read_config(self.config)
- self.datafile = ""
- self.video = ""
- self.manager = None
- self.viz = None
- # design the panel
- sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - OPTIONAL Refine Tracklets")
- sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- sizer.Add(icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5)
-
- line1 = wx.StaticLine(self)
- sizer.Add(line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.ntracks_text = wx.StaticText(self, label="Number of animals")
- sizer.Add(self.ntracks_text, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=5)
- self.ntracks = wx.SpinCtrl(
- self, value=str(len(self.cfg["individuals"])), min=1, max=1000
- )
- sizer.Add(
- self.ntracks, pos=(3, 1), span=(1, 3), flag=wx.EXPAND | wx.TOP, border=5
- )
-
- self.video_text = wx.StaticText(self, label="Select the video")
- sizer.Add(self.video_text, pos=(4, 0), flag=wx.TOP | wx.LEFT, border=5)
- self.sel_video = wx.FilePickerCtrl(
- self, path="", style=wx.FLP_USE_TEXTCTRL, message="Open video"
- )
- sizer.Add(
- self.sel_video, pos=(4, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_video.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_video)
-
- vbox_ = wx.BoxSizer(wx.VERTICAL)
- hbox_ = wx.BoxSizer(wx.HORIZONTAL)
- videotype_text = wx.StaticBox(self, label="Specify the videotype")
- videotype_text_boxsizer = wx.StaticBoxSizer(videotype_text, wx.VERTICAL)
- self.videotype = wx.ComboBox(self, choices=("",) + SUPPORTED_VIDEOS, style=wx.CB_READONLY)
- self.videotype.SetValue("")
- videotype_text_boxsizer.Add(
- self.videotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- shuffle_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffle_boxsizer = wx.StaticBoxSizer(shuffle_text, wx.VERTICAL)
- self.shuffle = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffle_boxsizer.Add(self.shuffle, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- #trainingset = wx.StaticBox(self, label="Specify the trainingset index")
- #trainingset_boxsizer = wx.StaticBoxSizer(trainingset, wx.VERTICAL)
- #self.trainingset = wx.SpinCtrl(self, value="0", min=0, max=100)
- #trainingset_boxsizer.Add(
- # self.trainingset, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- #)
-
- hbox_.Add(videotype_text_boxsizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox_.Add(shuffle_boxsizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- #hbox_.Add(trainingset_boxsizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- vbox_.Add(hbox_)
- sizer.Add(vbox_, pos=(5, 0))
-
- self.create_tracks_btn = wx.Button(self, label="Run(or re-run) tracking")
- sizer.Add(self.create_tracks_btn, pos=(6, 1))
- self.create_tracks_btn.Bind(wx.EVT_BUTTON, self.create_tracks)
-
- line2 = wx.StaticLine(self)
- sizer.Add(line2, pos=(7, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- hbox = wx.BoxSizer(wx.HORIZONTAL)
-
- slider_swap_text = wx.StaticBox(
- self, label="Specify the min swap length to highlight"
- )
- slider_swap_sizer = wx.StaticBoxSizer(slider_swap_text, wx.VERTICAL)
- self.slider_swap = wx.SpinCtrl(self, value="2")
- slider_swap_sizer.Add(self.slider_swap, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox.Add(slider_swap_sizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- sizer.Add(
- hbox, pos=(8, 0), flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=10
- )
-
- hbox_ = wx.BoxSizer(wx.HORIZONTAL)
-
- slider_gap_text = wx.StaticBox(
- self, label="Specify the max gap size of missing data to fill"
- )
- slider_gap_sizer = wx.StaticBoxSizer(slider_gap_text, wx.VERTICAL)
- self.slider_gap = wx.SpinCtrl(self, value="5")
- slider_gap_sizer.Add(self.slider_gap, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox_.Add(slider_gap_sizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- traillength_text = wx.StaticBox(self, label="Trail Length (for visualization)")
- traillength_sizer = wx.StaticBoxSizer(traillength_text, wx.VERTICAL)
- self.length_track = wx.SpinCtrl(self, value="25")
- traillength_sizer.Add(self.length_track, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox_.Add(traillength_sizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- sizer.Add(
- hbox_, pos=(9, 0), flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=10
- )
-
- line3 = wx.StaticLine(self)
- sizer.Add(
- line3, pos=(10, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
-
- filter_text = wx.StaticBox(self, label="filter type")
- filter_sizer = wx.StaticBoxSizer(filter_text, wx.VERTICAL)
- filtertypes = ["median"]
- self.filter_track = wx.ComboBox(self, choices=filtertypes, style=wx.CB_READONLY)
- self.filter_track.SetValue("median")
- filter_sizer.Add(self.filter_track, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- filterlength_text = wx.StaticBox(self, label="filter: window length")
- filterlength_sizer = wx.StaticBoxSizer(filterlength_text, wx.VERTICAL)
- self.filterlength_track = wx.SpinCtrl(self, value="5")
- filterlength_sizer.Add(
- self.filterlength_track, 20, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
-
- hbox2.Add(filter_sizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(filterlength_sizer, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- sizer.Add(
- hbox2, pos=(11, 0), flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT, border=10
- )
-
- self.inf_cfg_text = wx.Button(self, label="Edit inference_config.yaml")
- sizer.Add(self.inf_cfg_text, pos=(12, 2), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.inf_cfg_text.Bind(wx.EVT_BUTTON, self.edit_inf_config)
-
- self.ok = wx.Button(self, label="Refine tracks GUI Launch")
- sizer.Add(self.ok, pos=(9, 1))
- self.ok.Bind(wx.EVT_BUTTON, self.refine_tracklets)
-
- self.help_button = wx.Button(self, label="Help")
- sizer.Add(self.help_button, pos=(12, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.reset = wx.Button(self, label="Reset")
- sizer.Add(self.reset, pos=(12, 1), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.reset.Bind(wx.EVT_BUTTON, self.reset_refine_tracklets)
-
- self.filter = wx.Button(
- self, label=" Optional: Filter Tracks (then you also get a CSV file!)"
- )
- sizer.Add(self.filter, pos=(11, 1), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.filter.Bind(wx.EVT_BUTTON, self.filter_after_refinement)
-
- self.export = wx.Button(self, label="Optional: Merge refined data into training set")
- sizer.Add(self.export, pos=(13, 1), flag=wx.BOTTOM | wx.RIGHT, border=10)
- self.export.Bind(wx.EVT_BUTTON, self.export_data)
- self.export.Disable()
-
- sizer.AddGrowableCol(2)
-
- self.SetSizer(sizer)
- sizer.Fit(self)
-
- def edit_inf_config(self, event):
- # Read the infer config file
- #trainingsetindex = self.trainingset.GetValue()
- trainFraction = self.cfg["TrainingFraction"]
- self.inf_cfg_path = os.path.join(
- self.cfg["project_path"],
- auxiliaryfunctions.get_model_folder(
- trainFraction[-1], self.shuffle.GetValue(), self.cfg
- ),
- "test",
- "inference_cfg.yaml",
- )
- # let the user open the file with default text editor. Also make it mac compatible
- if sys.platform == "darwin":
- self.file_open_bool = subprocess.call(["open", self.inf_cfg_path])
- self.file_open_bool = True
- else:
- import webbrowser
-
- self.file_open_bool = webbrowser.open(self.inf_cfg_path)
- if self.file_open_bool:
- self.inf_cfg = auxiliaryfunctions.read_config(self.inf_cfg_path)
- else:
- raise FileNotFoundError("File not found!")
-
- def filter_after_refinement(self, event): # why is video type needed?
- shuffle = self.shuffle.GetValue()
- #trainingsetindex = self.trainingset.GetValue()
- window_length = self.filterlength_track.GetValue()
- if window_length % 2 != 1:
- raise ValueError("Window length should be odd.")
-
- deeplabcut.filterpredictions(
- self.config,
- [self.video],
- videotype=self.videotype.GetValue(),
- shuffle=shuffle,
- #trainingsetindex=trainingsetindex,
- filtertype=self.filter_track.GetValue(),
- windowlength=self.filterlength_track.GetValue(),
- save_as_csv=True,
- )
-
- def export_data(self, event):
- self.viz.export_to_training_data()
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.refine_tracklets"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_video(self, event):
- self.video = self.sel_video.GetPath()
- self.sel_video.SetPath(os.path.basename(self.video))
-
- def create_tracks(self, event):
- deeplabcut.stitch_tracklets(
- self.config,
- [self.video],
- videotype=self.videotype.GetValue(),
- shuffle=self.shuffle.GetValue(),
- #trainingsetindex=self.trainingset.GetValue(),
- n_tracks=self.ntracks.GetValue(),
- )
-
- def refine_tracklets(self, event):
- DLCscorer, _ = auxiliaryfunctions.GetScorerName(
- self.cfg,
- self.shuffle.GetValue(),
- self.cfg["TrainingFraction"][-1],
- )
- track_method = self.cfg.get("default_track_method", "ellipse")
- if track_method == "ellipse":
- method = "el"
- elif track_method == "box":
- method = "bx"
- else:
- method = "sk"
- dest = str(Path(self.video).parents[0])
- vname = Path(self.video).stem
- datafile = os.path.join(dest, vname + DLCscorer + f"_{method}.h5")
- self.manager, self.viz = deeplabcut.refine_tracklets(
- self.config,
- datafile,
- self.video,
- min_swap_len=self.slider_swap.GetValue(),
- trail_len=self.length_track.GetValue(),
- max_gap=self.slider_gap.GetValue(),
- )
- self.export.Enable()
-
- def reset_refine_tracklets(self, event):
- """
- Reset to default
- """
- self.config = ""
- self.datafile = ""
- self.video = ""
- self.sel_config.SetPath("")
- self.sel_datafile.SetPath("")
- self.sel_video.SetPath("")
- self.slider_swap.SetValue(2)
- self.length_track.SetValue(25)
- self.slider_gap.SetValue(5)
- # self.save.Enable(False)
diff --git a/deeplabcut/gui/refinement.py b/deeplabcut/gui/refinement.py
deleted file mode 100644
index a9f48e5370..0000000000
--- a/deeplabcut/gui/refinement.py
+++ /dev/null
@@ -1,910 +0,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 argparse
-import os
-import os.path
-import platform
-from pathlib import Path
-
-# from skimage import io
-import PIL
-import matplotlib.colors as mcolors
-import matplotlib.patches as patches
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-import wx
-import wx.lib.scrolledpanel as SP
-from matplotlib.backends.backend_wxagg import (
- NavigationToolbar2WxAgg as NavigationToolbar,
-)
-from mpl_toolkits.axes_grid1 import make_axes_locatable
-from skimage import io
-
-from deeplabcut.gui import auxfun_drag
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-from deeplabcut.utils import auxiliaryfunctions, conversioncode
-
-
-# ###########################################################################
-# Class for GUI MainFrame
-# ###########################################################################
-class ImagePanel(BasePanel):
- def drawplot(
- self,
- img,
- img_name,
- itr,
- index,
- threshold,
- bodyparts,
- cmap,
- preview,
- keep_view=False,
- ):
- xlim = self.axes.get_xlim()
- ylim = self.axes.get_ylim()
- self.axes.clear()
- im = io.imread(img)
- ax = self.axes.imshow(im, cmap=cmap)
- self.orig_xlim = self.axes.get_xlim()
- self.orig_ylim = self.axes.get_ylim()
- divider = make_axes_locatable(self.axes)
- colorIndex = np.linspace(np.min(im), np.max(im), len(bodyparts))
- cax = divider.append_axes("right", size="5%", pad=0.05)
- cbar = self.figure.colorbar(
- ax, cax=cax, spacing="proportional", ticks=colorIndex
- )
- cbar.set_ticklabels(bodyparts[::-1])
-
- if not preview:
- self.axes.set_title(
- str(
- str(itr)
- + "/"
- + str(len(index) - 1)
- + " "
- + str(Path(*index[itr]).stem)
- + " "
- + " Threshold chosen is: "
- + str("{0:.2f}".format(threshold))
- )
- )
- else:
- self.axes.set_title(
- str(
- str(itr)
- + "/"
- + str(len(index) - 1)
- + " "
- + str(Path(*index[itr]).stem)
- )
- )
-
- if keep_view:
- self.axes.set_xlim(xlim)
- self.axes.set_ylim(ylim)
- self.figure.canvas.draw()
- if not hasattr(self, "toolbar"):
- self.toolbar = NavigationToolbar(self.canvas)
- return (self.figure, self.axes, self.canvas, self.toolbar)
-
- def getColorIndices(self, img, bodyparts):
- """
- Returns the colormaps ticks and . The order of ticks labels is reversed.
- """
- im = io.imread(img)
- norm = mcolors.Normalize(vmin=0, vmax=np.max(im))
- ticks = np.linspace(0, np.max(im), len(bodyparts))[::-1]
- return norm, ticks
-
-
-class ScrollPanel(SP.ScrolledPanel):
- def __init__(self, parent):
- SP.ScrolledPanel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
- self.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- self.Layout()
-
- def on_focus(self, event):
- pass
-
- def addCheckBoxSlider(self, bodyparts, fileIndex, markersize):
- """
- Adds checkbox and a slider
- """
- self.choiceBox = wx.BoxSizer(wx.VERTICAL)
-
- self.slider = wx.Slider(
- self,
- -1,
- markersize,
- 1,
- markersize * 3,
- size=(250, -1),
- style=wx.SL_HORIZONTAL | wx.SL_AUTOTICKS | wx.SL_LABELS,
- )
- self.slider.Enable(False)
- self.checkBox = wx.CheckBox(self, id=wx.ID_ANY, label="Adjust marker size.")
- self.choiceBox.Add(self.slider, 0, wx.ALL, 5)
- self.choiceBox.Add(self.checkBox, 0, wx.ALL, 5)
- self.SetSizerAndFit(self.choiceBox)
- self.Layout()
- return (self.choiceBox, self.slider, self.checkBox)
-
- def clearBoxer(self):
- self.choiceBox.Clear(True)
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, jump_unlabeled):
- super(MainFrame, self).__init__("DeepLabCut2.0 - Refinement ToolBox", parent)
- self.Bind(wx.EVT_CHAR_HOOK, self.OnKeyPressed)
- self.jump_unlabeled = jump_unlabeled
- ###################################################################################################################################################
-
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
-
- topSplitter = wx.SplitterWindow(self)
- vSplitter = wx.SplitterWindow(topSplitter)
-
- self.image_panel = ImagePanel(vSplitter, config, self.gui_size)
- self.choice_panel = ScrollPanel(vSplitter)
- # self.choice_panel.SetupScrolling(scroll_x=True, scroll_y=True, scrollToTop=False)
- # self.choice_panel.SetupScrolling(scroll_x=True, scrollToTop=False)
- vSplitter.SplitVertically(
- self.image_panel, self.choice_panel, sashPosition=self.gui_size[0] * 0.8
- )
- vSplitter.SetSashGravity(1)
- self.widget_panel = WidgetPanel(topSplitter)
- topSplitter.SplitHorizontally(
- vSplitter, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
- self.load = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Load labels")
- widgetsizer.Add(self.load, 1, wx.ALL, 15)
- self.load.Bind(wx.EVT_BUTTON, self.browseDir)
-
- self.prev = wx.Button(self.widget_panel, id=wx.ID_ANY, label="< self.iter:
- self.updatedCoords = []
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- # Plotting
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.bodyparts,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- im = io.imread(self.img)
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- if np.max(im) == 0:
- msg = wx.MessageBox(
- "Invalid image. Click Yes to remove",
- "Error!",
- wx.YES_NO | wx.ICON_WARNING,
- )
- if msg == 2:
- self.Dataframe = self.Dataframe.drop(self.index[self.iter])
- self.index = list(self.Dataframe.iloc[:, 0].index)
- self.iter = self.iter - 1
-
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.bodyparts,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- MainFrame.plot(self, self.img)
- else:
- self.next.Enable(False)
- MainFrame.saveEachImage(self)
-
- def prevImage(self, event):
- """
- Checks the previous Image and enables user to move the annotations.
- """
-
- MainFrame.saveEachImage(self)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
-
- self.statusbar.SetStatusText(
- "Working on folder: {}".format(os.path.split(str(self.dir))[-1])
- )
- self.next.Enable(True)
- self.iter = self.iter - 1
-
- # Checks for the first image and disables the Previous button
- if self.iter == 0:
- self.prev.Enable(False)
-
- if self.iter >= 0:
- self.updatedCoords = []
- # Reading Image
- self.img = os.path.join(self.project_path, *self.index[self.iter])
- img_name = Path(self.img).name
-
- # Plotting
- self.figure.delaxes(
- self.figure.axes[1]
- ) # Removes the axes corresponding to the colorbar
- (
- self.figure,
- self.axes,
- self.canvas,
- self.toolbar,
- ) = self.image_panel.drawplot(
- self.img,
- img_name,
- self.iter,
- self.index,
- self.threshold,
- self.bodyparts,
- self.colormap,
- self.preview,
- keep_view=self.view_locked,
- )
- self.axes.callbacks.connect("xlim_changed", self.onZoom)
- self.axes.callbacks.connect("ylim_changed", self.onZoom)
- MainFrame.plot(self, self.img)
- else:
- self.prev.Enable(False)
- MainFrame.saveEachImage(self)
-
- def quitButton(self, event):
- """
- Quits the GUI
- """
- self.statusbar.SetStatusText("")
- dlg = wx.MessageDialog(
- None, "Are you sure?", "Quit!", wx.YES_NO | wx.ICON_WARNING
- )
- result = dlg.ShowModal()
- if result == wx.ID_YES:
- print(
- "Closing... The refined labels are stored in a subdirectory under labeled-data. Use the function 'merge_datasets' to augment the training dataset, and then re-train a network using create_training_dataset followed by train_network!"
- )
- self.Destroy()
- else:
- self.save.Enable(True)
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- self.statusbar.SetStatusText("Help")
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- wx.MessageBox(
- "1. Enter the likelihood threshold. \n\n2. All the data points above the threshold will be marked as circle filled with a unique color. All the data points below the threshold will be marked with a hollow circle. \n\n3. Enable the checkbox to adjust the marker size (you will not be able to zoom/pan/home until the next frame). \n\n4. Hover your mouse over data points to see the labels and their likelihood. \n\n5. LEFT click+drag to move the data points. \n\n6. MIDDLE click on any data point to remove it. Be careful, you cannot undo this step! \n Click once on the zoom button to zoom-in the image. The cursor will become cross, click and drag over a point to zoom in. \n Click on the zoom button again to disable the zooming function and recover the cursor. \n Use pan button to pan across the image while zoomed in. Use home button to go back to the full default view. \n\n7. When finished click 'Save' to save all the changes. \n\n8. Click OK to continue",
- "User instructions",
- wx.OK | wx.ICON_INFORMATION,
- )
-
- def onChecked(self, event):
- MainFrame.saveEachImage(self)
- self.cb = event.GetEventObject()
- if self.cb.GetValue():
- self.slider.Enable(True)
- else:
- self.slider.Enable(False)
-
- def check_labels(self):
- print("Checking labels if they are outside the image")
- for i in self.Dataframe.index:
- image_name = os.path.join(self.project_path, *i)
- im = PIL.Image.open(image_name)
- width, height = im.size
- for bpindex, bp in enumerate(self.bodyparts):
- testCondition = (
- self.Dataframe.loc[i, (self.scorer, bp, "x")] > width
- or self.Dataframe.loc[i, (self.scorer, bp, "x")] < 0
- or self.Dataframe.loc[i, (self.scorer, bp, "y")] > height
- or self.Dataframe.loc[i, (self.scorer, bp, "y")] < 0
- )
- if testCondition:
- print("Found %s outside the image %s.Setting it to NaN" % (bp, i))
- self.Dataframe.loc[i, (self.scorer, bp, "x")] = np.nan
- self.Dataframe.loc[i, (self.scorer, bp, "y")] = np.nan
- return self.Dataframe
-
- def saveDataSet(self, event):
-
- MainFrame.saveEachImage(self)
-
- # Checks if zoom/pan button is ON
- MainFrame.updateZoomPan(self)
- self.statusbar.SetStatusText("File saved")
-
- self.Dataframe = MainFrame.check_labels(self)
- # Overwrite machine label file
- self.Dataframe.to_hdf(self.dataname, key="df_with_missing", mode="w")
-
- self.Dataframe.columns.set_levels(
- [self.scorer.replace(self.scorer, self.humanscorer)], level=0, inplace=True
- )
- self.Dataframe = self.Dataframe.drop("likelihood", axis=1, level=2)
-
- if Path(self.dir, "CollectedData_" + self.humanscorer + ".h5").is_file():
- print(
- "A training dataset file is already found for this video. The refined machine labels are merged to this data!"
- )
- DataU1 = pd.read_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5")
- )
- # combine datasets Original Col. + corrected machinefiles:
- DataCombined = pd.concat([self.Dataframe, DataU1])
- # Now drop redundant ones keeping the first one [this will make sure that the refined machine file gets preference]
- DataCombined = DataCombined[~DataCombined.index.duplicated(keep="first")]
- """
- if len(self.droppedframes)>0: #i.e. frames were dropped/corrupt. also remove them from original file (if they exist!)
- for fn in self.droppedframes:
- try:
- DataCombined.drop(fn,inplace=True)
- except KeyError:
- pass
- """
- DataCombined.sort_index(inplace=True)
- DataCombined.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5"),
- key="df_with_missing",
- mode="w",
- )
- DataCombined.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".csv")
- )
- else:
- self.Dataframe.sort_index(inplace=True)
- self.Dataframe.to_hdf(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".h5"),
- key="df_with_missing",
- mode="w",
- )
- self.Dataframe.to_csv(
- os.path.join(self.dir, "CollectedData_" + self.humanscorer + ".csv")
- )
- self.next.Enable(False)
- self.prev.Enable(False)
- self.slider.Enable(False)
- self.checkBox.Enable(False)
-
- nextFilemsg = wx.MessageBox(
- "File saved. Do you want to refine another file?",
- "Repeat?",
- wx.YES_NO | wx.ICON_INFORMATION,
- )
- if nextFilemsg == 2:
- self.file = 1
- # self.buttonCounter = []
- self.updatedCoords = []
- self.dataFrame = None
- self.prev.Enable(False)
- # self.bodyparts = []
- self.figure.delaxes(self.figure.axes[1])
- self.axes.clear()
- self.choiceBox.Clear(True)
- MainFrame.updateZoomPan(self)
- MainFrame.browseDir(self, event)
-
- # ###########################################################################
- # Other functions
- # ###########################################################################
- def saveEachImage(self):
- """
- Updates the dataframe for the current image with the new datapoints
- """
- for bpindex, bp in enumerate(self.bodyparts):
- if self.updatedCoords[bpindex]:
- self.Dataframe.loc[
- self.Dataframe.index[self.iter], (self.scorer, bp, "x")
- ] = self.updatedCoords[bpindex][-1][0]
- self.Dataframe.loc[
- self.Dataframe.index[self.iter], (self.scorer, bp, "y")
- ] = self.updatedCoords[bpindex][-1][1]
-
- def getLabels(self, img_index):
- """
- Returns a list of x and y labels of the corresponding image index
- """
- self.previous_image_points = []
- for bpindex, bp in enumerate(self.bodyparts):
- image_points = [
- [
- self.Dataframe[self.scorer][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][bp]["y"].values[self.iter],
- bp,
- bpindex,
- ]
- ]
- self.previous_image_points.append(image_points)
- return self.previous_image_points
-
- def plot(self, im):
- """
- Plots and call auxfun_drag class for moving and removing points.
- """
- # small hack in case there are any 0 intensity images!
- im = io.imread(im)
- maxIntensity = np.max(im)
- if maxIntensity == 0:
- maxIntensity = np.max(im) + 255
- self.drs = []
- for bpindex, bp in enumerate(self.bodyparts):
- color = self.colormap(self.norm(self.colorIndex[bpindex]))
- if "CollectedData_" in self.fileName:
- self.points = [
- self.Dataframe[self.scorer][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][bp]["y"].values[self.iter],
- 1.0,
- ]
- self.likelihood = self.points[2]
- else:
- self.points = [
- self.Dataframe[self.scorer][bp]["x"].values[self.iter],
- self.Dataframe[self.scorer][bp]["y"].values[self.iter],
- self.Dataframe[self.scorer][bp]["likelihood"].values[self.iter],
- ]
- self.likelihood = self.points[2]
-
- if self.move2corner:
- ny, nx = np.shape(im)[0], np.shape(im)[1]
- if self.points[0] > nx or self.points[0] < 0:
- self.points[0] = self.center[0]
- if self.points[1] > ny or self.points[1] < 0:
- self.points[1] = self.center[1]
-
- if (
- not ("CollectedData_" in self.fileName)
- and self.likelihood < self.threshold
- ):
- circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- facecolor="None",
- edgecolor=color,
- )
- ]
- else:
- circle = [
- patches.Circle(
- (self.points[0], self.points[1]),
- radius=self.markerSize,
- fc=color,
- alpha=self.alpha,
- )
- ]
-
- self.axes.add_patch(circle[0])
- self.dr = auxfun_drag.DraggablePoint(
- circle[0], bp, likelihood=self.likelihood
- )
- self.dr.connect()
- self.dr.coords = MainFrame.getLabels(self, self.iter)[bpindex]
- self.drs.append(self.dr)
- self.updatedCoords.append(self.dr.coords)
- self.figure.canvas.draw()
-
-
-def show(config, jump_unlabeled=False):
- app = wx.App()
- frame = MainFrame(None, config, jump_unlabeled).Show()
- app.MainLoop()
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/select_crop_parameters.py b/deeplabcut/gui/select_crop_parameters.py
deleted file mode 100644
index efbb351e42..0000000000
--- a/deeplabcut/gui/select_crop_parameters.py
+++ /dev/null
@@ -1,130 +0,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 argparse
-
-import wx
-from matplotlib.figure import Figure
-from matplotlib.widgets import RectangleSelector
-from deeplabcut.gui.widgets import BasePanel, WidgetPanel, BaseFrame
-
-
-class MainFrame(BaseFrame):
- def __init__(self, parent, config, image):
- super(MainFrame, self).__init__(
- "DeepLabCut2.0 - Select Crop Parameters", parent
- )
-
- ###################################################################################################################################################
- # Splitting the frame into top and bottom panels. Bottom panels contains the widgets. The top panel is for showing images and plotting!
- topSplitter = wx.SplitterWindow(self)
-
- self.image_panel = BasePanel(topSplitter, config, self.gui_size)
- self.widget_panel = WidgetPanel(topSplitter)
-
- topSplitter.SplitHorizontally(
- self.image_panel, self.widget_panel, sashPosition=self.gui_size[1] * 0.83
- ) # 0.9
- topSplitter.SetSashGravity(1)
- sizer = wx.BoxSizer(wx.VERTICAL)
- sizer.Add(topSplitter, 1, wx.EXPAND)
- self.SetSizer(sizer)
-
- ###################################################################################################################################################
- # Add Buttons to the WidgetPanel and bind them to their respective functions.
-
- widgetsizer = wx.WrapSizer(orient=wx.HORIZONTAL)
-
- self.help = wx.Button(self.widget_panel, id=wx.ID_ANY, label="Help")
- widgetsizer.Add(self.help, 1, wx.ALL, 15)
- self.help.Bind(wx.EVT_BUTTON, self.helpButton)
-
- self.quit = wx.Button(
- self.widget_panel, id=wx.ID_ANY, label="Save parameters and Quit"
- )
- widgetsizer.Add(self.quit, 1, wx.ALL, 15)
- self.quit.Bind(wx.EVT_BUTTON, self.quitButton)
-
- self.widget_panel.SetSizer(widgetsizer)
- self.widget_panel.SetSizerAndFit(widgetsizer)
- self.widget_panel.Layout()
-
- # Variables initialization
- self.image = image
- self.coords = []
- self.figure = Figure()
- self.axes = self.figure.add_subplot(111)
- # self.cfg = auxiliaryfunctions.read_config(config)
- MainFrame.show_image(self)
-
- def quitButton(self, event):
- """
- Quits the GUI
- """
- # self.statusbar.SetStatusText("")
- # dlg = wx.MessageDialog(None,"Are you sure?", "Quit!",wx.YES_NO | wx.ICON_WARNING)
- # result = dlg.ShowModal()
- # if result == wx.ID_YES:
- self.Destroy()
-
- def show_image(self):
- self.figure, self.axes, self.canvas = self.image_panel.getfigure()
- self.ax = self.axes.imshow(self.image)
- self.figure.canvas.draw()
- self.cid = RectangleSelector(
- self.axes,
- self.line_select_callback,
- drawtype="box",
- useblit=False,
- button=[1],
- minspanx=5,
- minspany=5,
- spancoords="pixels",
- interactive=True,
- )
- self.canvas.mpl_connect("key_press_event", self.cid)
-
- def line_select_callback(self, eclick, erelease):
- "eclick and erelease are the press and release events"
- new_x1, new_y1 = eclick.xdata, eclick.ydata
- new_x2, new_y2 = erelease.xdata, erelease.ydata
- coords = [
- str(int(new_x1)),
- str(int(new_x2)),
- str(int(new_y1)),
- str(int(new_y2)),
- ]
- self.coords = coords
-
- def helpButton(self, event):
- """
- Opens Instructions
- """
- wx.MessageBox(
- "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 "
- "Save parameters and Quit"
- " to save the croppeing parameters and close the GUI. \n\n Click OK to continue",
- "Instructions to use!",
- wx.OK | wx.ICON_INFORMATION,
- )
-
-
-def show(config, image):
- app = wx.App()
- main = MainFrame(None, config, image)
- main.Show()
- app.MainLoop()
- return main.coords
-
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("config", "image")
- cli_args = parser.parse_args()
diff --git a/deeplabcut/gui/tabs/__init__.py b/deeplabcut/gui/tabs/__init__.py
new file mode 100644
index 0000000000..c7f7f534fb
--- /dev/null
+++ b/deeplabcut/gui/tabs/__init__.py
@@ -0,0 +1,25 @@
+#
+# 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 deeplabcut.gui.tabs.analyze_videos import AnalyzeVideos
+from deeplabcut.gui.tabs.create_project import ProjectCreator
+from deeplabcut.gui.tabs.create_training_dataset import CreateTrainingDataset
+from deeplabcut.gui.tabs.create_videos import CreateVideos
+from deeplabcut.gui.tabs.evaluate_network import EvaluateNetwork
+from deeplabcut.gui.tabs.extract_frames import ExtractFrames
+from deeplabcut.gui.tabs.extract_outlier_frames import ExtractOutlierFrames
+from deeplabcut.gui.tabs.label_frames import LabelFrames
+from deeplabcut.gui.tabs.manage_project import ManageProject
+from deeplabcut.gui.tabs.modelzoo import ModelZoo
+from deeplabcut.gui.tabs.open_project import OpenProject
+from deeplabcut.gui.tabs.refine_tracklets import RefineTracklets
+from deeplabcut.gui.tabs.train_network import TrainNetwork
+from deeplabcut.gui.tabs.unsupervised_id_tracking import UnsupervizedIdTracking
+from deeplabcut.gui.tabs.video_editor import VideoEditor
diff --git a/deeplabcut/gui/tabs/analyze_videos.py b/deeplabcut/gui/tabs/analyze_videos.py
new file mode 100644
index 0000000000..0a3a448ae4
--- /dev/null
+++ b/deeplabcut/gui/tabs/analyze_videos.py
@@ -0,0 +1,456 @@
+#
+# 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 dataclasses import dataclass
+from functools import partial
+from pathlib import Path
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt, QTimer
+
+import deeplabcut
+from deeplabcut.core.config import ProjectConfig
+from deeplabcut.gui.components import (
+ BodypartListWidget,
+ DefaultTab,
+ ShuffleSpinBox,
+ VideoSelectionWidget,
+ _create_grid_layout,
+ _create_horizontal_layout,
+ _create_label_widget,
+ _create_vertical_layout,
+)
+from deeplabcut.gui.utils import move_to_separate_thread
+from deeplabcut.gui.widgets import ConfigEditor
+
+
+@dataclass(frozen=True)
+class AnalyzeVideosOptions:
+ config_path: str | Path
+ 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().__init__(root, parent, h1_description)
+
+ self._reload_timer = QTimer(self)
+ self._reload_timer.setSingleShot(True)
+ self._reload_timer.setInterval(0)
+ self._reload_timer.timeout.connect(self.root.reload_project_config)
+
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.video_selection_widget.files
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ 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()
+
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_grid_layout()
+ self._generate_layout_attributes(self.layout_attributes)
+ tmp_layout.addLayout(self.layout_attributes)
+
+ # Single / Multi animal Only Layouts
+ self.layout_singleanimal = _create_horizontal_layout()
+ self.layout_multianimal = _create_horizontal_layout()
+
+ if self.root.is_multianimal:
+ self._generate_layout_multianimal(self.layout_multianimal)
+ tmp_layout.addLayout(self.layout_multianimal)
+ else:
+ self._generate_layout_single_animal(self.layout_singleanimal)
+ tmp_layout.addLayout(self.layout_singleanimal)
+
+ self.main_layout.addLayout(tmp_layout)
+
+ self.main_layout.addWidget(_create_label_widget("", "font:bold"))
+ self.layout_other_options = _create_vertical_layout()
+ self._generate_layout_other_options(self.layout_other_options)
+ self.main_layout.addLayout(self.layout_other_options)
+
+ self.analyze_videos_btn = QtWidgets.QPushButton("Analyze Videos")
+ self.analyze_videos_btn.clicked.connect(self.analyze_videos)
+
+ self.edit_config_file_btn = QtWidgets.QPushButton("Edit config.yaml")
+ self.edit_config_file_btn.clicked.connect(self.edit_config_file)
+
+ self.main_layout.addWidget(self.analyze_videos_btn, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.edit_config_file_btn, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(deeplabcut.analyze_videos.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_single_animal(self, layout):
+ # Dynamic bodypart cropping
+ self.crop_bodyparts = QtWidgets.QCheckBox("Dynamically crop bodyparts")
+ self.crop_bodyparts.setCheckState(Qt.Unchecked)
+ self.crop_bodyparts.stateChanged.connect(self.update_crop_choice)
+
+ self.dynamic_cropping = False
+ layout.addWidget(self.crop_bodyparts)
+
+ def _generate_layout_other_options(self, layout):
+ tmp_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
+
+ # Save results as csv
+ self.save_as_csv = QtWidgets.QCheckBox("Save result(s) as csv")
+ self.save_as_csv.setCheckState(Qt.Unchecked)
+ self.save_as_csv.stateChanged.connect(self.update_csv_choice)
+
+ tmp_layout.addWidget(self.save_as_csv)
+
+ # Filter predictions
+ self.filter_predictions = QtWidgets.QCheckBox("Filter predictions")
+ self.filter_predictions.setCheckState(Qt.Unchecked)
+ self.filter_predictions.stateChanged.connect(self.update_filter_choice)
+
+ tmp_layout.addWidget(self.filter_predictions)
+
+ # Plot Trajectories
+ self.plot_trajectories = QtWidgets.QCheckBox("Plot trajectories")
+ self.plot_trajectories.setCheckState(Qt.Unchecked)
+ self.plot_trajectories.stateChanged.connect(self.update_plot_trajectory_choice)
+
+ tmp_layout.addWidget(self.plot_trajectories)
+
+ # Show trajectory plots
+ self.show_trajectory_plots = QtWidgets.QCheckBox("Show trajectory plots")
+ self.show_trajectory_plots.setCheckState(Qt.Unchecked)
+ self.show_trajectory_plots.setEnabled(False)
+ self.show_trajectory_plots.stateChanged.connect(self.update_showfigs_choice)
+
+ tmp_layout.addWidget(self.show_trajectory_plots)
+
+ layout.addLayout(tmp_layout)
+
+ self.bodyparts_list_widget = BodypartListWidget(root=self.root, parent=self)
+ layout.addWidget(self.bodyparts_list_widget, Qt.AlignLeft)
+
+ def _generate_layout_attributes(self, layout):
+ # Shuffle
+ opt_text = QtWidgets.QLabel("Shuffle")
+ self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+
+ layout.addWidget(opt_text, 0, 0)
+ layout.addWidget(self.shuffle, 0, 1)
+
+ def _generate_layout_multianimal(self, layout):
+ tmp_layout = QtWidgets.QGridLayout()
+
+ opt_text = QtWidgets.QLabel("Tracking method")
+ self.tracker_type_widget = QtWidgets.QComboBox()
+ self.tracker_type_widget.addItems(["ellipse", "box", "skeleton"])
+ self.tracker_type_widget.currentTextChanged.connect(self.update_tracker_type)
+ tmp_layout.addWidget(opt_text, 0, 0)
+ tmp_layout.addWidget(self.tracker_type_widget, 0, 1)
+
+ opt_text = QtWidgets.QLabel("Number of animals in videos")
+ self.num_animals_in_videos = QtWidgets.QSpinBox()
+ self.num_animals_in_videos.setMaximum(100)
+ self.num_animals_in_videos.setValue(len(self.root.all_individuals))
+ tmp_layout.addWidget(opt_text, 1, 0)
+ tmp_layout.addWidget(self.num_animals_in_videos, 1, 1)
+
+ # layout.addLayout(tmp_layout)
+
+ # tmp_layout = QtWidgets.QGridLayout()
+
+ 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)
+ 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.setCheckState(Qt.Unchecked)
+ 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.setCheckState(Qt.Unchecked)
+ 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 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 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 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 Qt.CheckState(state) == Qt.Checked else "DISABLED"
+ self.root.logger.info(f"Save results as CSV {s}")
+
+ def update_filter_choice(self, state):
+ 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 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 Qt.CheckState(state) == Qt.Checked:
+ self.root.logger.info("Dynamic bodypart cropping ENABLED.")
+ self.dynamic_cropping = True
+ else:
+ self.root.logger.info("Dynamic bodypart cropping DISABLED.")
+ self.dynamic_cropping = False
+
+ def update_plot_trajectory_choice(self, state):
+ 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)
+ self.root.logger.info("Plot trajectories ENABLED.")
+
+ else:
+ self.bodyparts_list_widget.hide()
+ self.bodyparts_list_widget.setEnabled(False)
+ self.show_trajectory_plots.setEnabled(False)
+ self.show_trajectory_plots.setCheckState(Qt.Unchecked)
+ self.root.logger.info("Plot trajectories DISABLED.")
+
+ def edit_config_file(self):
+ if not self.root.config_path:
+ return
+ config = self.root.config_path
+ editor = ConfigEditor(config, parent=self.root)
+ editor.accepted.connect(self._reload_timer.start)
+ editor.show()
+
+ def _collect_options(self) -> AnalyzeVideosOptions:
+ config_path = self.root.config_path
+ shuffle = self.root.shuffle_value
+ 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.isChecked()
+ assemble_with_ID_only = self.assemble_with_ID_only_checkbox.isChecked()
+ track_method = self.tracker_type_widget.currentText()
+ num_animals_in_videos = self.num_animals_in_videos.value()
+ create_video_all_detections = self.create_detections_video_checkbox.isChecked()
+ else:
+ calibrate_assembly = False
+ assemble_with_ID_only = False
+ track_method = None
+ num_animals_in_videos = None
+ create_video_all_detections = False
+
+ cropping = None
+ crop_flag = self.root.cfg.get("cropping", False)
+ if str(crop_flag).lower() == "true":
+ cropping = (
+ self.root.cfg["x1"],
+ self.root.cfg["x2"],
+ self.root.cfg["y1"],
+ self.root.cfg["y2"],
+ )
+
+ dynamic_cropping_params = (False, 0.5, 10)
+ if getattr(self, "dynamic_cropping", False):
+ dynamic_cropping_params = (True, 0.5, 10)
+
+ return AnalyzeVideosOptions(
+ config_path=config_path,
+ shuffle=shuffle,
+ save_as_csv=save_as_csv,
+ 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,
+ 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,
+ )
+
+ 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[Path]]]) -> list[Path]:
+ folders = []
+ seen = set()
+
+ for _, videos in batches:
+ for video in videos:
+ parent = video.parent.absolute()
+ if parent not in seen:
+ seen.add(parent)
+ folders.append(parent)
+
+ return folders
+
+ def _run_pipeline(self, options: AnalyzeVideosOptions, batches: list[tuple[str, list[Path]]]):
+ for videotype, videos in batches:
+ try:
+ self.root.logger.info(f"Analyzing {len(videos)} video(s) with extension {videotype}")
+
+ deeplabcut.analyze_videos(
+ options.config_path,
+ 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(
+ options.config_path,
+ videos=videos,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
+ )
+
+ if options.filter_data:
+ deeplabcut.filterpredictions(
+ options.config_path,
+ video=videos,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
+ filtertype="median",
+ windowlength=5,
+ save_as_csv=options.save_as_csv,
+ track_method=options.track_method,
+ )
+
+ if options.plot_trajectories:
+ deeplabcut.plot_trajectories(
+ options.config_path,
+ videos=videos,
+ displayedbodyparts=options.displayed_bodyparts,
+ video_extensions=videotype,
+ shuffle=options.shuffle,
+ filtered=options.filter_data,
+ showfigures=options.show_trajectory_plots,
+ track_method=options.track_method,
+ )
+
+ def _convert_outputs_to_csv_once_per_folder(self, batches: list[tuple[str, list[Path]]]):
+ 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(
+ folder,
+ listofvideos=False,
+ )
+
+ 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:
+ # NOTE @deruyter92 2026-06-23: Most of GUI is not migrated to typed configs yet, so we normalize here.
+ cfg = ProjectConfig.from_any(self.root.config_path, repair_path=True)
+
+ # Update track method and write to disk
+ cfg.default_track_method = options.track_method
+ cfg.to_yaml(self.root.config_path, overwrite=True, log_changes=True, mark_clean=True)
+
+ func = partial(self._run_pipeline, options, batches)
+
+ self.worker, self.thread = move_to_separate_thread(func)
+ self.worker.error.connect(self.root.show_task_error)
+ 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
new file mode 100644
index 0000000000..3450f40897
--- /dev/null
+++ b/deeplabcut/gui/tabs/create_project.py
@@ -0,0 +1,540 @@
+#
+# 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 datetime import datetime
+from pathlib import Path
+
+from PySide6 import QtCore, QtWidgets
+from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPainter, QPen
+
+from deeplabcut.create_project import create_new_project, create_new_project_3d
+from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.gui_assets import icon_from_resource
+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 deeplabcut.utils.auxfun_videos import collect_video_paths
+
+
+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().__init__(parent)
+ self.parent = parent
+ self.setWindowTitle("New Project")
+ self.setModal(True)
+ self.setMinimumWidth(parent.screen_width // 2)
+ today = datetime.today().strftime("%Y-%m-%d")
+ self.name_default = "-".join(("{}", "{}", today))
+ self.proj_default = ""
+ 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()
+ self.create_button = QtWidgets.QPushButton("Create")
+ self.create_button.setDefault(True)
+ self.create_button.clicked.connect(self.finalize_project)
+ main_layout.addWidget(self.user_frame)
+ main_layout.addWidget(self.video_frame)
+ main_layout.addWidget(self.create_button, alignment=QtCore.Qt.AlignRight)
+
+ def lay_out_user_frame(self):
+ user_frame = QtWidgets.QFrame(self)
+ user_frame.setFrameShape(user_frame.Shape.StyledPanel)
+ user_frame.setLineWidth(0)
+
+ proj_label = QtWidgets.QLabel("Project:", user_frame)
+ self.proj_line = QtWidgets.QLineEdit(self.proj_default, user_frame)
+ self.proj_line.setPlaceholderText("my project's name")
+ self._default_style = self.proj_line.styleSheet()
+ self.proj_line.textEdited.connect(self.update_project_name)
+
+ exp_label = QtWidgets.QLabel("Experimenter:", user_frame)
+ self.exp_line = QtWidgets.QLineEdit(self.exp_default, user_frame)
+ self.exp_line.setPlaceholderText("my nickname")
+ self.exp_line.textEdited.connect(self.update_experimenter_name)
+
+ loc_label = ClickableLabel("Location:", parent=user_frame)
+ loc_label.signal.connect(self.on_click)
+ self.loc_line = QtWidgets.QLineEdit(os.fspath(self.loc_default), user_frame)
+ self.loc_line.setReadOnly(True)
+ action = self.loc_line.addAction(
+ icon_from_resource("icons", "open2.png"),
+ QtWidgets.QLineEdit.TrailingPosition,
+ )
+ action.triggered.connect(self.on_click)
+
+ vbox = QtWidgets.QVBoxLayout(user_frame)
+ grid = QtWidgets.QGridLayout()
+ grid.addWidget(proj_label, 0, 0)
+ grid.addWidget(self.proj_line, 0, 1)
+ grid.addWidget(exp_label, 1, 0)
+ grid.addWidget(self.exp_line, 1, 1)
+ grid.addWidget(loc_label, 2, 0)
+ grid.addWidget(self.loc_line, 2, 1)
+ vbox.addLayout(grid)
+
+ 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.copy_box = QtWidgets.QCheckBox("Copy videos to project folder")
+ self.copy_box.setChecked(False)
+
+ # 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)
+
+ 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):
+ 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",
+ os.fspath(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",
+ os.fspath(self.loc_default),
+ options,
+ )
+ if not folder:
+ return
+
+ for video in collect_video_paths(folder):
+ if video.suffix[1:].lower() in DLCParams.VIDEOTYPES[1:]:
+ self.video_frame.fancy_list.add_item(str(video))
+
+ def finalize_project(self):
+ fields = [self.proj_line, self.exp_line]
+ empty = [i for i, field in enumerate(fields) if not field.text()]
+ for i, field in enumerate(fields):
+ if i in empty:
+ field.setStyleSheet("border: 1px solid red;")
+ else:
+ field.setStyleSheet(self._default_style)
+ if empty:
+ return
+
+ create_3d = self.toggle_3d.isChecked()
+ try:
+ if create_3d:
+ _ = create_new_project_3d(
+ self.proj_default,
+ self.exp_default,
+ 2,
+ self.loc_default,
+ )
+ else:
+ videos = list(self.video_frame.selected_items)
+ if not len(videos):
+ print("Add at least a video to the project.")
+ 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)
+ to_copy = self.copy_box.isChecked()
+ is_madlc = self.madlc_toggle.isChecked()
+ config = create_new_project(
+ self.proj_default,
+ self.exp_default,
+ videos,
+ self.loc_default,
+ 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)
+ except FileExistsError:
+ print(f'Project "{self.proj_default}" already exists!')
+ return
+
+ 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", os.fspath(self.loc_default)
+ )
+ if not dirname:
+ return
+ self.loc_default = Path(dirname).absolute()
+ self.update_project_location()
+
+ def update_project_name(self, text):
+ self.proj_default = text
+ self.update_project_location()
+
+ def update_experimenter_name(self, text):
+ self.exp_default = text
+ self.update_project_location()
+
+ def update_project_location(self):
+ full_name = self.name_default.format(self.proj_default, self.exp_default)
+ full_path = self.loc_default / full_name
+ self.loc_line.setText(os.fspath(full_path))
diff --git a/deeplabcut/gui/tabs/create_training_dataset.py b/deeplabcut/gui/tabs/create_training_dataset.py
new file mode 100644
index 0000000000..cb8ad5e0a4
--- /dev/null
+++ b/deeplabcut/gui/tabs/create_training_dataset.py
@@ -0,0 +1,805 @@
+#
+# 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 re
+from importlib import import_module
+from pathlib import Path
+
+import dlclibrary
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt, Slot
+
+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,
+)
+from deeplabcut.utils.auxiliaryfunctions import (
+ get_data_and_metadata_filenames,
+ get_training_set_folder,
+)
+
+
+class CreateTrainingDataset(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self.model_comparison = False
+
+ 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.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()
+ if self.root.is_multianimal:
+ func = deeplabcut.create_multianimaltraining_dataset
+ else:
+ func = deeplabcut.create_training_dataset
+ label = QtWidgets.QLabel(func.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self, layout):
+ layout.setColumnMinimumWidth(3, 300)
+
+ # Shuffle
+ 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.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()
+ 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(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()}")
+
+ 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_path]
+ if Path(conversion_matrix_out_path).exists():
+ 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,
+ # )
+ else:
+ 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_path,
+ 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_path,
+ 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_path,
+ 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),
+ )
+ 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(
+ get_data_and_metadata_filenames(
+ trainingsetfolder,
+ self.root.cfg["TrainingFraction"][0],
+ self.shuffle.value(),
+ self.root.cfg,
+ )
+ )
+ if self.root.is_multianimal:
+ filenames[0] = filenames[0].with_suffix(".pickle")
+ if all((Path(self.root.project_folder) / file).exists() 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!",
+ )
+ msg.exec_()
+ self.root.writer.write("Training dataset successfully created.")
+ else:
+ msg = _create_message_box(
+ "The training dataset could not be created.",
+ "Make sure there are annotated data under labeled-data.",
+ )
+ 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:
+ ValueError: 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()
+
+
+_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
new file mode 100644
index 0000000000..638c616d6f
--- /dev/null
+++ b/deeplabcut/gui/tabs/create_videos.py
@@ -0,0 +1,301 @@
+#
+# 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 PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+import deeplabcut
+from deeplabcut.gui.components import (
+ BodypartListWidget,
+ DefaultTab,
+ ShuffleSpinBox,
+ VideoSelectionWidget,
+ _create_horizontal_layout,
+ _create_label_widget,
+ _create_vertical_layout,
+)
+
+
+class CreateVideos(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self.skeleton_builder = None
+
+ self.bodyparts_to_use = self.root.all_bodyparts
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.video_selection_widget.files
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ tmp_layout = _create_horizontal_layout()
+
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_horizontal_layout(margins=(0, 0, 0, 0))
+ self._generate_layout_attributes(self.layout_attributes)
+ tmp_layout.addLayout(self.layout_attributes)
+
+ self.layout_multianimal = _create_horizontal_layout()
+
+ if self.root.is_multianimal:
+ self._generate_layout_multianimal(self.layout_multianimal)
+ tmp_layout.addLayout(self.layout_multianimal)
+
+ self.main_layout.addLayout(tmp_layout)
+
+ 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)
+
+ self.sk_button = QtWidgets.QPushButton("Build skeleton")
+ self.sk_button.clicked.connect(self.build_skeleton)
+ self.main_layout.addWidget(self.sk_button, alignment=Qt.AlignRight)
+
+ self.run_button = QtWidgets.QPushButton("Create videos")
+ self.run_button.clicked.connect(self.create_videos)
+ self.main_layout.addWidget(self.run_button, alignment=Qt.AlignRight)
+
+ 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 _on_skeleton_builder_destroyed(self):
+ self.skeleton_builder = None
+
+ def show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(deeplabcut.create_labeled_video.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_multianimal(self, layout):
+ tmp_text = QtWidgets.QLabel("Color keypoints by:")
+ self.color_by_widget = QtWidgets.QComboBox()
+ self.color_by_widget.addItems(["bodypart", "individual"])
+ self.color_by_widget.setCurrentText("bodypart")
+ self.color_by_widget.currentTextChanged.connect(self.update_color_by)
+
+ layout.addWidget(tmp_text)
+ layout.addWidget(self.color_by_widget)
+
+ def _generate_layout_attributes(self, layout):
+ # Shuffle
+ opt_text = QtWidgets.QLabel("Shuffle")
+ self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+
+ layout.addWidget(opt_text)
+ layout.addWidget(self.shuffle)
+
+ # Overwrite videos
+ self.overwrite_videos = QtWidgets.QCheckBox("Overwrite videos")
+ self.overwrite_videos.setCheckState(Qt.Unchecked)
+ self.overwrite_videos.stateChanged.connect(self.update_overwrite_videos)
+
+ layout.addWidget(self.overwrite_videos)
+
+ def _generate_layout_video_parameters(self, layout):
+ tmp_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
+
+ # Trail Points
+ opt_text = QtWidgets.QLabel("Specify the number of trail points")
+ self.trail_points = QtWidgets.QSpinBox()
+ self.trail_points.setValue(0)
+ tmp_layout.addWidget(opt_text)
+ tmp_layout.addWidget(self.trail_points)
+
+ layout.addLayout(tmp_layout)
+
+ tmp_layout = _create_vertical_layout(margins=(0, 0, 0, 0))
+
+ # Plot all bodyparts
+ self.plot_all_bodyparts = QtWidgets.QCheckBox("Plot all bodyparts")
+ self.plot_all_bodyparts.setCheckState(Qt.Checked)
+ self.plot_all_bodyparts.stateChanged.connect(self.update_use_all_bodyparts)
+ tmp_layout.addWidget(self.plot_all_bodyparts)
+
+ # Skeleton
+ self.draw_skeleton_checkbox = QtWidgets.QCheckBox("Draw skeleton")
+ 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)
+ 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)
+ self.plot_trajectories.stateChanged.connect(self.update_plot_trajectory_choice)
+ 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.setCheckState(Qt.Unchecked)
+ 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))
+ nested_tmp_layout.addLayout(tmp_layout)
+
+ tmp_layout = _create_vertical_layout(margins=(0, 0, 0, 0))
+
+ # Bodypart list
+ self.bodyparts_list_widget = BodypartListWidget(
+ root=self.root,
+ parent=self,
+ )
+ nested_tmp_layout.addWidget(self.bodyparts_list_widget, Qt.AlignLeft)
+
+ tmp_layout.addLayout(nested_tmp_layout, Qt.AlignLeft)
+
+ layout.addLayout(tmp_layout, Qt.AlignLeft)
+
+ def update_high_quality_video(self, state):
+ 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 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}")
+ self.bodyparts_to_use = selected_bodyparts
+
+ def update_use_all_bodyparts(self, s):
+ 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.")
+
+ else:
+ self.bodyparts_list_widget.setEnabled(True)
+ self.bodyparts_list_widget.show()
+ self.root.logger.info("Plot all bodyparts DISABLED.")
+
+ def update_use_filtered_data(self, state):
+ 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 Qt.CheckState(state) == Qt.Checked else "DISABLED"
+ self.root.logger.info(f"Draw skeleton {s}")
+
+ def update_overwrite_videos(self, state):
+ s = "ENABLED" if Qt.CheckState(state) == Qt.Checked else "DISABLED"
+ self.root.logger.info(f"Overwrite videos {s}")
+
+ def update_color_by(self, text):
+ self.root.logger.info(f"Coloring keypoints in videos by {text}")
+
+ def update_filter_choice(self, rb):
+ self.filtered = rb.text() == "Yes"
+
+ def update_video_slow_choice(self, rb):
+ self.slow = rb.text() == "Yes"
+
+ def update_draw_skeleton_choice(self, rb):
+ self.draw = rb.text() == "Yes"
+
+ def create_videos(self):
+ config_path = self.root.config_path
+ shuffle = self.root.shuffle_value
+ videos = self.files
+ trailpoints = self.trail_points.value()
+ if hasattr(self, "color_by_widget"):
+ # Multianimal scenario.
+ # Color is based on individual or bodypart.
+ color_by = self.color_by_widget.currentText()
+ else:
+ # Single animal scenario.
+ # Color is based on bodypart.
+ color_by = "bodypart"
+ filtered = self.use_filtered_data_checkbox.isChecked()
+
+ bodyparts = "all"
+ if len(self.bodyparts_to_use) != 0 and not self.plot_all_bodyparts.isChecked():
+ self.update_selected_bodyparts()
+ bodyparts = self.bodyparts_to_use
+
+ videos_created = deeplabcut.create_labeled_video(
+ config=config_path,
+ videos=videos,
+ shuffle=shuffle,
+ filtered=filtered,
+ save_frames=self.create_high_quality_video.isChecked(),
+ pcutoff=self.pcutoff_selector.value(),
+ displayedbodyparts=bodyparts,
+ 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, 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.isChecked():
+ deeplabcut.plot_trajectories(
+ config=config_path,
+ videos=videos,
+ shuffle=shuffle,
+ filtered=filtered,
+ displayedbodyparts=bodyparts,
+ pcutoff=self.pcutoff_selector.value(),
+ )
+
+ def build_skeleton(self, *args):
+ from deeplabcut.gui.widgets import SkeletonBuilder
+
+ if self.skeleton_builder is None:
+ self.skeleton_builder = SkeletonBuilder(
+ config_path=self.root.config_path,
+ parent=self.root,
+ )
+ self.skeleton_builder.destroyed.connect(self._on_skeleton_builder_destroyed)
+ self.skeleton_builder.show()
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
new file mode 100644
index 0000000000..bc39df9e7e
--- /dev/null
+++ b/deeplabcut/gui/tabs/evaluate_network.py
@@ -0,0 +1,229 @@
+#
+# 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
+
+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, Slot
+
+import deeplabcut
+from deeplabcut.core.engine import Engine
+from deeplabcut.gui.components import (
+ BodypartListWidget,
+ DefaultTab,
+ ShuffleSpinBox,
+ _create_horizontal_layout,
+ _create_label_widget,
+ _create_vertical_layout,
+)
+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):
+ def __init__(self, image_paths, parent=None):
+ super().__init__(parent)
+ self.image_paths = image_paths
+ layout = QtWidgets.QVBoxLayout(self)
+ self.figure = Figure()
+ self.figure.patch.set_facecolor("None")
+ self.grid = self.figure.add_gridspec(3, 3)
+ self.canvas = FigureCanvas(self.figure)
+ layout.addWidget(self.canvas)
+
+ 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)
+ ax.imshow(img)
+
+
+class EvaluateNetwork(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self.bodyparts_to_use = self.root.all_bodyparts
+
+ self._set_page()
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_horizontal_layout()
+ self._generate_layout_attributes(self.layout_attributes)
+ self.main_layout.addLayout(self.layout_attributes)
+
+ self.main_layout.addWidget(_create_label_widget("")) # dummy text
+ self.layout_additional_attributes = _create_vertical_layout()
+ self._generate_additional_attributes(self.layout_additional_attributes)
+ self.main_layout.addLayout(self.layout_additional_attributes)
+
+ self.ev_nw_button = QtWidgets.QPushButton("Evaluate Network")
+ self.ev_nw_button.setMinimumWidth(150)
+ self.ev_nw_button.clicked.connect(self.evaluate_network)
+
+ self.opt_button = QtWidgets.QPushButton("Plot 3 test maps")
+ self.opt_button.setMinimumWidth(150)
+ self.opt_button.clicked.connect(self.plot_maps)
+
+ self.edit_inferencecfg_btn = QtWidgets.QPushButton("Edit inference_cfg.yaml")
+ self.edit_inferencecfg_btn.setMinimumWidth(150)
+ 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.ev_nw_button, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.opt_button, alignment=Qt.AlignRight)
+
+ 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.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()
+ label = QtWidgets.QLabel(deeplabcut.evaluate_network.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ 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)
+ editor.show()
+
+ def plot_maps(self):
+ shuffle = self.root.shuffle_value
+ config_path = self.root.config_path
+ deeplabcut.extract_save_all_maps(config_path, shuffle=shuffle, Indices=[0, 1, 2])
+
+ # Display all images
+ dest_folder = (
+ Path(self.root.project_folder)
+ / str(
+ auxiliaryfunctions.get_evaluation_folder(self.root.cfg["TrainingFraction"][0], shuffle, self.root.cfg)
+ )
+ / "maps"
+ )
+ image_paths = [str(p) for p in Path(dest_folder).iterdir() if p.name.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.stateChanged.connect(self.update_plot_predictions)
+
+ tmp_layout.addWidget(self.plot_predictions)
+
+ self.bodyparts_list_widget = BodypartListWidget(root=self.root, parent=self)
+ self.use_all_bodyparts = QtWidgets.QCheckBox("Compare all bodyparts")
+ self.use_all_bodyparts.stateChanged.connect(self.update_bodypart_choice)
+ self.use_all_bodyparts.setCheckState(Qt.Checked)
+
+ tmp_layout.addWidget(self.use_all_bodyparts)
+ layout.addLayout(tmp_layout)
+
+ layout.addWidget(self.bodyparts_list_widget, alignment=Qt.AlignLeft)
+
+ def update_map_choice(self, state):
+ 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 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 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}")
+
+ def evaluate_network(self):
+ try:
+ config_path = self.root.config_path
+ 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 not self.use_all_bodyparts.isChecked():
+ bodyparts_to_use = self.bodyparts_list_widget.selected_bodyparts
+
+ deeplabcut.evaluate_network(
+ config_path,
+ 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)
+ except Exception as error:
+ self.root.show_task_error(error, self.root.pose_cfg_path)
+
+ @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
new file mode 100644
index 0000000000..6c04c7f43e
--- /dev/null
+++ b/deeplabcut/gui/tabs/extract_frames.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/master/AUTHORS
+#
+# 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.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
+
+
+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.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ videos (optional): List of videos whose cropping areas are to be defined. Note
+ that full paths are required. By default, all videos in the config are
+ successively loaded. Defaults to None.
+
+ Returns:
+ dict: Updated project configuration.
+ """
+ from deeplabcut.gui.widgets import FrameCropper
+ from deeplabcut.utils import auxiliaryfunctions
+
+ cfg = auxiliaryfunctions.read_config(config)
+ if videos is None:
+ videos = list(cfg.get("video_sets_original") or cfg["video_sets"])
+
+ for video in videos:
+ fc = FrameCropper(video)
+ coords = fc.draw_bbox()
+ if coords:
+ temp = {
+ "crop": ", ".join(
+ map(
+ str,
+ [
+ int(coords[0]),
+ int(coords[2]),
+ int(coords[1]),
+ int(coords[3]),
+ ],
+ )
+ )
+ }
+ try:
+ cfg["video_sets"][video] = temp
+ except KeyError:
+ cfg["video_sets_original"][video] = temp
+
+ auxiliaryfunctions.write_config(config, cfg)
+ return cfg
+
+
+class ExtractFrames(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+ self.worker = None
+ self.thread = None
+ self._set_page()
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_grid_layout(margins=(0, 0, 0, 0))
+ self._generate_layout_attributes(self.layout_attributes)
+ self.main_layout.addLayout(self.layout_attributes)
+
+ self.main_layout.addWidget(
+ _create_label_widget(
+ "Frame extraction from a video subset (optional for automatic extraction)",
+ "font:bold",
+ )
+ )
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ self.ok_button = QtWidgets.QPushButton("Extract Frames")
+ self.ok_button.clicked.connect(self.extract_frames)
+ self.main_layout.addWidget(self.ok_button, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(extract_frames.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self, layout):
+ layout.setColumnMinimumWidth(1, 300)
+ # Extraction method
+ ext_method_label = QtWidgets.QLabel("Extraction method")
+ 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)
+
+ # 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)
+
+ # 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)
+
+ # Cluster step
+ cluster_step_label = QtWidgets.QLabel("Cluster step")
+ self.cluster_step_widget = QtWidgets.QSpinBox()
+ self.cluster_step_widget.setValue(1)
+
+ # GUI Slider width
+ gui_slider_label = QtWidgets.QLabel("GUI slider width")
+ self.slider_width_widget = QtWidgets.QSpinBox()
+ self.slider_width_widget.setValue(25)
+ self.slider_width_widget.setEnabled(False)
+
+ layout.addWidget(ext_method_label, 1, 0)
+ layout.addWidget(self.extraction_method_widget, 1, 1)
+ layout.addWidget(gui_slider_label, 1, 2)
+ layout.addWidget(self.slider_width_widget, 1, 3)
+
+ layout.addWidget(ext_algo_label, 2, 0)
+ layout.addWidget(self.extraction_algorithm_widget, 2, 1)
+ layout.addWidget(cluster_step_label, 2, 2)
+ layout.addWidget(self.cluster_step_widget, 2, 3)
+
+ layout.addWidget(frame_crop_label, 3, 0)
+ layout.addWidget(self.frame_cropping_widget, 3, 1)
+
+ def log_extraction_algorithm(self, extraction_algorithm):
+ self.root.logger.info(f"Extraction method set to {extraction_algorithm}")
+
+ def log_extraction_method(self, extraction_method):
+ self.root.logger.info(f"Extraction method set to {extraction_method}")
+ if extraction_method == "manual":
+ self.extraction_algorithm_widget.setEnabled(False)
+ self.cluster_step_widget.setEnabled(False)
+ self.frame_cropping_widget.setEnabled(False)
+ self.slider_width_widget.setEnabled(True)
+ else:
+ self.extraction_algorithm_widget.setEnabled(True)
+ self.cluster_step_widget.setEnabled(True)
+ self.frame_cropping_widget.setEnabled(True)
+ self.slider_width_widget.setEnabled(False)
+
+ def log_frame_cropping_choice(self, cropping_option):
+ self.root.logger.info(f"Cropping set to '{cropping_option}'")
+
+ def extract_frames(self):
+ config_path = self.root.config_path
+ mode = self.extraction_method_widget.currentText()
+ if mode == "manual":
+ 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()
+ clusterstep = self.cluster_step_widget.value()
+ slider_width = self.slider_width_widget.value()
+
+ crop = False # default value
+ if self.frame_cropping_widget.currentText() == "GUI":
+ _ = select_cropping_area(config_path)
+ crop = True
+ elif self.frame_cropping_widget.currentText() == "read from config":
+ crop = True
+
+ func = partial(
+ extract_frames,
+ config_path,
+ mode,
+ algo,
+ crop=crop,
+ cluster_step=clusterstep,
+ cluster_resizewidth=30,
+ cluster_color=False,
+ slider_width=slider_width,
+ userfeedback=False,
+ videos_list=self.video_selection_widget.files or None,
+ )
+
+ self.worker, self.thread = move_to_separate_thread(func, capture_outputs=True)
+ self._extract_error = False
+ self.worker.error.connect(self.root.show_task_error)
+ self.worker.error.connect(lambda _err: setattr(self, "_extract_error", 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)
+ self.thread.start()
+ self.ok_button.setEnabled(False)
+ self.root._progress_bar.show()
+
+ def _show_success_message(self):
+ if getattr(self, "_extract_error", False):
+ return
+
+ 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(message)
+ msg.setWindowTitle("Info")
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+ 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).absolute()
+ project_videos = (Path(self.root.config_path).parent / "videos").absolute()
+ 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
new file mode 100644
index 0000000000..74774a2a5d
--- /dev/null
+++ b/deeplabcut/gui/tabs/extract_outlier_frames.py
@@ -0,0 +1,169 @@
+#
+# 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 PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+import deeplabcut
+from deeplabcut.gui.components import (
+ DefaultTab,
+ ShuffleSpinBox,
+ VideoSelectionWidget,
+ _create_horizontal_layout,
+ _create_label_widget,
+)
+from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.widgets import launch_napari
+
+
+class ExtractOutlierFrames(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+ self.filelist = []
+
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.video_selection_widget.files
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_horizontal_layout()
+ self._generate_layout_attributes(self.layout_attributes)
+
+ 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.layout_extraction_options = _create_horizontal_layout()
+ self._generate_layout_extraction_options(self.layout_extraction_options)
+ self.main_layout.addLayout(self.layout_extraction_options)
+
+ self.extract_outlierframes_button = QtWidgets.QPushButton("Extract frames")
+ self.extract_outlierframes_button.clicked.connect(self.extract_outlier_frames)
+ self.extract_outlierframes_button.setMinimumWidth(150)
+
+ self.label_outliers_button = QtWidgets.QPushButton("Labeling GUI")
+ self.label_outliers_button.setEnabled(True)
+ self.label_outliers_button.clicked.connect(self.launch_refinement_gui)
+ self.label_outliers_button.setMinimumWidth(150)
+
+ self.merge_data_button = QtWidgets.QPushButton("Merge data")
+ 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.label_outliers_button, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.merge_data_button, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(deeplabcut.extract_outlier_frames.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self, layout):
+ # Shuffle
+ opt_text = QtWidgets.QLabel("Shuffle")
+ self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+
+ layout.addWidget(opt_text)
+ layout.addWidget(self.shuffle)
+
+ def _generate_multianimal_options(self, layout):
+ opt_text = QtWidgets.QLabel("Tracking method")
+ self.tracker_type_widget = QtWidgets.QComboBox()
+ self.tracker_type_widget.addItems(DLCParams.TRACKERS)
+ self.tracker_type_widget.currentTextChanged.connect(self.update_tracker_type)
+
+ layout.addWidget(opt_text)
+ layout.addWidget(self.tracker_type_widget)
+ if not self.root.is_multianimal:
+ opt_text.hide()
+ self.tracker_type_widget.hide()
+
+ def _generate_layout_extraction_options(self, layout):
+ opt_text = QtWidgets.QLabel("Specify the algorithm")
+ 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)
+
+ layout.addWidget(opt_text)
+ layout.addWidget(self.outlier_algorithm_widget)
+
+ 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")
+
+ def extract_outlier_frames(self):
+ config_path = self.root.config_path
+ shuffle = self.root.shuffle_value
+ videos = self.files
+ videotype = self.video_selection_widget.videotype_widget.currentText()
+ outlieralgorithm = self.outlier_algorithm_widget.currentText()
+ track_method = ""
+ if self.root.is_multianimal:
+ track_method = self.tracker_type_widget.currentText()
+
+ self.root.logger.debug(
+ f"""Running extract outlier frames with options:
+ config_path: {config_path},
+ shuffle: {shuffle},
+ videos: {videos},
+ video_extensions: {videotype},
+ outlier algorithm: {outlieralgorithm},
+ track method: {track_method}
+ """
+ )
+ deeplabcut.extract_outlier_frames(
+ config=config_path,
+ videos=videos,
+ video_extensions=videotype,
+ shuffle=shuffle,
+ outlieralgorithm=outlieralgorithm,
+ track_method=track_method,
+ automatic=True,
+ )
+
+ def launch_refinement_gui(self):
+ self.merge_data_button.setEnabled(True)
+ _ = launch_napari()
+
+ 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?"
+ )
+ msg.setWindowTitle("Warning")
+ msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ result = msg.exec_()
+ if result == QtWidgets.QMessageBox.Yes:
+ deeplabcut.merge_datasets(self.root.config_path, forceiterate=None)
diff --git a/deeplabcut/gui/tabs/label_frames.py b/deeplabcut/gui/tabs/label_frames.py
new file mode 100644
index 0000000000..2b4a92550d
--- /dev/null
+++ b/deeplabcut/gui/tabs/label_frames.py
@@ -0,0 +1,152 @@
+#
+# 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
+
+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 SkeletonBuilder, launch_napari
+
+
+def label_frames(config_path: str | Path | None = None, image_folder: str | Path | 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.
+
+ Args:
+ config_path (str | Path | None, optional): Full path of the project config.yaml
+ file. Defaults to None.
+ image_folder (str | Path | None, optional): Name of the image folder to open for
+ labelling.
+ Defaults to None.
+
+ 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
+
+
+class LabelFrames(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self._set_page()
+ self.skeleton_builder = None
+
+ def _set_page(self):
+ self.label_frames_btn = QtWidgets.QPushButton("Label Frames")
+ 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()}")
+
+ def label_frames(self):
+ dialog = QtWidgets.QFileDialog(self)
+ dialog.setFileMode(QtWidgets.QFileDialog.Directory)
+ dialog.setViewMode(QtWidgets.QFileDialog.Detail)
+ dialog.setDirectory(str(Path(self.root.config_path).parent / "labeled-data"))
+ if dialog.exec_():
+ folder = dialog.selectedFiles()[0]
+ has_h5 = False
+ for file in Path(folder).iterdir():
+ if file.name.endswith(".h5"):
+ has_h5 = True
+ break
+ if not has_h5:
+ folder = [folder, self.root.config_path]
+ _ = launch_napari(folder)
+
+ def check_labels(self):
+ check_labels(self.root.config_path, visualizeindividuals=self.root.is_multianimal)
+ labeled_images = (Path(self.root.config_path).parent / "labeled-data").rglob("*_labeled/*.png")
+ _ = launch_napari(labeled_images, plugin="napari", stack=True)
+
+ def _on_skeleton_builder_destroyed(self):
+ self.skeleton_builder = None
+
+ def build_skeleton(self, *args):
+ if self.skeleton_builder is None:
+ self.skeleton_builder = SkeletonBuilder(
+ config_path=self.root.config_path,
+ parent=self.root,
+ )
+ self.skeleton_builder.show()
+ self.skeleton_builder.destroyed.connect(self._on_skeleton_builder_destroyed)
diff --git a/deeplabcut/gui/tabs/manage_project.py b/deeplabcut/gui/tabs/manage_project.py
new file mode 100644
index 0000000000..c64b02c4a9
--- /dev/null
+++ b/deeplabcut/gui/tabs/manage_project.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+from pathlib import Path
+
+from PySide6.QtCore import Qt, QTimer
+from PySide6.QtWidgets import (
+ QFileDialog,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+)
+
+from deeplabcut.create_project import add_new_videos
+from deeplabcut.gui.components import DefaultTab, _create_horizontal_layout
+from deeplabcut.gui.dlc_params import DLCParams
+from deeplabcut.gui.widgets import ConfigEditor
+
+
+class ManageProject(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self._reload_timer = QTimer(self)
+ self._reload_timer.setSingleShot(True)
+ self._reload_timer.setInterval(0)
+ self._reload_timer.timeout.connect(self.root.reload_project_config)
+
+ self._set_page()
+ self._videos = []
+
+ def _set_page(self):
+ # Add config text field and button
+ project_config_layout = _create_horizontal_layout()
+
+ cfg_text = QLabel("Active config file:")
+
+ self.cfg_line = QLineEdit()
+ self.cfg_line.setText(os.fspath(self.root.config_path) if self.root.config_path else "")
+ self.cfg_line.textChanged[str].connect(self.root.update_cfg)
+
+ browse_button = QPushButton("Browse")
+ browse_button.setMaximumWidth(100)
+ browse_button.clicked.connect(self.root._open_project)
+
+ project_config_layout.addWidget(cfg_text)
+ project_config_layout.addWidget(self.cfg_line)
+ project_config_layout.addWidget(browse_button)
+
+ self.main_layout.addLayout(project_config_layout)
+
+ self.edit_btn = QPushButton("Edit config.yaml")
+ self.edit_btn.setMinimumWidth(150)
+ self.edit_btn.clicked.connect(self.open_config_editor)
+
+ self.add_videos_btn = QPushButton("Add new videos")
+ self.add_videos_btn.clicked.connect(self.add_new_videos)
+
+ self.main_layout.addWidget(self.edit_btn, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.add_videos_btn, alignment=Qt.AlignRight)
+
+ def open_config_editor(self):
+ config = self.root.config_path
+ editor = ConfigEditor(config, parent=self.root)
+ editor.accepted.connect(self._reload_timer.start)
+ editor.show()
+
+ def add_new_videos(self):
+ cwd = os.fspath(Path.cwd())
+ files = QFileDialog.getOpenFileNames(
+ self,
+ "Select videos to add to the project",
+ cwd,
+ f"Videos ({' *.'.join(DLCParams.VIDEOTYPES)[1:]})",
+ )[0]
+ if not files:
+ return
+
+ add_new_videos(self.root.config_path, [Path(video).absolute() for video in files])
diff --git a/deeplabcut/gui/tabs/modelzoo.py b/deeplabcut/gui/tabs/modelzoo.py
new file mode 100644
index 0000000000..d2eb319bcc
--- /dev/null
+++ b/deeplabcut/gui/tabs/modelzoo.py
@@ -0,0 +1,606 @@
+#
+# 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 webbrowser
+from functools import partial
+from pathlib import Path
+
+import dlclibrary
+from PySide6 import QtWidgets
+from PySide6.QtCore import QRegularExpression, QSize, Qt, QTimer, Signal, Slot
+from PySide6.QtGui import QRegularExpressionValidator
+
+import deeplabcut
+from deeplabcut.core.engine import Engine
+from deeplabcut.gui.components import (
+ DefaultTab,
+ VideoSelectionWidget,
+ _create_grid_layout,
+ _create_label_widget,
+ set_combo_items,
+ set_layout_contents_visible,
+)
+from deeplabcut.gui.gui_assets import icon_from_resource, pixmap_from_resource
+from deeplabcut.gui.utils import move_to_separate_thread
+from deeplabcut.gui.widgets import ClickableLabel
+from deeplabcut.pose_estimation_pytorch.apis.utils import TORCHVISION_DETECTORS
+
+
+class RegExpValidator(QRegularExpressionValidator):
+ validationChanged = Signal(QRegularExpressionValidator.State)
+
+ def validate(self, input_, pos):
+ state, input_, pos = super().validate(input_, pos)
+ self.validationChanged.emit(state)
+ return state, input_, pos
+
+
+class ModelZoo(DefaultTab):
+ 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, hide_videotype=True)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ 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)
+
+ 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)
+
+ 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()
+ 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(
+ icon_from_resource("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.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(pixmap_from_resource("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"
+ )
+ 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(icon_from_resource("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,
+ minimum=0.01,
+ maximum=1.0,
+ singleStep=0.05,
+ value=0.1,
+ wrapping=True,
+ )
+ 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)
+
+ def _build_tf_attributes(self) -> None:
+ tf_settings_layout = _create_grid_layout(margins=(20, 0, 0, 0))
+
+ 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.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", str(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()
+ help_text = deeplabcut.video_inference_superanimal.__doc__
+
+ label = QtWidgets.QLabel(help_text, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _handle_validation_change(self, state):
+ if state == RegExpValidator.Invalid:
+ color = "red"
+ elif state == RegExpValidator.Intermediate:
+ color = "gold"
+ elif state == RegExpValidator.Acceptable:
+ color = "lime"
+ self.scales_line.setStyleSheet(f"border: 1px solid {color}")
+ QTimer.singleShot(500, lambda: self.scales_line.setStyleSheet(""))
+
+ 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 video files")
+ msg.setWindowTitle("Error")
+ msg.setMinimumWidth(400)
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+ return
+
+ supermodel_name = self.model_combo.currentText()
+ 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()
+
+ 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.error.connect(self.root.show_task_error)
+ 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
new file mode 100644
index 0000000000..49375a04c2
--- /dev/null
+++ b/deeplabcut/gui/tabs/open_project.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import os
+from pathlib import Path
+
+from PySide6 import QtCore, QtWidgets
+
+from deeplabcut.gui.gui_assets import icon_from_resource
+
+
+class OpenProject(QtWidgets.QDialog):
+ def __init__(self, parent):
+ super().__init__(parent)
+
+ self.setWindowTitle("Load Existing Project")
+
+ self.config_path: Path | None = None
+ self.loaded = False
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ self.layout_open()
+
+ self.ok_button = QtWidgets.QPushButton("Load")
+ self.ok_button.setDefault(True)
+ self.ok_button.clicked.connect(self.open_project)
+
+ main_layout.addWidget(self.open_frame)
+ main_layout.addWidget(self.ok_button, alignment=QtCore.Qt.AlignRight)
+
+ def layout_open(self):
+ self.open_frame = QtWidgets.QFrame(self)
+ self.open_frame.setFrameShape(self.open_frame.Shape.StyledPanel)
+ self.open_frame.setLineWidth(0)
+ self.open_frame.setMinimumWidth(600)
+
+ open_label = QtWidgets.QLabel("Select the config file:", self.open_frame)
+ self.open_line = QtWidgets.QLineEdit(self.open_frame)
+ self.open_line.textChanged[str].connect(self.open_config_name)
+
+ load_button = QtWidgets.QPushButton("Browse")
+ load_button.clicked.connect(self.load_config)
+
+ grid = QtWidgets.QGridLayout(self.open_frame)
+ grid.setSpacing(30)
+ grid.addWidget(open_label, 0, 0)
+ grid.addWidget(self.open_line, 0, 1)
+ grid.addWidget(load_button, 1, 1)
+
+ return self.open_frame
+
+ def open_config_name(self):
+ self.open_line.text()
+
+ def load_config(self):
+ cwd = os.fspath(Path.cwd())
+ config = QtWidgets.QFileDialog.getOpenFileName(
+ self, "Select a configuration file", cwd, "Config files (*.yaml)"
+ )
+ if not config[0]:
+ return
+ self.config_path = Path(config[0]).absolute()
+ self.open_line.setText(os.fspath(self.config_path))
+ self.ok_button.setFocus()
+
+ def open_project(self):
+ config_text = self.open_line.text().strip()
+ if config_text:
+ self.config_path = Path(config_text).absolute()
+
+ if not self.config_path:
+ msg = QtWidgets.QMessageBox()
+ msg.setIcon(QtWidgets.QMessageBox.Critical)
+ msg.setText("Please choose the config.yaml file to load the project")
+
+ msg.setWindowTitle("Error")
+ msg.setMinimumWidth(400)
+ icon = icon_from_resource("logo.png")
+ msg.setWindowIcon(icon)
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+
+ self.loaded = False
+ else:
+ self.loaded = True
+ self.accept()
+ self.close()
diff --git a/deeplabcut/gui/tabs/refine_tracklets.py b/deeplabcut/gui/tabs/refine_tracklets.py
new file mode 100644
index 0000000000..4823ff0b24
--- /dev/null
+++ b/deeplabcut/gui/tabs/refine_tracklets.py
@@ -0,0 +1,261 @@
+#
+# 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 PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+import deeplabcut
+from deeplabcut.core import trackingutils
+from deeplabcut.gui.components import (
+ DefaultTab,
+ ShuffleSpinBox,
+ VideoSelectionWidget,
+ _create_grid_layout,
+ _create_horizontal_layout,
+ _create_label_widget,
+)
+from deeplabcut.gui.widgets import ConfigEditor
+from deeplabcut.utils.auxiliaryfunctions import GetScorerName
+
+
+class RefineTracklets(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.video_selection_widget.files
+
+ def _set_page(self):
+ # TODO: Multi video select.... have to change to single video!
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ self.main_layout.addWidget(_create_label_widget("Attributes", "font:bold"))
+ self.layout_attributes = _create_horizontal_layout()
+ self._generate_layout_attributes(self.layout_attributes)
+ self.main_layout.addLayout(self.layout_attributes)
+
+ self.container_layout = _create_horizontal_layout(margins=(0, 0, 0, 0))
+
+ self.layout_refinement_settings = _create_grid_layout(margins=(20, 0, 0, 0))
+ self._generate_layout_refinement(self.layout_refinement_settings)
+ self.container_layout.addLayout(self.layout_refinement_settings)
+
+ self.layout_filtering_settings = _create_grid_layout(margins=(20, 0, 0, 0))
+ self._generate_layout_filtering(self.layout_filtering_settings)
+ self.container_layout.addLayout(self.layout_filtering_settings)
+
+ self.main_layout.addLayout(self.container_layout)
+
+ self.stitch_tracklets_btn = QtWidgets.QPushButton("(Re-)run stitching")
+ self.stitch_tracklets_btn.setMinimumWidth(150)
+ self.stitch_tracklets_btn.clicked.connect(self.create_tracks)
+
+ self.edit_inferencecfg_btn = QtWidgets.QPushButton("Edit inference_cfg.yaml")
+ self.edit_inferencecfg_btn.setMinimumWidth(150)
+ self.edit_inferencecfg_btn.clicked.connect(self.open_inferencecfg_editor)
+
+ self.filter_tracks_button = QtWidgets.QPushButton("Filter tracks ( + .csv)")
+ self.filter_tracks_button.setMinimumWidth(150)
+ self.filter_tracks_button.clicked.connect(self.filter_tracks)
+
+ self.launch_button = QtWidgets.QPushButton("Launch track refinement GUI")
+ self.launch_button.setMinimumWidth(150)
+ self.launch_button.clicked.connect(self.refine_tracks)
+
+ self.merge_button = QtWidgets.QPushButton("Merge dataset")
+ self.merge_button.setMinimumWidth(150)
+ self.merge_button.clicked.connect(self.merge_dataset)
+ self.merge_button.setEnabled(False)
+
+ self.main_layout.addWidget(self.edit_inferencecfg_btn, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.stitch_tracklets_btn, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.launch_button, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.filter_tracks_button, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.merge_button, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(deeplabcut.stitch_tracklets.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self, layout):
+ # Shuffle
+ shuffle_text = QtWidgets.QLabel("Shuffle")
+ self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+
+ # Num animals
+ num_animals_text = QtWidgets.QLabel("Number of animals in video")
+ self.num_animals_in_videos = QtWidgets.QSpinBox()
+ self.num_animals_in_videos.setValue(len(self.root.all_individuals))
+ self.num_animals_in_videos.setMaximum(100)
+ self.num_animals_in_videos.valueChanged.connect(self.log_num_animals)
+
+ layout.addWidget(shuffle_text)
+ layout.addWidget(self.shuffle)
+ layout.addWidget(num_animals_text)
+ 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))
+
+ # Min swap length
+ swap_length_label = QtWidgets.QLabel("Min swap length to highlight")
+ self.swap_length_widget = QtWidgets.QSpinBox()
+ self.swap_length_widget.setValue(2)
+ self.swap_length_widget.setMinimumWidth(150)
+ self.swap_length_widget.valueChanged.connect(self.log_swap_length)
+
+ # Max gap to fill
+ max_gap_label = QtWidgets.QLabel("Max gap of missing data to fill")
+ self.max_gap_widget = QtWidgets.QSpinBox()
+ self.max_gap_widget.setValue(5)
+ self.max_gap_widget.setMinimumWidth(150)
+ self.max_gap_widget.valueChanged.connect(self.log_max_gap)
+
+ # Trail length
+ trail_length_label = QtWidgets.QLabel("Visualization trail length")
+ self.trail_length_widget = QtWidgets.QSpinBox()
+ self.trail_length_widget.setValue(20)
+ self.trail_length_widget.setMinimumWidth(150)
+ self.trail_length_widget.valueChanged.connect(self.log_trail_length)
+
+ layout.addWidget(section_title, 0, 0)
+ layout.addWidget(swap_length_label, 1, 0)
+ layout.addWidget(self.swap_length_widget, 1, 1)
+ layout.addWidget(max_gap_label, 2, 0)
+ layout.addWidget(self.max_gap_widget, 2, 1)
+ layout.addWidget(trail_length_label, 3, 0)
+ layout.addWidget(self.trail_length_widget, 3, 1)
+
+ def _generate_layout_filtering(self, layout):
+ section_title = _create_label_widget("Filtering", "font:bold", (0, 50, 0, 0))
+
+ # Filter type
+ filter_label = QtWidgets.QLabel("Filter type")
+ self.filter_type_widget = QtWidgets.QComboBox()
+ self.filter_type_widget.setMinimumWidth(150)
+ options = ["median"]
+ self.filter_type_widget.addItems(options)
+ self.filter_type_widget.currentTextChanged.connect(self.log_filter_type)
+
+ # Filter window length
+ window_length_label = QtWidgets.QLabel("Window length")
+ self.window_length_widget = QtWidgets.QSpinBox()
+ self.window_length_widget.setValue(5)
+ self.window_length_widget.setMinimumWidth(150)
+ self.window_length_widget.valueChanged.connect(self.log_window_length)
+
+ layout.addWidget(section_title, 0, 0)
+ layout.addWidget(filter_label, 1, 0)
+ layout.addWidget(self.filter_type_widget, 1, 1)
+ layout.addWidget(window_length_label, 2, 0)
+ layout.addWidget(self.window_length_widget, 2, 1)
+
+ def log_swap_length(self, value):
+ self.root.logger.info(f"Swap length set to {value}")
+
+ def log_max_gap(self, value):
+ self.root.logger.info(f"Max gap size of missing data to fill set to {value}")
+
+ def log_trail_length(self, value):
+ self.root.logger.info(f"Visualization trail length set to {value}")
+
+ def log_filter_type(self, filter_type):
+ self.root.logger.info(f"Filter type set to {filter_type.upper()}")
+
+ def log_window_length(self, window_length):
+ self.root.logger.info(f"Window length set to {window_length}")
+
+ def log_num_animals(self, num_animals):
+ self.root.logger.info(f"Number of animals in video set to {num_animals}")
+
+ def open_inferencecfg_editor(self):
+ editor = ConfigEditor(self.root.inference_cfg_path)
+ editor.show()
+
+ def create_tracks(self):
+ deeplabcut.stitch_tracklets(
+ self.root.config_path,
+ self.files,
+ video_extensions=self.video_selection_widget.videotype_widget.currentText(),
+ shuffle=self.shuffle.value(),
+ n_tracks=self.num_animals_in_videos.value(),
+ )
+
+ def filter_tracks(self):
+ window_length = self.window_length_widget.value()
+ if window_length % 2 != 1:
+ raise ValueError("Window length should be odd.")
+
+ videotype = self.video_selection_widget.videotype_widget.currentText()
+ deeplabcut.filterpredictions(
+ self.root.config_path,
+ self.files,
+ video_extensions=videotype,
+ shuffle=self.shuffle.value(),
+ filtertype=self.filter_type_widget.currentText(),
+ windowlength=self.window_length_widget.value(),
+ save_as_csv=True,
+ )
+
+ 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?"
+ )
+ msg.setWindowTitle("Warning")
+ msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
+ result = msg.exec_()
+ if result == QtWidgets.QMessageBox.Yes:
+ deeplabcut.merge_datasets(self.root.config_path, forceiterate=None)
+ self.viz.export_to_training_data()
+
+ def refine_tracks(self):
+ cfg = self.root.cfg
+ DLCscorer, _ = GetScorerName(
+ cfg,
+ self.shuffle.value(),
+ cfg["TrainingFraction"][-1],
+ )
+ video = list(self.files)[0]
+ track_method = cfg.get("default_track_method", "ellipse")
+ method = trackingutils.TRACK_METHODS[track_method]
+ dest = Path(video).parents[0]
+ vname = Path(video).stem
+ datafile = str(dest / (vname + DLCscorer + f"{method}.h5"))
+ self.manager, self.viz = deeplabcut.refine_tracklets(
+ self.root.config_path,
+ datafile,
+ video,
+ min_swap_len=self.swap_length_widget.value(),
+ trail_len=self.trail_length_widget.value(),
+ max_gap=self.max_gap_widget.value(),
+ )
+ self.merge_button.setEnabled(True)
diff --git a/deeplabcut/gui/tabs/train_network.py b/deeplabcut/gui/tabs/train_network.py
new file mode 100644
index 0000000000..5a9a545b98
--- /dev/null
+++ b/deeplabcut/gui/tabs/train_network.py
@@ -0,0 +1,375 @@
+#
+# 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
+
+from dataclasses import dataclass
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt, Slot
+
+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.gui_assets import icon_from_resource
+from deeplabcut.gui.widgets import ConfigEditor
+
+
+@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().__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
+ pose_cfg = self._shuffle_display.pose_cfg
+ method = pose_cfg.get("method") if pose_cfg is not None else None
+ if isinstance(method, str) and 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._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._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)
+ self.edit_posecfg_btn.clicked.connect(self.open_posecfg_editor)
+
+ self.ok_button = QtWidgets.QPushButton("Train Network")
+ self.ok_button.setMinimumWidth(150)
+ self.ok_button.clicked.connect(self.train_network)
+
+ self.main_layout.addWidget(self.edit_posecfg_btn, alignment=Qt.AlignRight)
+ self.main_layout.addWidget(self.ok_button, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(compat.train_network.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self) -> None:
+ row_margin = 25
+
+ # top layout
+ shuffle_label = QtWidgets.QLabel("Shuffle")
+ 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)
+ editor.show()
+
+ def train_network(self):
+ try:
+ config_path = self.root.config_path
+ 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_path, 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.setWindowTitle("Info")
+ msg.setMinimumWidth(900)
+ msg.setWindowIcon(icon_from_resource("logo.png"))
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+ except Exception as error:
+ self.root.show_task_error(error, self.root.pose_cfg_path)
+
+ @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
new file mode 100644
index 0000000000..f892ff0d05
--- /dev/null
+++ b/deeplabcut/gui/tabs/unsupervised_id_tracking.py
@@ -0,0 +1,136 @@
+#
+# 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 functools import partial
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+import deeplabcut
+from deeplabcut.gui.components import (
+ DefaultTab,
+ ShuffleSpinBox,
+ VideoSelectionWidget,
+ _create_grid_layout,
+ _create_label_widget,
+)
+from deeplabcut.gui.utils import move_to_separate_thread
+
+
+class UnsupervizedIdTracking(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.root.video_files
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ 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.run_transformer_button = QtWidgets.QPushButton("Run transformer")
+ self.run_transformer_button.clicked.connect(self.run_transformer)
+
+ self.main_layout.addWidget(self.run_transformer_button, alignment=Qt.AlignRight)
+
+ 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 show_help_dialog(self):
+ dialog = QtWidgets.QDialog(self)
+ layout = QtWidgets.QVBoxLayout()
+ label = QtWidgets.QLabel(deeplabcut.transformer_reID.__doc__, self)
+ scroll = QtWidgets.QScrollArea()
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(label)
+ layout.addWidget(scroll)
+ dialog.setLayout(layout)
+ dialog.exec_()
+
+ def _generate_layout_attributes(self, layout):
+ # Shuffle
+ shuffle_label = QtWidgets.QLabel("Shuffle")
+ self.shuffle = ShuffleSpinBox(root=self.root, parent=self)
+
+ # Tracker Type
+ trackingtype_label = QtWidgets.QLabel("Tracking method")
+ self.tracker_type_widget = QtWidgets.QComboBox()
+ self.tracker_type_widget.addItems(["ellipse", "box"])
+ self.tracker_type_widget.currentTextChanged.connect(self.log_tracker_type)
+
+ # Num animals
+ num_animals_label = QtWidgets.QLabel("Number of animals in videos")
+ self.num_animals_in_videos = QtWidgets.QSpinBox()
+ self.num_animals_in_videos.setValue(len(self.root.all_individuals))
+ self.num_animals_in_videos.setMaximum(100)
+ self.num_animals_in_videos.valueChanged.connect(self.log_num_animals)
+
+ # Num triplets
+ num_triplets_label = QtWidgets.QLabel("Number of triplets")
+ self.num_triplets = QtWidgets.QSpinBox()
+ self.num_triplets.setMaximum(1000000)
+ self.num_triplets.setValue(1000)
+ self.num_triplets.valueChanged.connect(self.log_num_triplets)
+
+ layout.addWidget(shuffle_label, 0, 0)
+ layout.addWidget(self.shuffle, 0, 1)
+ layout.addWidget(trackingtype_label, 0, 2)
+ layout.addWidget(self.tracker_type_widget, 0, 3)
+ layout.addWidget(num_animals_label, 1, 0)
+ layout.addWidget(self.num_animals_in_videos, 1, 1)
+ layout.addWidget(num_triplets_label, 1, 2)
+ layout.addWidget(self.num_triplets, 1, 3)
+ # layout.addWidget()
+
+ def log_tracker_type(self, tracker):
+ self.root.logger.info(f"Tracker type set to {tracker.upper()}")
+
+ def log_num_animals(self, value):
+ self.root.logger.info(f"Num animals set to {value}")
+
+ def log_num_triplets(self, value):
+ self.root.logger.info(f"Num triplets set to {value}")
+
+ def run_transformer(self):
+ config_path = self.root.config_path
+ 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()
+ track_method = self.tracker_type_widget.currentText()
+
+ func = partial(
+ deeplabcut.transformer_reID,
+ config=config_path,
+ videos=videos,
+ video_extensions=videotype,
+ n_tracks=n_tracks,
+ shuffle=shuffle,
+ track_method=track_method,
+ )
+ self.worker, self.thread = move_to_separate_thread(func)
+ self.worker.error.connect(self.root.show_task_error)
+ 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)
+ self.root._progress_bar.show()
diff --git a/deeplabcut/gui/tabs/video_editor.py b/deeplabcut/gui/tabs/video_editor.py
new file mode 100644
index 0000000000..dc7e8472e2
--- /dev/null
+++ b/deeplabcut/gui/tabs/video_editor.py
@@ -0,0 +1,188 @@
+#
+# 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
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+from deeplabcut.gui.components import (
+ DefaultTab,
+ VideoSelectionWidget,
+ _create_grid_layout,
+ _create_label_widget,
+)
+from deeplabcut.gui.widgets import FrameCropper
+from deeplabcut.utils import auxfun_videos
+
+
+class VideoEditor(DefaultTab):
+ def __init__(self, root, parent, h1_description):
+ super().__init__(root, parent, h1_description)
+
+ self._set_page()
+
+ @property
+ def files(self):
+ return self.video_selection_widget.files
+
+ def _set_page(self):
+ self.main_layout.addWidget(_create_label_widget("Video Selection", "font:bold"))
+ self.video_selection_widget = VideoSelectionWidget(self.root, self)
+ self.main_layout.addWidget(self.video_selection_widget)
+
+ 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.down_button = QtWidgets.QPushButton("Downsample")
+ self.down_button.setMinimumWidth(150)
+ 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)
+ self.main_layout.addWidget(self.trim_button, alignment=Qt.AlignRight)
+
+ self.crop_button = QtWidgets.QPushButton("Crop")
+ self.crop_button.setMinimumWidth(150)
+ self.crop_button.clicked.connect(self.crop_videos)
+ self.main_layout.addWidget(self.crop_button, alignment=Qt.AlignRight)
+
+ def _generate_layout_attributes(self, layout):
+ videoheight_label = QtWidgets.QLabel("Video height (aspect ratio fixed)")
+ self.video_height = QtWidgets.QSpinBox()
+ self.video_height.setMaximum(1000)
+ self.video_height.setValue(256)
+ self.video_height.valueChanged.connect(self.log_video_height)
+
+ rotate_label = QtWidgets.QLabel("Rotate video")
+ self.video_rotation = QtWidgets.QComboBox()
+ self.video_rotation.addItems(["no", "clockwise", "specific angle"])
+ self.video_rotation.currentTextChanged.connect(self.update_video_rotation)
+
+ trim_start_label = QtWidgets.QLabel("Trim start (sec)")
+ self.video_start = QtWidgets.QSpinBox()
+ self.video_start.setMaximum(3600)
+ self.video_start.setValue(1)
+ self.video_start.valueChanged.connect(self.log_video_start)
+
+ trim_end_label = QtWidgets.QLabel("Trim end (sec)")
+ self.video_stop = QtWidgets.QSpinBox()
+ self.video_stop.setMaximum(3600)
+ self.video_stop.setMinimum(1)
+ self.video_stop.setValue(30)
+ self.video_stop.valueChanged.connect(self.log_video_stop)
+
+ angle_label = QtWidgets.QLabel("Rotation angle (deg)")
+ self.rotation_angle = QtWidgets.QDoubleSpinBox()
+ self.rotation_angle.setMaximum(360.0)
+ self.rotation_angle.setMinimum(-360.0)
+ self.rotation_angle.setDecimals(2)
+ self.rotation_angle.setValue(0.0)
+ self.rotation_angle.setEnabled(False)
+ self.rotation_angle.valueChanged.connect(self.log_rotation_angle)
+
+ downsample_title = QtWidgets.QLabel("Downsample and rotate:")
+ trim_title = QtWidgets.QLabel("Shorten video (trim):")
+
+ layout.addWidget(downsample_title, 0, 0)
+ layout.addWidget(trim_title, 0, 4)
+ layout.addWidget(videoheight_label, 1, 0)
+ layout.addWidget(self.video_height, 1, 1)
+ layout.addWidget(rotate_label, 1, 2)
+ layout.addWidget(self.video_rotation, 1, 3)
+ layout.addWidget(angle_label, 2, 2)
+ layout.addWidget(self.rotation_angle, 2, 3)
+ layout.addWidget(trim_start_label, 1, 4)
+ layout.addWidget(self.video_start, 1, 5)
+ layout.addWidget(trim_end_label, 2, 4)
+ layout.addWidget(self.video_stop, 2, 5)
+ # layout.addWidget()
+
+ def update_video_rotation(self, option):
+ self.root.logger.info(f"Video rotation set to {option.upper()}")
+ if option == "specific angle":
+ self.rotation_angle.setEnabled(True)
+ else:
+ self.rotation_angle.setEnabled(False)
+
+ def log_video_height(self, value):
+ self.root.logger.info(f"Video height set to {value}")
+
+ def log_video_start(self, value):
+ start_time = time.strftime("%H:%M:%S", time.gmtime(value))
+ self.root.logger.info(f"Video start time set to {start_time}")
+
+ def log_video_stop(self, value):
+ stop_time = time.strftime("%H:%M:%S", time.gmtime(value))
+ self.root.logger.info(f"Video start time set to {stop_time}")
+
+ 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()))
+ if self.files:
+ for video in self.files:
+ auxfun_videos.ShortenVideo(video, start, stop)
+ else:
+ self.root.logger.error("No videos selected...")
+
+ def downsample_videos(self):
+ if self.files:
+ for video in self.files:
+ auxfun_videos.DownSampleVideo(
+ video,
+ width=-1,
+ height=self.video_height.value(),
+ rotatecw=self.video_rotation.currentData(),
+ angle=self.rotation_angle.value(),
+ )
+ else:
+ self.root.logger.error("No videos selected...")
+
+ def crop_videos(self):
+ if self.files:
+ for video in self.files:
+ _ = _crop_video(video)
+ else:
+ self.root.logger.error("No videos selected...")
+
+
+def _crop_video(video_path):
+ fc = FrameCropper(video_path)
+ coords = fc.draw_bbox()
+ if not coords:
+ return
+ origin_x, origin_y = coords[:2]
+ width = int(coords[2]) - int(coords[0])
+ height = int(coords[3]) - int(coords[1])
+ writer = auxfun_videos.VideoWriter(video_path)
+ writer.set_bbox(origin_x, origin_x + width, origin_y, origin_y + height)
+ return writer.crop("cropped", None)
diff --git a/deeplabcut/gui/tracklet_toolbox.py b/deeplabcut/gui/tracklet_toolbox.py
index e395f965b7..18bc42109e 100644
--- a/deeplabcut/gui/tracklet_toolbox.py
+++ b/deeplabcut/gui/tracklet_toolbox.py
@@ -1,15 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+from pathlib import Path
+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, Thread
-from deeplabcut.gui import auxfun_drag
+from matplotlib.path import Path as MPLPath
+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 attempttomakefolder
-from matplotlib.path import Path
-from matplotlib.widgets import Slider, LassoSelector, Button, CheckButtons
+from deeplabcut.utils.auxiliaryfunctions import attempt_to_make_folder
+
+
+class DraggablePoint:
+ lock = None # only one can be animated at a time
+
+ def __init__(self, point, bodyParts, individual_names=None, likelihood=None):
+ self.point = point
+ self.bodyParts = bodyParts
+ self.individual_names = individual_names
+ self.likelihood = likelihood
+ self.press = None
+ self.background = None
+ self.final_point = (0.0, 0.0)
+ self.annot = self.point.axes.annotate(
+ "",
+ xy=(0, 0),
+ xytext=(20, 20),
+ textcoords="offset points",
+ bbox=dict(boxstyle="round", fc="w"),
+ arrowprops=dict(arrowstyle="->"),
+ )
+ self.annot.set_visible(False)
+ self.coords = []
+
+ 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)
+
+ def on_press(self, event):
+ """Define the event for the button press!"""
+ if event.inaxes != self.point.axes:
+ return
+ if DraggablePoint.lock is not None:
+ return
+ contains, attrd = self.point.contains(event)
+ if not contains:
+ return
+ if event.button == 1:
+ """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
+ axes = self.point.axes
+ self.point.set_animated(True)
+ canvas.draw()
+ self.background = canvas.copy_from_bbox(self.point.axes.bbox)
+ 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.
+ """
+ message = f"Do you want to remove the label {self.bodyParts}?"
+ if self.likelihood is not None:
+ message += " You cannot undo this step!"
+ msg = QMessageBox()
+ msg.setWindowTitle("Warning!")
+ msg.setText(message)
+ msg.setStandardButtons(msg.Yes | msg.No)
+ if msg.exec() == msg.Yes:
+ self.delete_data()
+
+ def delete_data(self):
+ self.press = None
+ DraggablePoint.lock = None
+ self.point.set_animated(False)
+ self.background = None
+ self.final_point = (np.nan, np.nan, self.individual_names, self.bodyParts)
+ self.point.center = (np.nan, np.nan)
+ self.coords.append(self.final_point)
+ self.point.figure.canvas.draw()
+
+ def on_motion(self, event):
+ """During the drag!"""
+ if DraggablePoint.lock is not self:
+ return
+ if event.inaxes != self.point.axes:
+ return
+
+ if event.button == 1:
+ self.point.center, xpress, ypress = self.press
+ dx = event.xdata - xpress
+ dy = event.ydata - ypress
+ self.point.center = (self.point.center[0] + dx, self.point.center[1] + dy)
+ canvas = self.point.figure.canvas
+ axes = self.point.axes
+ # restore the background region
+ canvas.restore_region(self.background)
+ axes.draw_artist(self.point)
+ canvas.blit(axes.bbox)
+
+ def on_release(self, event):
+ """On release we reset the press data"""
+ if DraggablePoint.lock is not self:
+ return
+ if event.button == 1:
+ self.press = None
+ DraggablePoint.lock = None
+ self.point.set_animated(False)
+ self.background = None
+ self.point.figure.canvas.draw()
+ self.final_point = (
+ self.point.center[0],
+ self.point.center[1],
+ self.individual_names,
+ self.bodyParts,
+ )
+ self.coords.append(self.final_point)
+
+ def on_hover(self, event):
+ """Annotate the labels and likelihood when the user hovers over the data
+ points.
+ """
+ vis = self.annot.get_visible()
+
+ if event.inaxes == self.point.axes:
+ contains, attrd = self.point.contains(event)
+ if contains:
+ self.annot.xy = (self.point.center[0], self.point.center[1])
+ text = str(self.bodyParts)
+ if self.individual_names is not None:
+ text = f"{self.individual_names},{text}"
+ if self.likelihood is not None:
+ text += f",p={self.likelihood:.2f}"
+ self.annot.set_text(text)
+ self.annot.get_bbox_patch().set_alpha(0.4)
+ self.annot.set_visible(True)
+ self.point.figure.canvas.draw_idle()
+ else:
+ if vis:
+ self.annot.set_visible(False)
+
+ def disconnect(self):
+ """Disconnect all the stored connection ids"""
+ self.point.figure.canvas.mpl_disconnect(self.cidpress)
+ self.point.figure.canvas.mpl_disconnect(self.cidrelease)
+ self.point.figure.canvas.mpl_disconnect(self.cidmotion)
+ self.point.figure.canvas.mpl_disconnect(self.cidhover)
class BackgroundPlayer:
@@ -35,10 +194,10 @@ def run(self):
i -= 1
else:
i -= 2 * (len(self.speed) - 1)
- if i > self.viz.manager.nframes:
+ if i >= self.viz.manager.nframes:
i = 0
elif i < 0:
- i = self.viz.manager.nframes
+ i = self.viz.manager.nframes - 1
self.viz.slider.set_val(i)
def pause(self):
@@ -80,6 +239,7 @@ def rewind(self):
self.resume()
def terminate(self, *args):
+ self.can_run.set()
self.running = False
@@ -96,7 +256,7 @@ def __init__(self, tracker, ax, collection, alpha, alpha_other=0.2):
self.toggle()
def on_select(self, verts):
- path = Path(verts)
+ path = MPLPath(verts)
xy = self.collection.get_offsets()
self.tracker.picked = list(np.nonzero(path.contains_points(xy))[0])
self.fc[:, -1] = self.alpha_other
@@ -129,17 +289,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
@@ -150,12 +306,16 @@ def __init__(self, manager, videoname, trail_len=50):
self.picked_pair = []
self.cuts = []
+ self.mutex = QMutex()
self.player = BackgroundPlayer(self)
- self.thread_player = Thread(target=self.player.run, daemon=True)
+ self.worker, self.thread_player = move_to_separate_thread(self.player.run)
self.thread_player.start()
self.dps = []
+ self.swap_id1 = None
+ self.swap_id2 = None
+
def _prepare_canvas(self, manager, fig):
params = {
"keymap.save": "s",
@@ -187,12 +347,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],
[],
@@ -203,10 +361,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,
@@ -221,9 +377,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",
@@ -249,10 +403,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")
@@ -268,11 +427,64 @@ def _prepare_canvas(self, manager, fig):
self.lasso_toggle.on_clicked(self.selector.toggle)
self.display_traces(only_picked=False)
self.ax1_background = self.fig.canvas.copy_from_bbox(self.ax1.bbox)
- plt.show()
+ 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:
@@ -285,12 +497,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
@@ -307,7 +515,7 @@ def toggle_draggable_points(self, *args):
def add_point(self, center, animal, bodypart, **kwargs):
circle = patches.Circle(center, **kwargs)
self.ax1.add_patch(circle)
- dp = auxfun_drag.DraggablePoint(circle, bodypart, animal)
+ dp = DraggablePoint(circle, bodypart, animal)
dp.connect()
self.dps.append(dp)
@@ -336,14 +544,17 @@ def add_draggable_points(self):
def save_coords(self):
coords, nonempty, inds = self.manager.get_non_nan_elements(self._curr_frame)
+ if not inds.size:
+ return
prob = self.manager.prob[:, self._curr_frame]
for dp in self.dps:
label = dp.individual_names, dp.bodyParts
ind = self.manager._label_pairs.index(label)
- nrow = np.flatnonzero(inds == ind)[0]
- if not np.array_equal(
- coords[nrow], dp.point.center
- ): # Keypoint has been displaced
+ nrow = np.flatnonzero(inds == ind)
+ if not nrow.size:
+ return
+ nrow = nrow[0]
+ 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
@@ -363,12 +574,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):
@@ -411,12 +618,13 @@ 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 = []
- self.ax_slider.lines.clear()
+ for line in self.ax_slider.lines:
+ line.remove()
elif event.key == "backspace":
if not self.dps: # Last flag deletion
try:
@@ -427,12 +635,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()
@@ -466,14 +669,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)
@@ -499,16 +698,15 @@ 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)
if len(self.cuts) > 1:
mask[self.cuts[-2] : self.cuts[-1] + 1] = True
self.cuts = []
- self.ax_slider.lines.clear()
+ for line in self.ax_slider.lines:
+ line.remove()
self.clean_collections()
else:
return
@@ -546,9 +744,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):
@@ -569,7 +765,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])
@@ -612,9 +808,11 @@ def update_vlines(self, val):
self.vline_y.set_xdata([val, val])
def on_change(self, val):
+ self.mutex.lock() # Make video frame retrieval thread-safe
self.curr_frame = int(val)
self.video.set_to_frame(self.curr_frame)
img = self.video.read_frame()
+ self.mutex.unlock()
if img is not None:
# Automatically disable the draggable points
if self.draggable:
@@ -627,7 +825,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):
@@ -638,7 +836,7 @@ def save(self, *args):
self.manager.save()
def export_to_training_data(self, pcutoff=0.1):
- import os
+
from skimage import io
inds = self.manager.find_edited_frames()
@@ -648,24 +846,20 @@ 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
- )
- if os.path.isdir(tmpfolder):
+ tmpfolder = Path(self.manager.cfg["project_path"]) / "labeled-data" / self.video.name
+ if tmpfolder.is_dir():
print(
"Frames from video",
self.video.name,
" already extracted (more will be added)!",
)
else:
- attempttomakefolder(tmpfolder)
+ attempt_to_make_folder(str(tmpfolder))
index = []
for ind in inds:
- imagename = os.path.join(
- tmpfolder, "img" + str(ind).zfill(strwidth) + ".png"
- )
- index.append(os.path.join(*imagename.rsplit(os.path.sep, 3)[-3:]))
- if not os.path.isfile(imagename):
+ imagename = tmpfolder / ("img" + str(ind).zfill(strwidth) + ".png")
+ index.append(tuple(imagename.parts[-3:]))
+ if not imagename.is_file():
self.video.set_to_frame(ind)
frame = self.video.read_frame()
if frame is None:
@@ -673,60 +867,57 @@ 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)
+ io.imsave(str(imagename), frame)
# Store the newly-refined data
data = self.manager.format_data()
df = data.iloc[inds]
# Uncertain keypoints are ignored
+ # TODO @deruyter92 2026-06-10 (#3362):
+ # pandas migration 3.0 forbids in-place mutations of dataframe subset views.
def filter_low_prob(cols, prob):
mask = cols.iloc[:, 2] < prob
cols.loc[mask] = np.nan
return cols
- df = df.groupby(level="bodyparts", axis=1).apply(filter_low_prob, prob=pcutoff)
- df.index = index
- machinefile = os.path.join(
- tmpfolder, "machinelabels-iter" + str(self.manager.cfg["iteration"]) + ".h5"
- )
- if os.path.isfile(machinefile):
+ df = df.groupby(level="bodyparts", axis=1, group_keys=False).apply(filter_low_prob, prob=pcutoff)
+ df.index = pd.MultiIndex.from_tuples(index)
+
+ machinefile = tmpfolder / ("machinelabels-iter" + str(self.manager.cfg["iteration"]) + ".h5")
+ if machinefile.is_file():
df_old = pd.read_hdf(machinefile)
df_joint = pd.concat([df_old, df])
df_joint = df_joint[~df_joint.index.duplicated(keep="first")]
df_joint.to_hdf(machinefile, key="df_with_missing", mode="w")
- df_joint.to_csv(os.path.join(tmpfolder, "machinelabels.csv"))
+ df_joint.to_csv(tmpfolder / "machinelabels.csv")
else:
df.to_hdf(machinefile, key="df_with_missing", mode="w")
- df.to_csv(os.path.join(tmpfolder, "machinelabels.csv"))
+ df.to_csv(tmpfolder / "machinelabels.csv")
# Merge with the already existing annotated data
- df.columns.set_levels(
- [self.manager.cfg["scorer"]], level="scorer", inplace=True
- )
+ 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'
- )
- if os.path.isfile(output_path):
+ output_path = tmpfolder / f"CollectedData_{self.manager.cfg['scorer']}.h5"
+ if output_path.is_file():
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")
- df_joint.to_csv(output_path.replace("h5", "csv"))
+ df_joint.to_csv(output_path.with_suffix(".csv"))
else:
df.sort_index(inplace=True)
df.to_hdf(output_path, key="df_with_missing", mode="w")
- df.to_csv(output_path.replace("h5", "csv"))
+ df.to_csv(output_path.with_suffix(".csv"))
def refine_tracklets(
@@ -738,8 +929,8 @@ def refine_tracklets(
max_gap=2,
trail_len=0,
):
- """
- Refine tracklets stored either in pickle or h5 format.
+ """Refine tracklets stored either in pickle or h5 format.
+
The procedure is done in two stages:
(i) freshly-converted detections are read by the TrackletManager,
which automatically attempts to optimize tracklet continuity by
@@ -751,40 +942,30 @@ def refine_tracklets(
selected using the Lasso tool in order to re-assign multiple tracks
to another identity at once.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- pickle_or_h5_file: str
- Full path of either the pickle file obtained after calling
- deeplabcut.convert_detections2tracklets, or the h5 file written after
- refining the tracklets a first time. Note that refined tracklets are
- always stored in the h5 format.
-
- video: str
- Full path of the corresponding video.
- If the video duration and the total length of the tracklets disagree
- by more than 5%, a message is printed indicating that the selected
- video may not be the right one.
-
- min_swap_len : float, optional (default=2)
- Minimum swap length.
- Set to 2 by default. Retained swaps appear in the right panel in
- shaded regions.
-
- min_tracklet_len : float, optional (default=2)
- Minimum tracklet length.
- By default, tracklets shorter than 2 frames are discarded,
- leaving missing data instead. If set to 0, all tracklets are kept.
-
- max_gap : int, optional (default=2).
- Maximal gap size (in number of frames) of missing data to be filled.
- The procedure fits a cubic spline over all individual trajectories,
- and fills all gaps smaller than or equal to 2 frames by default.
-
- trail_len : int, optional (default=0)
- Number of trailing points. None by default, to accelerate visualization.
+ Args:
+ config (str): Full path of the config.yaml file.
+ pickle_or_h5_file (str): Full path of either the pickle file obtained after calling
+ deeplabcut.convert_detections2tracklets, or the h5 file written after
+ refining the tracklets a first time. Note that refined tracklets are
+ always stored in the h5 format.
+ video (str): Full path of the corresponding video.
+ If the video duration and the total length of the tracklets disagree
+ by more than 5%, a message is printed indicating that the selected
+ video may not be the right one.
+ min_swap_len (int, optional): Minimum swap length.
+ Set to 2 by default. Retained swaps appear in the right panel in
+ shaded regions. Defaults to 2.
+ min_tracklet_len (int, optional): Minimum tracklet length.
+ By default, tracklets shorter than 2 frames are discarded,
+ leaving missing data instead. If set to 0, all tracklets are kept. Defaults to 2.
+ max_gap (int, optional): Maximal gap size (in number of frames) of missing data to be filled.
+ The procedure fits a cubic spline over all individual trajectories,
+ and fills all gaps smaller than or equal to 2 frames by default. Defaults to 2.
+ trail_len (int, optional): Number of trailing points to display.
+ Defaults to 0.
+
+ Returns:
+ tuple[TrackletManager, TrackletVisualizer]: The tracklet manager and visualizer.
"""
manager = TrackletManager(config, min_swap_len, min_tracklet_len, max_gap)
if pickle_or_h5_file.endswith("pickle"):
diff --git a/deeplabcut/gui/train_network.py b/deeplabcut/gui/train_network.py
deleted file mode 100644
index 312e747a54..0000000000
--- a/deeplabcut/gui/train_network.py
+++ /dev/null
@@ -1,340 +0,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 pydoc
-import subprocess
-import sys
-import webbrowser
-from pathlib import Path
-
-import wx
-
-import deeplabcut
-from deeplabcut.utils import auxiliaryfunctions
-
-from deeplabcut.gui import LOGO_PATH
-
-
-class Train_network(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
- # variable initialization
- self.method = "automatic"
- self.config = cfg
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Step 5. Train network")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- vbox1 = wx.BoxSizer(wx.VERTICAL)
- self.pose_cfg_text = wx.Button(self, label="Click to open the pose config file")
- self.pose_cfg_text.Bind(wx.EVT_BUTTON, self.edit_pose_config)
- vbox1.Add(self.pose_cfg_text, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.update_params_text = wx.Button(self, label="Update the parameters")
- self.update_params_text.Bind(wx.EVT_BUTTON, self.update_params)
- vbox1.Add(self.update_params_text, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- self.pose_cfg_text.Hide()
- self.update_params_text.Hide()
-
- sb = wx.StaticBox(self, label="Optional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
-
- shuffles_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffles_text_boxsizer = wx.StaticBoxSizer(shuffles_text, wx.VERTICAL)
- self.shuffles = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffles_text_boxsizer.Add(self.shuffles, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- #trainingindex = wx.StaticBox(self, label="Specify the trainingset index")
- #trainingindex_boxsizer = wx.StaticBoxSizer(trainingindex, wx.VERTICAL)
- #self.trainingindex = wx.SpinCtrl(self, value="0", min=0, max=100)
- #trainingindex_boxsizer.Add(
- # self.trainingindex, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.pose_cfg_choice = wx.RadioBox(
- self,
- label="Want to edit pose_cfg.yaml file?",
- choices=["Yes", "No"],
- majorDimension=1,
- style=wx.RA_SPECIFY_COLS,
- )
- self.pose_cfg_choice.Bind(wx.EVT_RADIOBOX, self.chooseOption)
- self.pose_cfg_choice.SetSelection(1)
-
- # 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)
- display_iters = str(pose_cfg["display_iters"])
- save_iters = str(pose_cfg["save_iters"])
- max_iters = str(pose_cfg["multi_step"][-1][-1])
-
- display_iters_text = wx.StaticBox(self, label="Display iterations")
- display_iters_text_boxsizer = wx.StaticBoxSizer(display_iters_text, wx.VERTICAL)
- self.display_iters = wx.SpinCtrl(
- self, value=display_iters, min=1, max=int(max_iters)
- )
- display_iters_text_boxsizer.Add(
- self.display_iters, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
- # self.display_iters.Enable(False)
-
- save_iters_text = wx.StaticBox(self, label="Save X number of iterations")
- save_iters_text_boxsizer = wx.StaticBoxSizer(save_iters_text, wx.VERTICAL)
- self.save_iters = wx.SpinCtrl(self, value="10000", min=1, max=int(max_iters))
- save_iters_text_boxsizer.Add(
- self.save_iters, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
- # self.save_iters.Enable(False)
-
- max_iters_text = wx.StaticBox(self, label="Maximum iterations")
- max_iters_text_boxsizer = wx.StaticBoxSizer(max_iters_text, wx.VERTICAL)
- self.max_iters = wx.SpinCtrl(self, value="500000", min=1, max=int(max_iters))
- max_iters_text_boxsizer.Add(
- self.max_iters, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10
- )
- # self.max_iters.Enable(False)
-
- snapshots = wx.StaticBox(self, label="Number of network snapshots to keep")
- snapshots_boxsizer = wx.StaticBoxSizer(snapshots, wx.VERTICAL)
- self.snapshots = wx.SpinCtrl(self, value="10", min=1, max=100)
- snapshots_boxsizer.Add(self.snapshots, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- # self.snapshots.Enable(False)
-
- hbox1.Add(shuffles_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- #hbox1.Add(trainingindex_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(self.pose_cfg_choice, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox1.Add(vbox1, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- hbox2.Add(display_iters_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- hbox2.Add(save_iters_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(max_iters_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(snapshots_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.sizer.Add(
- boxsizer,
- pos=(4, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(5, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Ok")
- self.sizer.Add(self.ok, pos=(5, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.train_network)
-
- self.cancel = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.cancel, pos=(5, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.cancel.Bind(wx.EVT_BUTTON, self.cancel_train_network)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.train_network"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def chooseOption(self, event):
- if self.pose_cfg_choice.GetStringSelection() == "Yes":
- self.shuffles.Enable(False)
- #self.trainingindex.Enable(False)
- self.pose_cfg_text.Show()
- self.update_params_text.Show()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
- else:
- self.shuffles.Enable(True)
- #self.trainingindex.Enable(True)
- self.pose_cfg_text.Hide()
- self.update_params_text.Hide()
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def edit_pose_config(self, event):
- """
- """
- self.shuffles.Enable(True)
- #self.trainingindex.Enable(True)
- self.display_iters.Enable(True)
- self.save_iters.Enable(True)
- self.max_iters.Enable(True)
- self.snapshots.Enable(True)
- # Read the pose config file
-
- cfg = auxiliaryfunctions.read_config(self.config)
- trainFraction = cfg["TrainingFraction"]
- #print(trainFraction[-1])
- # print(os.path.join(cfg['project_path'],auxiliaryfunctions.get_model_folder(trainFraction, self.shuffles.GetValue(),cfg),'train','pose_cfg.yaml'))
- self.pose_cfg_path = os.path.join(
- cfg["project_path"],
- auxiliaryfunctions.get_model_folder(
- trainFraction[-1], self.shuffles.GetValue(), cfg
- ),
- "train",
- "pose_cfg.yaml",
- )
- # let the user open the file with default text editor. Also make it mac compatible
- if sys.platform == "darwin":
- self.file_open_bool = subprocess.call(["open", self.pose_cfg_path])
- self.file_open_bool = True
- else:
- self.file_open_bool = webbrowser.open(self.pose_cfg_path)
- if self.file_open_bool:
- self.pose_cfg = auxiliaryfunctions.read_plainconfig(self.pose_cfg_path)
- else:
- raise FileNotFoundError("File not found!")
-
- def update_params(self, event):
- # update the variables with the edited values in the pose config file
- if self.file_open_bool:
- self.pose_cfg = auxiliaryfunctions.read_plainconfig(self.pose_cfg_path)
- display_iters = str(self.pose_cfg["display_iters"])
- save_iters = str(self.pose_cfg["save_iters"])
- max_iters = str(self.pose_cfg["multi_step"][-1][-1])
- self.display_iters.SetValue(display_iters)
- self.save_iters.SetValue(save_iters)
- self.max_iters.SetValue(max_iters)
- self.shuffles.Enable(True)
- #self.trainingindex.Enable(True)
- self.display_iters.Enable(True)
- self.save_iters.Enable(True)
- self.max_iters.Enable(True)
- self.snapshots.Enable(True)
- else:
- raise FileNotFoundError("File not found!")
-
- def train_network(self, event):
- if self.shuffles.Children:
- shuffle = int(self.shuffles.Children[0].GetValue())
- else:
- shuffle = int(self.shuffles.GetValue())
-
- if self.snapshots.Children:
- max_snapshots_to_keep = int(self.snapshots.Children[0].GetValue())
- else:
- max_snapshots_to_keep = int(self.snapshots.GetValue())
-
- if self.display_iters.Children:
- displayiters = int(self.display_iters.Children[0].GetValue())
- else:
- displayiters = int(self.display_iters.GetValue())
-
- if self.save_iters.Children:
- saveiters = int(self.save_iters.Children[0].GetValue())
- else:
- saveiters = int(self.save_iters.GetValue())
-
- if self.max_iters.Children:
- maxiters = int(self.max_iters.Children[0].GetValue())
- else:
- maxiters = int(self.max_iters.GetValue())
-
- deeplabcut.train_network(
- self.config,
- shuffle,
- gputouse=None,
- max_snapshots_to_keep=max_snapshots_to_keep,
- autotune=None,
- displayiters=displayiters,
- saveiters=saveiters,
- maxiters=maxiters,
- )
-
- def cancel_train_network(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.pose_cfg_text.Hide()
- self.update_params_text.Hide()
- self.pose_cfg_choice.SetSelection(1)
- self.display_iters.SetValue(1000)
- self.save_iters.SetValue(10000)
- self.max_iters.SetValue(50000)
- self.snapshots.SetValue(5)
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
diff --git a/deeplabcut/gui/transformerID.py b/deeplabcut/gui/transformerID.py
deleted file mode 100644
index cdfa8ab649..0000000000
--- a/deeplabcut/gui/transformerID.py
+++ /dev/null
@@ -1,213 +0,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 os
-import pydoc
-import sys
-
-import wx
-
-import deeplabcut
-from deeplabcut import utils
-
-from deeplabcut.gui import LOGO_PATH
-from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-
-
-class TransformerID(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
-
- # variable initialization
- self.config = cfg
- self.cfg = utils.read_config(cfg)
- self.filelist = []
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - OPTIONAL: Unsupervised ID Tracking with Transformer")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
- # self.sel_config = wx.FilePickerCtrl(self, path="",style=wx.FLP_USE_TEXTCTRL,message="Choose the config.yaml file", wildcard="config.yaml")
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.vids = wx.StaticText(self, label="Choose the videos")
- self.sizer.Add(self.vids, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Select videos to analyze")
- self.sizer.Add(self.sel_vids, pos=(3, 1), flag=wx.TOP | wx.EXPAND, border=5)
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
-
- sb = wx.StaticBox(self, label="Optional Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
-
- videotype_text = wx.StaticBox(self, label="Specify the videotype")
- videotype_text_boxsizer = wx.StaticBoxSizer(videotype_text, wx.VERTICAL)
- self.videotype = wx.ComboBox(self, choices=("",) + SUPPORTED_VIDEOS, style=wx.CB_READONLY)
- self.videotype.SetValue("")
- videotype_text_boxsizer.Add(self.videotype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- shuffles_text = wx.StaticBox(self, label="Specify the shuffle")
- shuffles_text_boxsizer = wx.StaticBoxSizer(shuffles_text, wx.VERTICAL)
- self.shuffles = wx.SpinCtrl(self, value="1", min=0, max=100)
- shuffles_text_boxsizer.Add(self.shuffles, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- ntracks_text = wx.StaticBox(self, label="Specify the no. of animals")
- ntracks_text_boxsizer = wx.StaticBoxSizer(ntracks_text, wx.VERTICAL)
- self.n_tracks = wx.SpinCtrl(self, value="2", min=0, max=100)
- ntracks_text_boxsizer.Add(self.n_tracks, 2, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- ntriplets_text = wx.StaticBox(self, label="Specify the no. triplets")
- ntriplets_text_boxsizer = wx.StaticBoxSizer(ntriplets_text, wx.VERTICAL)
- self.ntriplets = wx.SpinCtrl(self, value="1000", min=100, max=5000)
- ntriplets_text_boxsizer.Add(self.ntriplets, 2, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
- trackertype_text = wx.StaticBox(self, label="Specify the tracking type (ellipse recommended)")
- trackertype_text_boxsizer = wx.StaticBoxSizer(trackertype_text, wx.VERTICAL)
- trackertype = ["ellipse", "box"]
- self.trackertype = wx.ComboBox(self, choices=trackertype, style=wx.CB_READONLY)
- self.trackertype.SetValue("ellipse")
- trackertype_text_boxsizer.Add(self.trackertype, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 1)
-
-
-
- hbox1.Add(videotype_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(shuffles_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(ntracks_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(ntriplets_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox2.Add(trackertype_text_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
-
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.sizer.Add(
- boxsizer,
- pos=(4, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
-
-### train and go ###
-
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(6, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="Run Transformer")
- self.sizer.Add(self.ok, pos=(6, 4))
- self.ok.Bind(wx.EVT_BUTTON, self.run_transformer_reID)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(
- self.reset, pos=(6, 1), span=(1, 1), flag=wx.BOTTOM | wx.RIGHT, border=10
- )
- self.reset.Bind(wx.EVT_BUTTON, self.reset_transformer_reID)
-
- self.sizer.AddGrowableCol(2)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.transformer_reID"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- help_file.close()
- os.remove("help.txt")
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select videos to analyze", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids
- self.sel_vids.SetLabel("Total %s Videos selected" % len(self.filelist))
-
- def run_transformer_reID(self, event):
- deeplabcut.transformer_reID(
- config=self.config,
- videos=self.filelist,
- videotype=self.videotype.GetValue(),
- n_tracks=self.n_tracks.GetValue(),
- shuffle=self.shuffles.GetValue(),
- track_method=self.trackertype.GetValue(),
- )
-
- def reset_transformer_reID(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.videotype.SetValue("")
- self.sel_vids.SetLabel("Select videos to analyze")
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
diff --git a/deeplabcut/gui/utils.py b/deeplabcut/gui/utils.py
new file mode 100644
index 0000000000..787b5ed6ca
--- /dev/null
+++ b/deeplabcut/gui/utils.py
@@ -0,0 +1,306 @@
+#
+# 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 json
+import re
+import shutil
+import sys
+import urllib.request
+from collections.abc import Callable
+from pathlib import Path
+
+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
+
+
+def absolute_path(path: str | Path) -> Path:
+ return Path(path).expanduser().absolute()
+
+
+class Worker(QtCore.QObject):
+ finished = QtCore.Signal()
+ error = QtCore.Signal(object)
+
+ def __init__(self, func):
+ super().__init__()
+ self.func = func
+
+ def run(self):
+ try:
+ self._execute()
+ except Exception as exc:
+ # Marshal the exception to the GUI thread instead of crashing or
+ # silently killing the worker thread.
+ self.error.emit(exc)
+ finally:
+ self.finished.emit()
+
+ def _execute(self):
+ self.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 _execute(self):
+ self.outputs = self.func()
+
+
+def move_to_separate_thread(func: Callable, capture_outputs: bool = False):
+ thread = QtCore.QThread()
+ if capture_outputs:
+ worker = CaptureWorker(func)
+ else:
+ worker = Worker(func)
+
+ worker.finished.connect(worker.deleteLater)
+ worker.moveToThread(thread)
+ thread.started.connect(worker.run)
+
+ def stop_thread():
+ thread.quit()
+ thread.wait()
+
+ worker.finished.connect(stop_thread)
+ return worker, thread
+
+
+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()
+
+ latest_version = json.loads(contents)["info"]["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
+
+
+def _package_specs_for_update(packages: list[str]) -> list[str]:
+ """Return package specs to install for GUI updates.
+
+ DeepLabCut itself should be updated with the GUI extra so GUI dependencies
+ are kept in sync.
+ """
+ specs = []
+
+ for package in packages:
+ normalized = package.strip()
+
+ if normalized == "deeplabcut":
+ specs.append("deeplabcut[gui]")
+ else:
+ specs.append(normalized)
+
+ return specs
+
+
+def _build_update_commands(packages: list[str]) -> list[tuple[str, str, list[str]]]:
+ """Build installer commands, ordered from preferred to fallback.
+
+ Returns tuples of:
+
+ (backend_name, program, arguments)
+
+ The order is:
+ 1. uv, if available
+ 2. current-interpreter pip fallback
+ """
+ specs = _package_specs_for_update(packages)
+ commands = []
+
+ uv = shutil.which("uv")
+ if uv:
+ commands.append(
+ (
+ "uv",
+ uv,
+ ["pip", "install", "--python", sys.executable, "-U", *specs],
+ )
+ )
+
+ commands.append(
+ (
+ "pip",
+ sys.executable,
+ ["-m", "pip", "install", "-U", *specs],
+ )
+ )
+
+ return commands
diff --git a/deeplabcut/gui/video_editing.py b/deeplabcut/gui/video_editing.py
deleted file mode 100644
index bb918b21aa..0000000000
--- a/deeplabcut/gui/video_editing.py
+++ /dev/null
@@ -1,254 +0,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 datetime
-import os
-import pydoc
-import sys
-
-import wx
-import wx.lib.agw.floatspin as FS
-
-import deeplabcut
-
-from deeplabcut.gui import LOGO_PATH
-
-
-# DownSampleVideo(vname,width=-1,height=200,outsuffix='downsampled',outpath=None,rotatecw=False):
-
-
-class Video_Editing(wx.Panel):
- """
- """
-
- def __init__(self, parent, gui_size, cfg):
- """Constructor"""
- wx.Panel.__init__(self, parent=parent)
- # variable initialization
- self.filelist = []
- self.config = cfg
-
- # design the panel
- self.sizer = wx.GridBagSizer(5, 5)
-
- text = wx.StaticText(self, label="DeepLabCut - Optional Video Editor")
- self.sizer.Add(text, pos=(0, 0), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=15)
- # Add logo of DLC
- icon = wx.StaticBitmap(self, bitmap=wx.Bitmap(LOGO_PATH))
- self.sizer.Add(
- icon, pos=(0, 4), flag=wx.TOP | wx.RIGHT | wx.ALIGN_RIGHT, border=5
- )
-
- line1 = wx.StaticLine(self)
- self.sizer.Add(
- line1, pos=(1, 0), span=(1, 5), flag=wx.EXPAND | wx.BOTTOM, border=10
- )
-
- self.cfg_text = wx.StaticText(self, label="Select the config file")
- self.sizer.Add(self.cfg_text, pos=(2, 0), flag=wx.TOP | wx.LEFT, border=5)
-
- if sys.platform == "darwin":
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="*.yaml",
- )
- else:
- self.sel_config = wx.FilePickerCtrl(
- self,
- path="",
- style=wx.FLP_USE_TEXTCTRL,
- message="Choose the config.yaml file",
- wildcard="config.yaml",
- )
-
- self.sizer.Add(
- self.sel_config, pos=(2, 1), span=(1, 3), flag=wx.TOP | wx.EXPAND, border=5
- )
- self.sel_config.SetPath(self.config)
- self.sel_config.Bind(wx.EVT_FILEPICKER_CHANGED, self.select_config)
-
- self.vids = wx.StaticText(self, label="Choose the video")
- self.sizer.Add(self.vids, pos=(3, 0), flag=wx.TOP | wx.LEFT, border=10)
-
- self.sel_vids = wx.Button(self, label="Select video")
- self.sizer.Add(self.sel_vids, pos=(3, 1), flag=wx.TOP | wx.EXPAND, border=5)
- self.sel_vids.Bind(wx.EVT_BUTTON, self.select_videos)
-
- sb = wx.StaticBox(self, label="Attributes")
- boxsizer = wx.StaticBoxSizer(sb, wx.VERTICAL)
-
- hbox1 = wx.BoxSizer(wx.HORIZONTAL)
- hbox2 = wx.BoxSizer(wx.HORIZONTAL)
-
- video_height = wx.StaticBox(
- self, label="Downsample - specify the video height (aspect ratio fixed)"
- )
- vheight_boxsizer = wx.StaticBoxSizer(video_height, wx.VERTICAL)
- self.height = wx.SpinCtrl(self, value="256", min=0, max=1000)
- vheight_boxsizer.Add(self.height, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.rotate = wx.RadioBox(
- self,
- label="Downsample: rotate video?",
- choices=["Yes", "No", "Arbitrary"],
- # majorDimension=0,
- style=wx.RA_SPECIFY_COLS,
- )
- self.rotate.SetSelection(1)
-
- hbox1.Add(vheight_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- hbox1.Add(self.rotate, 5, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
- boxsizer.Add(hbox1, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- self.sizer.Add(
- boxsizer,
- pos=(4, 0),
- span=(1, 5),
- flag=wx.EXPAND | wx.TOP | wx.LEFT | wx.RIGHT,
- border=10,
- )
- angle = wx.StaticBox(self, label="Angle for arbitrary rotation (deg)")
- vangle_boxsizer = wx.StaticBoxSizer(angle, wx.VERTICAL)
- self.vangle = FS.FloatSpin(
- self, value="0.0", min_val=-360.0, max_val=360.0, digits=2
- )
- vangle_boxsizer.Add(self.vangle, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- video_start = wx.StaticBox(self, label="Shorten: start time (sec)")
- vstart_boxsizer = wx.StaticBoxSizer(video_start, wx.VERTICAL)
- self.vstart = wx.SpinCtrl(self, value="1", min=0, max=3600)
- vstart_boxsizer.Add(self.vstart, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- video_stop = wx.StaticBox(self, label="Shorten: stop time (sec)")
- vstop_boxsizer = wx.StaticBoxSizer(video_stop, wx.VERTICAL)
- self.vstop = wx.SpinCtrl(self, value="30", min=1, max=3600)
- vstop_boxsizer.Add(self.vstop, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- hbox2.Add(vstart_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox2.Add(vstop_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- hbox2.Add(vangle_boxsizer, 10, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
- boxsizer.Add(hbox2, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 10)
-
- self.help_button = wx.Button(self, label="Help")
- self.sizer.Add(self.help_button, pos=(5, 0), flag=wx.LEFT, border=10)
- self.help_button.Bind(wx.EVT_BUTTON, self.help_function)
-
- self.ok = wx.Button(self, label="DOWNSAMPLE")
- self.sizer.Add(self.ok, pos=(5, 2), flag=wx.LEFT, border=10)
- self.ok.Bind(wx.EVT_BUTTON, self.downsample_video)
-
- self.ok = wx.Button(self, label="SHORTEN")
- self.sizer.Add(self.ok, pos=(5, 3), flag=wx.LEFT, border=10)
- self.ok.Bind(wx.EVT_BUTTON, self.shorten_video)
-
- self.ok = wx.Button(self, label="CROP")
- self.sizer.Add(self.ok, pos=(5, 4), flag=wx.LEFT, border=10)
- self.ok.Bind(wx.EVT_BUTTON, self.crop_video)
-
- self.reset = wx.Button(self, label="Reset")
- self.sizer.Add(self.reset, pos=(6, 0), flag=wx.LEFT, border=10)
- self.reset.Bind(wx.EVT_BUTTON, self.reset_edit_videos)
-
- self.sizer.AddGrowableCol(3)
-
- self.SetSizer(self.sizer)
- self.sizer.Fit(self)
-
- def select_config(self, event):
- """
- """
- self.config = self.sel_config.GetPath()
-
- def select_videos(self, event):
- """
- Selects the videos from the directory
- """
- cwd = os.getcwd()
- dlg = wx.FileDialog(
- self, "Select video to modify", cwd, "", "*.*", wx.FD_MULTIPLE
- )
- if dlg.ShowModal() == wx.ID_OK:
- self.vids = dlg.GetPaths()
- self.filelist = self.filelist + self.vids # [0]
- self.sel_vids.SetLabel("%s Video selected" % len(self.filelist))
-
- def downsample_video(self, event):
- if self.rotate.GetStringSelection() == "Yes":
- self.rotate_val = "Yes"
-
- elif self.rotate.GetStringSelection() == "Arbitrary":
- self.rotate_val = "Arbitrary"
-
- else:
- self.rotate_val = "No"
-
- Videos = self.filelist
- if len(Videos) > 0:
- for video in Videos:
- deeplabcut.DownSampleVideo(
- video,
- width=-1,
- height=self.height.GetValue(),
- rotatecw=self.rotate_val,
- angle=self.vangle.GetValue(),
- )
- else:
- print("Please select a video first!")
-
- def shorten_video(self, event):
- def sweet_time_format(val):
- return str(datetime.timedelta(seconds=val))
-
- Videos = self.filelist
- if len(Videos) > 0:
- for video in Videos:
- deeplabcut.ShortenVideo(
- video,
- start=sweet_time_format(self.vstart.GetValue()),
- stop=sweet_time_format(self.vstop.GetValue()),
- )
-
- else:
- print("Please select a video first!")
-
- def crop_video(self, event):
- Videos = self.filelist
- if len(Videos) > 0:
- for video in Videos:
- deeplabcut.CropVideo(video, useGUI=True)
- else:
- print("Please select a video first!")
-
- def help_function(self, event):
-
- filepath = "help.txt"
- f = open(filepath, "w")
- sys.stdout = f
- fnc_name = "deeplabcut.DownSampleVideo"
- pydoc.help(fnc_name)
- f.close()
- sys.stdout = sys.__stdout__
- help_file = open("help.txt", "r+")
- help_text = help_file.read()
- wx.MessageBox(help_text, "Help", wx.OK | wx.ICON_INFORMATION)
- os.remove("help.txt")
-
- def reset_edit_videos(self, event):
- """
- Reset to default
- """
- self.config = []
- self.sel_config.SetPath("")
- self.sel_vids.SetLabel("Select videos")
- self.filelist = []
- self.rotate.SetSelection(1)
diff --git a/deeplabcut/gui/welcome.py b/deeplabcut/gui/welcome.py
deleted file mode 100644
index 15385e5d85..0000000000
--- a/deeplabcut/gui/welcome.py
+++ /dev/null
@@ -1,44 +0,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 wx
-
-
-from deeplabcut.gui import MEDIA_PATH
-
-
-class Welcome(wx.Panel):
- def __init__(self, parent, gui_size):
- h = gui_size[0]
- w = gui_size[1]
- wx.Panel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER, size=(h, w))
-
- ## design the panel
- sizer = wx.GridBagSizer(10, 7)
- # Add image of DLC
- icon = wx.StaticBitmap(
- self, bitmap=wx.Bitmap(os.path.join(MEDIA_PATH, "dlc_1-01.png"))
- )
- sizer.Add(icon, pos=(0, 0), span=(0, 8), flag=wx.EXPAND | wx.BOTTOM, border=10)
- line = wx.StaticLine(self)
- sizer.Add(line, pos=(1, 0), span=(1, 8), flag=wx.EXPAND | wx.BOTTOM, border=10)
-
- from deeplabcut import VERSION
- # if editing this text make sure you add the '\n' to get the new line. The sizer is unable to format lines correctly.
- description = f"DeepLabCutâą is an open source tool for markerless\npose estimation of user-defined body parts with deep learning.\nA. and M.W. Mathis Labs | http://www.deeplabcut.org\n \nWelcome to the DeepLabCut Project Manager GUI v{VERSION}!\nTo get started, please click on the 'Manage Project'\n tab to create or load an existing project. \n "
-
- self.proj_name = wx.StaticText(self, label=description, style=wx.ALIGN_CENTRE)
- sizer.Add(self.proj_name, pos=(2, 3), border=10)
- sizer.AddGrowableCol(2)
- self.SetSizer(sizer)
- sizer.Fit(self)
diff --git a/deeplabcut/gui/widgets.py b/deeplabcut/gui/widgets.py
index d34cc53e99..d90a1effb3 100644
--- a/deeplabcut/gui/widgets.py
+++ b/deeplabcut/gui/widgets.py
@@ -1,40 +1,60 @@
-"""
-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
-
-"""
+#
+# 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 ast
+import os
+from pathlib import Path
+from queue import Queue
+import napari
import numpy as np
-import wx
-from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg 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 Button, LassoSelector, RectangleSelector
+from PySide6 import QtCore, QtWidgets
+from PySide6.QtGui import QAction, QCursor, QStandardItem, QStandardItemModel, Qt
+from deeplabcut.utils import auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import VideoWriter
+from deeplabcut.utils.skeleton import SkeletonBuilder as BaseSkeletonBuilder
-class BasePanel(wx.Panel):
- def __init__(self, parent, config, gui_size, **kwargs):
- h = gui_size[0] / 2
- w = gui_size[1] / 3
- super(BasePanel, self).__init__(parent, -1, style=wx.SUNKEN_BORDER, size=(h, w))
+
+def launch_napari(files=None, plugin="napari-deeplabcut", stack=False):
+ viewer = napari.Viewer()
+ 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=plugin, stack=stack)
+ return viewer
+
+
+class BaseFrame(QtWidgets.QFrame):
+ def __init__(self, parent, **kwargs):
+ super().__init__(parent)
self.figure = Figure()
self.axes = self.figure.add_subplot(1, 1, 1)
- self.canvas = FigureCanvas(self, -1, self.figure)
+ self.canvas = FigureCanvas(self.figure)
self.orig_xlim = None
self.orig_ylim = None
- self.sizer = wx.BoxSizer(wx.VERTICAL)
- self.sizer.Add(self.canvas, 1, wx.LEFT | wx.TOP | wx.GROW)
- self.SetSizer(self.sizer)
- self.Fit()
+
+ layout = QtWidgets.QVBoxLayout(self)
+ 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):
@@ -42,99 +62,533 @@ def resetView(self):
self.axes.set_ylim(self.orig_ylim)
-class WidgetPanel(wx.Panel):
+class DragDropListView(QtWidgets.QListView):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent = parent
+ self.setAcceptDrops(True)
+ self.setDropIndicatorShown(True)
+ self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
+ self.model = QStandardItemModel(self)
+ self.setModel(self.model)
+ self._default_style = self.styleSheet()
+
+ @property
+ def items(self):
+ for i in range(self.model.rowCount()):
+ yield self.model.item(i)
+
+ @property
+ def state(self):
+ tests = [item.checkState() == QtCore.Qt.Checked for item in self.items]
+ n_checked = sum(tests)
+ if all(tests):
+ state = QtCore.Qt.Checked
+ elif any(tests):
+ state = QtCore.Qt.PartiallyChecked
+ else:
+ state = QtCore.Qt.Unchecked
+ return state, n_checked
+
+ def add_item(self, path: str | Path):
+ item = QStandardItem(os.fspath(Path(path).absolute()))
+ item.setCheckable(True)
+ item.setCheckState(QtCore.Qt.Checked)
+ self.model.appendRow(item)
+
+ def clear(self):
+ self.model.removeRows(0, self.model.rowCount())
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ event.accept()
+ else:
+ event.ignore()
+
+ def dropEvent(self, event):
+ for url in event.mimeData().urls():
+ path = Path(url.toLocalFile())
+ if path.is_file():
+ self.add_item(path)
+ elif path.is_dir():
+ for child in path.rglob("*"):
+ if child.is_file() and not child.name.startswith("."):
+ self.add_item(child)
+
+
+class ItemSelectionFrame(QtWidgets.QFrame):
+ def __init__(self, items, parent=None):
+ super().__init__(parent)
+ self.setFrameShape(self.Shape.StyledPanel)
+ self.setLineWidth(0)
+
+ self.select_box = QtWidgets.QCheckBox("Files")
+ self.select_box.setChecked(True)
+ self.select_box.stateChanged.connect(self.toggle_select)
+
+ self.fancy_list = DragDropListView(self)
+ self._model = self.fancy_list.model
+ self._model.rowsInserted.connect(self.check_select_box)
+ self._model.rowsRemoved.connect(self.check_select_box)
+ self._model.itemChanged.connect(self.check_select_box)
+ for item in items:
+ self.fancy_list.add_item(item)
+
+ self.layout = QtWidgets.QVBoxLayout(self)
+ self.layout.addWidget(self.select_box)
+ self.layout.addWidget(self.fancy_list)
+
+ @property
+ def selected_items(self):
+ for item in self.fancy_list.items:
+ if item.checkState() == QtCore.Qt.Checked:
+ yield item.text()
+
+ def check_select_box(self):
+ state, n_checked = self.fancy_list.state
+ if self.select_box.checkState() != state:
+ self.select_box.blockSignals(True)
+ self.select_box.setCheckState(state)
+ self.select_box.blockSignals(False)
+ string = "file"
+ if n_checked > 1:
+ string += "s"
+ self.select_box.setText(f"{n_checked} {string} selected")
+
+ def toggle_select(self, state):
+ state = QtCore.Qt.CheckState(state)
+ if state == QtCore.Qt.PartiallyChecked:
+ return
+ for item in self.fancy_list.items:
+ if item.checkState() != state:
+ item.setCheckState(state)
+
+
+class NavigationToolbar(NavigationToolbar2QT):
+ 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().release_zoom(event)
+ self.zoom()
+
+
+class StreamWriter:
+ def __init__(self):
+ self.queue = Queue()
+
+ def write(self, text):
+ if text != "\n":
+ self.queue.put(text)
+
+ def flush(self):
+ pass
+
+ def isatty(self):
+ return False
+
+
+class StreamReceiver(QtCore.QThread):
+ new_text = QtCore.Signal(str)
+
+ def __init__(self, queue):
+ super().__init__()
+ self.queue = queue
+
+ def run(self):
+ while True:
+ text = self.queue.get()
+ self.new_text.emit(text)
+
+
+class ClickableLabel(QtWidgets.QLabel):
+ signal = QtCore.Signal()
+
+ def __init__(self, text="", color="turquoise", parent=None):
+ super().__init__(text, parent)
+ self._default_style = self.styleSheet()
+ self.color = color
+ self.setStyleSheet(f"color: {self.color}")
+
+ def mouseReleaseEvent(self, event):
+ self.signal.emit()
+
+ def enterEvent(self, event):
+ self.setCursor(QCursor(QtCore.Qt.PointingHandCursor))
+ self.setStyleSheet(f"color: {self.color}")
+
+ def leaveEvent(self, event):
+ self.unsetCursor()
+ self.setStyleSheet(self._default_style)
+
+
+class ItemCreator(QtWidgets.QDialog):
+ created = QtCore.Signal(QtWidgets.QTreeWidgetItem)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.parent = parent
+ vbox = QtWidgets.QVBoxLayout(self)
+ self.field1 = QtWidgets.QLineEdit(self)
+ self.field1.setPlaceholderText("Parameter")
+ self.field2 = QtWidgets.QLineEdit(self)
+ self.field2.setPlaceholderText("Value")
+ create_button = QtWidgets.QPushButton(self)
+ create_button.setText("Create")
+ create_button.clicked.connect(self.form_item)
+ vbox.addWidget(self.field1)
+ vbox.addWidget(self.field2)
+ vbox.addWidget(create_button)
+ self.show()
+
+ def form_item(self):
+ key = self.field1.text()
+ value = self.field2.text()
+ item = QtWidgets.QTreeWidgetItem([key, value])
+ item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable)
+ self.created.emit(item)
+ self.accept()
+
+
+# TODO Insert new video
+# TODO Insert skeleton link
+class ContextMenu(QtWidgets.QMenu):
def __init__(self, parent):
- wx.Panel.__init__(self, parent, -1, style=wx.SUNKEN_BORDER)
-
-
-class BaseFrame(wx.Frame):
- """Contains the main GUI and button boxes"""
-
- def __init__(self, frame_title="", parent=None, imtypes=None):
- # Setting the GUI size and panels design
- displays = (
- wx.Display(i) for i in range(wx.Display.GetCount())
- ) # Gets the number of displays
- screenSizes = [
- display.GetGeometry().GetSize() for display in displays
- ] # Gets the size of each display
- index = 0 # For display 1.
- screenWidth = screenSizes[index][0]
- screenHeight = screenSizes[index][1]
- self.gui_size = (screenWidth * 0.8, screenHeight * 0.75)
- self.imtypes = imtypes # imagetypes to look for in folder e.g. *.png
-
- wx.Frame.__init__(
- self,
- parent,
- id=wx.ID_ANY,
- title=frame_title,
- size=wx.Size(self.gui_size),
- pos=wx.DefaultPosition,
- style=wx.RESIZE_BORDER | wx.DEFAULT_FRAME_STYLE | wx.TAB_TRAVERSAL,
- )
+ super().__init__(parent)
+ self.parent = parent
+ self.current_item = parent.tree.currentItem()
+ insert = QAction("Insert", self)
+ insert.triggered.connect(self.create_item)
+ delete = QAction("Delete", self)
+ delete.triggered.connect(parent.remove_items)
+ self.addAction(insert)
+ self.addAction(delete)
+ if self.current_item.text(0) == "project_path":
+ fix_path = QAction("Fix Path", self)
+ fix_path.triggered.connect(self.fix_path)
+ self.addAction(fix_path)
+
+ def create_item(self):
+ creator = ItemCreator(self)
+ creator.created.connect(self.parent.insert)
+
+ def fix_path(self):
+ self.current_item.setText(1, os.fspath(Path(self.parent.filename).parent))
+
+
+class DictViewer(QtWidgets.QWidget):
+ def __init__(self, cfg, filename="", parent=None):
+ super().__init__(parent)
+ self.cfg = cfg
+ self.filename = filename
+ self.parent = parent
+ self.tree = QtWidgets.QTreeWidget()
+ self.tree.setHeaderLabels(["Parameter", "Value"])
+ self.tree.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
+ self.tree.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ self.tree.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems)
+ self.tree.setAlternatingRowColors(True)
+ self.tree.setSortingEnabled(False)
+ self.tree.setHeaderHidden(False)
+ self.tree.itemChanged.connect(self.edit_value)
+ self.tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.tree.customContextMenuRequested.connect(self.pop_context_menu)
- self.statusbar = self.CreateStatusBar()
- self.statusbar.SetStatusText("")
+ self.root = self.tree.invisibleRootItem()
+ self.tree.addTopLevelItem(self.root)
+ self.populate_tree(cfg, self.root)
- # This sets the minimum size of the GUI. It can scale now!
- self.SetSizeHints(wx.Size(800, 600))
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(self.tree)
+ layout2 = QtWidgets.QVBoxLayout()
+ layout2.addWidget(QtWidgets.QLabel(filename))
+ layout2.addWidget(self.tree)
+ self.setLayout(layout2)
+
+ def pop_context_menu(self, point):
+ index = self.tree.indexAt(point)
+ if not index.isValid():
+ return
+ menu = ContextMenu(self)
+ menu.exec_(self.tree.mapToGlobal(point))
+
+ def get_position_in_parent(self, item):
+ parent = item.parent() or self.root
+ index = parent.indexOfChild(item)
+ return index, parent
+
+ def insert(self, item):
+ current = self.tree.selectedItems()[0]
+ ind, parent = self.get_position_in_parent(current)
+ parent.insertChild(ind + 1, item)
+
+ value = self.cast_to_right_type(item.text(1))
+ if parent is self.root:
+ self.set_value(self.cfg, [item.text(0)], value)
+ else:
+ keys, _ = self.walk_recursively_to_root(current)
+ self.set_value(self.cfg, keys, value, ind + 1)
+
+ def remove(self, item):
+ ind, parent = self.get_position_in_parent(item)
+ keys, value = self.walk_recursively_to_root(item)
+ if item.parent() and item.childCount(): # Handle nested dict or list
+ keys = [keys[0], value]
+ success = self.remove_key(self.cfg, keys, ind)
+ if success:
+ parent.removeChild(item)
+
+ def remove_items(self):
+ for item in self.tree.selectedItems():
+ self.remove(item)
@staticmethod
- def calc_distance(x1, y1, x2, y2):
- return np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
-
- def homeButton(self, event):
- self.image_panel.resetView()
- self.figure.canvas.draw()
- self.updateZoomPan(self)
- self.zoom.SetValue(False)
- self.pan.SetValue(False)
- self.statusbar.SetStatusText("")
-
- def updateZoomPan(self, *args):
- # Checks if zoom/pan button is ON
- if self.pan.GetValue():
- self.toolbar.pan()
- self.pan.SetValue(False)
- if self.zoom.GetValue():
- self.toolbar.zoom()
- self.zoom.SetValue(False)
-
- def panButton(self, event):
- if self.pan.GetValue():
- self.toolbar.pan()
- self.statusbar.SetStatusText("Pan On")
- self.zoom.SetValue(False)
+ def cast_to_right_type(val):
+ try:
+ val = ast.literal_eval(val)
+ except ValueError:
+ # Leave untouched when it is already a string
+ pass
+ except SyntaxError:
+ # Slashes also raise the error, but no need to print anything since it is then likely to be a path
+ if "/" not in val and "\\" not in val:
+ print("Consider removing leading zeros or spaces in the string.")
+ return val
+
+ @staticmethod
+ def walk_recursively_to_root(item):
+ vals = []
+ # Walk backwards across parents to get all keys
+ while item is not None:
+ for i in range(item.columnCount() - 1, -1, -1):
+ vals.append(item.text(i))
+ item = item.parent()
+ *keys, value = vals[::-1]
+ return keys, value
+
+ @staticmethod
+ def get_nested_key(cfg, keys):
+ temp = cfg
+ for key in keys[:-1]:
+ try:
+ temp = temp.setdefault(key, {})
+ except AttributeError: # Handle nested lists
+ temp = temp[int(key)]
+ return temp
+
+ 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
+ value = self.cast_to_right_type(value)
+ self.set_value(self.cfg, keys, value)
+
+ def set_value(self, cfg, keys, value, ind=None):
+ temp = self.get_nested_key(cfg, keys)
+ try: # Work for a dict
+ temp[keys[-1]] = value
+ except TypeError: # Needed to index a list
+ if ind is None: # Edit the list in place
+ temp[self.tree.currentIndex().row()] = value
+ else:
+ temp.insert(ind, value)
+
+ def remove_key(self, cfg, keys, ind=None):
+ if not len(keys): # Avoid deleting a parent list or dict
+ return
+ temp = self.get_nested_key(cfg, keys)
+ try:
+ temp.pop(keys[-1])
+ except TypeError:
+ if ind is None:
+ ind = self.tree.currentIndex().row()
+ temp.pop(ind)
+ return True
+
+ def populate_tree(self, data, tree_widget):
+ if isinstance(data, dict):
+ for key, val in data.items():
+ self.add_row(key, val, tree_widget)
+ elif isinstance(data, list):
+ for i, val in enumerate(data):
+ self.add_row(str(i), val, tree_widget)
+ else:
+ print("This should never be reached!")
+
+ def add_row(self, key, val, tree_widget):
+ if isinstance(val, dict) or isinstance(val, list):
+ item = QtWidgets.QTreeWidgetItem([key])
+ self.populate_tree(val, item)
+ else:
+ item = QtWidgets.QTreeWidgetItem([key, str(val)])
+ item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable)
+ tree_widget.addChild(item)
+
+
+class ConfigEditor(QtWidgets.QDialog):
+ def __init__(self, config_path: str | Path, parent=None):
+ super().__init__(parent)
+ self.config_path = Path(config_path).absolute()
+ config_name = self.config_path.name
+ if config_name == "config.yaml":
+ self.read_func = auxiliaryfunctions.read_plainconfig
+ self.write_func = auxiliaryfunctions.write_config
else:
- self.toolbar.pan()
- self.statusbar.SetStatusText("Pan Off")
-
- def zoomButton(self, event):
- if self.zoom.GetValue():
- # Save pre-zoom xlim and ylim values
- self.prezoom_xlim = self.axes.get_xlim()
- self.prezoom_ylim = self.axes.get_ylim()
- self.toolbar.zoom()
- self.statusbar.SetStatusText("Zoom On")
- self.pan.SetValue(False)
+ self.read_func = auxiliaryfunctions.read_plainconfig
+ self.write_func = auxiliaryfunctions.write_plainconfig
+ self.cfg = self.read_func(self.config_path)
+ self.parent = parent
+ self.setWindowTitle("Configuration Editor")
+ if parent is not None:
+ self.setMinimumWidth(parent.screen_width // 2)
+ self.setMinimumHeight(parent.screen_height // 2)
+ self.viewer = DictViewer(self.cfg, self.config_path, self)
+
+ self.save_button = QtWidgets.QPushButton("Save", self)
+ self.save_button.setDefault(True)
+ self.save_button.clicked.connect(self.accept)
+ self.cancel_button = QtWidgets.QPushButton("Cancel", self)
+ self.cancel_button.clicked.connect(self.close)
+
+ vbox = QtWidgets.QVBoxLayout(self)
+ vbox.addWidget(self.viewer)
+ hbox = QtWidgets.QHBoxLayout()
+ hbox.addWidget(self.save_button)
+ hbox.addWidget(self.cancel_button)
+ vbox.addLayout(hbox)
+
+ def keyPressEvent(self, e):
+ if e.key() == QtCore.Qt.Key_Escape:
+ self.close()
+
+ def accept(self):
+ self.write_func(self.config_path, self.cfg)
+ super().accept()
+
+
+class FrameCropper(QtWidgets.QDialog):
+ def __init__(self, video, parent=None):
+ super().__init__(parent)
+ self.clip = VideoWriter(video)
+
+ self.fig = Figure()
+ self.ax = self.fig.add_subplot(111)
+ self.ax_help = self.fig.add_axes([0.9, 0.2, 0.1, 0.1])
+ self.ax_save = self.fig.add_axes([0.9, 0.1, 0.1, 0.1])
+ self.crop_button = Button(self.ax_save, "Crop")
+ self.crop_button.on_clicked(self.validate_crop)
+ self.help_button = Button(self.ax_help, "Help")
+ self.help_button.on_clicked(self.display_help)
+
+ self.canvas = FigureCanvas(self.fig)
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(self.canvas)
+ self.setLayout(layout)
+
+ self.bbox = [0, 0, 0, 0]
+
+ def draw_bbox(self):
+ frame = None
+ # Read the video until a frame is successfully read
+ while frame is None:
+ frame = self.clip.read_frame()
+ self.bbox[-2:] = frame.shape[1], frame.shape[0]
+ self.ax.imshow(frame[:, :, ::-1])
+
+ self.rs = RectangleSelector(
+ self.ax,
+ self.line_select_callback,
+ minspanx=5,
+ minspany=5,
+ interactive=True,
+ spancoords="pixels",
+ )
+ self.show()
+ self.fig.canvas.start_event_loop(timeout=-1)
+ return self.bbox
+
+ def line_select_callback(self, eclick, erelease):
+ self.bbox[:2] = int(eclick.xdata), int(eclick.ydata) # x1, y1
+ self.bbox[2:] = int(erelease.xdata), int(erelease.ydata) # x2, y2
+
+ def validate_crop(self, *args):
+ self.fig.canvas.stop_event_loop()
+ self.close()
+
+ 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"
+ )
+
+
+class SkeletonBuilder(BaseSkeletonBuilder, QtWidgets.QDialog):
+ def __init__(self, config_path, *, parent=None):
+ QtWidgets.QDialog.__init__(self, parent)
+ self._parent = parent
+ self.setWindowTitle("Skeleton Builder")
+ self.setAttribute(Qt.WA_DeleteOnClose, True)
+ BaseSkeletonBuilder.__init__(self, config_path)
+
+ def build_ui(self):
+ self.fig = Figure()
+ self.canvas = FigureCanvas(self.fig)
+
+ self._ax = self.fig.add_subplot(111)
+ self._ax.axis("off")
+
+ ax_clear = self.fig.add_axes(self.clear_button_axes)
+ ax_export = self.fig.add_axes(self.export_button_axes)
+
+ self.clear_button = Button(ax_clear, self.clear_button_text)
+ self.clear_button.on_clicked(self.clear)
+
+ self.export_button = Button(ax_export, self.export_button_text)
+ self.export_button.on_clicked(self.export)
+
+ self.fig.canvas.mpl_connect("pick_event", self.on_pick)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(self.canvas)
+ self.setLayout(layout)
+
+ lo = np.nanmin(self.xy, axis=0)
+ hi = np.nanmax(self.xy, axis=0)
+ center = (hi + lo) / 2
+ w, h = hi - lo
+ w *= self.ampl
+ h *= self.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)
+ self._ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2)
+ self._ax.add_collection(self.lines)
+ self._ax.invert_yaxis()
+
+ self.lasso = LassoSelector(self._ax, onselect=self.on_select)
+ self.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
+
+ def export(self, *args):
+ success = super().export(*args)
+ if success:
+ self._parent.logger.info("Skeleton exported successfully.")
+ self._parent.status_bar.showMessage("Skeleton exported successfully.", 5000)
else:
- self.toolbar.zoom()
- self.statusbar.SetStatusText("Zoom Off")
-
- def onZoom(self, ax):
- # See if axis limits have actually changed
- curr_xlim = self.axes.get_xlim()
- curr_ylim = self.axes.get_ylim()
- if self.zoom.GetValue() and not (
- self.prezoom_xlim[0] == curr_xlim[0]
- and self.prezoom_xlim[1] == curr_xlim[1]
- and self.prezoom_ylim[0] == curr_ylim[0]
- and self.prezoom_ylim[1] == curr_ylim[1]
- ):
- self.updateZoomPan()
- self.statusbar.SetStatusText("Zoom Off")
-
- def lockChecked(self, event):
- self.cb = event.GetEventObject()
- self.view_locked = self.cb.GetValue()
+ self._parent.logger.warning("Failed to export skeleton.")
+ self._parent.status_bar.showMessage("Failed to export skeleton.", 5000)
diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py
new file mode 100644
index 0000000000..e803ca13ae
--- /dev/null
+++ b/deeplabcut/gui/window.py
@@ -0,0 +1,1396 @@
+#
+# 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 logging
+import sys
+import warnings
+from enum import Enum, auto
+from functools import cached_property
+from html import escape
+from importlib.resources import files
+from importlib.util import find_spec
+from pathlib import Path
+
+import qdarkstyle
+from napari_deeplabcut import __version__ as NAPARI_DLC_VERSION
+from pydantic import ValidationError
+from PySide6 import QtCore, QtGui, QtWidgets
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction, QDesktopServices, QPixmap
+from PySide6.QtWidgets import (
+ QComboBox,
+ QLabel,
+ QMainWindow,
+ QMenu,
+ QMessageBox,
+ QSizePolicy,
+ QWidget,
+)
+
+import deeplabcut
+from deeplabcut import __version__ as DLC_VERSION
+from deeplabcut import auxiliaryfunctions
+from deeplabcut.core.config import ProjectConfig
+from deeplabcut.core.debug import install_debug_recorder
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.metadata import get_shuffle_engine
+from deeplabcut.gui import components
+from deeplabcut.gui.config_file_monitor import ConfigFileMonitor
+from deeplabcut.gui.dialogs import create_generate_debug_log_action
+from deeplabcut.gui.dialogs.config_errors import (
+ CONFIG_LOAD_ERRORS,
+ ConfigErrorReport,
+ format_config_error,
+)
+from deeplabcut.gui.gui_assets import icon_from_resource, pixmap_from_resource
+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, _build_update_commands, absolute_path
+from deeplabcut.gui.widgets import (
+ StreamReceiver,
+ StreamWriter,
+)
+
+warnings.filterwarnings(
+ "ignore",
+ message=r".*shibokensupport/signature/parser.py:269: RuntimeWarning: pyside_type_init:_resolve_value.*",
+ category=RuntimeWarning,
+)
+
+WINDOW_RESIZE_FACTOR = 0.8
+DEFAULT_MINIMUM_WIDTH, DEFAULT_MINIMUM_HEIGHT = 800, 600
+
+
+def _is_pose_config_validation_error(error: ValidationError) -> bool:
+ """Return True if *error* was raised by a PoseConfig model."""
+ try:
+ from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig as _PoseConfig
+ except (ImportError, RuntimeError):
+ return False
+
+ return getattr(error, "model", None) is _PoseConfig
+
+
+class ConfigErrorAction(Enum):
+ """Recovery action selected after a configuration error."""
+
+ CANCEL = auto()
+ RETRY = auto()
+
+
+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().__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("deeplabcut.gui")
+ self.console_logger = logging.getLogger("deeplabcut.gui.console")
+
+ self._config_monitor = None
+ self.config_path: Path | None = None
+ self.loaded = False
+
+ self.shuffle_value = 1
+ self.trainingset_index = 0
+ self.videotype = "mp4"
+ self.files: set[Path] = set()
+
+ self._engine = Engine.PYTORCH
+
+ # Update checks
+ self._update_process = None
+ self._update_process_output = []
+ self._update_commands = []
+ self._update_attempt_outputs = []
+ self._current_update_backend = None
+
+ 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()
+ self.window_set()
+ self.default_set()
+
+ names = ["new_project.png", "open.png", "help.png"]
+ self.create_actions(names)
+ self.create_menu_bar()
+ self.load_settings()
+ self._toolbar = None
+ self.create_toolbar()
+
+ # Thread-safe Stdout redirector
+ self.writer = StreamWriter()
+ sys.stdout = self.writer
+ 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()
+ self.status_bar.addPermanentWidget(self._progress_bar)
+
+ self._config_monitor = ConfigFileMonitor(
+ status_bar=self.status_bar,
+ on_reload=self.reload_project_config,
+ parent=self,
+ )
+
+ 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):
+ if self._toolbar is None:
+ self._toolbar = self.addToolBar("File")
+ return self._toolbar
+
+ @cached_property
+ def settings(self):
+ return QtCore.QSettings()
+
+ def load_settings(self):
+ filenames = self.settings.value("recent_files") or []
+ for filename in filenames:
+ self.add_recent_filename(filename)
+
+ def save_settings(self):
+ recent_files = []
+ for action in self.recentfiles_menu.actions()[::-1]:
+ recent_files.append(action.text())
+ self.settings.setValue("recent_files", recent_files)
+
+ def add_recent_filename(self, filename):
+ actions = self.recentfiles_menu.actions()
+ filenames = [action.text() for action in actions]
+ if filename in filenames:
+ return
+ action = QAction(filename, self)
+ before_action = actions[0] if actions else None
+ self.recentfiles_menu.insertAction(before_action, action)
+
+ @property
+ def config_path(self) -> Path | None:
+ return self._config_path
+
+ @config_path.setter
+ def config_path(self, value: Path | None) -> None:
+ self._config_path = value
+ self.invalidate_config_cache()
+ if self._config_monitor is not None:
+ self._config_monitor.set_path(str(value) if value is not None else None)
+
+ @property
+ def cfg(self) -> ProjectConfig | None:
+ """The loaded project configuration, read from disk and cached.
+
+ The cache is invalidated whenever ``config_path`` is assigned; retry
+ loops reset it explicitly to force a fresh read from disk.
+ """
+ if self.config_path is None:
+ return None
+ if self._cfg is None:
+ self._cfg = auxiliaryfunctions.read_config(self.config_path)
+ return self._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)
+ icon = icon_from_resource("logo.png")
+ msg.setWindowIcon(icon)
+ msg.setStandardButtons(QtWidgets.QMessageBox.Ok)
+ msg.exec_()
+
+ self._engine = e
+ self.engine_change.emit(e)
+
+ @property
+ def project_folder(self) -> Path:
+ cfg = self.cfg
+ if cfg is None:
+ return Path("~/Desktop").expanduser().absolute()
+ return cfg.project_path
+
+ @property
+ def is_multianimal(self) -> bool:
+ cfg = self.cfg
+ return cfg is not None and cfg.multianimalproject
+
+ @property
+ def all_bodyparts(self) -> list:
+ cfg = self.cfg
+ if cfg is None:
+ return []
+ if cfg.multianimalproject:
+ return cfg.multianimalbodyparts
+ return cfg.bodyparts
+
+ @property
+ def all_individuals(self) -> list:
+ cfg = self.cfg
+ if cfg is not None and cfg.multianimalproject:
+ return cfg.individuals
+ return [""]
+
+ @property
+ def _selected_model_train_folder(self) -> tuple[Path, Engine]:
+ """Resolve model paths from the validated project-config snapshot."""
+ cfg = self.cfg
+ if cfg is None:
+ raise RuntimeError("No project configuration is loaded.")
+
+ shuffle = int(self.shuffle_value)
+ trainingset_index = int(self.trainingset_index)
+ engine = get_shuffle_engine(
+ cfg,
+ trainingsetindex=trainingset_index,
+ shuffle=shuffle,
+ )
+ model_folder = auxiliaryfunctions.get_model_folder(
+ cfg.TrainingFraction[trainingset_index],
+ shuffle,
+ cfg,
+ engine=engine,
+ )
+ return Path(cfg.project_path) / model_folder / "train", engine
+
+ @property
+ def pose_cfg_path(self) -> Path:
+ try:
+ train_folder, engine = self._selected_model_train_folder
+ filename = "pytorch_config.yaml" if engine == Engine.PYTORCH else "pose_cfg.yaml"
+ return train_folder / filename
+ except FileNotFoundError:
+ return (Path(deeplabcut.__file__).parent / "pose_cfg.yaml").absolute()
+
+ @property
+ def models_folder(self) -> Path:
+ try:
+ train_folder, _ = self._selected_model_train_folder
+ return train_folder
+ except FileNotFoundError:
+ return self.project_folder
+
+ @property
+ def inference_cfg_path(self) -> Path:
+ cfg = self.cfg
+ if cfg is None:
+ raise RuntimeError("No project configuration is loaded.")
+ return (
+ cfg.project_path
+ / auxiliaryfunctions.get_model_folder(
+ cfg.TrainingFraction[int(self.trainingset_index)],
+ int(self.shuffle_value),
+ cfg,
+ )
+ / "test"
+ / "inference_cfg.yaml"
+ )
+
+ def update_cfg(self, text):
+ self.config_path = Path(text).absolute() if text else None
+ self.unsupervised_id_tracking.setEnabled(self.is_transreid_available())
+
+ 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
+ def video_type(self):
+ return self.videotype
+
+ @video_type.setter
+ def video_type(self, ext):
+ self.videotype = ext
+ self.video_type_.emit(ext)
+ self.logger.debug(f"Video type set to {self.video_type}")
+
+ @property
+ def video_files(self):
+ return self.files
+
+ def _disconnect_update_process(self, process):
+ if process is None:
+ return
+
+ for signal, slot in (
+ (process.finished, self._on_update_process_finished),
+ (process.errorOccurred, self._on_update_process_error),
+ (process.readyRead, self._drain_update_process_output),
+ ):
+ try:
+ signal.disconnect(slot)
+ except (TypeError, RuntimeError):
+ pass
+
+ 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 _start_next_update_command(self):
+ """Start the next update backend. Return True if one was started."""
+ if not self._update_commands:
+ return False
+
+ backend_name, program, arguments = self._update_commands.pop(0)
+ self._current_update_backend = backend_name
+
+ self.status_bar.showMessage(f"Installing updates with {backend_name}...")
+
+ self._update_process = QtCore.QProcess(self)
+ self._update_process.setProgram(program)
+ self._update_process.setArguments(arguments)
+
+ 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()
+
+ self.logger.info("Starting update command: %s %s", program, " ".join(arguments))
+ return True
+
+ 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_attempt_outputs = []
+ self._update_commands = _build_update_commands(packages)
+ self._current_update_backend = None
+
+ if not self._start_next_update_command():
+ self._cleanup_update_process()
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Update failed",
+ "No update commands were generated.",
+ )
+
+ def _drain_update_process_output(self, process=None):
+
+ if process is None:
+ sender = self.sender()
+ process = sender if sender is not None else self._update_process
+
+ if process is None or process is not self._update_process:
+ return
+
+ data = bytes(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._disconnect_update_process(self._update_process)
+ self._update_process.deleteLater()
+ self._update_process = None
+
+ self._update_process_output = []
+ self._update_commands = []
+ self._update_attempt_outputs = []
+ self._current_update_backend = None
+
+ self._progress_bar.hide()
+ self.status_bar.showMessage("www.deeplabcut.org")
+
+ def _on_update_process_error(self, error):
+
+ process = self.sender()
+ if process is not self._update_process:
+ return
+
+ if self._closing:
+ self._cleanup_update_process()
+ return
+
+ backend = self._current_update_backend or "installer"
+
+ error_strings = {
+ QtCore.QProcess.FailedToStart: f"The {backend} update process failed to start.",
+ QtCore.QProcess.Crashed: f"The {backend} update process crashed unexpectedly.",
+ QtCore.QProcess.Timedout: f"The {backend} update process timed out.",
+ QtCore.QProcess.WriteError: f"Could not write to the {backend} update process.",
+ QtCore.QProcess.ReadError: f"Could not read from the {backend} update process.",
+ QtCore.QProcess.UnknownError: f"An unknown {backend} update error occurred.",
+ }
+
+ if process is not None:
+ self._drain_update_process_output(process)
+
+ output = "".join(self._update_process_output).strip()
+ message = error_strings.get(error, f"An unknown {backend} update error occurred.")
+ failed_output = f"{message}\n\n{output}" if output else message
+ self.logger.warning(failed_output)
+ self._update_attempt_outputs.append(failed_output)
+
+ self._disconnect_update_process(process)
+ process.deleteLater()
+ self._update_process = None
+ self._update_process_output = []
+
+ if self._start_next_update_command():
+ return
+
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Update failed",
+ "No update backend completed successfully.\n\n" + "\n\n---\n\n".join(self._update_attempt_outputs),
+ )
+ self._cleanup_update_process()
+
+ def _on_update_process_finished(self, exit_code, exit_status):
+ process = self.sender()
+ if process is None or process is not self._update_process:
+ return
+
+ if self._closing:
+ self._cleanup_update_process()
+ return
+
+ if process is None:
+ return
+
+ self._drain_update_process_output(process)
+
+ output = "".join(self._update_process_output).strip()
+ backend = self._current_update_backend or "installer"
+
+ 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)
+
+ self._cleanup_update_process()
+ return
+
+ failed_output = (
+ f"{backend} failed with exit code {exit_code}.\n\n{output or 'No additional output was captured.'}"
+ )
+ self._update_attempt_outputs.append(failed_output)
+ self.logger.warning(failed_output)
+
+ self._disconnect_update_process(process)
+ process.deleteLater()
+ self._update_process = None
+ self._update_process_output = []
+
+ if self._start_next_update_command():
+ return
+
+ QtWidgets.QMessageBox.warning(
+ self,
+ "Update failed",
+ "No update backend completed successfully.\n\n" + "\n\n---\n\n".join(self._update_attempt_outputs),
+ )
+
+ 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 = {Path(video).absolute() for video in 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.debug("All video files have been cleared.")
+
+ def window_set(self):
+
+ self.setWindowTitle("DeepLabCut")
+
+ palette = QtGui.QPalette()
+ palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#ffffff"))
+ self.setPalette(palette)
+
+ self.setWindowIcon(icon_from_resource("logo.png"))
+
+ # 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) # prevents proper Maximize
+ 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")
+
+ def _generate_welcome_page(self):
+ self.layout = QtWidgets.QVBoxLayout()
+ self.layout.setAlignment(Qt.AlignCenter | Qt.AlignTop)
+ self.layout.setSpacing(30)
+
+ title = components._create_label_widget(
+ f"Welcome to the DeepLabCut Project Manager GUI {DLC_VERSION}!",
+ "font:bold; font-size:18px;",
+ margins=(0, 30, 0, 0),
+ )
+ title.setAlignment(Qt.AlignCenter)
+ self.layout.addWidget(title)
+
+ image_widget = QtWidgets.QLabel(self)
+ image_widget.setAlignment(Qt.AlignCenter)
+ image_widget.setContentsMargins(0, 0, 0, 0)
+ pixmap = pixmap_from_resource("logo_transparent.png")
+ 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.\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;",
+ margins=(0, 0, 0, 0),
+ )
+ label.setMinimumWidth(400)
+ label.setWordWrap(True)
+ label.setAlignment(Qt.AlignCenter)
+ self.layout.addWidget(label)
+
+ self.layout_buttons = QtWidgets.QHBoxLayout()
+ self.layout_buttons.setAlignment(Qt.AlignCenter | Qt.AlignCenter)
+ self.create_project_button = QtWidgets.QPushButton("Create New Project")
+ self.create_project_button.setFixedWidth(200)
+ self.create_project_button.clicked.connect(self._create_project)
+
+ self.load_project_button = QtWidgets.QPushButton("Load Project")
+ self.load_project_button.setFixedWidth(200)
+ self.load_project_button.clicked.connect(self._open_project)
+
+ self.run_superanimal_button = QtWidgets.QPushButton("Model Zoo")
+ self.run_superanimal_button.setFixedWidth(200)
+ self.run_superanimal_button.clicked.connect(self._goto_superanimal)
+
+ self.layout_buttons.addWidget(self.create_project_button)
+ self.layout_buttons.addWidget(self.load_project_button)
+ self.layout_buttons.addWidget(self.run_superanimal_button)
+
+ self.layout.addLayout(self.layout_buttons)
+
+ 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 = ""
+ self.proj_default = ""
+ self.exp_default = ""
+ self.loc_default = Path.home()
+
+ def create_actions(self, names):
+ # Creating action using the first constructor
+ self.newAction = QAction(self)
+ self.newAction.setText("&New Project...")
+
+ self.newAction.setIcon(icon_from_resource("icons", names[0]))
+ self.newAction.setShortcut("Ctrl+N")
+ self.newAction.setStatusTip("Create a new project...")
+
+ self.newAction.triggered.connect(self._create_project)
+
+ # Creating actions using the second constructor
+ self.openAction = QAction("&Open...", self)
+ self.openAction.setIcon(icon_from_resource("icons", names[1]))
+ self.openAction.setShortcut("Ctrl+O")
+ self.openAction.setStatusTip("Open a project...")
+ self.openAction.triggered.connect(self._open_project)
+
+ self.saveAction = QAction("&Save", self)
+ self.exitAction = QAction("&Exit", self)
+
+ self.lightmodeAction = QAction("&Light theme", self)
+ self.lightmodeAction.triggered.connect(self.lightmode)
+ self.darkmodeAction = QAction("&Dark theme", self)
+ self.darkmodeAction.triggered.connect(self.darkmode)
+
+ self.helpAction = QAction("&Help", self)
+ self.helpAction.setIcon(icon_from_resource("icons", names[2]))
+ self.helpAction.setStatusTip("Ask for help...")
+ self.helpAction.triggered.connect(self._ask_for_help)
+
+ self.aboutAction = QAction("&Learn DLC", self)
+ self.aboutAction.triggered.connect(self._learn_dlc)
+
+ self.check_updates = QAction("&Check for Updates...", self)
+ 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()
+
+ # File menu
+ self.file_menu = QMenu("&File", self)
+ menu_bar.addMenu(self.file_menu)
+
+ self.file_menu.addAction(self.newAction)
+ 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(self._open_recent_project)
+ self.file_menu.addAction(self.saveAction)
+ self.file_menu.addAction(self.exitAction)
+
+ # View menu
+ view_menu = QMenu("&View", self)
+ mode = view_menu.addMenu("Appearance")
+ menu_bar.addMenu(view_menu)
+ mode.addAction(self.lightmodeAction)
+ mode.addAction(self.darkmodeAction)
+
+ # Help menu
+ help_menu = QMenu("&Help", self)
+ menu_bar.addMenu(help_menu)
+ help_menu.addAction(self.helpAction)
+ 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)
+ self.toolbar.removeAction(self.helpAction)
+
+ def _update_project_state(
+ self,
+ config: str | Path,
+ loaded: bool,
+ ) -> bool:
+ """Select a project and build its UI from the current config."""
+ self.config_path = absolute_path(config)
+ self.loaded = False
+
+ if not loaded:
+ return True
+
+ # Do not keep an old project's controls active while self.config_path
+ # points to a newly selected project.
+ self._generate_welcome_page()
+
+ if not self._build_project_ui_from_current_config():
+ return False
+
+ self.loaded = True
+ self._config_monitor.mark_current()
+ self.add_recent_filename(str(self.config_path))
+ return True
+
+ def reload_project_config(self) -> bool:
+ """Reload the active project configuration and rebuild its UI."""
+ if self.config_path is None:
+ return False
+ return self._update_project_state(self.config_path, loaded=True)
+
+ def invalidate_config_cache(self) -> None:
+ """Drop the cached project configuration so the next ``self.cfg``
+ access performs a fresh read-and-validate from disk."""
+ self._cfg = None
+
+ def _ask_for_help(self):
+ dlg = QMessageBox(self)
+ dlg.setWindowTitle("Ask for help")
+ 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 !"
+ )
+ _ = dlg.exec()
+
+ def _create_project(self):
+ dlg = ProjectCreator(self)
+ dlg.show()
+
+ def _open_config_with_system_application(
+ self,
+ config_path: str | Path | None = None,
+ ) -> bool:
+ """Open a config (default: the project config) with the OS default app."""
+ path = Path(config_path) if config_path is not None else self.config_path
+ if path is None:
+ return False
+
+ if not path.is_file():
+ QMessageBox.warning(
+ self,
+ "Configuration not found",
+ (f"The project configuration no longer exists:\n\n{path}"),
+ )
+ return False
+
+ config_url = QtCore.QUrl.fromLocalFile(str(path))
+ opened = QDesktopServices.openUrl(config_url)
+
+ if not opened:
+ self.logger.warning(
+ "The operating system could not open configuration %s",
+ path,
+ )
+
+ return opened
+
+ def _show_config_error(
+ self,
+ report: ConfigErrorReport,
+ ) -> ConfigErrorAction:
+ """Show a configuration error and offer external editing and reload."""
+ message = QMessageBox(self)
+ message.setIcon(QMessageBox.Critical)
+ message.setWindowTitle(report.title)
+ message.setText(report.summary)
+ message.setInformativeText(report.details)
+ message.setDetailedText(report.technical_details)
+
+ open_button = message.addButton(
+ "Open configuration",
+ QMessageBox.ActionRole,
+ )
+ reload_button = message.addButton(
+ "Reload",
+ QMessageBox.AcceptRole,
+ )
+ cancel_button = message.addButton(
+ QMessageBox.Cancel,
+ )
+
+ message.setDefaultButton(reload_button)
+
+ while True:
+ message.exec()
+ clicked_button = message.clickedButton()
+
+ # close -> cancel
+ if clicked_button is None:
+ return ConfigErrorAction.CANCEL
+
+ if clicked_button is reload_button:
+ return ConfigErrorAction.RETRY
+
+ if clicked_button is cancel_button:
+ return ConfigErrorAction.CANCEL
+
+ if clicked_button is open_button:
+ if self._open_config_with_system_application():
+ message.setTextFormat(Qt.RichText)
+ message.setInformativeText(
+ f"{escape(report.details).replace(chr(10), ' ')} "
+ "The configuration was opened in your default application. "
+ "Save your changes, then click Reload. "
+ )
+ else:
+ QMessageBox.warning(
+ self,
+ "Could not open configuration",
+ (
+ "DeepLabCut could not open the configuration "
+ "with a system application.\n\n"
+ f"Open it manually:\n{self.config_path}"
+ ),
+ )
+
+ def show_task_error(
+ self,
+ error: Exception,
+ config_path: str | Path | None = None,
+ ) -> None:
+ """Show an error raised by a GUI-launched task (e.g. training, analysis).
+
+ Unlike the project-config recovery loop, this only reports the failure;
+ the user fixes the file and re-runs the action.
+ """
+ if config_path is None:
+ if isinstance(error, ValidationError) and _is_pose_config_validation_error(error):
+ config_path = self.pose_cfg_path
+ else:
+ config_path = self.config_path
+
+ self.logger.error("Task failed: %s", error, exc_info=error)
+
+ if isinstance(error, ValidationError):
+ report = format_config_error(config_path or "", error)
+ else:
+ report = ConfigErrorReport(
+ title="Task failed",
+ summary="DeepLabCut could not complete the task.",
+ details=str(error),
+ technical_details=repr(error),
+ )
+
+ message = QMessageBox(self)
+ message.setIcon(QMessageBox.Critical)
+ message.setWindowTitle(report.title)
+ message.setText(report.summary)
+ message.setInformativeText(report.details)
+ message.setDetailedText(report.technical_details)
+
+ open_button = None
+ if config_path is not None and isinstance(error, ValidationError):
+ open_button = message.addButton(
+ "Open configuration",
+ QMessageBox.ActionRole,
+ )
+ message.addButton(QMessageBox.Close)
+
+ message.exec()
+
+ if open_button is not None and message.clickedButton() is open_button:
+ self._open_config_with_system_application(config_path)
+
+ def _handle_config_error(self, error: Exception) -> ConfigErrorAction:
+ """Log and present a recoverable project configuration error."""
+ if isinstance(error, ValidationError):
+ self.logger.warning(
+ "Invalid project configuration %s (%d validation error%s)",
+ self.config_path,
+ error.error_count(),
+ "" if error.error_count() == 1 else "s",
+ )
+ else:
+ self.logger.warning(
+ "Could not read project configuration %s: %s",
+ self.config_path,
+ error,
+ )
+
+ self.logger.debug(
+ "Project configuration error details",
+ exc_info=(
+ type(error),
+ error,
+ error.__traceback__,
+ ),
+ )
+
+ report = format_config_error(
+ self.config_path,
+ error,
+ )
+ return self._show_config_error(report)
+
+ def _build_project_ui_from_current_config(self) -> bool:
+ """Build project tabs using the current config file on disk.
+
+ Every attempt starts by invalidating the cached configuration, so
+ each retry performs a fresh read and validation from disk.
+ """
+ if self.config_path is None:
+ return False
+
+ while True:
+ self._discard_partial_project_tabs()
+ self.invalidate_config_cache()
+
+ try:
+ # Read and validate; tabs reuse the cached snapshot via self.cfg.
+ self._read_current_config_for_ui()
+ self.add_tabs()
+ return True
+
+ except CONFIG_LOAD_ERRORS as error:
+ self._discard_partial_project_tabs()
+ self._generate_welcome_page()
+ action = self._handle_config_error(error)
+
+ except Exception:
+ self._discard_partial_project_tabs()
+ self._generate_welcome_page()
+ raise
+
+ if action is ConfigErrorAction.RETRY:
+ # Loop back and perform a fresh read from disk.
+ continue
+
+ return False
+
+ def _open_project(self) -> None:
+ open_project = OpenProject(self)
+ open_project.load_config()
+ if not open_project.config_path:
+ return
+
+ self._update_project_state(
+ open_project.config_path,
+ loaded=True,
+ )
+
+ def _open_recent_project(
+ self,
+ action: QAction,
+ ) -> None:
+ """Open a recent project through the normal validation boundary."""
+ config_path = absolute_path(action.text())
+
+ if not config_path.is_file():
+ QMessageBox.warning(
+ self,
+ "Project not found",
+ (f"The project configuration no longer exists:\n\n{config_path}"),
+ )
+ self.recentfiles_menu.removeAction(action)
+ return
+
+ self._update_project_state(
+ config_path,
+ loaded=True,
+ )
+
+ 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.tab_widget.addTab(self.modelzoo, "Model Zoo")
+ self.setCentralWidget(self.tab_widget)
+
+ def _read_current_config_for_ui(self) -> ProjectConfig:
+ """Read the current config from disk for a UI-building attempt."""
+ cfg = self.cfg
+ if cfg is None:
+ raise FileNotFoundError("No project configuration is selected.")
+ return cfg
+
+ def load_config(
+ self,
+ config: str | Path,
+ ) -> bool:
+ """Reload a config from disk and notify config-dependent widgets."""
+ self.config_path = absolute_path(config)
+
+ while True:
+ self.invalidate_config_cache()
+
+ try:
+ cfg = self._read_current_config_for_ui()
+ except CONFIG_LOAD_ERRORS as error:
+ action = self._handle_config_error(error)
+
+ if action is ConfigErrorAction.RETRY:
+ continue
+
+ return False
+
+ self.config_loaded.emit()
+
+ task = cfg.Task or self.config_path.parent.name
+ self.logger.info(
+ 'Project "%s" successfully loaded.',
+ task,
+ )
+ self._config_monitor.mark_current()
+ return True
+
+ def darkmode(self):
+ dark_stylesheet = qdarkstyle.load_stylesheet_pyside2()
+ self.app.setStyleSheet(dark_stylesheet)
+
+ names = ["new_project2.png", "open2.png", "help2.png"]
+ self.remove_action()
+ self.create_actions(names)
+ self.update_menu_bar()
+ self.create_toolbar()
+
+ def lightmode(self):
+ from qdarkstyle.light.palette import LightPalette
+
+ style = qdarkstyle.load_stylesheet(palette=LightPalette)
+ self.app.setStyleSheet(style)
+
+ names = ["new_project.png", "open.png", "help.png"]
+ self.remove_action()
+ self.create_actions(names)
+ self.create_toolbar()
+ self.update_menu_bar()
+
+ def _discard_partial_project_tabs(self) -> None:
+ """Dispose of tabs created during a failed UI-building attempt."""
+ tab_widget = getattr(self, "tab_widget", None)
+
+ if tab_widget is None:
+ return
+
+ try:
+ if self.centralWidget() is tab_widget:
+ self.takeCentralWidget()
+ except RuntimeError:
+ # The Qt object may already have been scheduled for deletion.
+ pass
+
+ try:
+ tab_widget.deleteLater()
+ except RuntimeError:
+ pass
+
+ self.tab_widget = None
+
+ 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.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",
+ )
+ 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.unsupervised_id_tracking = UnsupervizedIdTracking(
+ root=self,
+ parent=None,
+ h1_description="DeepLabCut - Optional Unsupervised ID Tracking with Transformer",
+ )
+ self.create_videos = CreateVideos(
+ root=self,
+ parent=None,
+ h1_description="DeepLabCut - Create Videos",
+ )
+ self.extract_outlier_frames = ExtractOutlierFrames(
+ root=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.tab_widget.addTab(self.manage_project, "Manage project")
+ self.tab_widget.addTab(self.extract_frames, "Extract frames")
+ self.tab_widget.addTab(self.label_frames, "Label frames")
+ self.tab_widget.addTab(self.create_training_dataset, "Create training dataset")
+ 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.create_videos, "Create videos")
+ 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.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)
+
+ def refresh_active_tab(self):
+ active_tab = self.tab_widget.currentWidget()
+ tab_label = self.tab_widget.tabText(self.tab_widget.currentIndex())
+
+ widget_to_attribute_map = {
+ QtWidgets.QSpinBox: "setValue",
+ components.ShuffleSpinBox: "setValue",
+ components.TrainingSetSpinBox: "setValue",
+ QtWidgets.QLineEdit: "setText",
+ }
+
+ 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}'")
+ method(updated_value)
+ except AttributeError:
+ pass
+
+ _attempt_attribute_update("shuffle", self.shuffle_value)
+ _attempt_attribute_update("cfg_line", str(self.config_path) if self.config_path else "")
+
+ def is_transreid_available(self):
+ 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...")
+ answer = QtWidgets.QMessageBox.question(
+ self,
+ "Quit",
+ "Are you sure you want to quit?",
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel,
+ QtWidgets.QMessageBox.Cancel,
+ )
+ if answer == QtWidgets.QMessageBox.Yes:
+ self._closing = True
+ self.receiver.terminate()
+
+ 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
new file mode 100644
index 0000000000..96c227b4b4
--- /dev/null
+++ b/deeplabcut/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/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..28216574f3
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base.py
@@ -0,0 +1,306 @@
+#
+# 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 warnings
+from pathlib import Path
+
+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 = Path(file_name).name
+ 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 = Path(file_name).name
+ 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..f3dcedab27
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/base_dlc.py
@@ -0,0 +1,104 @@
+#
+# 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
+
+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 = Path(self.proj_root) / f"{modelprefix}_config.yaml"
+ else:
+ config_file = Path(self.proj_root) / "config.yaml"
+
+ cfg = auxiliaryfunctions.read_config(config_file)
+
+ task = cfg["Task"]
+
+ scorer = cfg["scorer"]
+
+ datasets_folder = Path(self.proj_root) / auxiliaryfunctions.GetTrainingSetFolder(cfg)
+
+ self.datasets_folder = datasets_folder
+
+ trainingFraction = int(cfg["TrainingFraction"][0] * 100)
+
+ path_dlc_collected = datasets_folder / f"CollectedData_{scorer}.h5"
+
+ path_dlc_document = datasets_folder / f"Documentation_data-{task}_{trainingFraction}shuffle{shuffle}.pickle"
+
+ df = pd.read_hdf(path_dlc_collected)
+
+ self.dlc_df = df
+
+ with path_dlc_document.open("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..4c6fc29178
--- /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
+from pathlib import Path
+
+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 = Path(self.proj_root) / "annotations" / json_fn
+ with path.open() 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"] = str(Path(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..d22b6e05f8
--- /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
+#
+from pathlib import Path
+
+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 = str(Path(self.proj_root).joinpath(*file_name))
+ else:
+ image_path = str(Path(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..e6b5ef7f91
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/ma_dlc_dataframe.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/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+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(cfg["project_path"]) / "labeled-data"
+ videos = cfg["video_sets"].keys()
+ video_filenames = parse_video_filenames(videos)
+ for filename in video_filenames:
+ file_path = 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 = str(Path(self.proj_root).joinpath(*file_name))
+ else:
+ image_path = str(Path(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..9089ae4f0e
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/materialize.py
@@ -0,0 +1,770 @@
+#
+# 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 pickle
+import shutil
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import scipy.io as sio
+
+import deeplabcut.compat as compat
+from deeplabcut.core.config import ProjectConfig, normalize_for_serialization, write_project_config
+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 = str(Path(*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)
+
+
+# Keys exposed via SingleDLC_config.cfg / MaDLC_config.cfg (modelzoo converter templates).
+_SINGLE_TEMPLATE_KEYS: tuple[str, ...] = (
+ "Task",
+ "project_path",
+ "scorer",
+ "date",
+ "video_sets",
+ "skeleton",
+ "bodyparts",
+ "start",
+ "stop",
+ "numframes2pick",
+ "skeleton_color",
+ "pcutoff",
+ "dotsize",
+ "alphavalue",
+ "colormap",
+ "TrainingFraction",
+ "iteration",
+ "default_net_type",
+ "default_augmenter",
+ "snapshotindex",
+ "batch_size",
+ "cropping",
+ "croppedtraining",
+ "multianimalproject",
+ "uniquebodyparts",
+ "x1",
+ "x2",
+ "y1",
+ "y2",
+ "corner2move2",
+ "move2corner",
+ "identity",
+)
+_MA_TEMPLATE_EXTRA_KEYS: tuple[str, ...] = ("individuals", "multianimalbodyparts")
+
+
+def _exposed_template_cfg(config: ProjectConfig, keys: tuple[str, ...]) -> dict:
+ """Subset of a ProjectConfig as a plain dict (for tests and legacy callers)."""
+ normalized = normalize_for_serialization(config.model_dump())
+ cfg = {key: normalized[key] for key in keys}
+ # Legacy modelzoo templates used empty strings for unset fields.
+ if cfg.get("project_path") in (".", ""):
+ cfg["project_path"] = ""
+ if cfg.get("video_sets") == {}:
+ cfg["video_sets"] = ""
+ if cfg.get("skeleton") == []:
+ cfg["skeleton"] = ""
+ if cfg.get("TrainingFraction") == []:
+ cfg["TrainingFraction"] = ""
+ return cfg
+
+
+def modelzoo_single_template() -> ProjectConfig:
+ """Default ProjectConfig for generalized_data_converter single-animal projects."""
+ return ProjectConfig(
+ Task="",
+ project_path=Path(),
+ scorer="",
+ date="",
+ video_sets={},
+ skeleton=[],
+ bodyparts=[],
+ start=0,
+ stop=1,
+ numframes2pick=42,
+ skeleton_color="black",
+ pcutoff=0.6,
+ dotsize=8,
+ alphavalue=0.7,
+ colormap="rainbow",
+ TrainingFraction=[],
+ iteration=0,
+ default_net_type="resnet_50",
+ default_augmenter="imgaug",
+ snapshotindex=-1,
+ batch_size=8,
+ cropping=False,
+ croppedtraining=False,
+ multianimalproject=False,
+ uniquebodyparts=[],
+ x1=0,
+ x2=640,
+ y1=277,
+ y2=624,
+ corner2move2=[50, 50],
+ move2corner=True,
+ identity=False,
+ )
+
+
+def modelzoo_ma_template() -> ProjectConfig:
+ """Default ProjectConfig for generalized_data_converter multi-animal projects."""
+ modelzoo_single_template()
+
+ return modelzoo_single_template().model_copy(
+ update={
+ "default_augmenter": "multi-animal-imgaug",
+ "croppedtraining": True,
+ "multianimalproject": True,
+ "individuals": [],
+ "multianimalbodyparts": [],
+ "bodyparts": "MULTI!",
+ }
+ )
+
+
+class SingleDLC_config:
+ def __init__(self) -> None:
+ self.project_config = modelzoo_single_template()
+
+ @property
+ def cfg(self) -> dict:
+ return _exposed_template_cfg(self.project_config, _SINGLE_TEMPLATE_KEYS)
+
+ def create_cfg(self, proj_root: str | Path, kwargs: dict) -> None:
+ self.project_config.update(kwargs)
+ write_project_config(Path(proj_root) / "config.yaml", self.project_config)
+
+
+class MaDLC_config:
+ def __init__(self) -> None:
+ self.project_config = modelzoo_ma_template()
+
+ @property
+ def cfg(self) -> dict:
+ return _exposed_template_cfg(
+ self.project_config,
+ _SINGLE_TEMPLATE_KEYS + _MA_TEMPLATE_EXTRA_KEYS,
+ )
+
+ def create_cfg(self, proj_root: str | Path, kwargs: dict) -> None:
+ self.project_config.update(kwargs)
+ write_project_config(Path(proj_root) / "config.yaml", self.project_config)
+
+
+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"
+
+ (Path(proj_root) / "labeled-data").mkdir(parents=True, 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"]:
+ (Path(proj_root) / "labeled-data" / dataset_name).mkdir(parents=True, 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 = Path(file_name).name
+ 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 = Path(proj_root) / "labeled-data" / dataset_name / dest_image_name
+ if deepcopy:
+ shutil.copy(file_name, dest)
+ else:
+ try:
+ dest.symlink_to(file_name)
+ except Exception:
+ pass
+
+ relative_dest = str(Path("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(
+ str(Path(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(str(Path(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 = str(Path(proj_root) / "config.yaml")
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ train_folder = str(Path(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(str(Path(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 = Path(file_name).name
+ video_folder = Path(file_name).parts[-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 (Path(train_folder) / "parent_trace.pickle").open("wb") as f:
+ pickle.dump(parent_trace, f)
+
+ trainIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if Path(get_filename(image)).name in _filter_train_images
+ ]
+ testIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if Path(get_filename(image)).name in _filter_test_images
+ ]
+
+ with Path(metafilename).open("rb") as f:
+ metafile = pickle.load(f)
+
+ metafile[1] = trainIndices
+ metafile[2] = testIndices
+
+ with Path(metafilename).open("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.with_suffix(".pickle")
+
+ print(f"overwriting data file {datafilename}")
+
+ with (Path(proj_root) / datafilename).open("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"
+
+ (Path(proj_root) / "labeled-data").mkdir(parents=True, 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"]:
+ (Path(proj_root) / "labeled-data" / dataset_name).mkdir(parents=True, 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 = Path(file_name).name
+ 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 = Path(proj_root) / "labeled-data" / dataset_name / dest_image_name
+ if deepcopy:
+ shutil.copy(file_name, dest)
+ else:
+ try:
+ dest.symlink_to(file_name)
+ except Exception:
+ pass
+
+ if dataset_name == "AwA-Pose":
+ count += 1
+
+ relative_dest = str(Path("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(
+ str(Path(proj_root) / "labeled-data" / dataset_name / f"CollectedData_{scorer}.h5"),
+ key="df_with_missing",
+ mode="w",
+ )
+
+ create_training_dataset(str(Path(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 = str(Path(proj_root) / "config.yaml")
+
+ cfg = auxiliaryfunctions.read_config(config_path)
+
+ train_folder = str(Path(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(str(Path(train_folder) / f"CollectedData_{scorer}.h5"))
+
+ parent_trace = {}
+
+ def _filter(image):
+ file_name = image["file_name"]
+ image_name = Path(file_name).name
+ video_folder = Path(file_name).parts[-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 (Path(train_folder) / "parent_trace.pickle").open("wb") as f:
+ pickle.dump(parent_trace, f)
+
+ trainIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if Path(get_filename(image)).name in _filter_train_images
+ ]
+ testIndices = [
+ idx for idx, image in enumerate(dlc_df.index) if Path(get_filename(image)).name in _filter_test_images
+ ]
+
+ with Path(metafilename).open("rb") as f:
+ metafile = pickle.load(f)
+
+ metafile[1] = trainIndices
+ metafile[2] = testIndices
+
+ with Path(metafilename).open("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(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.
+ """
+
+ (Path(proj_root) / "images").mkdir(parents=True, exist_ok=True)
+ (Path(proj_root) / "annotations").mkdir(parents=True, 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"]).absolute()
+ 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.absolute()
+
+ file_name = str(Path(*dest.parts[-2:]))
+ if full_image_path:
+ file_name = str(dest)
+
+ if deepcopy:
+ shutil.copy(src, dest)
+ else:
+ try:
+ dest.symlink_to(src)
+ except Exception as err:
+ print(f"Could not create a symlink from {src} to {dest}: {err}")
+
+ 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 (Path(proj_root) / "annotations" / "train.json").open("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 (Path(proj_root) / "annotations" / "test.json").open("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..2e1fb78158
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/multi.py
@@ -0,0 +1,266 @@
+#
+# 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..0c4aa020dc
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc.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
+#
+from pathlib import Path
+
+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 = str(Path(self.proj_root).joinpath(*file_name))
+ else:
+ image_path = str(Path(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..69cf94cd5e
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/datasets/single_dlc_dataframe.py
@@ -0,0 +1,235 @@
+#
+# 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 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(cfg["project_path"]) / "labeled-data"
+ videos = cfg["video_sets"].keys()
+ video_filenames = parse_video_filenames(videos)
+ for filename in video_filenames:
+ file_path = 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 = str(Path(self.proj_root).joinpath(*file_name))
+ else:
+ image_path = str(Path(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..27ebb5cd7e
--- /dev/null
+++ b/deeplabcut/modelzoo/generalized_data_converter/utils.py
@@ -0,0 +1,292 @@
+#
+# 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
+
+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 = list(Path(proj_root).glob("labeled-data/*"))
+
+ labeled_folders = [f.name 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 Path(reference_pickle).open("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 Path(reference_pickle).open("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 = str(Path(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 = str(Path(proj_root) / (vname + DLCscorer + ".h5"))
+
+ metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
+
+ with Path(metadata_path).open("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",
+ str(Path(modelprefix) / "dlc-models"),
+ dirs_exist_ok=True,
+ )
+
+
+if __name__ == "__main__":
+ customized_colormap("hei")
diff --git a/deeplabcut/modelzoo/model_configs/dlcrnet.yaml b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml
new file mode 100644
index 0000000000..570c1a0aaa
--- /dev/null
+++ b/deeplabcut/modelzoo/model_configs/dlcrnet.yaml
@@ -0,0 +1,121 @@
+ # 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
+clahe: true
+claheratio: 0.1
+crop_sampling: hybrid
+crop_size:
+- 400
+- 400
+cropratio: 0.4
+dataset:
+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
+mean_pixel:
+- 123.68
+- 116.779
+- 103.939
+metadataset:
+min_input_size: 64
+mirror: false
+multi_stage: true
+multi_step:
+- - 0.0001
+ - 7500
+- - 5.0e-05
+ - 12000
+- - 1.0e-05
+ - 200000
+net_type: resnet_50
+num_idchannel: 0
+num_joints: 27
+num_limbs: 351
+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: []
+rotation: 25
+rotratio: 0.4
+save_iters: 10000
+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_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..ee1b5c5ac0
--- /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:
+ - [left_ankle, left_knee]
+ - [left_knee, left_hip]
+ - [right_ankle, right_knee]
+ - [right_knee, right_hip]
+ - [left_hip, right_hip]
+ - [left_shoulder, left_hip]
+ - [right_shoulder, right_hip]
+ - [left_shoulder, right_shoulder]
+ - [left_shoulder, left_elbow]
+ - [right_shoulder, right_elbow]
+ - [left_elbow, left_wrist]
+ - [right_elbow, right_wrist]
+ - [left_eye, right_eye]
+ - [nose, left_eye]
+ - [nose, right_eye]
+ - [left_eye, left_ear]
+ - [right_eye, right_ear]
+ - [left_ear, left_shoulder]
+ - [right_ear, right_shoulder]
+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
new file mode 100644
index 0000000000..5fc4f80386
--- /dev/null
+++ b/deeplabcut/modelzoo/utils.py
@@ -0,0 +1,398 @@
+#
+# 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 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 = 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:
+ """Get the super animal scorer.
+
+ 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))
+
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ conversion_tables = cfg.get("SuperAnimalConversionTables") or {}
+ if not conversion_tables 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 = dlc_root_path / "modelzoo"
+
+ available_model_configs = list((modelzoo_path / "model_configs").glob("*.yaml"))
+ available_models = [path.stem 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 = list((modelzoo_path / "project_configs").glob("*.yaml"))
+ [path.stem for path in available_project_configs]
+
+ return project_name, model_name
+
+
+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
+ )
+
+ 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..ac30e72525
--- /dev/null
+++ b/deeplabcut/modelzoo/video_inference.py
@@ -0,0 +1,604 @@
+#
+# 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 warnings
+from collections.abc import Sequence
+from pathlib import Path
+
+import torch
+from dlclibrary.dlcmodelzoo.modelzoo_download import download_huggingface_model
+
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.modelzoo.utils import get_super_animal_scorer
+from deeplabcut.pose_estimation_pytorch.config import PoseConfig
+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,
+)
+from deeplabcut.utils.auxiliaryfunctions import get_deeplabcut_path
+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 | Path | 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
+
+ Args:
+ 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, optional):
+ Checks for the extension of the video in case the input to the
+ video is a directory. Only videos with this extension are analyzed. The
+ default is ``.mp4``.
+ dest_folder (str | Path | None, optional): The path to the folder where the results should be saved.
+ cropping (list or None, optional): 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. Defaults to None.
+ 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.
+
+ Warns:
+ UserWarning: If an fmpose3d ``model_name`` is used with a mismatched ``superanimal_name``.
+
+ Note:
+ (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
+
+ 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.
+ * 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.
+
+ Examples:
+ Using the module import path:
+
+ 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,
+ )
+ Using ``scale_list``:
+
+ 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,
+ )
+ """
+ if scale_list is None:
+ scale_list = []
+ if dest_folder is not None:
+ dest_folder = Path(dest_folder)
+ 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 = dlc_root_path / "modelzoo"
+ with (modelzoo_path / "models_to_framework.json").open() as _f:
+ available_architectures = json.load(_f)
+ 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 = PoseConfig.from_any(customized_model_config)
+ else:
+ config = PoseConfig.build_for_superanimal_inference(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=(detector_name if superanimal_name != "superanimal_humanbody" else None),
+ max_individuals=max_individuals,
+ device=device,
+ )
+
+ 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
+ )
+
+ 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, parents=True)
+ model_folder = pseudo_dataset_folder / "checkpoints"
+ model_folder.mkdir(exist_ok=True, parents=True)
+
+ image_folder = pseudo_dataset_folder / "images"
+ if image_folder.exists():
+ print(f"{image_folder} exists, skipping the frame extraction")
+ else:
+ image_folder.mkdir(exist_ok=True, parents=True)
+ 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(exist_ok=True, parents=True)
+
+ 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 (pseudo_anno_dir / pseudo_anno_name).open() 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"
+ config.to_yaml(model_config_path, overwrite=True)
+
+ # 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 train_file.open() 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..8e528c1145
--- /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
+
+
+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,
+ max_individuals=max_individuals,
+ device=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 81fe3ff696..2142379da3 100644
--- a/deeplabcut/pose_cfg.yaml
+++ b/deeplabcut/pose_cfg.yaml
@@ -77,19 +77,21 @@ mirror: False #imgaug & tensorpack do not consider mirror symmetric joints now,
# set True to use with default parameters, otherwise give a dictionary for keyword arguments
# If no ratio value is given, set to 0.4 by default
-# contrast:
-clahe: True
-claheratio: 0.1
-histeq: True
-histeqratio: 0.1
-#
-# convolution:
-sharpen: False
-sharpenratio: 0.3
-edge: False
-emboss:
- alpha: [0.0, 1.0]
- strength: [0.5, 1.5]
+# dictionary with contrast parameters
+contrast:
+ clahe: True
+ claheratio: 0.1
+ histeq: True
+ histeqratio: 0.1
+
+# dictionary with convolution parameters
+convolution:
+ sharpen: False
+ sharpenratio: 0.3
+ edge: False
+ emboss:
+ alpha: [0.0, 1.0]
+ strength: [0.5, 1.5]
embossratio: 0.1
##############################################################################
diff --git a/deeplabcut/pose_estimation_3d/__init__.py b/deeplabcut/pose_estimation_3d/__init__.py
index fe9eea00f5..81458685b4 100644
--- a/deeplabcut/pose_estimation_3d/__init__.py
+++ b/deeplabcut/pose_estimation_3d/__init__.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
from deeplabcut.pose_estimation_3d.camera_calibration import *
from deeplabcut.pose_estimation_3d.plotting3D import *
from deeplabcut.pose_estimation_3d.triangulation import *
diff --git a/deeplabcut/pose_estimation_3d/camera_calibration.py b/deeplabcut/pose_estimation_3d/camera_calibration.py
index 74d4ba91b8..0f495318a3 100644
--- a/deeplabcut/pose_estimation_3d/camera_calibration.py
+++ b/deeplabcut/pose_estimation_3d/camera_calibration.py
@@ -1,15 +1,14 @@
-"""
-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 glob
-import os
+#
+# 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 pickle
from pathlib import Path
@@ -19,51 +18,57 @@
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):
- """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.
-
- 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,
- use the parameter ``calibrate=True`` to calibrate the cameras.
+def calibrate_cameras(
+ config: str | Path,
+ cbrow=8,
+ cbcol=6,
+ calibrate=False,
+ alpha=0.4,
+ search_window_size=(11, 11),
+):
+ """Extract corner points from calibration images, calibrate cameras, and store results.
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- cbrow : int
- Integer specifying the number of rows in the calibration image.
-
- cbcol : int
- 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!
-
- 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
- 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
-
- Example
- --------
- Linux/MacOs/Windows
- >>> deeplabcut.calibrate_camera(config)
-
- Once the right set of calibration images are selected,
- >>> deeplabcut.calibrate_camera(config,calibrate=True)
+ 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).
+
+ 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.
+
+ Args:
+ config (str | Path): Full path of the config.yaml file as a string.
+ cbrow (int): Integer specifying the number of rows in the calibration image.
+ cbcol (int): Integer specifying the number of columns in the calibration image.
+ calibrate (bool): If True, calibrate cameras with the current calibration images.
+ Set to True only after checking corner detection and removing bad images.
+ Defaults to False.
+ alpha (float): Free scaling parameter between 0 and 1.
+ When alpha = 0, rectified images with only valid pixels are stored (zoomed in).
+ When alpha = 1, all 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
+ search_window_size (tuple of int): Half of the side length of the search window when
+ refining detected checkerboard corners for subpixel accuracy.
+
+ Examples:
+ Linux/MacOs/Windows:
+
+ deeplabcut.calibrate_cameras(config)
+
+ Once the right set of calibration images are selected:
+
+ deeplabcut.calibrate_cameras(config, calibrate=True)
"""
# Termination criteria
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
@@ -82,24 +87,20 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4):
path_removed_images,
) = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)
- images = glob.glob(os.path.join(img_path, "*.jpg"))
+ images = [str(p) for p in Path(img_path).glob("*.jpg")]
cam_names = cfg_3d["camera_names"]
# 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"]
- projconfigfile = os.path.join(str(project_path), "config.yaml")
+ projconfigfile = str(Path(project_path) / "config.yaml")
auxiliaryfunctions.write_config_3d(projconfigfile, cfg_3d)
# Initialize the dictionary
@@ -117,7 +118,9 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4):
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 = []
@@ -134,38 +137,34 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4):
) # (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, (11, 11), (-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(str(Path(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(
- os.path.join(str(img_path), remove_fname),
- os.path.join(str(path_removed_images), remove_fname),
- )
+ (Path(img_path) / remove_fname).rename(Path(path_removed_images) / remove_fname)
if new_cam != cam:
skip_images.append(remove_fname)
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(
@@ -181,35 +180,22 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4):
}
pickle.dump(
dist_pickle,
- open(
- os.path.join(path_camera_matrix, cam + "_intrinsic_params.pickle"),
- "wb",
- ),
- )
- print(
- "Saving intrinsic camera calibration matrices for %s as a pickle file in %s"
- % (cam, os.path.join(path_camera_matrix))
+ (Path(path_camera_matrix) / (cam + "_intrinsic_params.pickle")).open("wb"),
)
+ print(f"Saving intrinsic camera calibration matrices for {cam} as a pickle file in {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,
@@ -264,50 +250,35 @@ def calibrate_cameras(config, cbrow=8, cbcol=6, calibrate=False, alpha=0.4):
"image_shape": [img_shape[pair[0]], img_shape[pair[1]]],
}
- print(
- "Saving the stereo parameters for every pair of cameras as a pickle file in %s"
- % str(os.path.join(path_camera_matrix))
- )
+ print(f"Saving the stereo parameters for every pair of cameras as a pickle file in {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(str(Path(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.
-
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
+def check_undistortion(config: str | Path, cbrow=8, cbcol=6, plot=True):
+ """Undistort calibration images and store them for visual inspection.
- cbrow : int
- Int specifying the number of rows in the calibration image.
-
- cbcol : int
- Int specifying the number of columns in the calibration image.
+ Uses camera matrices from calibration to verify they are correct.
- 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``.
+ Args:
+ config (str | Path): Full path of the config.yaml file as a string.
+ cbrow (int): Number of rows in the calibration image.
+ cbcol (int): Number of columns in the calibration image.
+ plot (bool, optional): If True, save undistortion results as plots. Defaults to True.
- Example
- --------
- Linux/MacOs/Windows
- >>> deeplabcut.check_undistortion(config, cbrow = 8,cbcol = 6)
+ Examples:
+ Linux/MacOs/Windows:
+ deeplabcut.check_undistortion(config, cbrow=8, cbcol=6)
"""
-
# Read the config file
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
cfg_3d = auxiliaryfunctions.read_config(config)
@@ -326,7 +297,7 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
markerColor = cfg_3d["markerColor"]
cam_names = cfg_3d["camera_names"]
- images = glob.glob(os.path.join(img_path, "*.jpg"))
+ images = [str(p) for p in Path(img_path).glob("*.jpg")]
# Sort the images
images.sort(key=lambda f: int("".join(filter(str.isdigit, f))))
@@ -340,9 +311,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(str(Path(path_camera_matrix) / "stereo_params.pickle"))
for pair in camera_pair:
map1_x, map1_y = cv2.initUndistortRectifyMap(
@@ -371,24 +340,20 @@ 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"],
)
cam1_undistort.append(imgpoints_proj_undistort)
cv2.imwrite(
- os.path.join(str(path_undistort), filename + "_undistort.jpg"),
+ str(Path(path_undistort) / (filename + "_undistort.jpg")),
im_remapped1,
)
imgpoints_proj_undistort = []
@@ -399,36 +364,30 @@ 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"],
)
cam2_undistort.append(imgpoints_proj_undistort2)
cv2.imwrite(
- os.path.join(str(path_undistort), filename + "_undistort.jpg"),
+ str(Path(path_undistort) / (filename + "_undistort.jpg")),
im_remapped2,
)
imgpoints_proj_undistort2 = []
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]),
@@ -439,17 +398,15 @@ 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])
- plt.savefig(os.path.join(str(path_undistort), "Original_Image.png"))
+ mcolors.Normalize(vmin=0.0, vmax=cam1_undistort.shape[1])
+ plt.savefig(str(Path(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]):
+ for i in range(cam1_undistort.shape[1]):
ax1.scatter(
[cam1_undistort[-1][i, 0, 0]],
[cam1_undistort[-1][i, 0, 1]],
@@ -466,7 +423,7 @@ def check_undistortion(config, cbrow=8, cbcol=6, plot=True):
color=markerColor,
alpha=alphaValue,
)
- plt.savefig(os.path.join(str(path_undistort), "undistorted_points.png"))
+ plt.savefig(str(Path(path_undistort) / "undistorted_points.png"))
# Triangulate
triangulate = auxiliaryfunctions_3d.compute_triangulation_calibration_images(
diff --git a/deeplabcut/pose_estimation_3d/plotting3D.py b/deeplabcut/pose_estimation_3d/plotting3D.py
index 500496c572..d39967a866 100644
--- a/deeplabcut/pose_estimation_3d/plotting3D.py
+++ b/deeplabcut/pose_estimation_3d/plotting3D.py
@@ -1,21 +1,25 @@
-"""
-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 glob
-import os
+#
+# 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
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,
@@ -25,11 +29,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):
@@ -53,9 +52,12 @@ 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,
+ config: str | Path,
+ path: str | Path,
videofolder=None,
start=0,
end=None,
@@ -71,66 +73,56 @@ def create_labeled_video_3d(
fps=30,
dpi=300,
):
+ """Create a video with two camera views and 3D reconstruction for selected frames.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ path (list): Full paths to triangulated files for analysis, or a directory containing them.
+ videofolder (string): Full path of the folder where videos are stored.
+ Use when videos are not co-located with triangulation files.
+ Defaults to None (videos searched next to the triangulation file).
+ start (int): Start frame index to select. Defaults to 0.
+ end (int): End frame index to select.
+ Defaults to None (all frames used).
+ trailpoints (int): Number of previous frames whose body parts are plotted (history).
+ Defaults to 0.
+ videotype (string, optional): When ``path`` is a directory, only videos with this extension are
+ analyzed. If unspecified, common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ view (list): Elevation (z plane) and azimuth (x,y plane) angles for the 3D view.
+ xlim (list): Limits for the 3D x-axis.
+ Defaults to [None, None] (min/max over all bodyparts).
+ ylim (list): Limits for the 3D y-axis.
+ Defaults to [None, None] (min/max over all bodyparts).
+ zlim (list): Limits for the 3D z-axis.
+ Defaults to [None, None] (min/max over all bodyparts).
+ draw_skeleton (bool): If True, draw skeleton lines on each frame (from config).
+ Defaults to True.
+ color_by (string, optional): Coloring rule. Each bodypart colored differently by default.
+ Use 'individual' to color all points of one individual the same. Defaults to 'bodypart'.
+ figsize (tuple, optional): Figure size for the matplotlib plot. Defaults to (20, 8).
+ fps (int, optional): Output video frame rate. Defaults to 30.
+ dpi (int, optional): Output video DPI. Defaults to 300.
+
+ Examples:
+ Linux/MacOs
+ deeplabcut.create_labeled_video_3d(config, ["/data/project1/videos/3d.h5"], start=100, end=500)
+
+ To create labeled videos for all the triangulated files in the folder
+ 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],
+ )
"""
- Creates a video with views from the two cameras and the 3d reconstruction for a selected number of frames.
-
- Parameters
- ----------
- config : string
- 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.
-
- 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.
-
- start: int
- 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.
-
- trailpoints: int
- 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.
- 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
-
- 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.
-
- 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.
-
- 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.
-
- Example
- -------
- Linux/MacOs
- >>> deeplabcut.create_labeled_video_3d(config,['/data/project1/videos/3d.h5'],start=100, end=500)
-
- To create labeled videos for all the triangulated files in the folder
- >>> 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])
-
- """
- start_path = os.getcwd()
-
# Read the config file and related variables
cfg_3d = auxiliaryfunctions.read_config(config)
cam_names = cfg_3d["camera_names"]
@@ -151,7 +143,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:
@@ -160,31 +154,23 @@ def create_labeled_video_3d(
# triangulated file is a list which is always sorted as [triangulated.h5,camera-1.videotype,camera-2.videotype]
# name for output video
file_name = str(Path(triangulate_file).stem)
- videooutname = os.path.join(path_h5_file, file_name + ".mp4")
- if os.path.isfile(videooutname):
+ videooutname = path_h5_file / (file_name + ".mp4")
+ if videooutname.is_file():
print("Video already created...")
else:
string_to_remove = str(Path(triangulate_file).suffix)
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
@@ -195,55 +181,21 @@ def create_labeled_video_3d(
try:
print("Looking for filtered predictions...")
df_cam1 = pd.read_hdf(
- glob.glob(
- os.path.join(
- path_h5_file,
- str(
- "*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"
- ),
- )
- )[0]
+ list(path_h5_file.glob("*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"))[0]
)
df_cam2 = pd.read_hdf(
- glob.glob(
- os.path.join(
- path_h5_file,
- str(
- "*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"
- ),
- )
- )[0]
+ list(path_h5_file.glob("*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"))[0]
)
# print("Found filtered predictions, will be use these for triangulation.")
print(
"Found the following filtered data: ",
- os.path.join(
- path_h5_file,
- str("*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"),
- ),
- os.path.join(
- path_h5_file,
- str("*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"),
- ),
- )
- except FileNotFoundError:
- 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]
- )
- df_cam2 = pd.read_hdf(
- glob.glob(
- os.path.join(
- path_h5_file, str(base_filename_cam2 + cam2_scorer + "*.h5")
- )
- )[0]
+ path_h5_file / ("*" + base_filename_cam1 + cam1_scorer + "*filtered.h5"),
+ path_h5_file / ("*" + base_filename_cam2 + cam2_scorer + "*filtered.h5"),
)
+ except IndexError:
+ print("No filtered predictions found, the unfiltered predictions will be used instead.")
+ df_cam1 = pd.read_hdf(list(path_h5_file.glob(base_filename_cam1 + cam1_scorer + "*.h5"))[0])
+ df_cam2 = pd.read_hdf(list(path_h5_file.glob(base_filename_cam2 + cam2_scorer + "*.h5"))[0])
df_3d = pd.read_hdf(triangulate_file)
try:
@@ -256,31 +208,30 @@ def create_labeled_video_3d(
end = min(end, min(len(vid_cam1), len(vid_cam2)))
frames = list(range(start, end))
- output_folder = Path(os.path.join(path_h5_file, "temp_" + file_name))
+ output_folder = path_h5_file / ("temp_" + file_name)
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.loc[:, mask2d].to_numpy().reshape((len(df_cam1), -1, 3))
+ 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))
visible1 = xy1[..., 2] >= pcutoff
xy1[~visible1] = np.nan
- xy2 = df_cam2.loc[:, mask2d].to_numpy().reshape((len(df_cam1), -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)
+ mask = df_3d.columns.get_level_values("bodyparts").isin(bodyparts2plot)
xyz = df_3d.loc[:, mask].to_numpy().reshape((len(df_3d), -1, 3))
xyz[~(visible1 & visible2)] = np.nan
- bpts = df_3d.columns.get_level_values('bodyparts')[mask][::3]
+ bpts = df_3d.columns.get_level_values("bodyparts")[mask][::3]
links = make_labeled_video.get_segment_indices(
- bodyparts2connect, bpts,
+ 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))
@@ -308,15 +259,21 @@ def create_labeled_video_3d(
# Set up the matplotlib figure beforehand
fig, axes1, axes2, axes3 = set_up_grid(figsize, xlim, ylim, zlim, view)
points_2d1 = axes1.scatter(
- *np.zeros((2, len(bodyparts2plot))), s=markerSize, alpha=alphaValue,
+ *np.zeros((2, len(bodyparts2plot))),
+ s=markerSize,
+ alpha=alphaValue,
)
im1 = axes1.imshow(np.zeros((vid_cam1.height, vid_cam1.width)))
points_2d2 = axes2.scatter(
- *np.zeros((2, len(bodyparts2plot))), s=markerSize, alpha=alphaValue,
+ *np.zeros((2, len(bodyparts2plot))),
+ s=markerSize,
+ alpha=alphaValue,
)
im2 = axes2.imshow(np.zeros((vid_cam2.height, vid_cam2.width)))
points_3d = axes3.scatter(
- *np.zeros((3, len(bodyparts2plot))), s=markerSize, alpha=alphaValue,
+ *np.zeros((3, len(bodyparts2plot))),
+ s=markerSize,
+ alpha=alphaValue,
)
if draw_skeleton:
# Set up skeleton LineCollections
@@ -330,14 +287,14 @@ def create_labeled_video_3d(
axes3.add_collection(coll_3d)
writer = FFMpegWriter(fps=fps)
- with writer.saving(fig, videooutname, dpi=dpi):
+ with writer.saving(fig, str(videooutname), dpi=dpi):
for k in tqdm(frames):
vid_cam1.set_to_frame(k)
vid_cam2.set_to_frame(k)
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 c111cda0b0..c213bfd312 100644
--- a/deeplabcut/pose_estimation_3d/triangulation.py
+++ b/deeplabcut/pose_estimation_3d/triangulation.py
@@ -1,31 +1,31 @@
-"""
-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
+#
+# 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 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,
+ config: str | Path,
+ video_path: str | Path | list[str | Path] | list[list[str | Path]],
videotype="",
filterpredictions=True,
filtertype="median",
@@ -34,60 +34,65 @@ def triangulate(
save_as_csv=False,
track_method="",
):
- """
- This function triangulates the detected DLC-keypoints from the two camera views
- using the camera matrices (derived from calibration) to calculate 3D predictions.
-
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
-
- video_path : string/list of list
- Full path of the directory where videos are saved. If the user wants to analyze
- only a pair of videos, the user needs to pass them as a list of list of videos,
- 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.
- If left unspecified, videos with common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
-
-
- filterpredictions: Bool, optional
- Filter the predictions with filter specified by "filtertype". If specified it
- should be either ``True`` or ``False``.
-
- filtertype: string
- Select which filter, 'arima' or 'median' filter (currently supported).
-
- 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
-
- destfolder: string, optional
- Specifies the destination folder for analysis data (default is the path of the video)
-
- save_as_csv: bool, optional
- Saves the predictions in a .csv file. The default is ``False``
-
- Example
- -------
- Linux/MacOS
- To analyze all the videos in the directory:
- >>> 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']])
-
-
- Windows
- To analyze all the videos in the directory:
- >>> deeplabcut.triangulate(config,'C:\\yourusername\\rig-95\\Videos')
+ """Triangulate DLC keypoints from two camera views into 3D predictions.
+
+ Uses camera matrices from calibration.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ video_path (string/list of list): Directory where videos are saved, or a list of video pairs,
+ e.g. [['video1-camera-1.avi', 'video1-camera-2.avi']].
+ videotype (string, optional): When ``video_path`` is a directory, only videos with this extension
+ are analyzed. If unspecified, common extensions ('avi', 'mp4', 'mov', 'mpeg', 'mkv') are kept.
+ filterpredictions (bool, optional): Filter predictions with ``filtertype``.
+ Defaults to True.
+ filtertype (string): Filter to use: 'arima' or 'median' (currently supported).
+ gputouse (int, optional): GPU index (see nvidia-smi). Use None if no GPU.
+ See: https://nvidia.custhelp.com/app/answers/detail/a_id/3751/~/useful-nvidia-smi-queries
+ destfolder (string, optional): Destination folder for analysis data.
+ Defaults to the video path.
+ save_as_csv (bool, optional): Save predictions as .csv. Defaults to False.
+ track_method (str, optional): Tracking method suffix for multi-animal projects.
+ Defaults to "".
+
+ Examples:
+ Linux/MacOS â analyze all videos in the directory:
+ 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",
+ ],
+ ],
+ )
- 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']])
+ Windows â analyze all videos in the directory:
+ deeplabcut.triangulate(config, "C:\\yourusername\\rig-95\\Videos")
+
+ To analyze only a few pairs 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",
+ ],
+ ],
+ )
"""
- 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)
@@ -99,29 +104,25 @@ def triangulate(
for cam in cam_names:
snapshots[cam] = cfg_3d[str("config_file_" + cam)]
# Check if the config file exists
- if not os.path.exists(snapshots[cam]):
+ if not Path(snapshots[cam]).exists():
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:",
@@ -134,23 +135,18 @@ def triangulate(
for i in range(len(video_list)):
dataname = []
for j in range(len(video_list[i])): # looping over cameras
- if cam_names[j] in video_list[i][j]:
- print(
- "Analyzing video %s using %s"
- % (video_list[i][j], str("config_file_" + cam_names[j]))
- )
+ if cam_names[j] not in 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 {} 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
- )
+ 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..."
- )
+ warnings.warn("Switching to `box` tracker for single point tracking...", stacklevel=2)
track_method = "box"
# Get track method suffix
@@ -159,11 +155,11 @@ 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:
- video = os.path.join(video_path, video_list[i][j])
+ if flag:
+ video = str(Path(video_path) / video_list[i][j])
else:
video_path = str(Path(video_list[i][j]).parents[0])
- video = os.path.join(video_path, video_list[i][j])
+ video = str(Path(video_path) / video_list[i][j])
if destfolder is None:
destfolder = str(Path(video).parents[0])
@@ -182,27 +178,22 @@ def triangulate(
suffix = suffix[1:]
if prefix == "":
- output_file = os.path.join(destfolder, suffix)
+ output_file = str(Path(destfolder) / suffix)
else:
if suffix == "":
- output_file = os.path.join(destfolder, prefix)
+ output_file = str(Path(destfolder) / prefix)
else:
- output_file = os.path.join(destfolder, prefix + "_" + suffix)
-
- output_filename = os.path.join(
- 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"
- ):
+ output_file = str(Path(destfolder) / (prefix + "_" + suffix))
+
+ output_filename = output_file + "_" + scorer_3d # Check if the videos are already analyzed for 3d
+ if Path(output_filename + ".h5").is_file():
+ if save_as_csv is True and not Path(output_filename + ".csv").exists():
# 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")
@@ -214,29 +205,23 @@ def triangulate(
path_undistort,
_,
) = auxiliaryfunctions_3d.Foldernames3Dproject(cfg_3d)
- path_stereo_file = os.path.join(
- path_camera_matrix, "stereo_params.pickle"
- )
+ path_stereo_file = str(Path(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
# Check for scorer names in the pickle file of 3d output
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
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
@@ -246,18 +231,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(str(Path(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,
@@ -271,7 +252,7 @@ def triangulate(
filtering.filterpredictions(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
filtertype=filtertype,
@@ -279,17 +260,13 @@ def triangulate(
)
suffix += "_filtered"
- dataname.append(
- os.path.join(
- destfolder, vname + DLCscorer + suffix + ".h5"
- )
- )
+ dataname.append(str(Path(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,
@@ -298,21 +275,19 @@ def triangulate(
scorer_name[cam_names[j]] = DLCscorer
run_triangulate = True
print(destfolder, vname, DLCscorer)
+ suffix = tr_method_suffix
if filterpredictions:
filtering.filterpredictions(
config_2d,
[video],
- videotype=videotype,
+ video_extensions=videotype,
shuffle=shuffle,
trainingsetindex=trainingsetindex,
filtertype=filtertype,
destfolder=destfolder,
)
- dataname.append(
- os.path.join(
- destfolder, vname + DLCscorer + tr_method_suffix + "_filtered.h5"
- )
- )
+ suffix += "_filtered"
+ dataname.append(str(Path(destfolder) / (vname + DLCscorer + suffix + ".h5")))
if run_triangulate:
# if len(dataname)>0:
@@ -323,31 +298,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]
-
- scorer_3d = scorer_cam1
- bodyparts = dataFrame_camera1_undistort.columns.get_level_values(
- "bodyparts"
- ).unique()
+ 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]
+
+ dataFrame_camera1_undistort.columns.get_level_values("bodyparts").unique()
P1 = stereomatrix["P1"]
P2 = stereomatrix["P2"]
@@ -358,12 +326,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
@@ -379,19 +343,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(
@@ -406,12 +364,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 = []
@@ -422,9 +380,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))
@@ -440,18 +396,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)
@@ -462,10 +432,10 @@ 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
@@ -473,16 +443,19 @@ def triangulate(
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.to_hdf(dataname[1], "tracks", format="table", mode="w",)
+ df_2d_view2.to_hdf(
+ dataname[1],
+ 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]):
@@ -508,7 +481,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):
@@ -528,26 +501,36 @@ 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 interm. saving of this due to high speed.
+ #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'))
else:
"""
+ if len(dataframe) != 2:
+ raise ValueError(
+ "undistort_points(config, dataframe, camera_pair) "
+ f"needs filenames to two data frames, but got dataframe={dataframe}."
+ )
+ for filename in dataframe:
+ if not Path(filename).exists():
+ raise FileNotFoundError(f"Dataframe path '{filename}' could not be found in the filesystem.")
+ if not Path(path_camera_matrix).exists():
+ 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])
- path_stereo_file = os.path.join(path_camera_matrix, "stereo_params.pickle")
+ path_stereo_file = str(Path(path_camera_matrix) / "stereo_params.pickle")
stereo_file = auxiliaryfunctions.read_pickle(path_stereo_file)
dataFrame_cam1_undistort, dataFrame_cam2_undistort = _undistort_views(
- [(dataframe_cam1, dataframe_cam2)], stereo_file,
+ [(dataframe_cam1, dataframe_cam2)],
+ stereo_file,
)[0]
return (
@@ -555,4 +538,4 @@ def undistort_points(config, dataframe, camera_pair):
dataFrame_cam2_undistort,
stereo_file[camera_pair],
path_stereo_file,
- )
\ No newline at end of file
+ )
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..42bdaf4f4d
--- /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 (
+ GenSamplingConfig,
+ 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,
+ 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..2fc9d19bfa
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/analyze_images.py
@@ -0,0 +1,675 @@
+#
+# 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
+from collections import defaultdict
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+import numpy as np
+from tqdm import tqdm
+
+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.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.config.ctd_conditions import ConditionsModelConfig, ConditionsShuffleConfig
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ COCO_PERSON_CATEGORY_ID,
+)
+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: PoseConfig | dict | str | Path | 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 (PoseConfig | dict | str | Path | None, optional):
+ A customized SuperAnimal model configuration, as an alternative to the
+ default SuperAnimal model configuration. 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 | Path | None, optional):
+ A customized SuperAnimal pose checkpoint, as an alternative to the
+ HuggingFace SuperAnimal models.
+
+ customized_detector_checkpoint (str | Path | None, optional):
+ 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),
+ max_individuals=max_individuals,
+ device=device,
+ )
+ else:
+ config = PoseConfig.from_any(customized_model_config)
+
+ 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 | ConditionsShuffleConfig | ConditionsModelConfig | 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. Specifies the BU model used to generate
+ conditions. If None, loaded from the pytorch_config file (under
+ ``"inference": "conditions"``). Accepts a raw dict or a typed
+ ``ConditionsModelConfig`` / ``ConditionsShuffleConfig``. A BU model
+ is required for live inference; file / path conditions not valid.
+
+ 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 = 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 = PoseConfig.from_any(model_cfg_path)
+ pose_task = Task(model_cfg["method"])
+
+ if pose_task == Task.COND_TOP_DOWN:
+ ctd_conditions = model_cfg.inference.conditions if ctd_conditions is None else ctd_conditions
+ if ctd_conditions is None:
+ raise ValueError("CTD conditions are required for image analysis with cond-top-down models")
+ ctd_conditions = ConditionsModelConfig.resolve_from_conditions(ctd_conditions, config=config)
+
+ # 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]
+
+ 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=ctd_conditions,
+ )
+
+ 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.absolute()
+ 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: PoseConfig | dict | str | Path,
+ 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: ConditionsModelConfig | 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 (PoseConfig | dict | str | Path): The PyTorch pose configuration.
+ 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 - needed to configure the BU model for generating conditions. A
+ ``ConditionsModelConfig`` is required (resolve shuffle refs first). File conditions are
+ evaluation-only and not valid here.
+
+ 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
+ """
+ model_cfg = PoseConfig.from_any(model_cfg)
+
+ pose_task = Task(model_cfg["method"])
+ if pose_task == Task.COND_TOP_DOWN:
+ if not isinstance(cond_provider, ConditionsModelConfig):
+ raise TypeError(
+ "A ``ConditionsModelConfig`` must be specified for image folder analysis with cond-top-down models. Got"
+ f" type(cond_provider)={type(cond_provider).__name__!r}. Please correctly specify ``cond_provider``."
+ )
+
+ 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)
+
+ 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: ConditionsModelConfig | 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 Path(data_json_path).open() 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 = Path(image["file_name"]).name
+ 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 = list(Path(image_folder).glob("*.png"))
+ corresponded_images = []
+ for image in images_in_folder:
+ image_path = image
+ image_name = Path(image).name
+ 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,
+ )
+
+ Path(out_path).mkdir(parents=True, exist_ok=True)
+
+ coco_format_predictions = []
+ for image_path, prediction in predictions.items():
+ image_name = Path(image_path).name
+ 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 = Path(image_path).name
+ fig.savefig(Path(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..cbb485d785
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/ctd.py
@@ -0,0 +1,157 @@
+#
+# 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.config.ctd_conditions import (
+ ConditionsConfig,
+ ConditionsFileConfig,
+ ConditionsModelConfig,
+ ConditionsShuffleConfig,
+)
+from deeplabcut.pose_estimation_pytorch.data.ctd import CondFromFile
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def get_conditions_provider_for_video(
+ cond_provider: ConditionsModelConfig,
+ video: str | Path,
+) -> CondFromFile | None:
+ """Tries to create a conditions loader from a pre-computed video predictions file.
+
+ Args:
+ cond_provider: The resolved ``ConditionsModelConfig``. The scorer must be
+ set, or pre-computed 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.
+
+ Evaluation loads pre-computed BU predictions from disk via ``CondFromFile``.
+ Supported ``inference.conditions`` forms:
+
+ - ``ConditionsFileConfig`` / path string â load that predictions file
+ - ``ConditionsShuffleConfig`` / shuffle dict â resolve the BU shuffle's evaluation
+ ``.h5`` (project ``config.yaml`` is injected from the loader when missing)
+
+ ``ConditionsModelConfig`` is not valid here (that form is for live BU inference).
+ File-path conditions are evaluation-only and cannot be used with ``analyze_*``.
+
+ 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}. "
+ f"Evaluation accepts file paths or shuffle refs (not a Model config). "
+ f"Valid examples:\n" + _CONDITION_EXAMPLES_SHUFFLE + _CONDITION_EXAMPLES_FROM_FILE
+ )
+
+ try:
+ conditions = ConditionsConfig.build(condition_cfg)
+ except (TypeError, ValueError) as e:
+ raise ValueError(error_message) from e
+
+ if conditions is None:
+ raise ValueError(
+ "CTD evaluation requires conditions in the pytorch_config "
+ "(`inference.conditions`). Got None.\n" + error_message
+ )
+
+ if isinstance(conditions, ConditionsFileConfig):
+ cond_provider = CondFromFile(filepath=conditions.filepath)
+ elif isinstance(conditions, ConditionsShuffleConfig):
+ config = conditions.config
+ if config is None and isinstance(loader, data.DLCLoader):
+ config = loader.project_root / "config.yaml"
+ if config is None:
+ raise ValueError(
+ "Cannot load shuffle conditions for evaluation: no project config "
+ "available. Set 'config' in the shuffle conditions.\n" + error_message
+ )
+ cond_provider = CondFromFile(
+ config=config,
+ shuffle=conditions.shuffle,
+ trainset_index=conditions.trainset_index,
+ modelprefix=conditions.modelprefix,
+ snapshot=conditions.snapshot,
+ snapshot_index=conditions.snapshot_index,
+ )
+ else:
+ raise ValueError(error_message)
+
+ return cond_provider.load_conditions(images, path_prefix=loader.image_root)
+
+
+_CONDITION_EXAMPLES_SHUFFLE = """
+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..5103f6927c
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/evaluation.py
@@ -0,0 +1,1008 @@
+#
+# 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.config import ProjectConfig
+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]]]:
+ """Evaluate.
+
+ 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=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: Path,
+ output_dir: 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
+ """
+ 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,
+ image_path,
+ output_dir,
+ belongs_to_train=False,
+ )
+ erase_artists(ax)
+ plt.close()
+
+
+def evaluate_snapshot(
+ cfg: ProjectConfig,
+ 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.select("detector.model.box_score_thresh") or 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=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, ...]:
+ """Image to dlc df index.
+
+ 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..18c9172f57
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/export.py
@@ -0,0 +1,197 @@
+#
+# 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
+from deeplabcut.core.config import ProjectConfig
+from deeplabcut.pose_estimation_pytorch.config import PoseConfig
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def export_model(
+ config: ProjectConfig | dict | Path | str,
+ 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 = ProjectConfig.from_any(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)
+
+ # Convert typed config to plain dict so torch.save doesn't pickle
+ # custom types (which require weights_only=False to load)
+ if isinstance(model_cfg, PoseConfig):
+ model_cfg = model_cfg.to_dict()
+
+ 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:
+ """Get the export folder name.
+
+ 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:
+ """Get the export filename.
+
+ 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: PoseConfig) -> 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 = None
+ model_cfg.metadata.pose_config_path = None
+ model_cfg.train_settings.weight_init = None
+ model_cfg.resume_training_from = None
+ if model_cfg.detector is not None:
+ 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..0fb26e1460
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/prune_paf_graph.py
@@ -0,0 +1,289 @@
+#
+# 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.model_cfg.model.heads["bodypart"]["predictor"]["edges_to_keep"] = best_edges
+ loader.model_cfg.log_changes()
+ loader.model_cfg.to_yaml(loader.model_config_path)
+
+ # update the edges indices
+ test_config = loader.model_folder.parent / "test" / "pose_cfg.yaml"
+
+ # NOTE @deruyter92 2026-06-23: Pruned edges are persisted on loader.model_cfg
+ # (edges_to_keep). The paf_best on test/pose_cfg.yaml is a legacy field still
+ # read by PyTorch shelving/videos metadata (PAFinds).
+ 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 + order[:length].tolist())
+
+ 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..c608316e8a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/tracking_dataset.py
@@ -0,0 +1,288 @@
+#
+# 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.core.deprecation import renamed_parameter
+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
+
+
+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"]
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ crop_cfg = data_cfg.get("top_down_crop") or {}
+ 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 | Path,
+ videos: list[str] | list[Path],
+ track_method: str,
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ destfolder: str | Path | 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..731b8b3d6f
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/tracklets.py
@@ -0,0 +1,337 @@
+#
+# 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.deprecation import renamed_parameter
+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
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def convert_detections2tracklets(
+ config: str | Path,
+ videos: str | list[str],
+ video_extensions: str | Sequence[str] | None = None,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ overwrite: bool = False,
+ destfolder: str | Path | 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 = Path.cwd() # 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 = cfg.project_path / rel_model_dir
+ path_test_config = model_dir / "test" / "pose_cfg.yaml"
+ dlc_cfg = auxiliaryfunctions.read_plainconfig(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 Path(track_filename).open("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..9afcfe1986
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/training.py
@@ -0,0 +1,390 @@
+#
+# 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)
+
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ if (
+ loader.model_cfg["model"].get("freeze_bn_stats", False)
+ or (loader.model_cfg["model"].get("backbone") or {}).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",
+ )
+
+ # Pose model training settings
+ if batch_size is not None:
+ loader.model_cfg.train_settings.batch_size = batch_size
+ if epochs is not None:
+ loader.model_cfg.train_settings.epochs = epochs
+ if save_epochs is not None:
+ loader.model_cfg.runner.snapshots.save_epochs = save_epochs
+ if display_iters is not None:
+ loader.model_cfg.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:
+ loader.model_cfg.detector.train_settings.batch_size = detector_batch_size
+ if detector_epochs is not None:
+ loader.model_cfg.detector.train_settings.epochs = detector_epochs
+ if detector_save_epochs is not None:
+ loader.model_cfg.detector.runner.snapshots.save_epochs = detector_save_epochs
+ if display_iters is not None:
+ loader.model_cfg.detector.train_settings.display_iters = display_iters
+
+ # Optional generic overrides
+ if pytorch_cfg_updates is not None:
+ for key, value in pytorch_cfg_updates.items():
+ loader.model_cfg.set_nested(key, value)
+
+ # Save the updated config
+ loader.model_cfg.log_changes()
+ loader.model_cfg.to_yaml(loader.model_config_path)
+
+ 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..b82f15dec1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/utils.py
@@ -0,0 +1,934 @@
+#
+# 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 ProjectConfig
+from deeplabcut.core.deprecation import deprecated
+from deeplabcut.core.engine import Engine
+from deeplabcut.pose_estimation_pytorch.config.ctd_conditions import ConditionsModelConfig
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+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
+
+
+def parse_snapshot_index_for_analysis(
+ cfg: ProjectConfig | dict | Path | str,
+ model_cfg: PoseConfig | dict | Path | str,
+ 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 (ProjectConfig | dict | Path | str): The project configuration.
+ model_cfg (PoseConfig | dict | Path | str): The PyTorch pose 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
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ model_cfg = PoseConfig.from_any(model_cfg)
+
+ 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 | Path,
+ shuffle: int = 1,
+ trainingsetindex: int = 0,
+ modelprefix: str = "",
+) -> tuple[Path, Path, Path]:
+ """Return the train network 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 = 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]:
+ """Get the model snapshots.
+
+ 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:
+ """Get the scorer uid.
+
+ 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: ProjectConfig | dict | Path | str,
+ 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 (ProjectConfig | dict | Path | str): 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
+ """
+ cfg = ProjectConfig.from_any(cfg)
+ model_dir = cfg.project_path / auxiliaryfunctions.get_model_folder(
+ train_fraction,
+ shuffle,
+ cfg,
+ engine=Engine.PYTORCH,
+ modelprefix=modelprefix,
+ )
+ train_dir = model_dir / "train"
+ model_cfg = PoseConfig.from_yaml(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: PoseConfig | dict | str | Path,
+ 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 (PoseConfig | dict | str | Path): The PyTorch pose configuration.
+ 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
+ """
+ model_config = PoseConfig.from_any(model_config)
+ 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:
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ crop_cfg = model_config["data"]["inference"].get("top_down_crop") or {}
+ 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: PoseConfig | dict | str | Path,
+ 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 (PoseConfig | dict | str | Path): The PyTorch pose configuration.
+ 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
+ """
+ model_config = PoseConfig.from_any(model_config)
+ 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: PoseConfig | dict | str | Path | 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 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 (PoseConfig | dict | str | Path | None, optional): Optional pose
+ configuration 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:
+ model_config = PoseConfig.from_any(model_config)
+ 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 and model_config is not 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: PoseConfig | dict | str | Path,
+ 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: ConditionsModelConfig | None = None,
+ ctd_tracking: bool | CTDTrackingConfig = False,
+ inference_cfg: InferenceConfig | dict | None = None,
+) -> PoseInferenceRunner:
+ """Builds an inference runner for pose estimation.
+
+ Args:
+ model_config (PoseConfig | dict | str | Path): The PyTorch pose configuration.
+ 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. A resolved ``ConditionsModelConfig`` for
+ the BU model used to generate conditions. Resolve shuffle / dict inputs
+ via ``ConditionsModelConfig.resolve_from_conditions()`` before calling
+ this function. File configs are not valid for live inference.
+ 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
+ """
+ model_config = PoseConfig.from_any(model_config)
+ 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:
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ crop_cfg = model_config["data"]["inference"].get("top_down_crop") or {}
+ 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=PoseConfig.from_yaml(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..019103dddc
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/videos.py
@@ -0,0 +1,946 @@
+#
+# 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.core.config import ProjectConfig
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.pose_estimation_pytorch.apis.ctd import get_conditions_provider_for_video
+from deeplabcut.pose_estimation_pytorch.apis.tracklets import (
+ convert_detections2tracklets,
+)
+from deeplabcut.pose_estimation_pytorch.config.ctd_conditions import ConditionsModelConfig, ConditionsShuffleConfig
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.data import DLCLoader
+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
+
+
+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 | Path,
+ videos: str | Path | list[str | Path],
+ 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 | Path | 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 | ConditionsShuffleConfig | ConditionsModelConfig | 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. Specifies the BU model used to generate
+ conditions. If None, loaded from the pytorch_config file (under
+ ``"inference": "conditions"``). Accepts a raw dict or a typed
+ ``ConditionsModelConfig`` / ``ConditionsShuffleConfig``. A BU model
+ is required for live inference; file / path conditions not valid.
+ Example: ``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
+ """
+ config = Path(config)
+ destfolder = Path(destfolder) if destfolder is not None else None
+ # 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]
+
+ cond_provider = None
+ if loader.pose_task == Task.COND_TOP_DOWN:
+ ctd_conditions = loader.model_cfg.inference.conditions if ctd_conditions is None else ctd_conditions
+ if ctd_conditions is None:
+ raise ValueError("CTD conditions are required for video analysis with cond-top-down models")
+ cond_provider = ConditionsModelConfig.resolve_from_conditions(ctd_conditions, config=config)
+
+ 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 (output_path / f"{output_prefix}_meta.pickle").open("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 Path(output_pkl).open("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 full_data_path.open("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 Path(output_path).open("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, exist_ok=True)
+
+ assert Path(output_folder).is_dir(), f"Output folder must be a directory: you passed '{output_folder}'"
+
+
+def _generate_metadata(
+ cfg: ProjectConfig,
+ pytorch_config: PoseConfig,
+ 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.to_dict(),
+ "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..0bc4aa0b66
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/apis/visualization.py
@@ -0,0 +1,660 @@
+#
+# 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 ProjectConfig
+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: ProjectConfig | dict | Path | str,
+ 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 (ProjectConfig | dict | Path | str): The project configuration.
+ 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 = ProjectConfig.from_any(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"]
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ if bpt_head_cfg["type"] == "DLCRNetHead":
+ paf_graph = (bpt_head_cfg.get("predictor") or {}).get("graph")
+ paf_indices = (bpt_head_cfg.get("predictor") or {}).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).absolute() 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: ProjectConfig | dict | Path | str,
+ 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 (ProjectConfig | dict | Path | str): The project configuration.
+ 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 = ProjectConfig.from_any(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:
+ tuple: keys, images, and 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:
+ tuple: img, scmap, locref, and 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: ProjectConfig,
+ 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 = 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..0023ae7e54
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/__init__.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
+#
+# For backwards compatibility
+from deeplabcut.core.config import (
+ pretty_print,
+ read_config_as_dict,
+ write_config,
+)
+from deeplabcut.pose_estimation_pytorch.config.ctd_conditions import (
+ ConditionsConfig,
+ ConditionsFileConfig,
+ ConditionsModelConfig,
+ ConditionsShuffleConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.data import (
+ COCOLoaderConfig,
+ DataConfig,
+ DataLoaderType,
+ DataTransformationConfig,
+ DLCLoaderConfig,
+ GenSamplingConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.enums import (
+ DatasetType,
+ DetectorType,
+ MethodType,
+ NetType,
+)
+from deeplabcut.pose_estimation_pytorch.config.inference import (
+ AutocastConfig,
+ CompileConfig,
+ EvaluationConfig,
+ InferenceConfig,
+ MultithreadingConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.logger import (
+ CSVLoggerConfig,
+ LoggerConfig,
+ LoggerType,
+ WandbLoggerConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ # add_detector,
+ # add_identity_head,
+ # add_metadata,
+ # add_unique_bodypart_head,
+ # create_backbone_with_heatmap_model,
+ # create_backbone_with_paf_model,
+ make_basic_project_config,
+ make_pytorch_pose_config,
+ make_pytorch_test_config,
+)
+from deeplabcut.pose_estimation_pytorch.config.metadata import PoseMetadata
+from deeplabcut.pose_estimation_pytorch.config.model import (
+ DetectorModelConfig,
+ ModelConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.paf_parameters import PAFParameters
+from deeplabcut.pose_estimation_pytorch.config.pose import (
+ DetectorConfig,
+ PoseConfig,
+ TestConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.runner import (
+ OptimizerConfig,
+ RunnerConfig,
+ SchedulerConfig,
+ SnapshotCheckpointConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.training import TrainSettingsConfig
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ available_detectors,
+ available_models,
+ get_config_folder_path,
+ is_model_cond_top_down,
+ is_model_top_down,
+ load_backbones,
+ load_base_config,
+ load_detectors,
+ replace_default_values,
+ update_config,
+ update_config_by_dotpath,
+)
+
+__all__ = [
+ # Backwards compatibility (core config I/O)
+ "pretty_print",
+ "read_config_as_dict",
+ "write_config",
+ # Config creation API
+ # "add_detector",
+ # "add_identity_head",
+ # "add_metadata",
+ # "add_unique_bodypart_head",
+ # "create_backbone_with_heatmap_model",
+ # "create_backbone_with_paf_model",
+ "make_basic_project_config", # deprecated
+ "make_pytorch_pose_config", # deprecated
+ "make_pytorch_test_config", # deprecated
+ # Config utilities
+ "available_detectors",
+ "available_models",
+ "get_config_folder_path",
+ "is_model_cond_top_down",
+ "is_model_top_down",
+ "load_backbones",
+ "load_base_config",
+ "load_detectors",
+ "replace_default_values",
+ "update_config",
+ "update_config_by_dotpath",
+ # Data config
+ "COCOLoaderConfig",
+ "DataConfig",
+ "DataLoaderType",
+ "DataTransformationConfig",
+ "DLCLoaderConfig",
+ "GenSamplingConfig",
+ # CTD conditions config
+ "ConditionsConfig",
+ "ConditionsFileConfig",
+ "ConditionsModelConfig",
+ "ConditionsShuffleConfig",
+ # Inference config
+ "AutocastConfig",
+ "CompileConfig",
+ "EvaluationConfig",
+ "InferenceConfig",
+ "MultithreadingConfig",
+ # Logger config
+ "CSVLoggerConfig",
+ "LoggerConfig",
+ "LoggerType",
+ "WandbLoggerConfig",
+ # Model config
+ "DetectorModelConfig",
+ "ModelConfig",
+ # Pose config
+ "DetectorType",
+ "DatasetType",
+ "DetectorConfig",
+ "MethodType",
+ "NetType",
+ "PoseConfig",
+ "PoseMetadata",
+ "TestConfig",
+ # Runner config
+ "OptimizerConfig",
+ "RunnerConfig",
+ "SchedulerConfig",
+ "SnapshotCheckpointConfig",
+ # Training config
+ "TrainSettingsConfig",
+ # PAF parameters
+ "PAFParameters",
+]
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/ctd_conditions.py b/deeplabcut/pose_estimation_pytorch/config/ctd_conditions.py
new file mode 100644
index 0000000000..4bd4283028
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/ctd_conditions.py
@@ -0,0 +1,243 @@
+#
+# 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
+#
+"""Typed configuration for CTD (Conditional Top-Down) model conditions.
+
+Three subclasses cover the supported input forms:
+
+- ``ConditionsFileConfig`` â pre-computed predictions from a file (evaluation only)
+- ``ConditionsModelConfig`` â fully resolved BU model (config path + snapshot path)
+ for live ``analyze_*`` inference
+- ``ConditionsShuffleConfig`` â unresolved shuffle shorthand. At runtime:
+ - live inference â ``ConditionsModelConfig.resolve_from_conditions()``
+ - evaluation â ``CondFromFile(config=..., shuffle=..., ...)``
+
+Use ``ConditionsConfig.build()`` to normalise any raw input (str, Path, dict) into one
+of these types. ``build()`` is pure (no filesystem access) and safe to call from Pydantic
+validators. Resolution that requires the filesystem must go through
+``ConditionsModelConfig.resolve_from_conditions()``.
+
+Context rules for ``inference.conditions`` / ``ctd_conditions``:
+
+- **Evaluation** accepts File and Shuffle (loads pre-computed BU predictions).
+- **Live analyze** (``analyze_images`` / ``analyze_videos``) accepts Shuffle and Model
+ only. A predictions file path in the YAML is evaluation-only and is rejected by
+ ``resolve_from_conditions``.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from deeplabcut.core.config import DLCBaseConfig
+
+
+class ConditionsConfig(DLCBaseConfig):
+ """Base class for CTD conditions configuration.
+
+ Use ``ConditionsConfig.build()`` to normalise any raw input into a typed subclass.
+
+ Subclasses:
+ - ``ConditionsFileConfig`` â pre-computed predictions file (evaluation only)
+ - ``ConditionsModelConfig`` â resolved BU model (config + snapshot paths)
+ - ``ConditionsShuffleConfig`` â unresolved shuffle shorthand (resolve to Model
+ for live inference, or to ``CondFromFile`` for evaluation)
+ """
+
+ source: Literal["file", "model", "shuffle"]
+
+ @classmethod
+ def build(
+ cls,
+ v: str | Path | dict | ConditionsConfig | None,
+ ) -> ConditionsFileConfig | ConditionsModelConfig | ConditionsShuffleConfig | None:
+ """Normalise any raw input into a typed conditions config.
+
+ This method is pure â it never touches the filesystem. For shuffle
+ shorthand inputs it returns a ``ConditionsShuffleConfig`` (unresolved).
+ To obtain a fully resolved ``ConditionsModelConfig`` call
+ ``ConditionsModelConfig.resolve_from_conditions()`` at the point where
+ the project config is available.
+
+ Args:
+ v: Raw input. Accepted forms:
+ - ``None`` or an existing ``ConditionsConfig`` â returned unchanged
+ - ``str`` / ``Path`` â ``ConditionsFileConfig``
+ - ``dict`` with ``filepath`` â ``ConditionsFileConfig``
+ - ``dict`` with ``config_path`` + ``snapshot_path`` â ``ConditionsModelConfig``
+ - ``dict`` with ``shuffle`` â ``ConditionsShuffleConfig``
+
+ Returns:
+ A typed ``ConditionsConfig`` subclass, or ``None``.
+ """
+ if v is None or isinstance(v, ConditionsConfig):
+ return v
+ if isinstance(v, (str, Path)):
+ return ConditionsFileConfig(filepath=Path(v))
+ if isinstance(v, dict):
+ v = v.copy()
+ source = v.pop("source", None)
+ if source == "file" or (source is None and "filepath" in v):
+ return ConditionsFileConfig(**v)
+ if source == "shuffle" or (source is None and "shuffle" in v):
+ return ConditionsShuffleConfig(**v)
+ if source == "model" or "config_path" in v or "snapshot_path" in v:
+ return ConditionsModelConfig(**v)
+ raise ValueError(
+ "Cannot determine conditions source from dict. "
+ "Provide 'filepath' for a file source, "
+ "'config_path'+'snapshot_path' for a model source, "
+ "or 'shuffle' for a shuffle shorthand."
+ )
+ raise TypeError(f"Cannot build a ConditionsConfig from {type(v).__name__!r}: {v!r}")
+
+
+class ConditionsFileConfig(ConditionsConfig):
+ """Conditions loaded from a pre-computed predictions file (.h5, .json, .pickle).
+
+ File-based conditions are for **evaluation only** (``load_conditions_for_evaluation``
+ / ``CondFromFile``). They cannot be used for live ``analyze_images`` /
+ ``analyze_videos`` inference â use a shuffle or ``ConditionsModelConfig`` instead.
+
+ Attributes:
+ filepath: Path to the predictions file.
+ """
+
+ source: Literal["file"] = "file"
+ filepath: Path
+
+
+class ConditionsModelConfig(ConditionsConfig):
+ """Resolved config for a BU model (i.e. a snapshot ref for live inference).
+
+ Attributes:
+ config_path: Path to the BU model's ``pytorch_config.yaml``.
+ snapshot_path: Path to the BU snapshot file.
+ scorer: Scorer name for the BU model. Used to look for pre-computed
+ conditions files on disk before running the model.
+ """
+
+ source: Literal["model"] = "model"
+ config_path: Path
+ snapshot_path: Path
+ scorer: str | None = None
+
+ @classmethod
+ def from_shuffle(
+ cls,
+ config: str | Path,
+ shuffle: int,
+ trainset_index: int = 0,
+ modelprefix: str = "",
+ snapshot: str | None = None,
+ snapshot_index: int | None = None,
+ ) -> ConditionsModelConfig:
+ """Resolve a DLC BU shuffle to its model config and snapshot paths."""
+ from deeplabcut.pose_estimation_pytorch.data.ctd import resolve_bu_shuffle
+
+ loader, bu_snapshot = resolve_bu_shuffle(config, shuffle, trainset_index, modelprefix, snapshot, snapshot_index)
+ return cls(
+ config_path=loader.model_config_path,
+ snapshot_path=bu_snapshot.path,
+ scorer=loader.scorer(bu_snapshot),
+ )
+
+ @classmethod
+ def resolve_from_conditions(
+ cls,
+ conditions: dict | ConditionsShuffleConfig | ConditionsModelConfig,
+ config: str | Path | None = None,
+ ) -> ConditionsModelConfig:
+ """Resolve conditions input to a ``ConditionsModelConfig`` for live BUCTD
+ inference (``analyze_images`` / ``analyze_videos``).
+
+ Call this in runtime code. It may touch the filesystem when resolving a
+ ``ConditionsShuffleConfig`` to a ``ConditionsModelConfig``.
+
+ Args:
+ conditions: A dict, ``ConditionsShuffleConfig``, or
+ ``ConditionsModelConfig``.File / path conditions cannot be resolved
+ to a BU model for live inference; they are rejected.
+ config: Project ``config.yaml`` path. Required when resolving a
+ ``ConditionsShuffleConfig`` that does not already carry one in its
+ ``config`` attribute.
+
+ Returns:
+ A resolved ``ConditionsModelConfig``.
+
+ Raises:
+ ValueError: If ``conditions`` builds to a ``ConditionsFileConfig``, or
+ if a shuffle config has no project config available.
+ TypeError: If ``conditions`` cannot be built into a supported type.
+ """
+ if not isinstance(conditions, ConditionsConfig):
+ conditions = ConditionsConfig.build(conditions)
+
+ if isinstance(conditions, ConditionsFileConfig):
+ raise ValueError(
+ "File-based conditions are for evaluation only and cannot be used "
+ "for live BU inference. Provide a ConditionsModelConfig "
+ "('config_path'+'snapshot_path') or a ConditionsShuffleConfig / "
+ "shuffle dict."
+ )
+ if isinstance(conditions, cls):
+ return conditions
+
+ if not isinstance(conditions, ConditionsShuffleConfig):
+ raise TypeError(
+ f"Cannot resolve conditions of type {type(conditions).__name__} "
+ "for live BU inference. Expected ConditionsShuffleConfig, "
+ "ConditionsModelConfig, or a dict that builds to one of those."
+ )
+ cfg = conditions.config or (Path(config) if config is not None else None)
+ if cfg is None:
+ raise ValueError(
+ "Cannot resolve shuffle conditions: no project config provided. "
+ "Set 'config' in the shuffle conditions or pass it to "
+ "resolve_from_conditions()."
+ )
+ return cls.from_shuffle(
+ config=cfg,
+ shuffle=conditions.shuffle,
+ trainset_index=conditions.trainset_index,
+ modelprefix=conditions.modelprefix,
+ snapshot=conditions.snapshot,
+ snapshot_index=conditions.snapshot_index,
+ )
+
+
+class ConditionsShuffleConfig(ConditionsConfig):
+ """Unresolved shuffle shorthand for CTD conditions.
+
+ Stores shuffle parameters without touching the filesystem. Resolve at runtime
+ when the project config is available:
+
+ - Live BU inference: ``ConditionsModelConfig.resolve_from_conditions()``
+ - Evaluation predictions file: ``CondFromFile(config=..., shuffle=..., ...)``
+
+ Attributes:
+ shuffle: The index of the BU shuffle to use for conditions.
+ config: Path to the DLC project ``config.yaml``. Optional â can be
+ injected later via ``resolve_from_conditions(config=...)``.
+ trainset_index: The TrainingsetFraction index.
+ modelprefix: The model prefix for the shuffle.
+ snapshot: Specific snapshot filename to use. Takes priority over
+ ``snapshot_index``.
+ snapshot_index: Index of the snapshot to use (default: -1, last).
+ """
+
+ source: Literal["shuffle"] = "shuffle"
+ shuffle: int
+ config: Path | None = None
+ trainset_index: int = 0
+ modelprefix: str = ""
+ snapshot: str | None = None
+ snapshot_index: int | None = None
diff --git a/deeplabcut/pose_estimation_pytorch/config/data.py b/deeplabcut/pose_estimation_pytorch/config/data.py
new file mode 100644
index 0000000000..95054c3a9b
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/data.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
+#
+"""Data configuration classes for DeepLabCut pose estimation models."""
+
+from enum import Enum
+from typing import Literal
+
+from pydantic import ConfigDict, Field, field_validator
+
+from deeplabcut.core.config import DLCBaseConfig
+from deeplabcut.core.config.validation import Fraction, NonNegativeFloat, NonNegativeInt
+
+
+class DataLoaderType(str, Enum):
+ DLCLoader = "DLCLoader"
+ COCOLoader = "COCOLoader"
+
+
+class DLCLoaderConfig(DLCBaseConfig):
+ """Configuration for DeepLabCut Loader.
+
+ Attributes:
+ type: Loader type identifier
+ config: Path to the DeepLabCut project config, or the project config itself
+ trainset_index: Index of the TrainingsetFraction for which to load data
+ shuffle: Index of the shuffle for which to load data
+ modelprefix: The modelprefix for the shuffle
+ """
+
+ type: Literal[DataLoaderType.DLCLoader]
+ config: str | dict
+ trainset_index: NonNegativeInt = 0
+ shuffle: NonNegativeInt = 0
+ modelprefix: str = ""
+
+
+class COCOLoaderConfig(DLCBaseConfig):
+ """Configuration for COCO Loader.
+
+ Attributes:
+ type: Loader type identifier
+ """
+
+ type: Literal[DataLoaderType.COCOLoader]
+ # TODO @deruyter92 2026-06-05: COCOLoader is never build from config, add config when needed
+
+
+class DataTransformationConfig(DLCBaseConfig):
+ """Data transformation configuration.
+
+ Attributes:
+ resize: Resize transformation configuration
+ longest_max_size: Maximum size for longest edge
+ hflip: Horizontal flip configuration
+ affine: Affine transformation configuration
+ random_bbox_transform: Random bbox transformation configuration
+ crop_sampling: Crop sampling configuration
+ hist_eq: Whether to apply histogram equalization
+ motion_blur: Whether to apply motion blur
+ covering: Covering/CoarseDropout transformation configuration
+ elastic_transform: Elastic transformation configuration
+ grayscale: Grayscale transformation configuration
+ gaussian_noise: Gaussian noise standard deviation
+ auto_padding: Auto padding configuration
+ normalize_images: Whether to normalize images
+ scale_to_unit_range: Whether to scale images to [0, 1] range
+ top_down_crop: Top-down crop configuration
+ collate: Collate function configuration
+ """
+
+ resize: dict | None = None
+ longest_max_size: int | dict | None = None
+ hflip: bool | float | dict | None = None
+ affine: dict | None = None
+ random_bbox_transform: dict | None = None
+ crop_sampling: dict | None = None
+ hist_eq: bool | dict | None = False
+ motion_blur: bool | dict | None = False
+ covering: bool | dict | None = None
+ elastic_transform: bool | dict | None = None
+ grayscale: bool | dict | None = None
+ gaussian_noise: bool | float | int | dict | None = None
+ auto_padding: dict | None = None
+ normalize_images: bool | dict | None = True
+ scale_to_unit_range: bool | dict | None = False
+ top_down_crop: dict | None = None
+ collate: dict | None = None
+
+
+class GenSamplingConfig(DLCBaseConfig):
+ """Configuration for CTD models.
+
+ Args:
+ 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.
+ """
+
+ model_config = ConfigDict(frozen=True)
+
+ keypoint_sigmas: NonNegativeFloat | list[NonNegativeFloat] = 0.1
+ keypoints_symmetry: list[tuple[int, int]] | None = None
+ jitter_prob: Fraction = 0.16
+ swap_prob: Fraction = 0.08
+ inv_prob: Fraction = 0.03
+ miss_prob: Fraction = 0.10
+
+
+class DetectorDataConfig(DLCBaseConfig):
+ """Data configuration for object-detector training (no pose-only fields)."""
+
+ colormode: Literal["RGB"] = "RGB"
+ inference: DataTransformationConfig | None = None
+ train: DataTransformationConfig | None = None
+
+ @field_validator("train", "inference", mode="before")
+ @classmethod
+ def validate_transforms(cls, v):
+ from deeplabcut.pose_estimation_pytorch.data import build_transforms
+
+ try:
+ build_transforms(v)
+ except Exception as e:
+ raise ValueError(f"Could not build transforms. Please check your config. Config: {v}; Error: {e}") from e
+ return v
+
+
+class DataConfig(DLCBaseConfig):
+ """Complete data configuration.
+
+ Attributes:
+ bbox_margin: Bounding box margin for top-down models
+ colormode: Color mode for images (e.g., 'RGB', 'BGR')
+ gen_sampling: Generation sampling configuration
+ inference: Inference data configuration
+ train: Training data configuration
+ loader: Data loader configuration
+ """
+
+ bbox_margin: NonNegativeInt = 25
+ colormode: Literal["RGB"] = "RGB" # Docs state that it should never be changed to BGR
+ gen_sampling: GenSamplingConfig | None = None
+ inference: DataTransformationConfig | None = None
+ train: DataTransformationConfig | None = None
+ loader: DLCLoaderConfig | COCOLoaderConfig | None = Field(default=None, discriminator="type")
+
+ @field_validator("train", "inference", mode="before")
+ @classmethod
+ def validate_transforms(cls, v):
+ from deeplabcut.pose_estimation_pytorch.data import build_transforms
+
+ try:
+ build_transforms(v)
+ except Exception as e:
+ raise ValueError(f"Could not build transforms. Please check your config. Config: {v}; Error: {e}") from e
+ return v
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/enums.py b/deeplabcut/pose_estimation_pytorch/config/enums.py
new file mode 100644
index 0000000000..e5921f45f2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/enums.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import functools
+from enum import Enum
+
+_LEGACY_NET_TYPE_ALIASES = {
+ "dlcrnet_ms5": "dlcrnet_stride16_ms5",
+}
+_TOP_DOWN_PREFIX = "top_down_"
+
+
+class MethodType(str, Enum):
+ """Enumeration of pose estimation method types."""
+
+ BOTTOM_UP = "bu"
+ TOP_DOWN = "td"
+ CONDITIONAL_TOP_DOWN = "ctd"
+
+
+class NetType(str, Enum):
+ """Enumeration of network architecture types as stored in configs.
+
+ Note:
+ Aliases (e.g. ``top_down_resnet_50``) are user-facing names that map to
+ a canonical member plus an optional top-down flag. See ``alias``,
+ ``from_alias``, and ``available_aliases``
+ """
+
+ RESNET_50 = "resnet_50"
+ RESNET_101 = "resnet_101"
+
+ HRNET_W18 = "hrnet_w18"
+ HRNET_W32 = "hrnet_w32"
+ HRNET_W48 = "hrnet_w48"
+
+ CSPNEXT_S = "cspnext_s"
+ CSPNEXT_M = "cspnext_m"
+ CSPNEXT_X = "cspnext_x"
+
+ DEKR_W18 = "dekr_w18"
+ DEKR_W32 = "dekr_w32"
+ DEKR_W48 = "dekr_w48"
+
+ CTD_COAM_W32 = "ctd_coam_w32"
+ CTD_COAM_W48 = "ctd_coam_w48"
+ CTD_COAM_W48_HUMAN = "ctd_coam_w48_human"
+ CTD_PRENET_HRNET_W32 = "ctd_prenet_hrnet_w32"
+ CTD_PRENET_HRNET_W48 = "ctd_prenet_hrnet_w48"
+ CTD_PRENET_RTMPOSE_S = "ctd_prenet_rtmpose_s"
+ CTD_PRENET_RTMPOSE_M = "ctd_prenet_rtmpose_m"
+ CTD_PRENET_RTMPOSE_X = "ctd_prenet_rtmpose_x"
+ CTD_PRENET_RTMPOSE_X_HUMAN = "ctd_prenet_rtmpose_x_human"
+
+ DLCRNET_STRIDE16_MS5 = "dlcrnet_stride16_ms5"
+ DLCRNET_STRIDE32_MS5 = "dlcrnet_stride32_ms5"
+
+ RTMPOSE_S = "rtmpose_s"
+ RTMPOSE_M = "rtmpose_m"
+ RTMPOSE_X = "rtmpose_x"
+
+ ANIMALTOKENPOSE_BASE = "animaltokenpose_base"
+
+ @functools.cached_property
+ def is_backbone(self) -> bool:
+ from deeplabcut.pose_estimation_pytorch.config.utils import (
+ get_config_folder_path,
+ load_backbones,
+ )
+
+ return self.value in frozenset(load_backbones(get_config_folder_path()))
+
+ def alias(self, *, top_down: bool = False) -> str:
+ """User-facing name (e.g. ``top_down_resnet_50`` for backbone + TD)."""
+ if top_down and self.is_backbone:
+ return f"{_TOP_DOWN_PREFIX}{self.value}"
+ return self.value
+
+ @classmethod
+ def from_alias(cls, label: str) -> tuple[NetType, bool]:
+ """Parse user-facing / legacy name â (canonical enum, top_down)."""
+ label = _LEGACY_NET_TYPE_ALIASES.get(label, label)
+ label_has_td_prefix = False
+ if label.startswith(_TOP_DOWN_PREFIX):
+ label = label.removeprefix(_TOP_DOWN_PREFIX)
+ label_has_td_prefix = True
+ return cls(label), label_has_td_prefix
+
+ @classmethod
+ def available_aliases(cls) -> list[str]:
+ """All selectable model names for GUI / docs / ``create_training_dataset``."""
+ labels: list[str] = []
+ for net_type in cls:
+ labels.append(net_type.alias(top_down=False))
+ if net_type.is_backbone:
+ labels.append(net_type.alias(top_down=True))
+ return sorted(labels)
+
+
+class DetectorType(str, Enum):
+ """Enumeration of detector types."""
+
+ SSDLITE = "ssdlite"
+ FASTERRCNN_RESNET50_FPN_V2 = "fasterrcnn_resnet50_fpn_v2"
+ FASTERRCNN_MOBILENET_V3_LARGE_FPN = "fasterrcnn_mobilenet_v3_large_fpn"
+
+
+class DatasetType(str, Enum):
+ """Enumeration of dataset types."""
+
+ # TODO @deruyter92 2026-02-05: Add other dataset types as needed.
+ MULTIANIMAL_IMGAUG = "multi-animal-imgaug"
diff --git a/deeplabcut/pose_estimation_pytorch/config/inference.py b/deeplabcut/pose_estimation_pytorch/config/inference.py
new file mode 100644
index 0000000000..89c97ff953
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/inference.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
+#
+"""Inference configuration classes for DeepLabCut pose estimation models."""
+
+from typing import Any, Literal
+
+from pydantic import Field, field_validator
+
+from deeplabcut.core.config import DLCBaseConfig
+from deeplabcut.pose_estimation_pytorch.config.ctd_conditions import (
+ ConditionsConfig,
+ ConditionsFileConfig,
+ ConditionsModelConfig,
+ ConditionsShuffleConfig,
+)
+
+
+class MultithreadingConfig(DLCBaseConfig):
+ """Multithreading configuration for inference.
+
+ Attributes:
+ enabled: Whether multithreading is enabled
+ queue_length: Length of the processing queue
+ timeout: Timeout for processing tasks
+ """
+
+ enabled: bool = True
+ queue_length: int = 4
+ timeout: float = 30.0
+
+
+class CompileConfig(DLCBaseConfig):
+ """Model compilation configuration for inference optimization.
+
+ Attributes:
+ enabled: Whether compilation is enabled
+ mode: Compilation mode
+ """
+
+ enabled: bool = False
+ backend: str = "inductor"
+
+
+class AutocastConfig(DLCBaseConfig):
+ """Automatic mixed precision configuration.
+
+ Attributes:
+ enabled: Whether autocast is enabled
+ dtype: Data type for autocast (float16, bfloat16)
+ """
+
+ enabled: bool = False
+
+
+class EvaluationConfig(DLCBaseConfig):
+ """Configuration for evaluation metrics computation.
+
+ Attributes:
+ pcutoff: Confidence threshold for RMSE computation. Can be:
+ - float: Single threshold for all bodyparts
+ - list[float]: One value per bodypart (and unique bodypart if any)
+ - dict[str, float]: Mapping bodypart names to thresholds
+ comparison_bodyparts: Subset of bodyparts to compute metrics for.
+ Can be "all", None (all bodyparts), or a list of bodypart names.
+ per_keypoint_evaluation: Whether to compute train and test RMSE
+ for each keypoint individually.
+ force_multi_animal: If True, use multi-animal evaluation even if
+ loader contains only a single animal.
+ """
+
+ mode: Literal["train", "test", "all"] = "all"
+ pcutoff: float | list[float] | dict[str, float] = 0.6
+ comparison_bodyparts: Literal["all"] | list[str] | None = "all"
+ per_keypoint_evaluation: bool = False
+ force_multi_animal: bool = False
+
+
+class InferenceConfig(DLCBaseConfig):
+ """Complete inference configuration.
+
+ Attributes:
+ multithreading: Multithreading configuration
+ compile: Compilation configuration
+ autocast: Autocast configuration
+ conditions: Conditions for conditional models (CTD). File configs are
+ evaluation-only; Shuffle/Model are used for live analyze.
+ snapshot: Snapshot(s) to use for inference
+ eval: Evaluation configuration
+ """
+
+ multithreading: MultithreadingConfig = Field(default_factory=MultithreadingConfig)
+ compile: CompileConfig = Field(default_factory=CompileConfig)
+ autocast: AutocastConfig = Field(default_factory=AutocastConfig)
+ conditions: ConditionsModelConfig | ConditionsFileConfig | ConditionsShuffleConfig | None = None
+ snapshot: int | str | list[int] | None = None
+ eval: EvaluationConfig = Field(default_factory=EvaluationConfig)
+ output_dir: str | None = None
+
+ @field_validator("conditions", mode="before")
+ @classmethod
+ def _normalize_conditions(cls, v: Any) -> Any:
+ return ConditionsConfig.build(v)
diff --git a/deeplabcut/pose_estimation_pytorch/config/logger.py b/deeplabcut/pose_estimation_pytorch/config/logger.py
new file mode 100644
index 0000000000..a37a7d33cf
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/logger.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
+#
+"""Logger configuration classes for DeepLabCut training runs."""
+
+from enum import Enum
+from typing import Literal
+
+from deeplabcut.core.config import DLCBaseConfig
+
+
+class LoggerType(str, Enum):
+ WandbLogger = "WandbLogger"
+ CSVLogger = "CSVLogger"
+
+
+class LoggerConfig(DLCBaseConfig):
+ """Base configuration for all loggers.
+
+ Attributes:
+ type: The type of logger to use (WandbLogger or CSVLogger)
+ """
+
+ type: str
+
+
+class WandbLoggerConfig(LoggerConfig): #
+ """Configuration for Weights & Biases (wandb) logger.
+
+ This logger tracks experiments and logs data to Weights & Biases.
+ Refer to: https://docs.wandb.ai/guides for more information.
+
+ Attributes:
+ type: Logger type (should be 'WandbLogger')
+ project_name: The name of the wandb project
+ run_name: The name of the wandb run
+ image_log_interval: How often train/test images are logged in epochs
+ (if None, train/test inputs are never logged)
+ model: The model architecture to log
+ train_folder: The path of the folder containing training files.
+ wandb_kwargs: Additional keyword arguments to pass to wandb.init
+ """
+
+ type: Literal[LoggerType.WandbLogger]
+ project_name: str = "deeplabcut"
+ run_name: str = "tmp"
+ image_log_interval: int | None = None
+ model: dict | None = None
+ train_folder: str | None = None
+ wandb_kwargs: dict | None = None
+
+
+class CSVLoggerConfig(LoggerConfig): #
+ """Configuration for CSV logger.
+
+ This logger saves training stats and metrics to a CSV file.
+
+ Attributes:
+ type: Logger type (should be 'CSVLogger')
+ train_folder: The path of the folder containing training files.
+ log_filename: The name of the file in which to store training stats
+ """
+
+ type: Literal[LoggerType.CSVLogger]
+ train_folder: str = ""
+ log_filename: str = "learning_stats.csv"
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..a75067e0e1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/make_pose_config.py
@@ -0,0 +1,557 @@
+#
+# 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
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from deeplabcut.core.config import ProjectConfig, read_config_as_dict
+from deeplabcut.core.deprecation import deprecated
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.config.enums import DetectorType, NetType
+from deeplabcut.pose_estimation_pytorch.config.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.config.metadata import PoseMetadata
+from deeplabcut.pose_estimation_pytorch.config.paf_parameters import PAFParameters
+from deeplabcut.pose_estimation_pytorch.config.utils import (
+ get_config_folder_path,
+ load_backbones,
+ load_base_config,
+ replace_default_values,
+)
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+if TYPE_CHECKING:
+ from deeplabcut.pose_estimation_pytorch.config import DetectorConfig, PoseConfig, TestConfig
+
+logger = logging.getLogger(__name__)
+
+
+@deprecated(replacement="pose_estimation_pytorch.config.TestConfig.build", since="3.0.1")
+def make_pytorch_test_config(
+ model_config: PoseConfig | dict | str | Path,
+ test_config_path: str | Path,
+ save: bool = False,
+) -> TestConfig:
+ from deeplabcut.pose_estimation_pytorch.config import TestConfig
+
+ return TestConfig.build(model_config, test_config_path=test_config_path, save=save)
+
+
+@deprecated(replacement="pose_estimation_pytorch.config.PoseConfig.build", since="3.0.1")
+def make_pytorch_pose_config(
+ project_config: ProjectConfig | dict | Path | str,
+ pose_config_path: str | Path,
+ net_type: NetType | 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,
+) -> PoseConfig:
+ from deeplabcut.pose_estimation_pytorch.config import PoseConfig
+
+ return PoseConfig.build(
+ project_config,
+ pose_config_path,
+ net_type=net_type,
+ top_down=top_down,
+ detector_type=detector_type,
+ weight_init=weight_init,
+ save=save,
+ ctd_conditions=ctd_conditions,
+ )
+
+
+@deprecated(replacement="pose_estimation_pytorch.config.PoseMetadata", since="3.0.1")
+def make_basic_project_config(
+ dataset_path: Path | str,
+ bodyparts: list[str],
+ max_individuals: int,
+ multi_animal: bool = True,
+) -> dict:
+ """Deprecated factory for basic config dict for non-DLC projects."""
+ return PoseMetadata(
+ project_path=dataset_path,
+ bodyparts=bodyparts,
+ individuals=[f"individual{i:03d}" for i in range(max_individuals)],
+ ).to_dict_legacy()
+
+
+def build_pose_config_defaults(
+ net_type: NetType,
+ metadata: PoseMetadata,
+ *,
+ task: Task,
+ multi_animal: bool,
+ paf_parameters: PAFParameters | None = None,
+ weight_init: WeightInitialization | None = None,
+ detector_config: DetectorConfig | None = None,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
+) -> dict:
+ """
+ Load the model config defaults (from model-specific yaml) for a given project and net_type.
+
+ 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 (used to infer individuals, bodyparts and identity tracking)
+ net_type: the architecture of the desired pose estimation model
+ task: 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
+ 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.
+ Valid for both evaluation and live analyze.
+ * A predictions file path (ctd_conditions: string | Path), which must correspond to a .json or .h5
+ predictions file. Evaluation-only â not valid for ``analyze_images`` / ``analyze_videos``.
+ * 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 model configuration defaults as a dictionary.
+ """
+ configs_dir = get_config_folder_path()
+ base_cfg = load_base_config(configs_dir)
+ backbones = load_backbones(configs_dir)
+
+ if net_type in backbones:
+ if task == Task.BOTTOM_UP and multi_animal:
+ if paf_parameters is None:
+ raise ValueError("PAF parameters are required for multi-animal bottom-up models.")
+ model_cfg = _create_backbone_with_paf_model(
+ configs_dir=configs_dir,
+ net_type=net_type,
+ num_individuals=metadata.num_individuals,
+ bodyparts=metadata.bodyparts,
+ paf_parameters=paf_parameters.to_dict(),
+ )
+ else:
+ model_cfg = _create_backbone_with_heatmap_model(
+ configs_dir=configs_dir,
+ net_type=net_type,
+ multianimal_project=multi_animal,
+ bodyparts=metadata.bodyparts,
+ top_down=task == Task.TOP_DOWN,
+ )
+ else:
+ architecture = net_type.value.split("_")[0]
+ default_value_kwargs = {}
+ if architecture == "dlcrnet":
+ if paf_parameters is None:
+ raise ValueError("PAF parameters are required for DLCRNet models.")
+ default_value_kwargs.update(paf_parameters.to_dict())
+
+ cfg_path = configs_dir / architecture / f"{net_type.value}.yaml"
+ model_cfg = read_config_as_dict(cfg_path)
+ model_cfg = replace_default_values(
+ model_cfg,
+ num_bodyparts=metadata.num_bodyparts,
+ num_individuals=metadata.num_individuals,
+ **default_value_kwargs,
+ )
+ model_cfg["net_type"] = net_type.value
+
+ if task == Task.TOP_DOWN:
+ if detector_config is None:
+ raise ValueError("detector_config is required for top-down pose configs.")
+ model_cfg["detector"] = detector_config.to_dict()
+
+ # 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)}
+
+ model_cfg = _update_config(model_cfg, aug_cfg)
+ model_cfg = _update_config(base_cfg, model_cfg)
+
+ # add a unique bodypart head if needed
+ if metadata.unique_bodyparts:
+ if task != Task.BOTTOM_UP:
+ raise ValueError(
+ f"You selected a top-down model architecture ({net_type.value}), 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."
+ )
+
+ model_cfg = _add_unique_bodypart_head(
+ configs_dir,
+ model_cfg,
+ num_unique_bodyparts=metadata.num_unique_bodyparts,
+ backbone_output_channels=model_cfg["model"]["backbone_output_channels"],
+ )
+
+ # add an identity head if needed
+ if metadata.with_identity:
+ if task != Task.BOTTOM_UP:
+ raise ValueError(
+ f"You selected a top-down model architecture ({net_type.value}), 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`."
+ )
+
+ model_cfg = _add_identity_head(
+ configs_dir,
+ model_cfg,
+ num_individuals=metadata.num_individuals,
+ backbone_output_channels=model_cfg["model"]["backbone_output_channels"],
+ )
+
+ model_cfg["inference"] = InferenceConfig().to_dict()
+ # Add conditions for CTD models if specified
+ if task == Task.COND_TOP_DOWN:
+ if ctd_conditions is None:
+ raise ValueError("A CTD conditions is required for conditional-top-down models.")
+ _add_ctd_conditions(model_cfg, ctd_conditions)
+
+ # Add metadata and weight init to the model config
+ model_cfg["metadata"] = metadata.to_dict()
+ if weight_init is not None:
+ model_cfg["train_settings"]["weight_init"] = weight_init.to_dict()
+ return model_cfg
+
+
+def build_detector_config_defaults(
+ num_individuals: int,
+ detector_type: DetectorType,
+) -> dict:
+ """Adds a detector to a model.
+
+ Args:
+ configs_dir: path to the DeepLabCut "configs" directory
+ 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
+ """
+ configs_dir = get_config_folder_path()
+ detector_config = _update_config(
+ read_config_as_dict(configs_dir / "base" / "base_detector.yaml"),
+ read_config_as_dict(configs_dir / "detectors" / f"{detector_type.value}.yaml"),
+ )
+ detector_config = replace_default_values(
+ detector_config,
+ num_individuals=num_individuals,
+ )
+ return dict(sorted(detector_config.items()))
+
+
+# TODO @deruyter92 2026-06-12: currently, the responsibility for determining the task
+# is controlled either in the default config (for "non-backbone" models) or by the
+# API parameter `top_down` (for "backbone" models). This should be refactored.
+def _resolve_task(net_type: NetType, *, top_down: bool) -> Task:
+ configs_dir = get_config_folder_path()
+ backbones = load_backbones(configs_dir)
+ if net_type in backbones:
+ return Task.TOP_DOWN if top_down else Task.BOTTOM_UP
+ architecture = net_type.value.split("_")[0]
+ cfg_path = configs_dir / architecture / f"{net_type.value}.yaml"
+ model_cfg = read_config_as_dict(cfg_path)
+ return Task(model_cfg.get("method", "BU").upper())
+
+
+def resolve_net_type_and_task(
+ net_type: str | NetType | None,
+ *,
+ default: str,
+ top_down: bool,
+) -> tuple[NetType, Task]:
+ """Resolve the net type from build args and project config default.
+
+ Args:
+ net_type (str | None): Architecture name, or None to use ``default``
+ from project config.
+ default (str): Fallback when ``net_type`` is None. Invalid values warn
+ and fall back to ``resnet_50``.
+ top_down (bool): Build a top-down backbone (ignored for non-backbones).
+
+ Returns:
+ (NetType, Task): the resolved canonical NetType and Task
+ """
+ if net_type is None:
+ try:
+ net_type, td_prefix = NetType.from_alias(default)
+ except ValueError as e:
+ raise ValueError(
+ f"Invalid default_net_type in project config: {default}. Must be one of {NetType.available_aliases()}"
+ ) from e
+ else:
+ net_type, td_prefix = NetType.from_alias(str(net_type)) # fails loudly if invalid
+
+ if td_prefix:
+ if top_down:
+ logger.warning(
+ "Passed net_type with top_down prefix. Instead use "
+ "PoseConfig.build(..., top_down=True) to specify the task."
+ )
+ else:
+ raise ValueError(
+ "Passed net_type with top_down prefix. but top_down is False."
+ "Please use only PoseConfig.build(..., top_down=True/False) to specify the task."
+ )
+ task = _resolve_task(net_type, top_down=top_down)
+ return net_type, task
+
+
+# NOTE @deruyter92 2026-06-19: Moved this from public API to internal use only.
+# Only used for construction of default config dictionaries. Can likely be refactored.
+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 _add_ctd_conditions(model_cfg: dict, ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int]):
+ """Add ctd conditions.
+
+ 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. Valid for both evaluation and live
+ analyze (resolved to a BU model at runtime).
+ * A predictions file path (ctd_conditions: string | Path), which must
+ correspond to a .json or .h5 predictions file. Evaluation-only â
+ ``analyze_images`` / ``analyze_videos`` reject file conditions.
+ * 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.absolute())
+
+ 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 _create_backbone_with_heatmap_model(
+ configs_dir: Path,
+ net_type: NetType,
+ 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.value} or use a detector to create a top-down model."
+ )
+
+ # add the backbone to the config
+ model_config = read_config_as_dict(configs_dir / "backbones" / f"{net_type.value}.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: NetType,
+ 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.value}.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_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
diff --git a/deeplabcut/pose_estimation_pytorch/config/metadata.py b/deeplabcut/pose_estimation_pytorch/config/metadata.py
new file mode 100644
index 0000000000..496f66bba1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/metadata.py
@@ -0,0 +1,85 @@
+from pathlib import Path
+
+from pydantic import Field
+from typing_extensions import Self
+
+from deeplabcut.core.config import DLCBaseConfig, ProjectConfig
+from deeplabcut.core.config.validation import UniqueStrList
+
+
+# TODO @deruyter92 2026-06-08: This is duplicated from ProjectConfig. Note that field names are misaligned!
+# Once field names are aligned (in v1), we should merge these ProjectConfig subsets.
+class PoseMetadata(DLCBaseConfig):
+ project_path: Path | None = None
+ pose_config_path: Path | None = None
+ bodyparts: UniqueStrList = Field(default_factory=list)
+ unique_bodyparts: UniqueStrList = Field(default_factory=list, json_schema_extra={"aliases": ["uniquebodyparts"]})
+ individuals: UniqueStrList = Field(default_factory=lambda: ["individual_1"])
+
+ # TODO @deruyter92 2026-06-09: Nullable field to support old configs with empty identity field -> fix in v1
+ with_identity: bool | None = Field(default=None, json_schema_extra={"aliases": ["identity"]})
+
+ @property
+ def num_individuals(self) -> int:
+ return len(self.individuals)
+
+ @property
+ def num_bodyparts(self) -> int:
+ return len(self.bodyparts)
+
+ @property
+ def num_unique_bodyparts(self) -> int:
+ return len(self.unique_bodyparts) if self.unique_bodyparts is not None else 0
+
+ @classmethod
+ def build(
+ cls,
+ project_config: ProjectConfig | dict | Path | str,
+ *,
+ project_path: Path | str | None = None,
+ pose_config_path: Path | str | None = None,
+ bodyparts: list[str] | None = None,
+ unique_bodyparts: list[str] | None = None,
+ individuals: list[str] | None = None,
+ with_identity: bool | None = None,
+ ) -> Self:
+ """Get metadata from a project configuration with optional overrides"""
+ cfg = ProjectConfig.from_any(project_config)
+
+ # Conversions for diverging fields in ProjectConfig
+ cfg_bodyparts = cfg.bodyparts_list
+ cfg_unique_bodyparts = cfg.uniquebodyparts
+ cfg_with_identity = cfg.identity
+
+ return cls(
+ project_path=project_path if project_path is not None else cfg.project_path,
+ pose_config_path=pose_config_path if pose_config_path is not None else cfg.pose_config_path,
+ bodyparts=bodyparts if bodyparts is not None else cfg_bodyparts,
+ unique_bodyparts=unique_bodyparts if unique_bodyparts is not None else cfg_unique_bodyparts,
+ individuals=individuals if individuals is not None else cfg.individuals,
+ with_identity=with_identity if with_identity is not None else cfg_with_identity,
+ )
+
+ @classmethod
+ def build_for_superanimal(cls, super_animal: str, model_name: str, max_individuals: int) -> Self:
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import build_superanimal_metadata
+
+ metadata = build_superanimal_metadata(
+ super_animal=super_animal,
+ model_name=model_name,
+ max_individuals=max_individuals,
+ )
+ return cls.from_dict(metadata)
+
+ # NOTE @deruyter92 2026-06-12
+ # This serves as a replacement for pose_estimation_pytorch.config.make_basic_project_config.
+ # It can safely be removed when we stop supporting that API. Prefer PoseMetadata(..) instead.
+ def to_dict_legacy(self) -> dict:
+ return dict(
+ project_path=self.project_path,
+ multianimalproject=self.num_individuals > 1,
+ bodyparts=self.bodyparts if self.num_individuals <= 1 else "MULTI!",
+ multianimalbodyparts=self.bodyparts if self.num_individuals > 1 else None,
+ uniquebodyparts=[],
+ individuals=self.individuals,
+ )
diff --git a/deeplabcut/pose_estimation_pytorch/config/model.py b/deeplabcut/pose_estimation_pytorch/config/model.py
new file mode 100644
index 0000000000..a2f66300d1
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/model.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
+#
+"""Model configuration class for DeepLabCut pose estimation models."""
+
+from pydantic import Field
+
+from deeplabcut.core.config import DLCBaseConfig
+from deeplabcut.core.config.validation import Fraction
+
+
+class ModelConfig(DLCBaseConfig):
+ """Complete model configuration.
+
+ Attributes:
+ backbone: Backbone configuration
+ backbone_output_channels: Number of output channels from backbone
+ heads: Dictionary of head configurations by name
+ neck: Neck configuration
+ pose_model: Pose model configuration
+ """
+
+ backbone: dict = Field(default_factory=dict)
+ heads: dict[str, dict] = Field(default_factory=dict)
+ backbone_output_channels: int | None = None
+ neck: dict | None = None
+ pose_model: dict | None = None
+
+
+class DetectorModelConfig(DLCBaseConfig):
+ """Configuration for detector models
+
+ Attributes:
+ type: Type of detector model (e.g., FasterRCNN)
+ freeze_bn_stats: Whether to freeze batch normalization statistics
+ freeze_bn_weights: Whether to freeze batch normalization weights
+ variant: Specific variant of the detector model
+ """
+
+ type: str = ""
+ freeze_bn_stats: bool = False
+ freeze_bn_weights: bool = False
+ variant: str | None = None
+ box_score_thresh: Fraction | None = None
diff --git a/deeplabcut/pose_estimation_pytorch/config/paf_parameters.py b/deeplabcut/pose_estimation_pytorch/config/paf_parameters.py
new file mode 100644
index 0000000000..9f732c8b38
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/paf_parameters.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+
+from pydantic import Field
+from typing_extensions import Self
+
+from deeplabcut.core.config import DLCBaseConfig, ProjectConfig
+from deeplabcut.core.config.validation import NonNegativeInt
+
+
+class PAFParameters(DLCBaseConfig):
+ paf_graph: list[list[NonNegativeInt]] = Field(default_factory=list)
+ num_limbs: NonNegativeInt = 0
+ paf_edges_to_keep: list[NonNegativeInt] = Field(default_factory=list)
+
+ @classmethod
+ def build(
+ cls,
+ project_config: ProjectConfig | dict | Path | str,
+ *,
+ bodyparts: list[str] | None = None,
+ num_limbs_threshold: int = 105,
+ paf_graph_degree: int = 6,
+ ) -> Self:
+
+ # Normalize ProjectConfig + set defaults if not provided
+ project_config = ProjectConfig.from_any(project_config)
+ if bodyparts is None:
+ bodyparts = project_config.bodyparts_list
+
+ # Build the PAF parameters
+ return cls.from_dict(get_paf_parameters(project_config, bodyparts, num_limbs_threshold, paf_graph_degree))
+
+
+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."""
+ from deeplabcut.utils import auxfun_multianimal
+
+ 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/pose.py b/deeplabcut/pose_estimation_pytorch/config/pose.py
new file mode 100644
index 0000000000..faa58a85a8
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/pose.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
+#
+"""Main pose configuration class for DeepLabCut pose estimation models."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from deeplabcut.core.weight_init import WeightInitialization
+
+from pydantic import Field, field_validator
+from typing_extensions import Self
+
+from deeplabcut.core.config import DLCBaseConfig, DLCVersionedConfig, ProjectConfig
+from deeplabcut.core.config.validation import Fraction, NonNegativeInt, UniqueStrList
+from deeplabcut.pose_estimation_pytorch.config.data import DataConfig, DetectorDataConfig
+from deeplabcut.pose_estimation_pytorch.config.enums import DatasetType, DetectorType, MethodType, NetType
+from deeplabcut.pose_estimation_pytorch.config.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.config.logger import (
+ CSVLoggerConfig,
+ WandbLoggerConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import (
+ build_detector_config_defaults,
+ build_pose_config_defaults,
+ resolve_net_type_and_task,
+)
+from deeplabcut.pose_estimation_pytorch.config.metadata import PoseMetadata
+from deeplabcut.pose_estimation_pytorch.config.model import (
+ DetectorModelConfig,
+ ModelConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.paf_parameters import PAFParameters
+from deeplabcut.pose_estimation_pytorch.config.runner import RunnerConfig
+from deeplabcut.pose_estimation_pytorch.config.training import TrainSettingsConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+class DetectorConfig(DLCBaseConfig):
+ model: DetectorModelConfig
+ device: str = "auto"
+ data: DetectorDataConfig | None = None
+ runner: RunnerConfig | None = None
+ train_settings: TrainSettingsConfig | None = None
+ inference: InferenceConfig = Field(default_factory=InferenceConfig)
+ resume_training_from: str | None = None
+
+ @classmethod
+ def build(
+ cls,
+ num_individuals: int,
+ detector_type: DetectorType | None = None,
+ ) -> Self:
+ if detector_type is None:
+ detector_type = DetectorType.SSDLITE
+
+ return cls.from_dict(
+ build_detector_config_defaults(
+ num_individuals=num_individuals,
+ detector_type=detector_type,
+ )
+ )
+
+
+class PoseConfig(DLCVersionedConfig):
+ """Main configuration class for DeepLabCut pose estimation models.
+
+ This is the top-level configuration that brings together all the different
+ configuration domains (project, model, data, training, etc.).
+
+ Attributes:
+ net_type: Network architecture type (e.g., resnet_50, hrnet_w32, dlcrnet_stride16_ms5)
+ method: Method type (bu=Bottom-Up, td=Top-Down, ctd=Conditional Top-Down)
+ device: Device configuration (auto, cpu, cuda)
+ project: Project configuration (skeleton, individuals, etc.)
+ model: Model configuration (backbone, heads, etc.)
+ detector: Detector configuration (for top-down models)
+ data: Data configuration (loaders, transforms, etc.)
+ training: Training configuration (runner, optimizer, etc.)
+ inference: Inference configuration (multithreading, compilation, etc.)
+ logger: Logger configuration (e.g., WandB or CSV logger)
+ with_center_keypoints: Whether to include center keypoints (for DEKR models)
+ """
+
+ model: ModelConfig = Field(default_factory=ModelConfig)
+ net_type: NetType = NetType.RESNET_50
+ method: MethodType = MethodType.BOTTOM_UP
+ device: str = "auto"
+ metadata: PoseMetadata
+ data: DataConfig
+ runner: RunnerConfig
+ train_settings: TrainSettingsConfig
+ inference: InferenceConfig = Field(default_factory=InferenceConfig)
+ logger: CSVLoggerConfig | WandbLoggerConfig | None = Field(default=None, discriminator="type")
+ with_center_keypoints: bool = False
+ detector: DetectorConfig | None = None
+ resume_training_from: str | None = None
+
+ @field_validator("net_type", mode="before")
+ @classmethod
+ def _coerce_net_type(cls, v: object) -> NetType:
+ if isinstance(v, NetType):
+ return v
+ net_type, _ = NetType.from_alias(str(v))
+ return net_type
+
+ @classmethod
+ def build(
+ cls,
+ project_config: ProjectConfig | dict | Path | str,
+ pose_config_path: str | Path,
+ *,
+ top_down: bool,
+ multi_animal: bool | None = None,
+ net_type: NetType | str | None = None,
+ detector_type: DetectorType | str | None = None,
+ weight_init: WeightInitialization | dict | Path | str | None = None,
+ ctd_conditions: int | str | Path | tuple[int, str] | tuple[int, int] | None = None,
+ save: bool = False,
+ ) -> Self:
+ """Build a typed PoseConfig for a project
+
+ Args:
+ project_config (ProjectConfig | dict | Path | str): The project configuration.
+ pose_config_path (str | Path): The path to the pose configuration.
+ top_down (bool): Whether to use a top-down backbone.
+ net_type (NetType | str | None, optional): The network architecture type (without 'top_down_' prefix).
+ If None, the default net type from the project config will be used.
+ detector_type (DetectorType | str | None, optional): The detector architecture. Required for td models.
+ weight_init (WeightInitialization | None, optional): The weight initialization object or path.
+ ctd_conditions (int | str | Path | tuple[int, str] | tuple[int, int] | None, optional):
+ The conditional top-down conditions. Only required for CTD models.
+ A predictions file path is evaluation-only; shuffle refs work for
+ both evaluation and live analyze.
+ save (bool, optional): Whether to save the pose configuration.
+
+ Note:
+ For generic backbone models, ``method`` is resolved from ``top_down``. For non-backbone models,
+ the ``top_down`` is ignored and ``method`` is resolved from the default config.
+ """
+ from deeplabcut.core.weight_init import WeightInitialization
+
+ # Normalize input parameters + set defaults if not provided
+ project_config = ProjectConfig.from_any(project_config)
+ if multi_animal is None:
+ multi_animal = project_config.multianimalproject
+ net_type, task = resolve_net_type_and_task(
+ net_type,
+ default=project_config.default_net_type,
+ top_down=top_down,
+ )
+ if detector_type is not None:
+ detector_type = DetectorType(detector_type)
+ if weight_init is not None:
+ weight_init = WeightInitialization.from_any(weight_init)
+
+ # Build related configurations (PoseMetadata, PAFParameters, DetectorConfig)
+ metadata = PoseMetadata.build(project_config, pose_config_path=pose_config_path)
+ paf_parameters = None
+ detector_config = None
+ if task == Task.BOTTOM_UP and multi_animal:
+ paf_parameters = PAFParameters.build(project_config)
+ elif task == Task.TOP_DOWN:
+ detector_config = DetectorConfig.build(metadata.num_individuals, detector_type)
+
+ # Build the pose model config
+ defaults: dict = build_pose_config_defaults(
+ net_type=net_type,
+ metadata=metadata,
+ paf_parameters=paf_parameters,
+ weight_init=weight_init,
+ task=task,
+ multi_animal=multi_animal,
+ detector_config=detector_config,
+ ctd_conditions=ctd_conditions,
+ )
+ pose_config = cls.from_dict(defaults)
+
+ # Save if needed
+ if save:
+ pose_config.to_yaml(pose_config_path, overwrite=True)
+
+ return pose_config
+
+ @classmethod
+ def build_for_superanimal_inference(
+ cls,
+ super_animal: str,
+ *,
+ model_name: str,
+ detector_name: str | None = None,
+ max_individuals: int = 30,
+ device: str | None = None,
+ ) -> Self:
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import build_superanimal_inference_config
+
+ metadata = PoseMetadata.build_for_superanimal(
+ super_animal=super_animal, model_name=model_name, max_individuals=max_individuals
+ )
+ return cls.from_dict(
+ build_superanimal_inference_config(
+ super_animal=super_animal,
+ model_name=model_name,
+ detector_name=detector_name,
+ metadata=metadata,
+ device=device,
+ )
+ )
+
+ @classmethod
+ def build_for_superanimal_finetune(
+ cls,
+ project_config: ProjectConfig | dict | Path | str,
+ *,
+ model_name: str,
+ detector_name: str | None,
+ pose_config_path: str | Path,
+ weight_init: WeightInitialization,
+ inference_config: InferenceConfig | dict | Path | str | None = None,
+ save: bool = False,
+ ) -> Self:
+ from deeplabcut.pose_estimation_pytorch.modelzoo.config import build_superanimal_finetune_config
+
+ # Normalize input parameters + build related configurations
+ project_config = ProjectConfig.from_any(project_config)
+ if inference_config is None:
+ inference_config = InferenceConfig()
+ else:
+ inference_config = InferenceConfig.from_any(inference_config)
+ metadata = PoseMetadata.build(project_config, pose_config_path=pose_config_path)
+
+ # Input validation
+ if weight_init.dataset is None:
+ raise ValueError("`WeightInitialization.dataset` is required for fine-tuning SuperAnimal models.")
+
+ if not weight_init.with_decoder:
+ raise ValueError(
+ "`weight_init.with_decoder=True` is required for fine-tuning SuperAnimal models."
+ "Please set `with_decoder=True` to fine-tune a model, or create a transfer learning config instead."
+ )
+
+ # Build the pose configuration
+ pose_config = cls.from_dict(
+ build_superanimal_finetune_config(
+ weight_init,
+ metadata,
+ model_name,
+ detector_name,
+ inference_config=inference_config,
+ )
+ )
+
+ if save:
+ pose_config.to_yaml(metadata.pose_config_path, overwrite=True)
+ return pose_config
+
+
+class TestConfig(DLCBaseConfig):
+ """Configuration class for DeepLabCut test/inference settings.
+
+ This configuration is used for downstream tracking and evaluation, containing
+ the essential metadata about joints and network architecture.
+
+ Attributes:
+ dataset: Path to the project/dataset.
+ dataset_type: Type of dataset (required for downstream tracking).
+ num_joints: Total number of joints (bodyparts + unique bodyparts).
+ all_joints: List of joint indices, each as a single-element list.
+ all_joints_names: List of joint names.
+ net_type: Network architecture type.
+ global_scale: Global scale factor for inference.
+ scoremap_dir: Directory for score maps.
+ """
+
+ # TODO @deruyter92 2026-02-05: Is this additional configuration really needed?
+ # We could aim for using the PoseConfig class or InferenceConfig class instead.
+ dataset: Path = Path()
+ num_joints: NonNegativeInt = 0
+ all_joints: list[list[NonNegativeInt]] = Field(default_factory=list)
+ all_joints_names: UniqueStrList = Field(default_factory=list)
+ net_type: NetType = NetType.RESNET_50
+ dataset_type: DatasetType = DatasetType.MULTIANIMAL_IMGAUG
+ global_scale: Fraction = 1.0
+ scoremap_dir: Path = Path()
+
+ @classmethod
+ def build(
+ cls,
+ pose_config: PoseConfig | dict | Path | str,
+ *,
+ dataset_type: DatasetType = DatasetType.MULTIANIMAL_IMGAUG,
+ scoremap_dir: Path | str = "test",
+ test_config_path: Path | str | None = None,
+ global_scale: Fraction = 1.0,
+ save: bool = False,
+ ) -> Self:
+
+ # Needs a validated PoseConfig
+ cfg = PoseConfig.from_any(pose_config)
+ metadata = cfg.metadata
+
+ # Build the test config
+ test_config = cls(
+ dataset=cfg.metadata.project_path,
+ dataset_type=dataset_type, # required for downstream tracking
+ num_joints=metadata.num_bodyparts + metadata.num_unique_bodyparts,
+ all_joints=[[i] for i in range(metadata.num_bodyparts + metadata.num_unique_bodyparts)],
+ all_joints_names=metadata.bodyparts + metadata.unique_bodyparts,
+ net_type=cfg.net_type,
+ global_scale=global_scale,
+ scoremap_dir=scoremap_dir,
+ )
+
+ # Save if needed
+ if save:
+ if test_config_path is None:
+ raise ValueError("test_config_path is required to save the test config.")
+ test_config.to_yaml(test_config_path, overwrite=True)
+
+ return test_config
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/runner.py b/deeplabcut/pose_estimation_pytorch/config/runner.py
new file mode 100644
index 0000000000..839c0389e8
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/runner.py
@@ -0,0 +1,84 @@
+#
+# 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
+#
+"""Runner configuration class for DeepLabCut pose estimation models."""
+
+from typing import Any
+
+from deeplabcut.core.config import DLCBaseConfig
+
+
+class OptimizerConfig(DLCBaseConfig):
+ """Optimizer configuration.
+
+ Attributes:
+ type: Optimizer type (e.g., AdamW, SGD)
+ params: Optimizer parameters
+ """
+
+ type: str = ""
+ params: dict[str, Any] | None = None
+
+
+class SchedulerConfig(DLCBaseConfig):
+ """Learning rate scheduler configuration.
+
+ Attributes:
+ type: Scheduler type (e.g., LRListScheduler, CosineAnnealingLR, SequentialLR)
+ params: Scheduler parameters
+ """
+
+ type: str = ""
+ params: dict[str, Any] | None = None
+
+
+class SnapshotCheckpointConfig(DLCBaseConfig):
+ """Snapshot configuration for model checkpoints.
+
+ Attributes:
+ max_snapshots: Maximum number of snapshots to keep
+ save_epochs: Interval for saving snapshots
+ save_optimizer_state: Whether to save optimizer state
+ """
+
+ max_snapshots: int = 5
+ save_epochs: int = 25
+ save_optimizer_state: bool = False
+
+
+class RunnerConfig(DLCBaseConfig):
+ """Training runner configuration.
+
+ Attributes:
+ type: Runner type (e.g., PoseTrainingRunner)
+ gpus: GPU configuration
+ key_metric: Key metric for evaluation
+ key_metric_asc: Whether key metric should be ascending
+ eval_interval: Evaluation interval in epochs
+ optimizer: Optimizer configuration
+ scheduler: Scheduler configuration
+ snapshots: Snapshot configuration
+ load_weights_only: Value for torch.load() weights_only parameter
+ """
+
+ type: str = "PoseTrainingRunner"
+ # TODO @deruyter92: Currently different configs for device are used in
+ # parallel. We should probably move to only 'PoseConfig.device'. This is
+ # kept here for backwards compatibility.
+ gpus: Any | None = None
+ device: str = "auto" # <- unused, but present in test scripts.
+ key_metric: str = "test.mAP"
+ key_metric_asc: bool = True
+ eval_interval: int = 10
+ optimizer: OptimizerConfig | None = None
+ scheduler: SchedulerConfig | None = None
+ snapshots: SnapshotCheckpointConfig | None = None
+ snapshot_prefix: str | None = None
+ load_weights_only: bool | None = None
diff --git a/deeplabcut/pose_estimation_pytorch/config/training.py b/deeplabcut/pose_estimation_pytorch/config/training.py
new file mode 100644
index 0000000000..42dd794230
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/training.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
+#
+"""Training configuration classes for DeepLabCut pose estimation models."""
+
+from deeplabcut.core.config import DLCBaseConfig
+from deeplabcut.core.weight_init import WeightInitialization
+
+
+class TrainSettingsConfig(DLCBaseConfig):
+ """Training settings configuration.
+
+ Attributes:
+ batch_size: Training batch size
+ dataloader_workers: Number of data loader workers
+ dataloader_pin_memory: Whether to pin memory in data loader
+ display_iters: Display interval for training progress
+ epochs: Number of training epochs
+ seed: Random seed for reproducibility
+ weight_init: Weight initialization configuration
+ """
+
+ batch_size: int = 8
+ dataloader_workers: int = 0
+ dataloader_pin_memory: bool = False
+ display_iters: int = 500
+ epochs: int = 200
+ seed: int = 42
+ # @TODO @deruyter92 2026-02-13: V0 pipeline uses None for default initialization
+ # (with ImageNet weights). We should update this to explicit WeightInitialization.
+ weight_init: WeightInitialization | None = None
diff --git a/deeplabcut/pose_estimation_pytorch/config/utils.py b/deeplabcut/pose_estimation_pytorch/config/utils.py
new file mode 100644
index 0000000000..14e3101b57
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/config/utils.py
@@ -0,0 +1,273 @@
+#
+# 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 ensure_plain_config, read_config_as_dict
+from deeplabcut.core.deprecation import deprecated
+from deeplabcut.utils import auxiliaryfunctions
+
+
+@ensure_plain_config
+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
+
+
+@deprecated(replacement=None, since="3.0.1")
+def update_config(config: dict, updates: dict, copy_original: bool = True) -> dict:
+ """Deprecated helper for updating config dictionaries."""
+ from deeplabcut.pose_estimation_pytorch.config.make_pose_config import _update_config
+
+ return _update_config(config, updates, copy_original)
+
+
+@deprecated(replacement=None, since="3.0.1")
+def update_config_by_dotpath(config: dict, updates: dict, copy_original: bool = True) -> dict:
+ """Deprecated helper for updating config dictionaries using dot notation.
+ ``DLCBaseConfig.set_nested`` (new in 3.0.1) can be used instead (not identical).
+
+ 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 or current[part] is None:
+ 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 = 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]:
+ """Load backbones.
+
+ 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]:
+ """Load detectors.
+
+ 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..d61ff3af47
--- /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.config import GenSamplingConfig
+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,
+)
+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..39220ed2fe
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/base.py
@@ -0,0 +1,390 @@
+#
+# 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
+
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.pose_estimation_pytorch.config import GenSamplingConfig, PoseConfig
+from deeplabcut.pose_estimation_pytorch.data.dataset import (
+ PoseDataset,
+ PoseDatasetParameters,
+)
+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.
+ """
+
+ @renamed_parameter(old="model_config_path", new="model_config", since="3.0.1")
+ def __init__(
+ self,
+ project_root: str | Path,
+ image_root: str | Path,
+ model_config: PoseConfig | dict | Path | str | None = None,
+ ) -> None:
+ """
+ Initialize the Loader.
+
+ Args:
+ project_root: The root directory of the project.
+ image_root: The root directory of the images.
+ model_config (Path | str | PoseConfig | dict):
+ The pose model configuration. Can be a path to a YAML file, a PoseConfig object, or a dictionary.
+ (model_config_path: The path to the pose model configuration. Deprecated, use `model_config` instead.)
+ """
+ normalized_config: PoseConfig = PoseConfig.from_any(model_config)
+
+ def _infer_model_config_path(
+ model_config: PoseConfig | dict | Path | str,
+ normalized_config: PoseConfig,
+ ) -> Path:
+ """Resolve the pose config path from the input argument (if path), or the normalized config."""
+ provided_path = Path(model_config) if isinstance(model_config, (Path, str)) else None
+ path_from_config = normalized_config.select("metadata.pose_config_path")
+ model_config_path = provided_path or path_from_config
+ if model_config_path is None:
+ raise ValueError("`model_config` must contain a `metadata.pose_config_path` field.")
+ return Path(model_config_path)
+
+ self.model_config_path = _infer_model_config_path(model_config, normalized_config)
+ self.model_cfg = normalized_config
+
+ self.project_root = Path(project_root)
+ self.image_root = Path(image_root)
+ 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, log_changes: bool = False, save_config: bool = True) -> None:
+ """Updates the model configuration.
+
+ Args:
+ updates: the items to update in the model configuration
+ """
+ for key, value in updates.items():
+ self.model_cfg.set_nested(key, value)
+ if log_changes:
+ self.model_cfg.log_changes()
+ if save_config:
+ self.model_cfg.to_yaml(self.model_config_path)
+
+ @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 self.model_cfg["train_settings"].get("weight_init") is not None:
+ 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:
+ # TODO @deruyter92: when moving to typed configs, this conversion becomes unnecessary
+ ctd_config = GenSamplingConfig.from_any(self.model_cfg.data.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..2f7ed00f36
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/cocoloader.py
@@ -0,0 +1,368 @@
+#
+# 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 warnings
+from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.pose_estimation_pytorch.config import MethodType, PoseConfig
+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.
+ model_cfg: the model configuration instead of loading from file
+
+ Examples:
+ loader = COCOLoader(
+ project_root='/path/to/project/',
+ model_config='/path/to/project/experiments/train/pytorch_config.yaml',
+ train_json_filename="train.json",
+ test_json_filename="test.json",
+ )
+ """
+
+ @renamed_parameter(old="model_config_path", new="model_config", since="3.0.0")
+ def __init__(
+ self,
+ project_root: str | Path,
+ model_config: PoseConfig | dict | Path | str | None = None,
+ train_json_filename: str = "train.json",
+ test_json_filename: str = "test.json",
+ ):
+ """
+ Initialize the COCOLoader.
+
+ Args:
+ project_root: The root directory of the project.
+ model_config: The pose model configuration. Can be a path to a YAML
+ file, a PoseConfig object, or a dictionary.
+ train_json_filename: The name of the JSON file containing the train annotations.
+ test_json_filename: The name of the JSON file containing the test annotations.
+ """
+ image_root = Path(project_root) / "images"
+ super().__init__(project_root, image_root, model_config)
+ 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.select("data.train.top_down_crop") or {}
+ 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)
+
+ ctd_bbox_margin = None
+ if self.model_cfg["method"] == MethodType.CONDITIONAL_TOP_DOWN:
+ ctd_bbox_margin = self.model_cfg["data"].get("bbox_margin", 20)
+
+ 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"),
+ ctd_bbox_margin=ctd_bbox_margin,
+ 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 = Path(project_root) / "annotations" / filename
+ if not json_path.exists():
+ raise FileNotFoundError(f"File {json_path} does not exist.")
+
+ with json_path.open() 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:
+ coco_json (dict): 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..9cce0122ca
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/ctd.py
@@ -0,0 +1,412 @@
+#
+# 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 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
+
+
+def resolve_bu_shuffle(
+ config: str | Path,
+ shuffle: int,
+ trainset_index: int = 0,
+ modelprefix: str = "",
+ snapshot: str | None = None,
+ snapshot_index: int | None = None,
+) -> tuple[DLCLoader, Snapshot]:
+ """Resolves a DLC BU shuffle to a (loader, snapshot) pair.
+
+ Args:
+ config: Path to the DeepLabCut project config.
+ shuffle: The index of the shuffle for which to load data.
+ trainset_index: The index of the TrainingsetFraction for which to load data.
+ modelprefix: The modelprefix for the shuffle.
+ snapshot: The name of the snapshot to use. Takes priority over snapshot_index.
+ snapshot_index: The index of the snapshot to use. Defaults to -1 (last).
+
+ 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:
+ """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 = resolve_bu_shuffle(**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
+
+ 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 Path(filepath).open() as f:
+ conditions = json.load(f)
+
+ # Parse list and return
+ if images is None:
+ # TODO @deruyter92: decide on typed / plain list
+ 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
+
+ # TODO @deruyter92: decide on typed / plain dict
+ 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 Path(filepath).open("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
diff --git a/deeplabcut/pose_estimation_pytorch/data/dataset.py b/deeplabcut/pose_estimation_pytorch/data/dataset.py
new file mode 100644
index 0000000000..4de45b3c92
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/dataset.py
@@ -0,0 +1,506 @@
+#
+# 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.config import GenSamplingConfig
+from deeplabcut.pose_estimation_pytorch.data.generative_sampling import GenerativeSampler
+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_bbox_margin: the margin (in pixels) to add around keypoints when generating
+ bounding boxes from pose
+ 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_bbox_margin: int | 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.parameters.ctd_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..6cc094df72
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/dlcloader.py
@@ -0,0 +1,768 @@
+#
+# 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.config import ProjectConfig
+from deeplabcut.core.engine import Engine
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns
+from deeplabcut.pose_estimation_pytorch.config import MethodType
+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: ProjectConfig | dict | Path | str,
+ 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
+ """
+ provided_root_dir = Path(config).parent if isinstance(config, (str, Path)) else None
+ self._project_config: ProjectConfig = ProjectConfig.from_any(config)
+ self._project_root = provided_root_dir or self._project_config.project_path
+ if self._project_root is None:
+ raise ValueError("`config` must contain a `project_path` field.")
+
+ 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) -> ProjectConfig:
+ """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.select("data.train.top_down_crop") or {}
+ 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)
+ ctd_bbox_margin = None
+ if self.model_cfg["method"] == MethodType.CONDITIONAL_TOP_DOWN:
+ ctd_bbox_margin = self.model_cfg["data"].get("bbox_margin", 20)
+ 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"),
+ ctd_bbox_margin=ctd_bbox_margin,
+ 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: ProjectConfig,
+ 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 = 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: ProjectConfig,
+ 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 = 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 doc_path.open("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:
+ """Convert a DeepLabCut annotation DataFrame to a COCO-like dict.
+
+ 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 Path(file).open("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
+
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ for idv_idx, idv_bodyparts in (image_data.get("joints") or {}).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..ebb013c4ae
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/generative_sampling.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
+#
+"""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
+
+import numpy as np
+
+
+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..9f2cc229cb
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/helper.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
+#
+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.")
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ properties = attrs.get("properties") or {}
+ 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..5a6342454e
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/image.py
@@ -0,0 +1,304 @@
+#
+# 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
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ anns = targets.get("annotations") or {}
+ context = targets.get("context") or {}
+
+ 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..ba0e6c40c2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/postprocessor.py
@@ -0,0 +1,593 @@
+#
+# 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
+ """
+
+
+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):
+ xy = individual_keypoints[:, :2]
+ valid = np.all(np.isfinite(xy), axis=1)
+
+ heatmap_indices = np.zeros((num_keypoints, 2), dtype=int)
+ heatmap_indices[valid] = np.rint(xy[valid]).astype(int)
+ xs = np.clip(heatmap_indices[:, 0], 0, w - 1)
+ ys = np.clip(heatmap_indices[:, 1], 0, h - 1)
+ for kpt_idx, (x, y) in enumerate(zip(xs, ys, strict=False)):
+ if valid[kpt_idx]:
+ 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..7314c99e04
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/preprocessor.py
@@ -0,0 +1,501 @@
+#
+# 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
+ """
+
+
+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):
+ # TODO @deruyter92: decide on typed / plain list
+ 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:
+ # TODO @deruyter92: decide on typed / plain list
+ 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..c17a7898c2
--- /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
+ # TODO @deruyter92: decide on typed / plain dict
+ 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: If both ``min_height`` and ``pad_height_divisor`` are set, or both
+ ``min_width`` and ``pad_width_divisor`` are 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..4bef06ec45
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/data/utils.py
@@ -0,0 +1,545 @@
+#
+# 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]]:
+ """Extract keypoints and bboxes.
+
+ 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..9920985f43
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/metrics/scoring.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
+#
+from __future__ import annotations
+
+import pickle
+from pathlib import Path
+
+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 Path(full_pickle_path).open("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.T.groupby(level="bodyparts"):
+ xy_gt = xy_gt.T
+ 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..b5d2e85421
--- /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
+from deeplabcut.utils.auxiliaryfunctions import safe_resolve
+
+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')
+ """
+
+ 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).absolute()
+
+ 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))
+
+ # safe_resolve gets the actual path if the output path is a symlink
+ output_path = safe_resolve(output_path)
+ # 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..25fadb1361
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/cspnext.py
@@ -0,0 +1,209 @@
+#
+# 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 CSPNeXt: A new efficient token hybrid backbone:
+https://www.sciencedirect.com/science/article/abs/pii/S0952197624000447
+and
+RTMPose: Real-Time Multi-Person Pose Estimation based on MMPose:
+https://arxiv.org/pdf/2303.07399
+"""
+
+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..e8c3b8a2b2
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet.py
@@ -0,0 +1,127 @@
+#
+# 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..8619fb54fd
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/hrnet_coam.py
@@ -0,0 +1,189 @@
+# ------------------------------------------------------------------------------
+# 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..bbe8065321
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/backbones/resnet.py
@@ -0,0 +1,142 @@
+#
+# 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.
+
+ Examples:
+ Create a ResNet50 backbone and forward a random tensor:
+
+ 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..689702e412
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/detectors/base.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
+#
+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
+ """
+ detector: BaseDetector = build_from_cfg(
+ cfg,
+ **kwargs,
+ default_args={"pretrained": pretrained},
+ )
+
+ 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}
+ """
+
+ @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.
+ """
+
+ 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..c4be4f5bc0
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/base.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
+#
+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}.")
+
+ # TODO @deruyter92: decide on typed / plain dict
+ 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")
+ """
+
+ 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]
+ )
+ ``
+ """
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..4789b33b26
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/dekr.py
@@ -0,0 +1,411 @@
+#
+# 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:
+ Initialize a DEKRHeatmap:
+
+ DEKRHeatmap(
+ 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:
+ Create a layer:
+
+ _make_layer(
+ 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..4645b95f36
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/heads/transformer.py
@@ -0,0 +1,100 @@
+#
+# 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..8ff858680d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/model.py
@@ -0,0 +1,267 @@
+#
+# 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
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.config import ModelConfig
+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: ModelConfig | dict | Path | str,
+ 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: ModelConfig = ModelConfig.from_any(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():
+ # NOTE @deruyter92 2026-02-05: currently modules are added inside the config
+ # dictionary. This seems like a bad practice and is unsupported for typed configs.
+ # The intermediate head_cfg container is converted to a dictionary to avoid this issue.
+ head_cfg = dict(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..431cff9fa3
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/conv_block.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
+#
+"""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.
+ """
+
+ 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 (torch.Tensor): Input tensor.
+
+ Returns:
+ torch.Tensor: 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..df22f82562
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/gated_attention_unit.py
@@ -0,0 +1,230 @@
+#
+# 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..15f0dcf7dd
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/modules/kpt_encoders.py
@@ -0,0 +1,240 @@
+#
+# 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..737005e611
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/base.py
@@ -0,0 +1,47 @@
+#
+# 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.
+ """
+
+ 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..457c8c1944
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/necks/utils.py
@@ -0,0 +1,58 @@
+#
+# 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).
+
+ Examples:
+ Generate sine position embedding:
+
+ 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..c175d7162a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/base.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
+#
+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.
+ """
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..6225d2812d
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/dekr_predictor.py
@@ -0,0 +1,423 @@
+#
+# 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.
+
+ Examples:
+ Create a DEKRPredictor instance with 2 animals and make a forward pass with outputs and scale factors:
+
+ # 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 (float): the stride of the model
+ outputs (dict[str, torch.Tensor]): outputs of the model heads (heatmap, locref)
+
+ Returns:
+ dict[str, torch.Tensor]: A dictionary containing a "poses" key with the output tensor as value, and
+ optionally a "unique_bodyparts" with the unique bodyparts tensor as value.
+
+ Examples:
+ 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.
+
+ Examples:
+ 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 (torch.Tensor): Offsets tensor.
+ num_joints (int): Number of joints.
+
+ Returns:
+ torch.Tensor: Regression poses.
+
+ Examples:
+ 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.
+
+ Examples:
+ 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.
+
+ Examples:
+ 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 (torch.Tensor): Heatmap tensor.
+
+ Returns:
+ tuple[torch.Tensor, torch.Tensor]: Position indices and scores.
+
+ Examples:
+ 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 (torch.Tensor): poses tensor, shape (batch_size, num_animals, num_keypoints, 2)
+ kpt_heatmaps (torch.Tensor): heatmaps (excl. 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.
+
+ Examples:
+ 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 (torch.Tensor): 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:
+ torch.Tensor: 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..f6991a5083
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/paf_predictor.py
@@ -0,0 +1,533 @@
+#
+# 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 (float): the stride of the model
+ outputs (dict[str, torch.Tensor]): 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:
+ dict[str, torch.Tensor]: A dictionary containing a "poses" key with the output tensor as value.
+
+ Examples:
+ create a ``PartAffinityFieldPredictor`` and forward a random tensor:
+
+ import torch
+ from deeplabcut.pose_estimation_pytorch.models.predictors import PartAffinityFieldPredictor
+
+ 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].item()
+ k = edge_idx[st].item()
+ 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..9bf2d0f57e
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/predictors/sim_cc.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
+#
+"""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..1e604666e6
--- /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:
+ apply_sigmoid (bool): Apply sigmoid to heatmaps. Defaults to True.
+ clip_scores (bool): If a sigmoid is not applied, this can be used to clip scores
+ for predicted keypoints to values in [0, 1].
+ location_refinement (bool): Enable location refinement.
+ locref_std (float): Standard deviation for location refinement.
+
+ 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,
+ ):
+ 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.
+
+ Examples:
+ Assuming you have ``outputs`` (heatmaps and locrefs) and ``stride`` for pose predictions:
+
+ 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.
+
+ Examples:
+ Get the top values from a heatmap:
+
+ 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)
+
+ Examples:
+ Get the pose prediction from a heatmap and locref:
+
+ 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..7e3dffbbcb
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/dekr_targets.py
@@ -0,0 +1,205 @@
+#
+# 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..b5a86b2300
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/heatmap_targets.py
@@ -0,0 +1,329 @@
+#
+# 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'.
+ heatmap_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.
+ 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..1d1fc2653a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/models/target_generators/pafs_targets.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
+
+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..3ccbb12511
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/config.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
+#
+"""Methods to create the configuration files to fine-tune SuperAnimal models."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+ from deeplabcut.pose_estimation_pytorch.runners.inference import InferenceConfig
+
+
+import deeplabcut.pose_estimation_pytorch.config.utils as config_utils
+import deeplabcut.utils.auxiliaryfunctions as af
+from deeplabcut.core.config import (
+ ProjectConfig,
+ get_yaml_dumper,
+ read_config_as_dict,
+)
+from deeplabcut.core.deprecation import deprecated
+from deeplabcut.core.engine import Engine
+from deeplabcut.core.weight_init import WeightInitialization
+from deeplabcut.pose_estimation_pytorch.config.metadata import PoseMetadata
+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.task import Task
+
+
+def build_superanimal_metadata(super_animal: str, model_name: str, max_individuals: int) -> dict:
+ project_cfg_path = get_super_animal_project_config_path(super_animal=super_animal)
+ project_config = ProjectConfig.from_yaml(project_cfg_path)
+ model_cfg_path = get_super_animal_model_config_path(model_name=model_name)
+ metadata = {
+ "project_path": project_config.project_path,
+ "pose_config_path": model_cfg_path,
+ "bodyparts": af.get_bodyparts(project_config),
+ "unique_bodyparts": af.get_unique_bodyparts(project_config),
+ "individuals": [f"animal{i}" for i in range(max_individuals)],
+ "with_identity": project_config.get("identity", False),
+ }
+ return metadata
+
+
+def build_superanimal_inference_config(
+ super_animal: str,
+ model_name: str,
+ detector_name: str | None,
+ metadata: PoseMetadata,
+ device: str | None = None,
+) -> dict:
+
+ max_individuals = metadata.num_individuals
+
+ model_cfg_path = get_super_animal_model_config_path(model_name=model_name)
+ model_config = read_config_as_dict(model_cfg_path)
+ model_config = config_utils.replace_default_values(
+ model_config,
+ num_bodyparts=metadata.num_bodyparts,
+ num_individuals=max_individuals,
+ backbone_output_channels=model_config["model"]["backbone_output_channels"],
+ )
+ if detector_name is None and super_animal != "superanimal_humanbody":
+ model_config["method"] = Task.BOTTOM_UP.aliases[0].lower()
+ else:
+ model_config["method"] = Task.TOP_DOWN.aliases[0].lower()
+ 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
+ else:
+ model_config.pop("detector", None)
+
+ model_config["metadata"] = metadata.to_dict()
+ if device is not None:
+ model_config["device"] = device
+ if model_config.get("detector", None) is not None:
+ model_config["detector"]["device"] = device
+ return model_config
+
+
+def build_superanimal_finetune_config(
+ weight_init: WeightInitialization,
+ metadata: PoseMetadata,
+ model_name: str,
+ detector_name: str | None,
+ inference_config: InferenceConfig,
+) -> dict:
+
+ # Bodyparts mapping
+ converted_bodyparts = metadata.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(metadata.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 ({metadata.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 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(weight_init.dataset))
+ converted_bodyparts = super_animal_project_config["bodyparts"]
+
+ model_cfg["net_type"] = model_name
+ model_cfg["metadata"] = metadata.to_dict()
+ 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"] = inference_config.to_dict()
+ return model_cfg
+
+
+@deprecated(replacement="PoseConfig.build_for_superanimal_finetune", since="3.0.1")
+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,
+) -> PoseConfig:
+ from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+
+ return PoseConfig.build_for_superanimal_finetune(
+ weight_init=weight_init,
+ project_config=project_config,
+ pose_config_path=pose_config_path,
+ model_name=model_name,
+ detector_name=detector_name,
+ save=save,
+ )
+
+
+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
+ )
+ (model_folder / "train").mkdir(parents=True, exist_ok=True)
+ out_path = model_folder / "train" / "pytorch_config.yaml"
+ with out_path.open("w") as f:
+ get_yaml_dumper().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..095d17a994
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/fmpose_3d/fmpose3d.py
@@ -0,0 +1,141 @@
+"""
+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.
+
+ Examples:
+ Lift 2d predictions to 3d using the FMPose3D API:
+
+ # 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..b6be062a18
--- /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 output_json.open("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 output_3d_json.open("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..3582d20d95
--- /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
+
+ Warns:
+ UserWarning: If the function is called directly (outside internal pose estimation code).
+ """
+ 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 output_json.open("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..cedef854c0
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/inference_helpers.py
@@ -0,0 +1,210 @@
+#
+# 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 logging
+from pathlib import Path
+
+import deeplabcut.modelzoo.weight_initialization as weight_initialization
+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.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.modelzoo.utils import (
+ COCO_PERSON_CATEGORY_ID,
+ get_super_animal_snapshot_path,
+ load_super_animal_config,
+)
+from deeplabcut.pose_estimation_pytorch.runners import InferenceRunner
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def _build_humanbody_inference_runners(
+ model_cfg: PoseConfig,
+ 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, PoseConfig]:
+ 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: PoseConfig | dict | str | Path | None = None,
+ customized_pose_checkpoint: str | Path | None = None,
+ customized_detector_checkpoint: str | Path | None = None,
+) -> tuple[InferenceRunner, InferenceRunner | None, PoseConfig]:
+ """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 (PoseConfig | dict | str | Path | None, optional):
+ Optional pose configuration for a custom SuperAnimal model. If not
+ provided, uses the default SuperAnimal configuration. This 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 ``PoseConfig``
+
+ Examples:
+ Run inference on a SuperAnimal model:
+
+ 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:
+ model_cfg = PoseConfig.from_any(customized_model_config)
+ else:
+ model_cfg = load_super_animal_config(
+ super_animal=superanimal_name,
+ model_name=model_name,
+ detector_name=detector_name,
+ 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..e0a249b9a5
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/memory_replay.py
@@ -0,0 +1,358 @@
+#
+# 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
+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_snapshot_path = Path(model_snapshot_path)
+ detector_snapshot_path = Path(detector_snapshot_path)
+
+ 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 predictions_file.open() 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 = [(loader.project_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 predictions_file.open("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 | Path,
+ detector_snapshot_path: str | Path,
+ 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."""
+ # Use safe_resolve so that symlinks are followed where possible, but we
+ # fall back to abspath on Windows 11 SMB drives that produce unusable
+ # \\?\Volume{GUID}\... paths. See https://github.com/DeepLabCut/DeepLabCut/issues/3348
+ project_root = af.safe_resolve(loader.project_path)
+ source_dataset_folder = af.safe_resolve(Path(source_dataset_folder))
+ model_snapshot_path = Path(model_snapshot_path)
+ detector_snapshot_path = Path(detector_snapshot_path)
+
+ # Contains the ground truth annotations for the DeepLabCut project
+ # .../dlc-models-pytorch/.../...shuffle0/train/memory_replay/annotations/train.json
+ with (source_dataset_folder / "annotations" / train_file).open() 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_train_file_path = 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 / Path(*image_rel_path))
+
+ with memory_replay_train_file_path.open("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(loader.project_path, "temp_dataset", shuffle=loader.shuffle)
+ else:
+ temp_dataset = SingleDLCPoseDataset(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..2db3436752
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/train_from_coco.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
+#
+"""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 = 4,
+ save_epochs: int = 1,
+ detector_epochs: int = 4,
+ detector_save_epochs: int = 1,
+ snapshot_path: str | None = None,
+ detector_path: str | None = 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"])
+
+ # Config updates
+ loader.model_cfg.model.backbone["freeze_bn_stats"] = True
+ loader.model_cfg.runner.snapshots.save_epochs = save_epochs
+ loader.model_cfg.train_settings.batch_size = batch_size
+ loader.model_cfg.train_settings.epochs = epochs
+
+ if not skip_detector:
+ loader.model_cfg.detector.model.freeze_bn_stats = True
+ loader.model_cfg.detector.runner.snapshots.save_epochs = detector_save_epochs
+ loader.model_cfg.detector.train_settings.batch_size = detector_batch_size
+ loader.model_cfg.detector.train_settings.epochs = detector_epochs
+
+ if eval_interval is not None:
+ loader.model_cfg.runner.eval_interval = eval_interval
+
+ # Save the updated config
+ loader.model_cfg.log_changes()
+ loader.model_cfg.to_yaml(loader.model_config_path)
+
+ 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 is not None and 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..9911156105
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/modelzoo/utils.py
@@ -0,0 +1,196 @@
+#
+# 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
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+
+import torch
+from dlclibrary import download_huggingface_model
+
+import deeplabcut.pose_estimation_pytorch.config.utils as config_utils
+from deeplabcut.core.deprecation import deprecated
+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 auxiliaryfunctions.get_deeplabcut_path() / "modelzoo" / "model_configs"
+
+
+def get_project_configs_folder_path() -> Path:
+ """Returns: the folder containing the SuperAnimal project configuration files"""
+ return 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 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
+
+
+@deprecated(replacement="PoseConfig.build_for_superanimal_inference", since="3.0.1")
+def load_super_animal_config(
+ super_animal: str,
+ model_name: str,
+ detector_name: str | None = None,
+ max_individuals: int = 30,
+ device: str | None = None,
+) -> "PoseConfig":
+ from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+
+ return PoseConfig.build_for_superanimal_inference(
+ super_animal=super_animal,
+ model_name=model_name,
+ detector_name=detector_name,
+ max_individuals=max_individuals,
+ device=device,
+ )
+
+
+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)]
+
+ resolved_device = device if device is not None else "auto"
+ config["device"] = resolved_device
+ if config.get("detector") is not None:
+ config["detector"]["device"] = resolved_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..4ef5fc4c1a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/identity.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
+#
+"""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:
+ """Assign identity.
+
+ 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..df4bf9f428
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/post_processing/match_predictions_to_gt.py
@@ -0,0 +1,185 @@
+#
+# 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.
+ individual_names: names of individuals
+
+ Note:
+ The shape of the ``gt_kpts`` array is:
+ * (num_animals, num_keypoints, 2) if not including the center.
+ * (num_animals, num_keypoints + 1, 2) if including the center.
+
+ 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..05fe64ebda
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/registry.py
@@ -0,0 +1,356 @@
+#
+# 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
+
+from deeplabcut.core.config import ensure_plain_config
+
+
+# NOTE @deruyter92 2026-02-17: The configuration is currently converted to a
+# plain dictionary, using this decorator, since fully typed configs are not
+# supported for all modules in the registry.
+@ensure_plain_config
+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.
+
+ Examples:
+ Build a model from a configuration dictionary:
+
+ 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:
+ Split the scope and key:
+
+ Registry.split_scope_key("mmdet.ResNet")
+ # Output: ('mmdet', 'ResNet')
+
+ Registry.split_scope_key("ResNet")
+ # Output: (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.
+
+ Examples:
+ Get a model class from the registry:
+
+ 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.
+
+ Examples:
+ Build an instance from the registry:
+
+ 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
+
+ Examples:
+ Register and build a model from the registry:
+
+ 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
+
+ Examples:
+ Register a model and get it from the registry:
+
+ 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.
+
+ Examples:
+ Register a class in the registry with the ``deprecated_register_module`` decorator:
+
+ 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..4a8eb27ecc
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/base.py
@@ -0,0 +1,229 @@
+#
+# 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)
+
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ metrics = (snapshot.get("metadata") or {}).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..1e605a68ed
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/ctd.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
+#
+"""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:
+
+ Build a CTDTrackingConfig from a basic dict:
+
+ ctd_tracking = CTDTrackingConfig.build(
+ dict(bu_on_lost_idv=True, threshold_nms=0.75)
+ )
+
+ Build 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,
+ )
+ )
+
+ Build 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..439e3654cf
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/dynamic_cropping.py
@@ -0,0 +1,533 @@
+#
+# 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:
+ Use a DynamicCropper to crop images and update the crop window:
+
+ 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..2602bf1df4
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/inference.py
@@ -0,0 +1,965 @@
+#
+# 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 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 import InferenceConfig
+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
+
+
+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()
+ else:
+ raise ValueError(f"Invalid inference config: {inference_cfg}")
+
+ 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.
+ ctd_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._ctd_track_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..22c747bf12
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/logger.py
@@ -0,0 +1,508 @@
+#
+# 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
+ """
+
+ 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"]
+
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ keypoints = (inputs.get("annotations") or {}).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 output_path.open("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 Path(self.log_file).open("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.
+ """
+
+ 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 Path(self.log_file).open(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..f03b269d92
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/schedulers.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
+#
+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:
+ """Load scheduler state.
+
+ 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..16b1474c7a
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/runners/train.py
@@ -0,0 +1,757 @@
+#
+# 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)
+ # TODO @deruyter92: decide on typed / plain dict
+ if isinstance(optimizer, dict):
+ optimizer = build_optimizer(model, optimizer)
+ # TODO @deruyter92: decide on typed / plain dict
+ 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,
+ )
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ self.starting_epoch = (snapshot.get("metadata") or {}).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: If the runner is not in train or eval mode.
+
+ 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"""
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ epoch_gt_metric = self._epoch_ground_truth.get(name) or {}
+ epoch_metric = self._epoch_predictions.get(name) or {}
+ 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: If the runner is not in train or eval mode.
+
+ 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..7982e5aaba
--- /dev/null
+++ b/deeplabcut/pose_estimation_pytorch/utils.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
+#
+from __future__ import annotations
+
+import random
+
+import numpy as np
+import torch
+
+from deeplabcut.pose_estimation_pytorch.config.pose import DetectorConfig, PoseConfig
+
+
+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: PoseConfig | DetectorConfig) -> 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 (PoseConfig | dict | str | Path): The PyTorch pose configuration.
+
+ Returns:
+ the device on which training should be run
+ """
+ device = model_config.device
+
+ if isinstance(model_config, DetectorConfig):
+ supports_mps = False
+ else:
+ supports_mps = "resnet" in model_config.get("net_type", "")
+
+ 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 cca4332e25..31282b7a4f 100644
--- a/deeplabcut/pose_estimation_tensorflow/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/__init__.py
@@ -1,19 +1,30 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-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 *
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/__init__.py b/deeplabcut/pose_estimation_tensorflow/backbones/__init__.py
index e69de29bb2..38ef00b553 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/__init__.py
@@ -0,0 +1,15 @@
+#
+# Copyright 2019 The TensorFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
index 0b7d9932f2..a7147eac1f 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_builder.py
@@ -1,3 +1,4 @@
+#
# Copyright 2019 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,10 +12,11 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-# ==============================================================================
+#
import functools
-import os
import re
+from pathlib import Path
+
import tensorflow as tf
import deeplabcut.pose_estimation_tensorflow.backbones.efficientnet_model as efficientnet_model
@@ -40,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):
@@ -72,24 +74,26 @@ 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:
A list of namedtuples to represent blocks arguments.
"""
@@ -101,8 +105,10 @@ 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:
a list of strings, each string is a notation of block.
"""
@@ -114,17 +120,20 @@ 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
provide this wrapper that can allow to select whether to use the native
TensorFlow swish operation, or whether to use a customized operation that
has uses default TensorFlow gradient computation.
+
Args:
features: A `Tensor` representing preactivation values.
use_native: Whether to use the native swish from tf.nn that uses a custom
gradient to reduce memory usage, or to use customized swish that uses
default TensorFlow gradient computation.
+
Returns:
The activation value.
"""
@@ -182,14 +191,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
@@ -211,6 +216,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.
@@ -220,12 +226,14 @@ def build_model(
model_dir: string, optional model dir for saving configs.
fine_tuning: boolean, whether the model is used for finetuning.
features_only: build the base feature network only.
+
Returns:
logits: the logits tensor of classes.
endpoints: the endpoints for each layer.
+
Raises:
- When model_name specified an undefined model, raises NotImplementedError.
- When override_params has invalid fields, raises ValueError.
+ NotImplementedError: If ``model_name`` specifies an undefined model.
+ ValueError: If ``override_params`` contains invalid fields.
"""
assert isinstance(images, tf.Tensor)
if not training or fine_tuning:
@@ -236,15 +244,15 @@ def build_model(
blocks_args, global_params = get_model_params(model_name, override_params)
if model_dir:
- param_file = os.path.join(model_dir, "model_params.txt")
+ param_file = Path(model_dir) / "model_params.txt"
if not tf.io.gfile.exists(param_file):
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)
@@ -253,31 +261,29 @@ 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.
- training: boolean, whether the model is constructed for training.
override_params: A dictionary of params for overriding. Fields must exist in
efficientnet_model.GlobalParams.
+
Returns:
features: global pool features.
endpoints: the endpoints for each layer.
+
Raises:
- When model_name specified an undefined model, raises NotImplementedError.
- When override_params has invalid fields, raises ValueError.
+ NotImplementedError: If ``model_name`` specifies an undefined model.
+ ValueError: If ``override_params`` contains invalid fields.
"""
assert isinstance(images, tf.Tensor)
blocks_args, global_params = get_model_params(model_name, override_params)
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 ba7a41c23a..96dfb05bf8 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/efficientnet_model.py
@@ -1,3 +1,4 @@
+#
# Copyright 2019 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,14 +12,17 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# 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
@@ -65,11 +69,13 @@
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,
tf.contrib.layers.variance_scaling_initializer uses a truncated normal with
a corrected standard deviation.
+
Args:
shape: shape of variable
dtype: dtype of variable
@@ -85,10 +91,12 @@ 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').
It is written out explicitly here for clarity.
+
Args:
shape: shape of variable
dtype: dtype of variable
@@ -116,9 +124,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)
@@ -132,17 +138,19 @@ def round_repeats(repeats, global_params):
class MBConvBlock(tf.keras.layers.Layer):
"""A class of MBConv: Mobile Inverted Residual Bottleneck.
+
Attributes:
endpoints: dict. A list of internal tensors.
"""
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
@@ -157,12 +165,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()
@@ -207,9 +213,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,
@@ -249,44 +253,37 @@ 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.
drop_connect_rate: float, between 0 to 1, drop connect rate.
+
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"):
@@ -304,7 +301,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
@@ -346,27 +343,22 @@ 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.
drop_connect_rate: float, between 0 to 1, drop connect rate.
+
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}
@@ -380,24 +372,27 @@ 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.
+ ValueError: If ``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
@@ -405,7 +400,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()
@@ -421,12 +416,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),
)
@@ -435,9 +426,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))
@@ -459,9 +448,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(
@@ -472,13 +459,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,
@@ -494,10 +477,11 @@ 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.
features_only: build the base feature network only.
+
Returns:
output tensors.
"""
@@ -505,53 +489,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 0858b9c699..d40a66db66 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet.py
@@ -1,3 +1,4 @@
+#
# Copyright 2018 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,11 +12,11 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-# ==============================================================================
+#
import collections
import contextlib
import copy
-import os
+from pathlib import Path
import tensorflow as tf
import tf_slim as slim
@@ -56,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"])
@@ -75,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):
@@ -133,7 +130,7 @@ def mobilenet_base( # pylint: disable=invalid-name
usage will be to set this value in (0, 1) to reduce the number of
parameters or computation cost of the model.
final_endpoint: The name of last layer, for early termination for
- for V1-based networks: last layer is "layer_14", for V2: "layer_20"
+ for V1-based networks: last layer is "layer_14", for V2: "layer_20"
output_stride: An integer that specifies the requested ratio of input to
output spatial resolution. If not None, then we invoke atrous convolution
if necessary to prevent the network from reducing the spatial resolution
@@ -164,15 +161,16 @@ def mobilenet_base( # pylint: disable=invalid-name
losses.
Raises:
- ValueError: depth_multiplier <= 0, or the target output_stride is not
- allowed.
+ ValueError: If ``depth_multiplier`` <= 0 or the target ``output_stride`` is not allowed.
"""
if multiplier <= 0:
raise ValueError("multiplier is not greater than zero.")
# Set conv defs defaults and overrides.
- conv_defs_defaults = conv_defs.get("defaults", {})
- conv_defs_overrides = conv_defs.get("overrides", {})
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ conv_defs_defaults = conv_defs.get("defaults") or {}
+ conv_defs_overrides = conv_defs.get("overrides") or {}
if use_explicit_padding:
conv_defs_overrides = copy.deepcopy(conv_defs_overrides)
conv_defs_overrides[(slim.conv2d, slim.separable_conv2d)] = {"padding": "VALID"}
@@ -187,10 +185,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
@@ -241,14 +240,14 @@ 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)
+ scope = str(Path(net.name).parent)
scopes[scope] = end_point
if final_endpoint is not None and end_point == final_endpoint:
break
@@ -256,8 +255,8 @@ def mobilenet_base( # pylint: disable=invalid-name
# Add all tensors that end with 'output' to
# endpoints
for t in net.graph.get_operations():
- scope = os.path.dirname(t.name)
- bn = os.path.basename(t.name)
+ scope = str(Path(t.name).parent)
+ bn = Path(t.name).name
if scope in scopes and t.name.endswith("output"):
end_points[scopes[scope] + "/" + bn] = t.outputs[0]
return net, end_points
@@ -265,9 +264,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
@@ -279,7 +279,7 @@ def mobilenet(
reuse=None,
scope="Mobilenet",
base_only=False,
- **mobilenet_args
+ **mobilenet_args,
):
"""Mobilenet model for classification, supports both V1 and V2.
@@ -318,12 +318,12 @@ def mobilenet(
activation tensor.
Raises:
- ValueError: Input rank is invalid.
+ ValueError: If the input rank is invalid.
"""
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")
@@ -369,6 +369,7 @@ def global_pool(input_tensor, pool_op=tf.nn.avg_pool2d):
Args:
input_tensor: input tensor
pool_op: pooling op (avg pool is default)
+
Returns:
a tensor batch_size x 1 x 1 x depth.
"""
@@ -384,9 +385,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
@@ -407,6 +406,7 @@ def training_scope(
# the network created will be trainble with dropout/batch norm
# initialized appropriately.
+
Args:
is_training: if set to False this will ensure that all customizations are
set to non-training mode. This might be helpful for code that is reused
@@ -432,20 +432,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 7ec8e469d7..3ead148647 100644
--- a/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
+++ b/deeplabcut/pose_estimation_tensorflow/backbones/mobilenet_v2.py
@@ -1,3 +1,4 @@
+#
# Copyright 2018 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,7 +12,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-# ==============================================================================
+#
"""Implementation of Mobilenet V2.
@@ -28,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
@@ -97,7 +98,7 @@ def mobilenet(
min_depth=None,
divisible_by=None,
activation_fn=None,
- **kwargs
+ **kwargs,
):
"""Creates mobilenet V2 network.
@@ -129,19 +130,17 @@ def mobilenet(
prediction_fn- what prediction function to use.
reuse-: whether to reuse variables (if reuse set to true, scope
must be given).
+
Returns:
logits/endpoints pair
Raises:
- ValueError: On invalid arguments
+ ValueError: If arguments are invalid.
"""
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:
@@ -149,9 +148,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 = {}
@@ -169,7 +166,7 @@ def mobilenet(
conv_defs=conv_defs,
scope=scope,
multiplier=depth_multiplier,
- **kwargs
+ **kwargs,
)
@@ -186,20 +183,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 98b688bd0e..5ae54dd30d 100644
--- a/deeplabcut/pose_estimation_tensorflow/config.py
+++ b/deeplabcut/pose_estimation_tensorflow/config.py
@@ -1,18 +1,27 @@
-"""
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import logging
import pprint
+from pathlib import Path
import yaml
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
@@ -26,18 +35,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 Path(filename).open() as f:
yaml_cfg = yaml.load(f, Loader=yaml.SafeLoader)
# Update the snapshot path to the corresponding path!
@@ -47,6 +54,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/__init__.py b/deeplabcut/pose_estimation_tensorflow/core/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
index d4c3376da3..cb48afef62 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate.py
@@ -1,70 +1,61 @@
-"""
-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
-"""
+#
+# 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 argparse
import os
from pathlib import Path
+
import numpy as np
import pandas as pd
from tqdm import tqdm
+from deeplabcut.pose_estimation_tensorflow.training import return_train_network_path
+
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 == None:
+ 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):
+ """Returns distances along paf edges in train/test data.
-
-def calculatepafdistancebounds(
- config, shuffle=0, trainingsetindex=0, modelprefix="", numdigits=0, onlytrain=False
-):
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ shuffle (int): Integer specifying shuffle index of the training dataset.
+ Defaults to 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".
+ numdigits (int): Number of digits to round for distances.
"""
- Returns distances along paf edges in train/test data
-
- ----------
- 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".
- 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)
@@ -77,50 +68,36 @@ def calculatepafdistancebounds(
) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
# Loading human annotatated data
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
- datafn, metadatafn = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, trainFraction, shuffle, cfg
- )
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
# Load meta data & annotations
- (
- data,
- trainIndices,
- testIndices,
- trainFraction,
- ) = auxiliaryfunctions.LoadMetadata(
- os.path.join(cfg["project_path"], metadatafn)
- )
Data = pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
+ Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5")
)[cfg["scorer"]]
- path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
- dlc_cfg = load_config(str(path_test_config))
+ path_train_config, path_test_config, _ = return_train_network_path(
+ config=config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ modelprefix=modelprefix,
+ )
+ train_pose_cfg = load_config(str(path_train_config))
+ test_pose_cfg = load_config(str(path_test_config))
- # get the graph!
- partaffinityfield_graph = dlc_cfg["partaffinityfield_graph"]
- jointnames = [
- dlc_cfg["all_joints_names"][i] for i in range(len(dlc_cfg["all_joints"]))
- ]
- path_inferencebounds_config = (
- Path(modelfolder) / "test" / "inferencebounds.yaml"
+ _, trainIndices, _, _ = auxiliaryfunctions.load_metadata(
+ Path(cfg["project_path"]) / train_pose_cfg["metadataset"]
)
+
+ # 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"
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 = []
@@ -137,7 +114,7 @@ def calculatepafdistancebounds(
(Data[ind, j1, "x"] - Data[ind2, j2, "x"]) ** 2
+ (Data[ind, j1, "y"] - Data[ind2, j2, "y"]) ** 2
)
- / dlc_cfg["stride"]
+ / test_pose_cfg["stride"]
)
else:
distances = None
@@ -153,12 +130,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
@@ -167,31 +140,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"])
@@ -225,165 +190,140 @@ 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])
- ----------
- 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".
-
- 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!
-
- 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],True)
- """
+ Returns list of:
+ (DataMachine, Data, data, trainIndices, testIndices, trainFraction,
+ DLCscorer,comparisonbodyparts, cfg, Snapshots[snapindex])
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ shuffle (int): Integer specifying shuffle index of the training dataset.
+ Defaults to 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".
+ comparisonbodyparts (list of bodyparts): The average error will be computed for
+ those body parts only (Has to be a subset of the body parts). Defaults to
+ "all".
+ rescale (bool): 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! Defaults to False.
+
+ 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,
+ )
+ """
import os
from deeplabcut.pose_estimation_tensorflow.config import load_config
from deeplabcut.utils import auxiliaryfunctions
- start_path = os.getcwd()
+ start_path = Path.cwd()
# Read file path for pose_config file. >> pass it on
cfg = auxiliaryfunctions.read_config(config)
# Loading human annotatated data
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- # Data=pd.read_hdf(os.path.join(cfg["project_path"],str(trainingsetfolder),'CollectedData_' + cfg["scorer"] + '.h5'),'df_with_missing')
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ # Data=pd.read_hdf(
+ # Path(cfg["project_path"]) / str(trainingsetfolder) / ('CollectedData_' + cfg["scorer"] + '.h5'),
+ # 'df_with_missing'
+ # )
# Get list of body parts to evaluate network for
- comparisonbodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
- cfg, comparisonbodyparts
- )
+ comparisonbodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
##################################################
# Load data...
##################################################
trainFraction = cfg["TrainingFraction"][trainingsetindex]
- datafn, metadatafn = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, trainFraction, shuffle, cfg
+ modelfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
- )
- path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
- # Load meta data
- data, trainIndices, testIndices, trainFraction = auxiliaryfunctions.LoadMetadata(
- os.path.join(cfg["project_path"], metadatafn)
+ path_train_config, path_test_config, _ = return_train_network_path(
+ config=config,
+ shuffle=shuffle,
+ trainingsetindex=trainingsetindex,
+ modelprefix=modelprefix,
)
try:
- dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ test_pose_cfg = load_config(str(path_test_config))
+ 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
+ data, trainIndices, testIndices, _ = auxiliaryfunctions.load_metadata(
+ Path(cfg["project_path"]) / train_pose_cfg["metadataset"],
+ )
########################### RESCALING (to global scale)
- if rescale == True:
- scale = dlc_cfg["global_scale"]
+ if rescale:
+ scale = test_pose_cfg["global_scale"]
print("Rescaling Data to ", scale)
Data = (
- pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
- )
+ pd.read_hdf(Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5"))
* scale
)
else:
scale = 1
Data = pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
+ Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5")
)
- evaluationfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ evaluationfolder = Path(cfg["project_path"]) / 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 == 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:
- dlc_cfg["init_weights"] = os.path.join(
- str(modelfolder), "train", Snapshots[snapindex]
+ for snapshot_name in snapshot_names:
+ test_pose_cfg["init_weights"] = str(
+ Path(modelfolder) / "train" / snapshot_name
) # setting weights to corresponding snapshot.
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[
+ trainingsiterations = Path(test_pose_cfg["init_weights"]).name.split("-")[
-1
] # read how many training siterations that corresponds to.
# name for deeplabcut net (based on its parameters)
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg, shuffle, trainFraction, trainingsiterations, modelprefix=modelprefix
)
if not returnjustfns:
@@ -398,14 +338,14 @@ def return_evaluate_network_data(
notanalyzed,
resultsfilename,
DLCscorer,
- ) = auxiliaryfunctions.CheckifNotEvaluated(
- 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:
- if not notanalyzed and os.path.isfile(resultsfilename): # data exists..
+ if not notanalyzed and Path(resultsfilename).is_file(): # data exists..
DataMachine = pd.read_hdf(resultsfilename)
DataCombined = pd.concat([Data.T, DataMachine.T], axis=0).T
RMSE, RMSEpcutoff = pairwisedistances(
@@ -418,13 +358,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,
@@ -446,7 +382,7 @@ def return_evaluate_network_data(
np.round(testerrorpcutoff, 2),
"pixels",
)
- print("Snapshot", Snapshots[snapindex])
+ print("Snapshot", snapshot_name)
r = [
trainingsiterations,
@@ -457,14 +393,14 @@ def return_evaluate_network_data(
cfg["pcutoff"],
np.round(trainerrorpcutoff, 2),
np.round(testerrorpcutoff, 2),
- Snapshots[snapindex],
+ snapshot_name,
scale,
- dlc_cfg["net_type"],
+ test_pose_cfg["net_type"],
]
results.append(r)
else:
print("Model not trained/evaluated!")
- if fulldata == True:
+ if fulldata:
DATA.append(
[
DataMachine,
@@ -477,7 +413,7 @@ def return_evaluate_network_data(
comparisonbodyparts,
cfg,
evaluationfolder,
- Snapshots[snapindex],
+ snapshot_name,
]
)
@@ -485,15 +421,64 @@ def return_evaluate_network_data(
if returnjustfns:
return resultsfns
else:
- if fulldata == True:
+ if fulldata:
return DATA, results
else:
return results
+def keypoint_error(
+ df_error: pd.DataFrame,
+ df_error_p_cutoff: pd.DataFrame,
+ train_indices: list[int],
+ test_indices: list[int],
+) -> pd.DataFrame:
+ """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
+ levels: "scorer", "individuals", "bodyparts").
+
+ Args:
+ df_error: dataframe containing the RMSE error for each image, individual and
+ bodypart
+ df_error_p_cutoff: dataframe containing the RMSE error with p-cutoff for each
+ image, individual and bodypart
+ train_indices: the indices of rows in the dataframe that are in the train set
+ test_indices: the indices of rows in the dataframe that are in the test set
+
+ Returns:
+ A dataframe containing 4 rows (train and test error, with and without p-cutoff)
+ and one column for each bodypart.
+ """
+ df_error = df_error.copy()
+ df_error_p_cutoff = df_error_p_cutoff.copy()
+
+ error_rows = []
+ for row_name, df in [
+ ("Train error (px)", df_error.iloc[train_indices, :]),
+ ("Test error (px)", df_error.iloc[test_indices, :]),
+ ("Train error (px) with p-cutoff", df_error_p_cutoff.iloc[train_indices, :]),
+ ("Test error (px) with p-cutoff", df_error_p_cutoff.iloc[test_indices, :]),
+ ]:
+ df_flat = df.copy()
+ if isinstance(df.columns, pd.MultiIndex):
+ # MA projects have column indices "scorer", "individuals" and "bodyparts"
+ # Drop the scorer level, and put individuals in rows
+ df_flat = df.droplevel("scorer", axis=1).stack(level="individuals").copy()
+
+ bodypart_error = df_flat.mean()
+ bodypart_error["Error Type"] = row_name
+ error_rows.append(bodypart_error)
+
+ # The error rows are series; stack in axis 1 and pivot to get DF
+ keypoint_error_df = pd.concat(error_rows, axis=1)
+ return keypoint_error_df.T.set_index("Error Type")
+
+
def evaluate_network(
config,
- Shuffles=[1],
+ Shuffles=None,
trainingsetindex=0,
plotting=False,
show_errors=True,
@@ -501,6 +486,8 @@ def evaluate_network(
gputouse=None,
rescale=False,
modelprefix="",
+ per_keypoint_evaluation: bool = False,
+ snapshots_to_evaluate: list[str] = None,
):
"""Evaluates the network.
@@ -509,87 +496,78 @@ def evaluate_network(
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: list, 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.
-
- show_errors: bool, optional, default=True
- Display train and test errors.
-
- comparisonbodyparts: 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.
-
- 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],
- )
+ Args:
+ config (string): Full path of the config.yaml file.
+ shuffles (list, optional): List of integers specifying the shuffle indices of
+ the training dataset. Defaults to [1].
+ trainingsetindex (int or str, optional): Integer specifying which
+ "TrainingsetFraction" to use. Note that "TrainingFraction" is a list in
+ config.yaml. This variable can also be set to "all". Defaults to 0.
+ plotting (bool or str, optional): 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. Defaults to False.
+ show_errors (bool, optional): Display train and test errors. Defaults to True.
+ comparisonbodyparts (str or list, optional): The average error will be computed
+ for those body parts only. The provided list has to be a subset of the
+ defined body parts. Defaults to "all".
+ gputouse (int or None, optional): 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
+ Defaults to None.
+ rescale (bool, optional): 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! Defaults to
+ False.
+ 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. Defaults to "".
+ per_keypoint_evaluation (bool, optional): 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. Defaults to False.
+ snapshots_to_evaluate (list[str], optional): List of snapshot names to evaluate
+ (e.g. ["snapshot-50000", "snapshot-75000", ...]). Defaults to None.
+
+ 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.
+ 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,
- )
+ deeplabcut.evaluate_network(
+ "/analysis/project/reaching-task/config.yaml",
+ Shuffles=[0, 1],
+ plotting=True,
+ )
- If you want to plot assemblies for a maDLC project
+ If you want to plot assemblies for a maDLC project:
- >>> deeplabcut.evaluate_network(
- '/analysis/project/reaching-task/config.yaml',
- Shuffles=[1],
- plotting="individual",
- )
+ deeplabcut.evaluate_network(
+ "/analysis/project/reaching-task/config.yaml",
+ Shuffles=[1],
+ plotting="individual",
+ )
- Note: This defaults to standard plotting for single-animal projects.
+ 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}")
- import os
-
- start_path = os.getcwd()
+ start_path = Path.cwd()
from deeplabcut.utils import auxiliaryfunctions
cfg = auxiliaryfunctions.read_config(config)
@@ -606,28 +584,29 @@ def evaluate_network(
comparisonbodyparts=comparisonbodyparts,
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" #
# tf.logging.set_verbosity(tf.logging.WARN)
- start_path = os.getcwd()
+ start_path = Path.cwd()
# Read file path for pose_config file. >> pass it on
cfg = auxiliaryfunctions.read_config(config)
if gputouse is not None: # gpu selectinon
@@ -636,10 +615,7 @@ def evaluate_network(
if trainingsetindex == "all":
TrainingFractions = cfg["TrainingFraction"]
else:
- if (
- trainingsetindex < len(cfg["TrainingFraction"])
- and trainingsetindex >= 0
- ):
+ if 0 <= trainingsetindex < len(cfg["TrainingFraction"]):
TrainingFractions = [cfg["TrainingFraction"][int(trainingsetindex)]]
else:
raise Exception(
@@ -650,118 +626,90 @@ def evaluate_network(
)
# Loading human annotatated data
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
Data = pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
+ Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5")
)
# Get list of body parts to evaluate network for
- comparisonbodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
+ comparisonbodyparts = auxiliaryfunctions.intersection_of_body_parts_and_ones_given_by_user(
cfg, comparisonbodyparts
)
# Make folder for evaluation
- auxiliaryfunctions.attempttomakefolder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(Path(cfg["project_path"]) / "evaluation-results")
for shuffle in Shuffles:
for trainFraction in TrainingFractions:
##################################################
# Load and setup CNN part detector
##################################################
- datafn, metadatafn = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, trainFraction, shuffle, cfg
- )
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
- )
- path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
- # Load meta data
- (
- data,
- trainIndices,
- testIndices,
- trainFraction,
- ) = auxiliaryfunctions.LoadMetadata(
- os.path.join(cfg["project_path"], metadatafn)
+ modelfolder_rel_path = auxiliaryfunctions.get_model_folder(
+ trainFraction, shuffle, cfg, modelprefix=modelprefix
)
+ modelfolder = Path(cfg["project_path"]) / modelfolder_rel_path
- try:
- dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
+ # 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():
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
+ f"Model with shuffle {shuffle} and trainFraction {trainFraction} does not exist."
)
+ if trainingsetindex == "all":
+ train_frac_idx = cfg["TrainingFraction"].index(trainFraction)
+ else:
+ train_frac_idx = trainingsetindex
+
+ path_train_config, path_test_config, _ = return_train_network_path(
+ config=config,
+ shuffle=shuffle,
+ trainingsetindex=train_frac_idx,
+ modelprefix=modelprefix,
+ )
+
+ test_pose_cfg = load_config(str(path_test_config))
+ train_pose_cfg = load_config(str(path_train_config))
+ # Load meta data
+ _, trainIndices, testIndices, _ = auxiliaryfunctions.load_metadata(
+ Path(cfg["project_path"], train_pose_cfg["metadataset"])
+ )
+
# change batch size, if it was edited during analysis!
- dlc_cfg["batch_size"] = 1 # in case this was edited for analysis.
+ test_pose_cfg["batch_size"] = 1 # in case this was edited for analysis.
# Create folder structure to store results.
- evaluationfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ evaluationfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
- auxiliaryfunctions.attempttomakefolder(evaluationfolder, recursive=True)
- # path_train_config = modelfolder / 'train' / 'pose_cfg.yaml'
+ 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 = []
########################### RESCALING (to global scale)
if rescale:
- scale = dlc_cfg["global_scale"]
+ scale = test_pose_cfg["global_scale"]
Data = (
pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
+ Path(cfg["project_path"])
+ / str(trainingsetfolder)
+ / ("CollectedData_" + cfg["scorer"] + ".h5")
)
* scale
)
@@ -772,51 +720,45 @@ def evaluate_network(
##################################################
# Compute predictions over images
##################################################
- for snapindex in snapindices:
- dlc_cfg["init_weights"] = os.path.join(
- str(modelfolder), "train", Snapshots[snapindex]
+ for snapshot_name in snapshot_names:
+ test_pose_cfg["init_weights"] = str(
+ Path(modelfolder) / "train" / snapshot_name
) # setting weights to corresponding snapshot.
- trainingsiterations = (
- dlc_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.GetScorerName(
+ 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,
resultsfilename,
DLCscorer,
- ) = auxiliaryfunctions.CheckifNotEvaluated(
+ ) = auxiliaryfunctions.check_if_not_evaluated(
str(evaluationfolder),
DLCscorer,
DLCscorerlegacy,
- Snapshots[snapindex],
+ snapshot_name,
)
if notanalyzed:
# Specifying state of model (snapshot / training state)
- sess, inputs, outputs = predict.setup_pose_prediction(dlc_cfg)
+ sess, inputs, outputs = predict.setup_pose_prediction(test_pose_cfg)
Numimages = len(Data.index)
- PredicteData = np.zeros(
- (Numimages, 3 * len(dlc_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(
- os.path.join(cfg["project_path"], *imagename),
+ Path(cfg["project_path"]).joinpath(*imagename),
mode="skimage",
)
if scale != 1:
@@ -824,20 +766,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, dlc_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, dlc_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!
@@ -846,25 +780,21 @@ def evaluate_network(
index = pd.MultiIndex.from_product(
[
[DLCscorer],
- dlc_cfg["all_joints_names"],
+ test_pose_cfg["all_joints_names"],
["x", "y", "likelihood"],
],
names=["scorer", "bodyparts", "coords"],
)
# 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,
@@ -874,17 +804,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),
@@ -895,10 +819,15 @@ def evaluate_network(
]
final_result.append(results)
+ if per_keypoint_evaluation:
+ df_keypoint_error = keypoint_error(RMSE, RMSEpcutoff, trainIndices, testIndices)
+ kpt_filename = DLCscorer + "-keypoint-results.csv"
+ df_keypoint_error.to_csv(Path(evaluationfolder) / kpt_filename)
+
if show_errors:
print(
"Results for",
- trainingsiterations,
+ training_iterations,
" training iterations:",
int(100 * trainFraction),
shuffle,
@@ -919,23 +848,18 @@ 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],
- )
- auxiliaryfunctions.attempttomakefolder(foldername)
+ foldername = Path(evaluationfolder) / ("LabeledImages_" + DLCscorer + "_" + snapshot_name)
+ auxiliaryfunctions.attempt_to_make_folder(foldername)
Plotting(
cfg,
comparisonbodyparts,
@@ -951,28 +875,25 @@ 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
- print(
- "Plotting...(attention scale might be inconsistent in comparison to when data was analyzed; i.e. if you used rescale)"
- )
- foldername = os.path.join(
- str(evaluationfolder),
- "LabeledImages_"
- + DLCscorer
- + "_"
- + Snapshots[snapindex],
- )
- auxiliaryfunctions.attempttomakefolder(foldername)
- Plotting(
- cfg,
- comparisonbodyparts,
- DLCscorer,
- trainIndices,
- DataCombined * 1.0 / scale,
- foldername,
- )
+ DataCombined = pd.concat([Data.T, DataMachine.T], axis=0, sort=False).T
+ foldername = Path(evaluationfolder) / ("LabeledImages_" + DLCscorer + "_" + snapshot_name)
+ if not Path(foldername).exists():
+ print(
+ "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(
+ cfg,
+ comparisonbodyparts,
+ DLCscorer,
+ trainIndices,
+ DataCombined * 1.0 / scale,
+ foldername,
+ )
+ else:
+ 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)
@@ -980,10 +901,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
@@ -991,10 +915,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:",
@@ -1007,26 +931,69 @@ def make_results_file(final_result, evaluationfolder, DLCscorer):
"Test error with p-cutoff",
]
df = pd.DataFrame(final_result, columns=col_names)
- output_path = os.path.join(str(evaluationfolder), DLCscorer + "-results.csv")
- if os.path.exists(output_path):
+ output_path = Path(evaluationfolder) / (DLCscorer + "-results.csv")
+ if Path(output_path).exists():
temp = pd.read_csv(output_path, index_col=0)
- df = pd.concat((df, temp)).reset_index(drop=True)
+ df = pd.concat((temp, df)).reset_index(drop=True)
df.to_csv(output_path)
## 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"
- )
- if os.path.exists(output_path):
+ output_path = Path(evaluationfolder).parents[0] / "CombinedEvaluation-results.csv"
+ if Path(output_path).exists():
temp = pd.read_csv(output_path, index_col=0)
- df = pd.concat((df, temp)).reset_index(drop=True)
+ df = pd.concat((temp, df)).reset_index(drop=True)
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:
+ list[str]: 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 03dbe3aba6..5cd893f7e2 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py
@@ -1,25 +1,33 @@
-"""
-DeepLabCut 2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-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.pose_estimation_tensorflow.core.evaluate import make_results_file
+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.lib import crossvalutils
+from deeplabcut.pose_estimation_tensorflow.core.evaluate import (
+ 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.utils import visualization
@@ -45,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]]
@@ -98,27 +88,33 @@ 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,
comparisonbodyparts="all",
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
@@ -127,7 +123,7 @@ def evaluate_multianimal_full(
if gputouse is not None: # gpu selectinon
os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
- start_path = os.getcwd()
+ start_path = Path.cwd()
if plotting is True:
plotting = "bodypart"
@@ -142,485 +138,252 @@ def evaluate_multianimal_full(
TrainingFractions = [cfg["TrainingFraction"][trainingsetindex]]
# Loading human annotatated data
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- Data = pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
- )
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ Data = pd.read_hdf(Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5"))
conversioncode.guarantee_multiindex_rows(Data)
# Get list of body parts to evaluate network for
- comparisonbodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
- 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.attempttomakefolder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(Path(cfg["project_path"]) / "evaluation-results")
for shuffle in Shuffles:
for trainFraction in TrainingFractions:
##################################################
# Load and setup CNN part detector
##################################################
- datafn, metadatafn = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, trainFraction, shuffle, cfg
+ modelfolder_rel_path = auxiliaryfunctions.get_model_folder(
+ trainFraction, shuffle, cfg, modelprefix=modelprefix
)
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / modelfolder_rel_path
+
+ # 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():
+ raise FileNotFoundError(
+ f"Model with shuffle {shuffle} and trainFraction {trainFraction} does not exist."
+ )
+
+ if trainingsetindex == "all":
+ train_frac_idx = cfg["TrainingFraction"].index(trainFraction)
+ else:
+ train_frac_idx = trainingsetindex
+
+ path_train_config, path_test_config, _ = return_train_network_path(
+ config=config,
+ shuffle=shuffle,
+ trainingsetindex=train_frac_idx,
+ modelprefix=modelprefix,
)
- path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
+ test_pose_cfg = load_config(str(path_test_config))
+ train_pose_cfg = load_config(str(path_train_config))
# Load meta data
- (
- data,
- trainIndices,
- testIndices,
- trainFraction,
- ) = auxiliaryfunctions.LoadMetadata(
- os.path.join(cfg["project_path"], metadatafn)
+ _, trainIndices, testIndices, _ = auxiliaryfunctions.load_metadata(
+ Path(cfg["project_path"]) / train_pose_cfg["metadataset"]
)
- try:
- dlc_cfg = load_config(str(path_test_config))
- except FileNotFoundError:
- raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, trainFraction)
- )
-
pipeline = iaa.Sequential(random_order=False)
- pre_resize = dlc_cfg.get("pre_resize")
+ pre_resize = test_pose_cfg.get("pre_resize")
if pre_resize:
width, height = pre_resize
pipeline.add(iaa.Resize({"height": height, "width": width}))
# TODO: IMPLEMENT for different batch sizes?
- dlc_cfg["batch_size"] = 1 # due to differently sized images!!!
+ test_pose_cfg["batch_size"] = 1 # due to differently sized images!!!
- stride = dlc_cfg["stride"]
+ stride = test_pose_cfg["stride"]
# Ignore best edges possibly defined during a prior evaluation
- _ = dlc_cfg.pop("paf_best", None)
- joints = dlc_cfg["all_joints_names"]
+ _ = test_pose_cfg.pop("paf_best", None)
+ joints = test_pose_cfg["all_joints_names"]
# Create folder structure to store results.
- evaluationfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
- )
- auxiliaryfunctions.attempttomakefolder(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
- ]
+ evaluationfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
- 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)
+ auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True)
+
+ 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:
- 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("-")[
- -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,
- 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.CheckifNotEvaluated(
- str(evaluationfolder),
- DLCscorer,
- DLCscorerlegacy,
- Snapshots[snapindex],
- )
-
- data_path = resultsfilename.split(".h5")[0] + "_full.pickle"
-
- if plotting:
- foldername = os.path.join(
- str(evaluationfolder),
- "LabeledImages_" + DLCscorer + "_" + Snapshots[snapindex],
- )
- auxiliaryfunctions.attempttomakefolder(foldername)
- if plotting == "bodypart":
- fig, ax = visualization.create_minimal_figure()
-
- if os.path.isfile(data_path):
- print("Model already evaluated.", resultsfilename)
- else:
+ print(err)
+ snapshot_names = []
- (sess, inputs, outputs,) = predict.setup_pose_prediction(
- dlc_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()
+ final_result = []
+ ##################################################
+ # Compute predictions over images
+ ##################################################
+ for snapshot_name in snapshot_names:
+ test_pose_cfg["init_weights"] = str(
+ Path(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,
+ )
- df = GT.unstack("coords").reindex(joints, level="bodyparts")
+ data_path = resultsfilename.split(".h5")[0] + "_full.pickle"
- # 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(
- dlc_cfg,
- np.expand_dims(frame, axis=0),
- sess,
- inputs,
- outputs,
- peaks_gt.astype(int),
- )
+ if plotting:
+ foldername = Path(evaluationfolder) / ("LabeledImages_" + DLCscorer + "_" + snapshot_name)
+ auxiliaryfunctions.attempt_to_make_folder(foldername)
+ if plotting == "bodypart":
+ fig, ax = visualization.create_minimal_figure()
- 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,
+ if Path(data_path).is_file():
+ 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 = Path(cfg["project_path"]).joinpath(*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()
+ # TODO @deruyter92 2026-06-10 (#3362): pandas migration 3.0 - this will break if bodyparts
+ # is a str type and future.no_silent_downcasting is also removed in pandas 3.0.
+ with pd.option_context("future.no_silent_downcasting", True):
+ temp["bodyparts"] = (
+ temp["bodyparts"]
+ .replace(
+ dict(zip(joints, range(len(joints)), strict=False)),
)
- 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
- )
- 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),
- ]
- final_result.append(results)
-
- 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."
+ .infer_objects(copy=False)
)
- 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": dlc_cfg["nmsradius"],
- "minimal confidence": dlc_cfg["minconfidence"],
- "sigma": dlc_cfg.get("sigma", 1),
- "PAFgraph": dlc_cfg["partaffinityfield_graph"],
- "PAFinds": 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"]))
- ],
- "stride": dlc_cfg.get("stride", 8),
- }
- print(
- "Done and results stored for snapshot: ",
- Snapshots[snapindex],
+ 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),
)
- dictionary = {
- "Scorer": DLCscorer,
- "DLC-model-config file": dlc_cfg,
- "trainIndices": trainIndices,
- "testIndices": testIndices,
- "trainFraction": trainFraction,
- }
- metadata = {"data": dictionary}
- _ = auxfun_multianimal.SaveFullMultiAnimalData(
- PredicteData, metadata, resultsfilename
- )
+ if not pred:
+ continue
+ else:
+ pred = pred[0]
+
+ PredicteData[imagename] = {}
+ PredicteData[imagename]["index"] = imageindex
+ PredicteData[imagename]["prediction"] = pred
+ PredicteData[imagename]["groundtruth"] = [
+ groundtruthidentity,
+ groundtruthcoordinates,
+ GT,
+ ]
- 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(dlc_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=dlc_cfg.get("oks_sigma", 0.1),
- margin=dlc_cfg.get("bbox_margin", 0),
- symmetric_kpts=dlc_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,
@@ -633,27 +396,219 @@ def evaluate_multianimal_full(
ax=ax,
)
visualization.save_labeled_frame(
- fig, image_path, foldername, k in trainIndices,
+ fig,
+ image_path,
+ foldername,
+ 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 = Path(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 = Path(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].T.groupby("individuals").mean().T.mean().to_string())
+ print("Average Euclidean distance to GT per bodypart (in pixels; test-only)")
+ print(error_masked.iloc[testIndices].T.groupby("bodyparts").mean().T.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,
+ )
- if len(final_result) > 0: # Only append if results were calculated
- make_results_file(final_result, evaluationfolder, DLCscorer)
+ 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 = Path(cfg["project_path"]).joinpath(*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)
+
+ 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 Path(data_path.replace("_full.", "_map.")).open("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/__init__.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/__init__.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/__init__.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/__init__.py b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/mo_extensions/front/tf/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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 876f3fb186..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
@@ -1,12 +1,22 @@
+#
+# 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 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):
@@ -33,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 1186cabd67..f733feb949 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/openvino/session.py
@@ -1,12 +1,22 @@
-import os
+#
+# 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 subprocess
+from pathlib import Path
+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:
@@ -16,22 +26,22 @@
class OpenVINOSession:
def __init__(self, cfg, device):
self.core = Core()
- self.xml_path = cfg["init_weights"] + ".xml"
+ self.xml_path = str(Path(cfg["init_weights"]).with_suffix(".xml"))
self.device = device
# Convert a frozen graph to OpenVINO IR format
- if not os.path.exists(self.xml_path):
+ if not Path(self.xml_path).exists():
subprocess.run(
[
"mo",
"--output_dir",
- os.path.dirname(cfg["init_weights"]),
+ str(Path(cfg["init_weights"]).parent),
"--input_model",
- cfg["init_weights"] + ".pb",
+ str(Path(cfg["init_weights"]).with_suffix(".pb")),
"--input_shape",
"[1, 747, 832, 3]",
"--extensions",
- os.path.join(os.path.dirname(__file__), "mo_extensions"),
+ str(Path(__file__).parent / "mo_extensions"),
"--data_type",
"FP16",
],
@@ -59,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")
@@ -75,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)
@@ -94,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)
@@ -127,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 c9f2fc6139..ffed2b10ce 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/predict.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/predict.py
@@ -1,38 +1,28 @@
-"""
-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
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-
-To do faster inference on videos (with numpy based code; introduced in Oct 2018)
-"On the inference speed and video-compression robustness of DeepLabCut"
-Alexander Mathis & Richard Warren
-doi: https://doi.org/10.1101/457242
-See https://www.biorxiv.org/content/early/2018/10/30/457242
-
-To do even faster inference on videos (with TensorFlow based code; introduced in Oct 2019)
-Pretraining boosts out-of-domain robustness for pose estimation
-by Alexander Mathis, Mert YĂŒksekgönĂŒl, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis
-https://arxiv.org/abs/1909.11229
-"""
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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"]]
@@ -69,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
@@ -88,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]])))
@@ -122,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)
@@ -139,8 +127,9 @@ def getpose(image, cfg, sess, inputs, outputs, outall=False):
## Functions below implement are for batch sizes > 1:
def extract_cnn_outputmulti(outputs_np, cfg):
- """extract locref + scmap from network
- Dimensions: image batch x imagedim1 x imagedim2 x bodypart"""
+ """Extract locref + scmap from network
+ Dimensions: image batch x imagedim1 x imagedim2 x bodypart
+ """
scmap = outputs_np[0]
locref = None
if cfg["location_refinement"]:
@@ -172,8 +161,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})
@@ -200,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)
@@ -216,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 a0d03954f9..52af04e3bd 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/predict_multianimal.py
@@ -1,24 +1,23 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-Extract_detections with C++ code & nms adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
import numpy as np
import tensorflow as tf
+from scipy.ndimage import center_of_mass, label
from skimage.feature import peak_local_max
-from scipy.ndimage import measurements
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"]:
@@ -39,8 +38,9 @@ def extract_cnn_output(outputs_np, cfg):
def extract_cnn_outputmulti(outputs_np, cfg):
- """extract locref + scmap from network
- Dimensions: image batch x imagedim1 x imagedim2 x bodypart"""
+ """Extract locref + scmap from network
+ Dimensions: image batch x imagedim1 x imagedim2 x bodypart
+ """
scmap = outputs_np[0]
if cfg["location_refinement"]:
locref = outputs_np[1]
@@ -60,7 +60,13 @@ def extract_cnn_outputmulti(outputs_np, cfg):
def compute_edge_costs(
- pafs, peak_inds_in_batch, graph, paf_inds, n_bodyparts, n_points=10, n_decimals=3,
+ pafs,
+ peak_inds_in_batch,
+ graph,
+ paf_inds,
+ n_bodyparts,
+ n_points=10,
+ n_decimals=3,
):
# Clip peak locations to PAFs dimensions
h, w = pafs.shape[1:3]
@@ -82,7 +88,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):
@@ -207,7 +213,6 @@ def predict_batched_peaks_and_costs(
n_decimals=3,
extra_dict=None,
):
-
if extra_dict:
features = sess.run(extra_dict["features"], feed_dict={inputs: images_batch})
@@ -238,7 +243,13 @@ def predict_batched_peaks_and_costs(
)
if peaks_gt is not None and graph:
costs_gt = compute_edge_costs(
- pafs, peaks_gt, graph, limbs, pose_cfg["num_joints"], n_points, n_decimals,
+ pafs,
+ peaks_gt,
+ graph,
+ limbs,
+ pose_cfg["num_joints"],
+ n_points,
+ n_decimals,
)
for i, costs in enumerate(costs_gt):
preds[i]["groundtruth_costs"] = costs
@@ -249,14 +260,12 @@ 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]
- xy = measurements.center_of_mass(grid, labels, range(1, np.max(labels) + 1))
- return np.asarray(xy, dtype=np.int).reshape((-1, 2))
+ labels = label(grid)[0]
+ xy = center_of_mass(grid, labels, range(1, np.max(labels) + 1))
+ return np.asarray(xy, dtype=int).reshape((-1, 2))
def find_local_peak_indices_maxpool_nms(scmaps, radius, threshold):
@@ -275,13 +284,19 @@ def find_local_peak_indices_dilation(scmaps, radius, threshold):
width = tf.shape(scmaps)[2]
depth = tf.shape(scmaps)[3]
scmaps_flat = tf.reshape(
- tf.transpose(scmaps, [0, 3, 1, 2]), [-1, height, width, 1],
+ tf.transpose(scmaps, [0, 3, 1, 2]),
+ [-1, height, width, 1],
)
scmaps_dil = tf.nn.dilation2d(
- scmaps_flat, kernel, strides=[1, 1, 1, 1], rates=[1, 1, 1, 1], padding="SAME",
+ scmaps_flat,
+ kernel,
+ strides=[1, 1, 1, 1],
+ rates=[1, 1, 1, 1],
+ padding="SAME",
)
scmaps_dil = tf.transpose(
- tf.reshape(scmaps_dil, [-1, depth, height, width]), [0, 2, 3, 1],
+ tf.reshape(scmaps_dil, [-1, depth, height, width]),
+ [0, 2, 3, 1],
)
argmax_and_thresh_img = (scmaps > scmaps_dil) & (scmaps > threshold)
return tf.cast(tf.where(argmax_and_thresh_img), tf.int32)
@@ -293,14 +308,17 @@ def find_local_peak_indices_skimage(scmaps, radius, threshold):
for j in range(scmaps.shape[3]):
scmap = scmaps[i, ..., j]
peaks = find_local_maxima(scmap, radius, threshold)
- samples_i = np.ones(len(peaks), dtype=np.int).reshape((-1, 1)) * i
+ samples_i = np.ones(len(peaks), dtype=int).reshape((-1, 1)) * i
bpts_j = np.ones_like(samples_i) * j
inds_gt.append(np.c_[samples_i, peaks, bpts_j])
return np.concatenate(inds_gt)
def calc_peak_locations(
- locrefs, peak_inds_in_batch, stride, n_decimals=3,
+ locrefs,
+ peak_inds_in_batch,
+ stride,
+ n_decimals=3,
):
s, r, c, b = peak_inds_in_batch.T
off = locrefs[s, r, c, b]
diff --git a/deeplabcut/pose_estimation_tensorflow/core/test.py b/deeplabcut/pose_estimation_tensorflow/core/test.py
index 89bf45424f..1d6520bd7f 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/test.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/test.py
@@ -1,25 +1,35 @@
-"""
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import argparse
import logging
-import os
+from pathlib import Path
import numpy as np
import scipy.io
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):
@@ -34,14 +44,14 @@ def test_net(visualise, cache_scoremaps):
if cache_scoremaps:
out_dir = cfg["scoremap_dir"]
- if not os.path.exists(out_dir):
- os.makedirs(out_dir)
+ if not Path(out_dir).exists():
+ Path(out_dir).mkdir(parents=True)
num_images = dataset.num_images
- predictions = np.zeros((num_images,), dtype=np.object)
+ predictions = np.zeros((num_images,), dtype=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()
@@ -61,16 +71,13 @@ def test_net(visualise, cache_scoremaps):
visualize.waitforbuttonpress()
if cache_scoremaps:
- base = os.path.basename(batch[Batch.data_item].im_path)
- raw_name = os.path.splitext(base)[0]
- out_fn = os.path.join(out_dir, raw_name + ".mat")
+ raw_name = Path(batch[Batch.data_item].im_path).stem
+ out_fn = Path(out_dir) / (raw_name + ".mat")
scipy.io.savemat(out_fn, mdict={"scoremaps": scmap.astype("float32")})
- out_fn = os.path.join(out_dir, raw_name + "_locreg" + ".mat")
+ out_fn = Path(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 3f824ca300..94844a93d9 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/train.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/train.py
@@ -1,25 +1,25 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-
-"""
import argparse
import logging
import os
import threading
+import warnings
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
@@ -29,9 +29,12 @@
)
from deeplabcut.pose_estimation_tensorflow.nnets import PoseNetFactory
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
@@ -57,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())
@@ -86,7 +86,8 @@ def load_and_enqueue(sess, enqueue_op, coord, dataset, placeholders):
def start_preloading(sess, enqueue_op, dataset, placeholders):
coord = tf.compat.v1.train.Coordinator()
t = threading.Thread(
- target=load_and_enqueue, args=(sess, enqueue_op, coord, dataset, placeholders),
+ target=load_and_enqueue,
+ args=(sess, enqueue_op, coord, dataset, placeholders),
)
t.start()
return coord, t
@@ -97,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:
@@ -120,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
@@ -149,17 +140,16 @@ def train(
keepdeconvweights=True,
allow_growth=True,
):
- start_path = os.getcwd()
- os.chdir(
- str(Path(config_yaml).parents[0])
- ) # switch to folder of config_yaml (for logging)
+ start_path = Path.cwd()
+ 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.-
@@ -185,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:
@@ -216,6 +201,16 @@ def train(
coord, thread = start_preloading(sess, enqueue_op, dataset, placeholders)
train_writer = tf.compat.v1.summary.FileWriter(cfg["log_dir"], sess.graph)
+ # Auto-switch to Adam on M1/M2 chips, as the momentum optimizer crashes
+ from tensorflow.python.platform import build_info
+
+ 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.", stacklevel=2)
+ cfg["optimizer"] = "adam"
+ cfg["lr_init"] = 5e-4
+ cfg["multi_step"] = [[1e-4, 7500], [5e-5, 12000], [1e-5, 200000]]
+
if cfg.get("freezeencoder", False):
if "efficientnet" in net_type:
print("Freezing ONLY supported MobileNet/ResNet currently!!")
@@ -230,7 +225,8 @@ def train(
sess.run(tf.compat.v1.local_variables_initializer())
# Restore variables from disk.
- restorer.restore(sess, cfg["init_weights"])
+ auxfun_models.smart_restore(restorer, sess, cfg["init_weights"], net_type)
+
if maxiters is None:
max_iter = int(cfg["multi_step"][-1][1])
else:
@@ -255,7 +251,7 @@ def train(
lr_gen = LearningRate(cfg)
stats_path = Path(config_yaml).with_name("learning_stats.csv")
- lrf = open(str(stats_path), "w")
+ lrf = stats_path.open("w")
print("Training parameter:")
print(cfg)
@@ -269,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
@@ -304,4 +294,4 @@ def train(
parser.add_argument("config", help="Path to yaml configuration file.")
cli_args = parser.parse_args()
- train(Path(cli_args.config).resolve())
+ train(Path(cli_args.config).absolute())
diff --git a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
index 37a8ce8f1e..c63c116ede 100644
--- a/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/core/train_multianimal.py
@@ -1,15 +1,14 @@
-"""
-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
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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 argparse
import logging
import os
@@ -19,16 +18,17 @@
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
def train(
@@ -39,15 +39,47 @@ def train(
max_to_keep=5,
keepdeconvweights=True,
allow_growth=True,
+ pseudo_labels="",
+ init_weights="",
+ pseudo_threshold=0.1,
+ modelfolder="",
+ traintime_resize=False,
+ video_path="",
+ superanimal=None,
+ remove_head=False,
):
- start_path = os.getcwd()
- os.chdir(
- str(Path(config_yaml).parents[0])
- ) # switch to folder of config_yaml (for logging)
+ # in case there was already a graph
+ tf.compat.v1.reset_default_graph()
+
+ start_path = Path.cwd()
+ if modelfolder == "":
+ 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
+ cfg["traintime_resize"] = traintime_resize
+
+ if superanimal is not None:
+ cfg["superanimal"] = superanimal
+
+ if pseudo_labels != "":
+ cfg["pseudo_label"] = pseudo_labels
+
+ if modelfolder != "":
+ cfg["log_dir"] = modelfolder
+ cfg["project_path"] = modelfolder
+ # have to overwrite this
+ cfg["snapshot_prefix"] = "snapshot"
+
if cfg["optimizer"] != "adam":
print(
"Setting batchsize to 1! Larger batchsize not supported for this loader:",
@@ -57,11 +89,14 @@ 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
dataset = PoseDatasetFactory.create(cfg)
+
batch_spec = get_batch_spec(cfg)
batch, enqueue_op, placeholders = setup_preloading(batch_spec)
@@ -73,27 +108,39 @@ def train(
merged_summaries = tf.compat.v1.summary.merge_all()
net_type = cfg["net_type"]
+ if init_weights != "":
+ cfg["init_weights"] = init_weights
+ cfg["resume_weights_only"] = True
+ print("replacing default init weights with: ", init_weights)
+
stem = Path(cfg["init_weights"]).stem
if "snapshot" in stem and keepdeconvweights:
print("Loading already trained DLC with backbone:", net_type)
variables_to_restore = slim.get_variables_to_restore()
- start_iter = int(stem.split("-")[1])
+ if cfg.get("resume_weights_only", False):
+ start_iter = 0
+ else:
+ start_iter = int(stem.split("-")[1])
+
+ if remove_head:
+ # removing the decoding layer from the checkpoint
+ temp = []
+ for variable in variables_to_restore:
+ if "pose" not in variable.name:
+ temp.append(variable)
+ variables_to_restore = temp
+
else:
print("Loading ImageNet-pretrained", net_type)
# loading backbone from ResNet, MobileNet etc.
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:
@@ -119,7 +166,8 @@ def train(
sess.run(tf.compat.v1.global_variables_initializer())
sess.run(tf.compat.v1.local_variables_initializer())
- restorer.restore(sess, cfg["init_weights"])
+ auxfun_models.smart_restore(restorer, sess, cfg["init_weights"], net_type)
+
if maxiters is None:
max_iter = int(cfg["multi_step"][-1][1])
else:
@@ -142,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 = stats_path.open("w")
print("Training parameters:")
print(cfg)
@@ -157,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
)
@@ -170,39 +221,40 @@ def train(
cumloss += loss_val
train_writer.add_summary(summary, it)
-
if it % display_iters == 0 and it > start_iter:
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()
@@ -217,4 +269,4 @@ def train(
parser.add_argument("config", help="Path to yaml configuration file.")
cli_args = parser.parse_args()
- train(Path(cli_args.config).resolve())
+ train(Path(cli_args.config).absolute())
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py b/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
index 4f4916b3e9..011f512d35 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/__init__.py
@@ -1,21 +1,22 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
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 7e07e271d1..818b800270 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/augmentation.py
@@ -1,42 +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/master/AUTHORS
+#
+# 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
-class KeypointAwareCropToFixedSize(iaa.CropToFixedSize):
+class KeypointFliplr(iaa.Fliplr):
def __init__(
- self, width, height, max_shift=0.4, crop_sampling="hybrid",
+ self,
+ keypoints: list[str],
+ symmetric_pairs: list[tuple | list],
+ p: float = 1.0,
):
- """
- Parameters
- ----------
- width : int
- Crop images down to this maximum width.
+ super().__init__(p=p)
+ self.keypoints = keypoints
+ self.symmetric_pairs = symmetric_pairs
+
+ @property
+ def n_keypoints(self):
+ return len(self.keypoints)
- height : int
- Crop images down to this maximum height.
+ def _augment_batch_(self, batch, random_state, parents, hooks):
+ batch = super()._augment_batch_(batch, random_state, parents, hooks)
+ keypoints = []
+ for kpts in batch.keypoints:
+ kpts_ = list(kpts)
+ n_kpts = len(kpts_)
+ for i1, i2 in self.symmetric_pairs:
+ for j in range(0, n_kpts, self.n_keypoints):
+ kpts_[i1 + j], kpts_[i2 + j] = kpts_[i2 + j], kpts_[i1 + j]
+ keypoints.append(KeypointsOnImage(kpts_, kpts.shape))
+ batch.keypoints = keypoints
+ return batch
- max_shift : float, optional (default=0.25)
- Maximum allowed shift of the cropping center position
- as a fraction of the crop size.
- crop_sampling : str, optional (default="hybrid")
- 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),
- or "hybrid" (alternating randomly between "uniform" and "density").
+class KeypointAwareCropToFixedSize(iaa.CropToFixedSize):
+ def __init__(
+ self,
+ width,
+ height,
+ max_shift=0.4,
+ crop_sampling="hybrid",
+ ):
+ """Crop images to fixed size with keypoint-aware center sampling.
+
+ Args:
+ width (int): Crop images down to this maximum width.
+ height (int): Crop images down to this maximum height.
+ max_shift (float, optional): Maximum allowed shift of the cropping center position
+ as a fraction of the crop size. Defaults to 0.4.
+ crop_sampling (str, optional): 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),
+ or "hybrid" (alternating randomly between "uniform" and "density").
+ Defaults to "hybrid".
"""
- super(KeypointAwareCropToFixedSize, self).__init__(
- width, height, name="kptscrop",
+ super().__init__(
+ width,
+ height,
+ name="kptscrop",
)
# 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 "
- 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
@@ -61,6 +101,7 @@ def _draw_samples(self, batch, random_state):
else:
h, w = batch.images[n].shape[:2]
kpts = batch.keypoints[n].to_xy_array()
+ kpts = kpts[~np.isnan(kpts).all(axis=1)]
n_kpts = kpts.shape[0]
inds = np.arange(n_kpts)
if sampling == "density":
diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/factory.py b/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
index 805a83a664..62d71efe2f 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/factory.py
@@ -1,14 +1,17 @@
-"""
-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
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
import warnings
@@ -20,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 6e3cfb3281..b0a5606cd7 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_base.py
@@ -1,15 +1,17 @@
-"""
-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
-"""
+#
+# 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 abc
+
import numpy as np
@@ -19,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 0cb485765f..7e998a0ef6 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_deterministic.py
@@ -1,44 +1,43 @@
-"""
-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
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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 logging
+from pathlib import Path
+
import numpy as np
-import os
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,
+ _normalize_image_path,
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"]
@@ -48,7 +47,7 @@ def __init__(self, cfg):
def load_dataset(self):
cfg = self.cfg
- file_name = os.path.join(self.cfg["project_path"], cfg["dataset"])
+ file_name = Path(self.cfg["project_path"]) / cfg["dataset"]
mlab = sio.loadmat(file_name)
self.raw_data = mlab
mlab = mlab["dataset"]
@@ -62,12 +61,7 @@ def load_dataset(self):
item = DataItem()
item.image_id = i
- im_path = sample[0][0]
- if isinstance(im_path, str):
- im_path = robust_split_path(im_path)
- else:
- im_path = [s.strip() for s in im_path]
- item.im_path = os.path.join(*im_path)
+ item.im_path: str = _normalize_image_path(sample[0][0])
item.im_size = sample[1][0]
if len(sample) >= 3:
joints = sample[2][0][0]
@@ -158,10 +152,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
@@ -172,7 +163,7 @@ def make_batch(self, data_item, scale, mirror):
im_file = data_item.im_path
logging.debug("image %s", im_file)
logging.debug("mirror %r", mirror)
- image = imread(os.path.join(self.cfg["project_path"], im_file), mode="skimage")
+ image = imread(Path(self.cfg["project_path"]) / im_file, mode="skimage")
if self.has_gt:
joints = np.copy(data_item.joints)
@@ -180,9 +171,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])
@@ -196,10 +185,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]
@@ -209,9 +195,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(
{
@@ -230,7 +214,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])])
@@ -242,8 +226,8 @@ def compute_target_part_scoremap(self, joint_id, coords, data_item, size, scale)
for person_id in range(len(coords)):
for k, j_id in enumerate(joint_id[person_id]):
joint_pt = coords[person_id][k, :]
- j_x = np.asscalar(joint_pt[0])
- j_y = np.asscalar(joint_pt[1])
+ j_x = np.asarray(joint_pt[0]).item()
+ j_y = np.asarray(joint_pt[1]).item()
# don't loop over entire heatmap, but just relevant locations
j_x_sm = round((j_x - self.half_stride) / self.stride)
@@ -262,7 +246,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 fe6cd371d1..ffe4a28714 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_imgaug.py
@@ -1,40 +1,41 @@
-"""
-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
+#
+# 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
+#
+"""Uses imgaug dataflow for flexible augmentation Largely written by Mert YĂŒksekgönĂŒl
+during the summer in the Bethge lab -- Thanks!
-Loader structure adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-
-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/
"""
import logging
-import os
import pickle
+from pathlib import Path
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, _normalize_image_path
@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)
self.num_images = len(self.data)
@@ -54,7 +55,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,15 +71,13 @@ 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
- file_name = os.path.join(self.cfg["project_path"], cfg["dataset"])
+ file_name = str(Path(self.cfg["project_path"]) / cfg["dataset"])
if ".mat" in file_name: # legacy loader
mlab = sio.loadmat(file_name)
self.raw_data = mlab
@@ -93,12 +92,7 @@ def load_dataset(self):
item = DataItem()
item.image_id = i
- im_path = sample[0][0]
- if isinstance(im_path, str):
- im_path = robust_split_path(im_path)
- else:
- im_path = [s.strip() for s in im_path]
- item.im_path = os.path.join(*im_path)
+ item.im_path: str = _normalize_image_path(sample[0][0])
item.im_size = sample[1][0]
if len(sample) >= 3:
joints = sample[2][0][0]
@@ -117,7 +111,7 @@ def load_dataset(self):
else:
print("Loading pickle data with float coordinates!")
file_name = cfg["dataset"].split(".")[0] + ".pickle"
- with open(os.path.join(self.cfg["project_path"], file_name), "rb") as f:
+ with (Path(self.cfg["project_path"]) / file_name).open("rb") as f:
pickledata = pickle.load(f)
self.raw_data = pickledata
@@ -128,7 +122,7 @@ def load_dataset(self):
sample = pickledata[i] # mlab[0, i]
item = DataItem()
item.image_id = i
- item.im_path = os.path.join(*sample["image"]) # [0][0]
+ item.im_path: str = _normalize_image_path(sample["image"]) # [0][0]
item.im_size = sample["size"] # sample[1][0]
if len(sample) >= 3:
item.num_animals = len(sample["joints"])
@@ -141,17 +135,35 @@ 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 isinstance(opt, int):
+ p = opt
+ else:
+ p = 0.5
+ pipeline.add(
+ sometimes(
+ augmentation.KeypointFliplr(
+ cfg["all_joints_names"],
+ symmetric_pairs=cfg["symmetric_pairs"],
+ p=p,
+ )
+ )
+ )
+
if cfg["rotation"] > 0:
pipeline.add(
iaa.Sometimes(
@@ -165,31 +177,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))))
@@ -200,8 +198,10 @@ def get_aug_param(cfg_value):
opt = {}
return opt
- cfg_cnt = cfg.get("contrast", {})
- cfg_cnv = cfg.get("convolution", {})
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ cfg_cnt = cfg.get("contrast") or {}
+ cfg_cnv = cfg.get("convolution") or {}
contrast_aug = ["histeq", "clahe", "gamma", "sigmoid", "log", "linear"]
for aug in contrast_aug:
@@ -219,17 +219,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"])
@@ -237,15 +231,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"])
@@ -283,6 +273,7 @@ def get_batch(self):
batch_joints = []
joint_ids = []
data_items = []
+
# Scale is sampled only once to transform all of the images of a batch into same size.
scale = self.sample_scale()
@@ -314,17 +305,19 @@ 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(Path(self.cfg["project_path"]) / im_file, mode="skimage")
if self.has_gt:
- joints = np.copy(data_item.joints)
- joint_id = [person_joints[:, 0].astype(int) for person_joints in joints]
- joint_points = [person_joints[:, 1:3] for person_joints in joints]
- joint_ids.append(joint_id)
- batch_joints.append(np.array(joint_points)[0])
+ joints = data_item.joints
+ kpts = np.full((self._n_kpts, 2), np.nan)
+
+ for n, x, y in joints[0]:
+ kpts[int(n)] = x, y
+
+ joint_ids.append([np.arange(self._n_kpts)])
+ batch_joints.append(kpts)
+
batch_images.append(image)
sm_size = np.ceil(target_size / (stride * 2)).astype(int) * 2
assert len(batch_images) == self.batch_size
@@ -349,18 +342,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)
@@ -374,6 +363,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,
@@ -383,26 +373,54 @@ def next_batch(self):
sm_size,
target_size,
) = self.get_batch()
+
pipeline = self.build_augmentation_pipeline(
- height=target_size[0], width=target_size[1], apply_prob=0.5
- )
- batch_images, batch_joints = pipeline(
- images=batch_images, keypoints=batch_joints
+ 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)
+
+ image_shape = np.array(batch_images).shape[1:3]
+
+ batch_joints_valid = []
+ joint_ids_valid = []
+ 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, :]
+ ids = ids[0][mask]
+ inside = np.logical_and.reduce(
+ (
+ joints[:, 0] < image_shape[1],
+ joints[:, 0] > 0,
+ joints[:, 1] < image_shape[0],
+ joints[:, 1] > 0,
+ )
+ )
+
+ batch_joints_valid.append(joints[inside])
+ joint_ids_valid.append([ids[inside]])
+
# If you would like to check the augmented images, script for saving
# the images with joints on:
# 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)
- image_shape = np.array(batch_images).shape[1:3]
batch = {Batch.inputs: np.array(batch_images).astype(np.float64)}
if self.has_gt:
scmap_update = self.get_scmap_update(
- joint_ids, batch_joints, data_items, sm_size, image_shape
+ joint_ids_valid,
+ batch_joints_valid,
+ data_items,
+ sm_size,
+ image_shape,
)
batch.update(scmap_update)
@@ -439,7 +457,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
@@ -448,14 +466,14 @@ def gaussian_scmap(self, joint_id, coords, data_item, size, scale):
for person_id in range(len(coords)):
for k, j_id in enumerate(joint_id[person_id]):
joint_pt = coords[person_id][k, :]
- j_x = np.asscalar(joint_pt[0])
- j_x_sm = round((j_x - self.half_stride) / self.stride)
- j_y = np.asscalar(joint_pt[1])
- j_y_sm = round((j_y - self.half_stride) / self.stride)
- map_j = grid.copy()
+ j_x = np.asarray(joint_pt[0]).item()
+ round((j_x - self.half_stride) / self.stride)
+ j_y = np.asarray(joint_pt[1]).item()
+ 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
@@ -476,11 +494,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])]))
@@ -495,9 +511,9 @@ def compute_target_part_scoremap_numpy(
for person_id in range(len(coords)):
for k, j_id in enumerate(joint_id[person_id]):
joint_pt = coords[person_id][k, :]
- j_x = np.asscalar(joint_pt[0])
+ j_x = np.asarray(joint_pt[0]).item()
j_x_sm = round((j_x - self.half_stride) / self.stride)
- j_y = np.asscalar(joint_pt[1])
+ j_y = np.asarray(joint_pt[1]).item()
j_y_sm = round((j_y - self.half_stride) / self.stride)
min_x = round(max(j_x_sm - dist_thresh - 1, 0))
max_x = round(min(j_x_sm + dist_thresh + 1, width - 1))
@@ -507,7 +523,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 c99ed54fa7..e9caaa495c 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py
@@ -1,38 +1,62 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
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.auxfun_videos import imread
-from deeplabcut.utils.conversioncode import robust_split_path
-from math import sqrt
+from deeplabcut.pose_estimation_tensorflow.datasets.utils import Batch, DataItem, _normalize_image_path
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import VideoReader, imread
@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(Path(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)
+
+ if cfg.get("pseudo_label", "").endswith(".h5"):
+ assert cfg["video_path"]
+ print("loading video for image source", cfg["video_path"])
+ self.vid = VideoReader(cfg["video_path"])
+ self.video_image_size = (3, self.vid.height, self.vid.width)
+ else:
+ self.vid = None
+
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),
@@ -48,8 +72,22 @@ def default_size(self, size):
def load_dataset(self):
cfg = self.cfg
- file_name = os.path.join(self.cfg["project_path"], cfg["dataset"])
- with open(os.path.join(self.cfg["project_path"], file_name), "rb") as f:
+
+ if cfg.get("pseudo_label", ""):
+ if cfg["pseudo_label"].endswith(".h5"):
+ pseudo_threshold = cfg.get("pseudo_threshold", 0)
+ print(f"Loading pseudo labels with threshold > {pseudo_threshold}")
+
+ # for topview, it's safe to mask keypoints under threshold
+ mask_kpts_below_thresh = "topview" in cfg.get("superanimal", "")
+ return self._load_pseudo_data_from_h5(
+ cfg,
+ threshold=pseudo_threshold,
+ mask_kpts_below_thresh=mask_kpts_below_thresh,
+ )
+
+ file_name = Path(self.cfg["project_path"]) / cfg["dataset"]
+ with file_name.open("rb") as f:
# Pickle the 'data' dictionary using the highest protocol available.
pickledata = pickle.load(f)
@@ -62,21 +100,11 @@ def load_dataset(self):
sample = pickledata[i] # mlab[0, i]
item = DataItem()
item.image_id = i
- im_path = sample["image"]
- if isinstance(im_path, str):
- im_path = robust_split_path(im_path)
- item.im_path = os.path.join(*im_path)
+ item.im_path: str = _normalize_image_path(sample["image"])
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!
@@ -88,13 +116,75 @@ 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):
+ gt_file = cfg["pseudo_label"]
+ assert Path(gt_file).exists()
+ path_ = Path(gt_file)
+ print("Using gt file:", path_.name)
+ 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):
+ item = DataItem()
+ data = df.loc[imagename]
+ # 3 for likelihood
+ kpts = data.to_numpy().reshape(-1, 3)
+ item.num_joints = kpts.shape[0]
+ joint_ids = np.arange(item.num_joints)[..., np.newaxis]
+ frame_name = "frame_" + str(int(imagename.split("frame")[1])) + ".png"
+ item.im_path = str(Path(video_root) / frame_name)
+
+ if self.vid:
+ item.im_size = self.video_image_size
+ else:
+ item.im_size = read_image_shape_fast(Path(video_root) / frame_name)
+
+ item.joints = {}
+
+ if not mask_kpts_below_thresh:
+ joints = np.concatenate([joint_ids, kpts], axis=1)
+ joints = np.nan_to_num(joints, nan=0)
+ else:
+ for kpt_id, kpt in enumerate(kpts):
+ if kpt[-1] < threshold:
+ kpts[kpt_id][:-1] = -1
+ if np.isnan(kpt[0]):
+ kpts[kpt_id][:-1] = -1
+ kpts[kpt_id][-1] = 1
+ joints = np.concatenate([joint_ids, kpts], axis=1)
+
+ sparse_joints = []
+
+ for coord in joints:
+ if coord[1] != 0 and coord[3] > threshold:
+ sparse_joints.append(coord[:3])
+
+ temp = np.array(sparse_joints)
+ # we only do single animal here
+ item.joints.update({0: temp})
+ itemlist.append(item)
+
+ self.has_gt = True
+ return itemlist
+
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")
+
+ if cfg.get("traintime_resize", False):
+ # let's hard code it
+ print("using traintime resize")
+ pipeline.add(iaa.Resize({"height": 400, "width": "keep-aspect-ratio"}))
+
crop_sampling = cfg.get("crop_sampling", "hybrid")
if pre_resize:
width, height = pre_resize
@@ -107,19 +197,30 @@ def build_augmentation_pipeline(self, apply_prob=0.5):
pipeline.add(iaa.PadToFixedSize(*self.default_size))
pipeline.add(
augmentation.KeypointAwareCropToFixedSize(
- *self.default_size, cfg.get("max_shift", 0.4), crop_sampling,
+ *self.default_size,
+ cfg.get("max_shift", 0.4),
+ crop_sampling,
)
)
- if cfg.get("fliplr", False):
+ if cfg.get("fliplr", False) and cfg.get("symmetric_pairs"):
opt = cfg.get("fliplr", False)
- if type(opt) == int:
- pipeline.add(sometimes(iaa.Fliplr(opt)))
+ if isinstance(opt, int):
+ p = opt
else:
- pipeline.add(sometimes(iaa.Fliplr(0.5)))
+ p = 0.5
+ pipeline.add(
+ sometimes(
+ augmentation.KeypointFliplr(
+ cfg["all_joints_names"],
+ symmetric_pairs=cfg["symmetric_pairs"],
+ p=p,
+ )
+ )
+ )
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))))
@@ -127,35 +228,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))))
@@ -166,8 +253,10 @@ def get_aug_param(cfg_value):
opt = {}
return opt
- cfg_cnt = cfg.get("contrast", {})
- cfg_cnv = cfg.get("convolution", {})
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ cfg_cnt = cfg.get("contrast") or {}
+ cfg_cnv = cfg.get("convolution") or {}
contrast_aug = ["histeq", "clahe", "gamma", "sigmoid", "log", "linear"]
for aug in contrast_aug:
@@ -185,17 +274,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"])
@@ -203,15 +286,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"])
@@ -231,6 +310,46 @@ def get_aug_param(cfg_value):
return pipeline
+ def get_batch_from_video(self):
+ len(self.vid)
+ batch_images = []
+ batch_joints = []
+ joint_ids = []
+ data_items = []
+ trim_ends = self.cfg.get("trim_ends", None)
+ 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)
+ for i in range(self.batch_size):
+ index = img_idx[i]
+ offset = trim_ends
+ data_item = self.data[index + offset]
+ data_items.append(data_item)
+ im_file = data_item.im_path
+
+ logging.debug("image %s", im_file)
+ self.vid.set_to_frame(index + offset)
+ image = self.vid.read_frame()
+ if self.has_gt:
+ joints = data_item.joints
+ if len(joints[0]) == 0:
+ # empty prediction for this frame
+ return None, None, None, None
+
+ 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_ids.append(joint_id)
+ batch_joints.append(kpts)
+
+ batch_images.append(image)
+
+ return batch_images, joint_ids, batch_joints, data_items
+
def get_batch(self):
img_idx = np.random.choice(self.num_images, size=self.batch_size, replace=True)
batch_images = []
@@ -244,25 +363,28 @@ 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(Path(self.cfg["project_path"]) / im_file, mode="skimage")
if self.has_gt:
- Joints = data_item.joints
- joint_id = [
- Joints[person_id][:, 0].astype(int) for person_id in Joints.keys()
- ]
- joint_points = np.concatenate(
- [Joints[person_id][:, 1:3] for person_id in Joints.keys()]
- )
+ 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_ids.append(joint_id)
- batch_joints.append(np.array(joint_points))
+ batch_joints.append(kpts)
batch_images.append(image)
+
return batch_images, joint_ids, batch_joints, data_items
def get_targetmaps_update(
- self, joint_ids, joints, data_items, sm_size, scale,
+ self,
+ joint_ids,
+ joints,
+ data_items,
+ sm_size,
+ scale,
):
part_score_targets = []
part_score_weights = []
@@ -278,9 +400,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,
@@ -289,9 +409,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)
@@ -315,9 +433,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
@@ -325,21 +443,34 @@ def calc_target_and_scoremap_sizes(self):
def next_batch(self, plotting=False):
while True:
- (batch_images, joint_ids, batch_joints, data_items,) = self.get_batch()
+ if self.vid:
+ (
+ batch_images,
+ joint_ids,
+ batch_joints,
+ data_items,
+ ) = self.get_batch_from_video()
+ else:
+ batch_images, joint_ids, batch_joints, data_items = self.get_batch()
+
+ # in case it's empty prediction
+ if batch_joints is None or batch_images is None:
+ continue
# Scale is sampled only once (per batch) to transform all of the images into same size.
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(
(
joints[:, 0] < image_shape[1],
@@ -348,12 +479,15 @@ def next_batch(self, plotting=False):
joints[:, 1] > 0,
)
)
- batch_joints_valid.append(joints[inside])
+ mask = visible & inside
+ batch_joints_valid.append(joints[mask])
+
temp = []
start = 0
for array in ids:
end = start + array.size
- temp.append(array[inside[start:end]])
+ inds = np.arange(start, end)
+ temp.append(array[mask[inds]])
start = end
joint_ids_valid.append(temp)
@@ -367,10 +501,7 @@ def next_batch(self, plotting=False):
shape=batch_images[i].shape,
)
im = kps.draw_on_image(batch_images[i])
- # imageio.imwrite(data_items[i].im_path.split('/')[-1],im)
- imageio.imwrite(
- os.path.join(self.cfg["project_path"], str(i) + ".png"), im
- )
+ imageio.imwrite(Path(self.cfg["project_path"]) / f"{i}.png", im)
batch = {Batch.inputs: batch_images.astype(np.float64)}
if self.has_gt:
@@ -411,17 +542,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)
@@ -433,7 +560,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)
@@ -452,14 +579,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])
@@ -475,11 +600,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
@@ -491,53 +612,47 @@ def compute_target_part_scoremap_numpy(
coordinateoffset = 0 # the offset based on
y, x = np.rollaxis(grid * stride + half_stride, 2)
- for person_id in range(len(joint_id)):
- joint_ids = joint_id[person_id].tolist()
- if len(joint_ids) >= 2: # there is a possible edge
- for l, (bp1, bp2) in enumerate(self.cfg["partaffinityfield_graph"]):
- try:
- ind1 = joint_ids.index(bp1)
- except ValueError:
- continue
- try:
- ind2 = joint_ids.index(bp2)
- except ValueError:
- continue
- j_x, j_y = coords[ind1 + coordinateoffset]
- linkedj_x, linkedj_y = coords[ind2 + coordinateoffset]
- dist = sqrt((linkedj_x - j_x) ** 2 + (linkedj_y - j_y) ** 2)
- if dist > 0:
- Dx = (linkedj_x - j_x) / dist # x-axis UNIT VECTOR
- Dy = (linkedj_y - j_y) / dist
- d1 = [
- Dx * j_x + Dy * j_y,
- Dx * linkedj_x + Dy * linkedj_y,
- ] # in-line with direct axis
- d1lowerboundary = min(d1)
- d1upperboundary = max(d1)
- 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
- )
-
- 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_map[mask, l * 2 + 0] = Dx * temp
- partaffinityfield_map[mask, l * 2 + 1] = Dy * temp
+ if self.cfg["partaffinityfield_graph"]:
+ for person_id in range(len(joint_id)):
+ joint_ids = joint_id[person_id].tolist()
+ if len(joint_ids) >= 2: # there is a possible edge
+ for l, (bp1, bp2) in enumerate(self.cfg["partaffinityfield_graph"]):
+ try:
+ ind1 = joint_ids.index(bp1)
+ except ValueError:
+ continue
+ try:
+ ind2 = joint_ids.index(bp2)
+ except ValueError:
+ continue
+ j_x, j_y = coords[ind1 + coordinateoffset]
+ linkedj_x, linkedj_y = coords[ind2 + coordinateoffset]
+ dist = sqrt((linkedj_x - j_x) ** 2 + (linkedj_y - j_y) ** 2)
+ if dist > 0:
+ Dx = (linkedj_x - j_x) / dist # x-axis UNIT VECTOR
+ Dy = (linkedj_y - j_y) / dist
+ d1 = [
+ Dx * j_x + Dy * j_y,
+ Dx * linkedj_x + Dy * linkedj_y,
+ ] # in-line with direct axis
+ d1lowerboundary = min(d1)
+ d1upperboundary = max(d1)
+ d2mid = j_y * Dx - j_x * Dy # orthogonal direction
- coordinateoffset += len(joint_ids) # keeping track of the blocks
+ distance_along = Dx * x + Dy * y
+ distance_across = ((y * Dx - x * Dy) - d2mid) * 1.0 / self.cfg["pafwidth"] * scale
+
+ 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_map[mask, l * 2 + 0] = Dx * temp
+ partaffinityfield_map[mask, l * 2 + 1] = Dy * temp
+
+ coordinateoffset += len(joint_ids) # keeping track of the blocks
weights = self.compute_scmap_weights(scmap.shape, joint_id)
return (
@@ -563,11 +678,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)
@@ -583,19 +696,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
@@ -640,24 +755,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))
@@ -666,12 +771,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 a276b23ffb..ff921a35ef 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_scalecrop.py
@@ -1,14 +1,14 @@
-"""
-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
+#
+# 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
+#
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
from .factory import PoseDatasetFactory
from .pose_deterministic import DeterministicPoseDataset
@@ -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 77ba502b3c..25f55c71d3 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_tensorpack.py
@@ -1,13 +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/master/AUTHORS
+#
+# 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
-
-Loader structure adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
See pull request:
https://github.com/DeepLabCut/DeepLabCut/pull/409
@@ -18,35 +19,34 @@
https://github.com/tensorpack/tensorpack
"""
-
import multiprocessing
import os
+from pathlib import Path
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 .factory import PoseDatasetFactory
from .pose_base import BasePoseDataset
-from .utils import Batch, data_to_input
+from .utils import Batch, _normalize_image_path, data_to_input
def img_to_bgr(im_path):
@@ -98,7 +98,7 @@ def __init__(self, cfg, shuffle=True, dir=None):
def load_dataset(self):
cfg = self.cfg
- file_name = os.path.join(self.cfg["project_path"], cfg["dataset"])
+ file_name = Path(self.cfg["project_path"]) / cfg["dataset"]
mlab = sio.loadmat(file_name)
self.raw_data = mlab
mlab = mlab["dataset"]
@@ -113,12 +113,7 @@ def load_dataset(self):
item = DataItem()
item.image_id = i
base = str(self.cfg["project_path"])
- im_path = sample[0][0]
- if isinstance(im_path, str):
- im_path = robust_split_path(im_path)
- else:
- im_path = [s.strip() for s in im_path]
- item.im_path = os.path.join(base, *im_path)
+ item.im_path = str(Path(base) / _normalize_image_path(sample[0][0]))
item.im_size = sample[1][0]
if len(sample) >= 3:
joints = sample[2][0][0]
@@ -165,7 +160,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
@@ -210,9 +205,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)
@@ -225,7 +218,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:
@@ -238,7 +232,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"],
@@ -260,9 +254,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 = [
@@ -304,15 +296,12 @@ 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]
def get_dataflow(self, cfg):
-
df = Pose(cfg)
df = MapData(df, self.augment)
df = MapData(df, self.compute_target_part_scoremap)
@@ -322,13 +311,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 +335,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]
@@ -358,8 +343,8 @@ def compute_target_part_scoremap(self, components):
for person_id in range(len(coords)):
for k, j_id in enumerate(joint_id[person_id]):
joint_pt = coords[person_id][k, :]
- j_x = np.asscalar(joint_pt[0])
- j_y = np.asscalar(joint_pt[1])
+ j_x = np.asarray(joint_pt[0]).item()
+ j_y = np.asarray(joint_pt[1]).item()
# don't loop over entire heatmap, but just relevant locations
j_x_sm = round((j_x - half_stride) / stride)
@@ -375,7 +360,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 +403,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 +412,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 0295eb63c2..ae50b4895f 100644
--- a/deeplabcut/pose_estimation_tensorflow/datasets/utils.py
+++ b/deeplabcut/pose_estimation_tensorflow/datasets/utils.py
@@ -1,5 +1,17 @@
-import numpy as np
+#
+# 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 enum import Enum
+from pathlib import Path
+
+import numpy as np
class Batch(Enum):
@@ -17,6 +29,15 @@ class DataItem:
pass
+# NOTE @deruyter92 2026-07-21: the TensorFlow branch is deprecated soon, and excluded from the migration to pathlib
+def _normalize_image_path(im_path: np.ndarray | list | tuple | Path | str) -> str:
+ """Convert a raw image path from .mat or pickle format to a string."""
+ if isinstance(im_path, np.ndarray):
+ im_path = [str(x).strip() for x in im_path.flatten()]
+ im_path = Path(*im_path) if isinstance(im_path, (list, tuple)) else Path(im_path)
+ return str(im_path)
+
+
def data_to_input(data):
return np.expand_dims(data, axis=0).astype(float)
@@ -32,7 +53,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/default_config.py b/deeplabcut/pose_estimation_tensorflow/default_config.py
index 776cf16e8f..9f1c8cdbc7 100644
--- a/deeplabcut/pose_estimation_tensorflow/default_config.py
+++ b/deeplabcut/pose_estimation_tensorflow/default_config.py
@@ -1,15 +1,16 @@
-"""
-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
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
cfg = dict()
diff --git a/deeplabcut/pose_estimation_tensorflow/export.py b/deeplabcut/pose_estimation_tensorflow/export.py
index c6cfb3d48b..a9eb7fcf51 100644
--- a/deeplabcut/pose_estimation_tensorflow/export.py
+++ b/deeplabcut/pose_estimation_tensorflow/export.py
@@ -1,25 +1,24 @@
-"""
-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 glob
-import os
+#
+# 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 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.core.config import get_yaml_dumper, get_yaml_loader
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():
@@ -30,7 +29,6 @@ def create_deploy_config_template():
Creates a template for config.yaml file.
This specific order is preserved while saving as yaml file.
"""
-
yaml_str = """\
# Deploy config.yaml - info about project origin:
Task:
@@ -50,127 +48,80 @@ def create_deploy_config_template():
\n
"""
- ruamelFile = ruamel.yaml.YAML()
+ ruamelFile = get_yaml_loader()
cfg_file = ruamelFile.load(yaml_str)
return cfg_file, ruamelFile
def write_deploy_config(configname, cfg):
- """
-
- CURRENTLY NOT IMPLEMENTED
+ """CURRENTLY NOT IMPLEMENTED.
Write structured config file.
"""
-
with open(configname, "w") as cf:
- ruamelFile = ruamel.yaml.YAML()
+ ruamelFile = get_yaml_loader()
cfg_file, ruamelFile = create_deploy_config_template()
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"
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
-
- Parameters:
- -----------
- cfg : dict
- Configuration read from the project's main config.yaml file
-
- shuffle : int, optional
- which shuffle to use
+ """Load a TensorFlow session with a DLC model from the associated configuration.
- trainingsetindex : int. optional
- which training fraction to use, identified by its index
-
- TFGPUinference : bool, optional
- use tensorflow inference model? default = True
+ Args:
+ cfg (dict): Configuration read from the project's main config.yaml file.
+ shuffle (int, optional): Which shuffle to use.
+ trainingsetindex (int, optional): Which training fraction to use, identified by
+ its index.
+ TFGPUinference (bool, optional): Use tensorflow inference model? Defaults to
+ True.
Returns:
- --------
- sess : tensorflow session
- tensorflow session with DLC model from the provided configuration, shuffle, and trainingsetindex
-
- checkpoint file path : string
- the path to the checkpoint file associated with the loaded model
+ tuple: sess, input, output, and dlc_cfg where sess is a tensorflow session with
+ DLC model from the provided configuration, shuffle, and trainingsetindex.
"""
-
########################
### find snapshot to use
########################
train_fraction = cfg["TrainingFraction"][trainingsetindex]
- model_folder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- train_fraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ model_folder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_model_folder(train_fraction, shuffle, cfg, modelprefix=modelprefix)
)
- path_test_config = os.path.normpath(model_folder + "/test/pose_cfg.yaml")
- path_train_config = os.path.normpath(model_folder + "/train/pose_cfg.yaml")
+ Path(model_folder) / "test" / "pose_cfg.yaml"
+ path_train_config = Path(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:
+ except FileNotFoundError as e:
raise FileNotFoundError(
- "It seems the model for shuffle %s and trainFraction %s does not exist."
- % (shuffle, train_fraction)
- )
+ f"It seems the model for shuffle {shuffle} and trainFraction {train_fraction} 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(model_folder, "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 trying to export.\n Use the function 'train_network' to train the network for shuffle %s."
- % (shuffle, shuffle)
- )
-
- 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"] = str(Path(model_folder) / "train" / Snapshots[snapshotindex])
+ Path(dlc_cfg["init_weights"]).name.split("-")[-1]
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
dlc_cfg["batch_size"] = None
@@ -191,42 +142,35 @@ def load_model(cfg, shuffle=1, trainingsetindex=0, TFGPUinference=True, modelpre
def tf_to_pb(sess, checkpoint, output, output_dir=None):
- """
+ """Saves a frozen tensorflow graph (a protobuf file).
- Saves a frozen tensorflow graph (a protobuf file).
See also https://leimao.github.io/blog/Save-Load-Inference-From-TF-Frozen-Graph/
- Parameters
- ----------
- sess : tensorflow session
- session with graph to be saved
-
- checkpoint : string
- checkpoint of tensorflow model to be converted to protobuf (output will be .pb)
-
- output : list of strings
- list of the names of output nodes (is returned by load_models)
-
- output_dir : string, optional
- path to the directory that exported models should be saved to.
- If None, will export to the directory of the checkpoint file.
+ Args:
+ sess: Session with graph to be saved.
+ checkpoint (string): Checkpoint of tensorflow model to be converted to protobuf
+ (output will be .pb).
+ output (list of strings): List of the names of output nodes (is returned by
+ load_models).
+ output_dir (string, optional): Path to the directory that exported models should
+ be saved to. 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)
- )
- ckpt_base = os.path.basename(checkpoint)
+ output_dir = Path(output_dir).expanduser() if output_dir else Path(checkpoint).parent
+ ckpt_base = Path(checkpoint).name
# save graph to pbtxt file
- pbtxt_file = os.path.normpath(output_dir + "/" + ckpt_base + ".pbtxt")
+ pbtxt_file = str(Path(output_dir) / (ckpt_base + ".pbtxt"))
tf.io.write_graph(sess.graph.as_graph_def(), "", pbtxt_file, as_text=True)
# create frozen graph from pbtxt file
- pb_file = os.path.normpath(output_dir + "/" + ckpt_base + ".pb")
+ pb_file = Path(output_dir) / (ckpt_base + ".pb")
frozen_graph_def = tf.compat.v1.graph_util.convert_variables_to_constants(
- sess, sess.graph_def, output,
+ sess,
+ sess.graph_def,
+ output,
)
- with open(pb_file, "wb") as file:
+ with Path(pb_file).open("wb") as file:
file.write(frozen_graph_def.SerializeToString())
@@ -242,98 +186,68 @@ 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
- 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.
-
- 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)
- --------
+ Args:
+ cfg_path (string): Path to the DLC Project config.yaml file.
+ shuffle (int, optional): The shuffle of the model to export. Defaults to 1.
+ trainingsetindex (int, optional): The index of the training fraction for the
+ model you wish to export. Defaults to 1.
+ snapshotindex (int, optional): The snapshot index for the weights you wish to
+ export. If None, uses the snapshotindex as defined in 'config.yaml'.
+ Defaults to 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. Defaults to 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.
+
+ Examples:
+ Export the first stored snapshot for model trained with shuffle 3:
+
+ deeplabcut.export_model("/analysis/project/reaching-task/config.yaml", shuffle=3, snapshotindex=-1)
"""
-
### read config file
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["project_path"] = Path(cfg_path).absolute().parent
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)
### set up export directory
- export_dir = os.path.normpath(cfg["project_path"] + "/" + "exported-models")
- if not os.path.isdir(export_dir):
- os.mkdir(export_dir)
+ export_dir = Path(cfg["project_path"]) / "exported-models"
+ if not export_dir.is_dir():
+ export_dir.mkdir()
- sub_dir_name = "DLC_%s_%s_iteration-%d_shuffle-%d" % (
- cfg["Task"],
- dlc_cfg["net_type"],
- cfg["iteration"],
- shuffle,
- )
- full_export_dir = os.path.normpath(export_dir + "/" + sub_dir_name)
+ sub_dir_name = f"DLC_{cfg['Task']}_{dlc_cfg['net_type']}_iteration-{cfg['iteration']}_shuffle-{shuffle}"
+ full_export_dir = export_dir / sub_dir_name
- if os.path.isdir(full_export_dir):
+ if full_export_dir.is_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)
+ full_export_dir.mkdir()
### write pose config file
@@ -349,18 +263,15 @@ def export_model(
else:
sorted_cfg[key] = value
- pose_cfg_file = os.path.normpath(full_export_dir + "/pose_cfg.yaml")
- ruamel_file = ruamel.yaml.YAML()
- ruamel_file.dump(sorted_cfg, open(pose_cfg_file, "w"))
+ pose_cfg_file = full_export_dir / "pose_cfg.yaml"
+ with pose_cfg_file.open("w") as f:
+ get_yaml_dumper().dump(sorted_cfg, f)
### 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_files = list(Path(ckpt).parent.glob(Path(ckpt).name + "*"))
+ ckpt_dest = [full_export_dir / Path(ckf).name 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
@@ -370,6 +281,6 @@ def export_model(
### tar export directory
if make_tar:
- tar_name = os.path.normpath(full_export_dir + ".tar.gz")
+ tar_name = str(full_export_dir) + ".tar.gz"
with tarfile.open(tar_name, "w:gz") as tar:
- tar.add(full_export_dir, arcname=os.path.basename(full_export_dir))
+ tar.add(full_export_dir, arcname=full_export_dir.name)
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
index 79125db2f0..52c30a86f5 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/__init__.py
@@ -1,12 +1,15 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-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 5bfdf7bdac..9a918bcaac 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/crossvalutils.py
@@ -1,444 +1,13 @@
-"""
-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 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.GetTrainingSetFolder(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]
+#
+# 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
+#
+"""Backwards compatibility."""
+
+from deeplabcut.core.crossvalutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
index b8e83b6e6a..f7603bb6f3 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/inferenceutils.py
@@ -1,1060 +1,13 @@
-"""
-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 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]
- # 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()
-
- @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(),
- }
+#
+# 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
+#
+"""Backwards compatibility."""
+
+from deeplabcut.core.inferenceutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
index f1d695c5d9..8fdd526ab1 100644
--- a/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
+++ b/deeplabcut/pose_estimation_tensorflow/lib/trackingutils.py
@@ -1,829 +1,13 @@
-"""
-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 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(np.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
+#
+# 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
+#
+"""Backwards compatibility."""
+
+from deeplabcut.core.trackingutils import *
diff --git a/deeplabcut/pose_estimation_tensorflow/models/__init__.py b/deeplabcut/pose_estimation_tensorflow/models/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/models/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/models/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
diff --git a/deeplabcut/pose_estimation_tensorflow/models/pretrained/__init__.py b/deeplabcut/pose_estimation_tensorflow/models/pretrained/__init__.py
index e69de29bb2..2dd1b06028 100644
--- a/deeplabcut/pose_estimation_tensorflow/models/pretrained/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/models/pretrained/__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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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/models/pretrained/pretrained_model_urls.yaml b/deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml
index 4b7144968e..428ca65cb0 100644
--- a/deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml
+++ b/deeplabcut/pose_estimation_tensorflow/models/pretrained/pretrained_model_urls.yaml
@@ -12,12 +12,16 @@ mobilenet_v2_0.35: https://storage.googleapis.com/mobilenet_v2/checkpoints/mobil
#Source: https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet
efficientnet: https://storage.googleapis.com/cloud-tpu-checkpoints/efficientnet/ckptsaug/
-#Model Zoo from where the Charles flow(s)
-full_human: http://deeplabcut.rowland.harvard.edu/models/DLC_human_fullbody_resnet_101.tar.gz
-full_dog: http://deeplabcut.rowland.harvard.edu/models/DLC_Dog_resnet_50_iteration-0_shuffle-0.tar.gz
-full_cat: http://deeplabcut.rowland.harvard.edu/models/DLC_Cat_resnet_50_iteration-0_shuffle-0.tar.gz
-primate_face: http://deeplabcut.rowland.harvard.edu/models/DLC_primate_face_resnet_50_iteration-1_shuffle-1.tar.gz
-mouse_pupil_vclose: http://deeplabcut.rowland.harvard.edu/models/DLC_mouse_pupil_vclose_resnet_50_iteration-0_shuffle-1.tar.gz
-horse_sideview: http://deeplabcut.rowland.harvard.edu/models/DLC_Horses_resnet_50_iteration-1_shuffle-1.tar.gz
-full_macaque: http://deeplabcut.rowland.harvard.edu/models/DLC_macaque_full_resnet50.tar.gz
-full_cheetah: http://deeplabcut.rowland.harvard.edu/models/DLC_full_cheetah_resnet_152.tar.gz
+### DEPRECIATED 2023 -- customized models are banked on HuggingFace and the dlclibrary hosts downloading.
+##Harvard server down since Nov 2022
+#Previous Model Zoo URLs from where the Charles flow(s)
+#full_human: http://deeplabcut.rowland.harvard.edu/models/DLC_human_fullbody_resnet_101.tar.gz
+#full_dog: http://deeplabcut.rowland.harvard.edu/models/DLC_Dog_resnet_50_iteration-0_shuffle-0.tar.gz
+#full_cat: http://deeplabcut.rowland.harvard.edu/models/DLC_Cat_resnet_50_iteration-0_shuffle-0.tar.gz
+#primate_face: http://deeplabcut.rowland.harvard.edu/models/DLC_primate_face_resnet_50_iteration-1_shuffle-1.tar.gz
+#mouse_pupil_vclose: http://deeplabcut.rowland.harvard.edu/models/DLC_mouse_pupil_vclose_resnet_50_iteration-0_shuffle-1.tar.gz
+#horse_sideview: http://deeplabcut.rowland.harvard.edu/models/DLC_Horses_resnet_50_iteration-1_shuffle-1.tar.gz
+#full_macaque: http://deeplabcut.rowland.harvard.edu/models/DLC_macaque_full_resnet50.tar.gz
+#full_cheetah: http://deeplabcut.rowland.harvard.edu/models/DLC_full_cheetah_resnet_152.tar.gz
+
+# Current HuggingFace URLs at: https://github.com/DeepLabCut/DLClibrary/blob/main/dlclibrary/modelzoo_urls.yaml
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/pose_estimation_tensorflow/modelzoo/api/__init__.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/__init__.py
new file mode 100644
index 0000000000..c9d1bfd351
--- /dev/null
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/__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 .spatiotemporal_adapt import SpatiotemporalAdaptation
diff --git a/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py
new file mode 100644
index 0000000000..6501363eff
--- /dev/null
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/spatiotemporal_adapt.py
@@ -0,0 +1,235 @@
+#
+# 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 collections.abc import Sequence
+from pathlib import Path
+
+from deeplabcut.core.deprecation import renamed_parameter
+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.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,
+ video_extensions: str | Sequence[str] | None = "mp4",
+ adapt_iterations=1000,
+ modelfolder="",
+ customized_pose_config="",
+ init_weights="",
+ ):
+ """Support video adaptation to a super model.
+
+ Args:
+ video_path (string): The string to the path of the video.
+ init_weights (string): The path to a superanimal model's checkpoint.
+ supermodel_name (string): Currently we support supertopview (LabMice) and
+ superquadruped (quadruped side-view animals).
+ scale_list (list): A list of different resolutions for the spatial pyramid.
+ video_extensions (string or Sequence[str], optional): When the input is a directory, only videos with
+ these extensions are analyzed. Defaults to ``"mp4"``.
+ 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.
+ 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.
+ customized_pose_config (string, optional): Path to a custom pose config for
+ non-modelzoo models. Defaults to "".
+
+ Examples:
+ Create a SpatiotemporalAdaptation object and perform inference, adaptation, and post-adaptation inference:
+
+ from deeplabcut.pose_estimation_tensorflow.modelzoo.api.spatiotemporal_adapt import (
+ SpatiotemporalAdaptation,
+ )
+ video_path = "/mnt/md0/shaokai/openfield_video/m3v1mp4.mp4"
+ supermodel_name = "superanimal_topviewmouse"
+ video_extensions = "mp4"
+ adapter = SpatiotemporalAdaptation(
+ video_path,
+ supermodel_name,
+ modelfolder="temp_topview",
+ video_extensions=video_extensions,
+ )
+ adapter.before_adapt_inference()
+ adapter.adaptation_training()
+ adapter.after_adapt_inference()
+ """
+ if scale_list is None:
+ scale_list = []
+
+ self.video_path = video_path
+ self.supermodel_name = supermodel_name
+ self.scale_list = scale_list
+ 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 = get_deeplabcut_path()
+
+ project_config = read_config(dlc_root_path / "modelzoo" / "project_configs" / f"{project_name}.yaml")
+
+ model_config = read_config(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 = video_inference(
+ [self.video_path],
+ 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 = video_inference(
+ [self.video_path],
+ self.project_name,
+ self.model_name,
+ video_extensions=self.video_extensions,
+ scale_list=self.scale_list,
+ customized_test_config=self.customized_pose_config,
+ )
+ if kwargs.pop("plot_trajectories", True):
+ if len(datafiles) == 0:
+ print("No data files found for plotting trajectory")
+ else:
+ _plot_trajectories(datafiles[0])
+
+ if make_video:
+ create_labeled_video(
+ "",
+ [self.video_path],
+ video_extensions=self.video_extensions,
+ filtered=False,
+ init_weights=self.init_weights,
+ draw_skeleton=True,
+ superanimal_name=self.project_name,
+ **kwargs,
+ )
+
+ def train_without_project(self, pseudo_label_path, **kwargs):
+ from deeplabcut.pose_estimation_tensorflow.core.train_multianimal import train
+
+ displayiters = kwargs.pop("displayiters", 500)
+ saveiters = kwargs.pop("saveiters", 1000)
+ self.adapt_iterations = kwargs.pop("adapt_iterations", self.adapt_iterations)
+
+ train(
+ self.customized_pose_config,
+ displayiters=displayiters,
+ saveiters=saveiters,
+ maxiters=self.adapt_iterations,
+ modelfolder=self.modelfolder,
+ init_weights=self.init_weights,
+ pseudo_labels=pseudo_label_path,
+ video_path=self.video_path,
+ superanimal=self.supermodel_name,
+ **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.
+
+ Or we make up a fake one, then we use a light way convention to do adaptation
+ """
+ # looking for the pseudo label path
+ DLCscorer = "DLC_" + Path(self.init_weights).stem
+ vname = str(Path(self.video_path).stem)
+ video_root = Path(self.video_path).parent
+
+ _, pseudo_label_path, _, _ = load_analyzed_data(video_root, vname, DLCscorer, False, "")
+ if self.modelfolder != "":
+ Path(self.modelfolder).mkdir(parents=True, exist_ok=True)
+
+ self.adapt_iterations = kwargs.get("adapt_iterations", self.adapt_iterations)
+
+ self.train_without_project(
+ pseudo_label_path,
+ displayiters=displayiters,
+ saveiters=saveiters,
+ **kwargs,
+ )
+
+ def after_adapt_inference(self, create_labeled_video, **kwargs):
+ ref_proj_config_path = ""
+
+ files = list(Path(self.modelfolder).glob(f"snapshot-{self.adapt_iterations}.index"))
+
+ if not len(files):
+ raise ValueError("Weights were not found.")
+
+ adapt_weights = files[0].replace(".index", "")
+
+ # spatial pyramid is not for adapted model
+
+ scale_list = kwargs.pop("scale_list", [])
+
+ # spatial pyramid can still be useful for reducing jittering and quantization error
+
+ _, datafiles = video_inference(
+ [self.video_path],
+ 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,
+ )
+
+ if kwargs.pop("plot_trajectories", True):
+ _plot_trajectories(datafiles[0])
+
+ 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/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py
new file mode 100644
index 0000000000..d8157fe376
--- /dev/null
+++ b/deeplabcut/pose_estimation_tensorflow/modelzoo/api/superanimal_inference.py
@@ -0,0 +1,506 @@
+#
+# 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 pickle
+import time
+import warnings
+from collections.abc import Sequence
+from pathlib import Path
+
+import imgaug.augmenters as iaa
+import numpy as np
+import pandas as pd
+from skimage.util import img_as_ubyte
+from tqdm import tqdm
+
+from deeplabcut.core.deprecation import renamed_parameter
+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, collect_video_paths
+
+warnings.simplefilter("ignore", category=RuntimeWarning)
+
+
+def get_multi_scale_frames(frame, scale_list):
+ augs = []
+ shapes = []
+ for scale in scale_list:
+ aug = iaa.Resize({"width": "keep-aspect-ratio", "height": scale})
+ augs.append(aug)
+
+ frames = []
+ for i in range(len(scale_list)):
+ resized_frame = augs[i](image=frame)
+ frames.append(resized_frame)
+ shapes.append(frames[-1].shape)
+
+ return frames, shapes
+
+
+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
+
+ coordinate = pred["coordinates"][0]
+ confidence = pred["confidence"]
+ for kpt_id, coord_list in enumerate(coordinate):
+ if len(coord_list) == 0:
+ continue
+ confidence_list = confidence[kpt_id]
+ max_idx = np.argmax(confidence_list)
+ # ratio_h and ratio_w should match though in reality it does not match exactly
+ max_pred = coordinate[kpt_id][max_idx] * ratio_h
+
+ # only keep the max
+
+ confidence[kpt_id] = confidence_list[max_idx]
+ coordinate[kpt_id] = max_pred
+ return pred
+
+
+def _average_multiple_scale_preds(
+ preds,
+ scale_list,
+ num_kpts,
+ cos_dist_threshold=0.997,
+ confidence_threshold=0.1,
+):
+ if len(scale_list) < 2:
+ return preds[0]
+
+ xyp = np.zeros((len(scale_list), num_kpts, 3))
+ for scale_id, pred in enumerate(preds):
+ # empty prediction if pred is not a dict
+ if not isinstance(pred, dict):
+ xyp[scale_id] = np.nan
+ continue
+ coordinates = pred["coordinates"][0]
+ confidence = pred["confidence"]
+ for i, (coords, conf) in enumerate(zip(coordinates, confidence, strict=False)):
+ if not np.any(coords):
+ continue
+ xyp[scale_id, i, :2] = coords
+ xyp[scale_id, i, 2] = conf
+ xy = xyp[..., :2]
+
+ # Compute cosine similarity
+ mean_vec = np.nanmedian(xy, axis=0)
+ dist_ = np.einsum("ijk,jk->ij", xy, mean_vec)
+ n = np.linalg.norm(xy, axis=2) * np.linalg.norm(mean_vec, axis=1)
+ dist = np.nan_to_num(dist_ / n)
+
+ mask = np.logical_or(
+ xyp[..., 2] < confidence_threshold,
+ dist < cos_dist_threshold,
+ )
+ xyp[mask] = np.nan
+ coords = np.nanmedian(xyp[..., :2], axis=0)
+ conf = np.nanmedian(xyp[..., 2], axis=0)
+ dict_ = {
+ "coordinates": [list(coords[:, None])],
+ "confidence": list(conf[:, None].astype(np.float32)),
+ }
+ return dict_
+
+
+def _video_inference(
+ test_cfg,
+ sess,
+ inputs,
+ outputs,
+ cap,
+ nframes,
+ batchsize,
+ 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
+
+ nx, ny = cap.dimensions
+
+ pbar = tqdm(total=nframes)
+ counter = 0
+ inds = []
+ print("scale list", scale_list)
+ PredicteData = {}
+ # len(frames) -> (n_scale,)
+ # frames[0].shape - > (batchsize, h, w, 3)
+ multi_scale_batched_frames = None
+ frame_shapes = None
+
+ num_kpts = len(test_cfg["all_joints_names"])
+
+ while cap.video.isOpened():
+ # no crop needed
+ _frame = cap.read_frame()
+ if _frame is not None:
+ frame = img_as_ubyte(_frame)
+
+ old_shape = frame.shape
+ frames, frame_shapes = get_multi_scale_frames(frame, scale_list)
+
+ 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
+ ]
+
+ for scale_id, frame in enumerate(frames):
+ multi_scale_batched_frames[scale_id][batch_ind] = frame
+ inds.append(counter)
+ if batch_ind == batchsize - 1:
+ 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)
+ preds.append(D)
+ # only do this when animal is detected
+ ind_start = inds[0]
+ for i in range(batchsize):
+ ind = ind_start + i
+ PredicteData["frame" + str(ind).zfill(strwidth)] = []
+
+ for scale_id in range(len(scale_list)):
+ if i >= len(preds[scale_id]):
+ pred = []
+ else:
+ pred = preds[scale_id][i]
+ if pred != []:
+ pred = _project_pred_to_original_size(pred, old_shape, frame_shapes[scale_id])
+
+ PredicteData["frame" + str(ind).zfill(strwidth)].append(pred)
+
+ batch_ind = 0
+ inds.clear()
+ else:
+ batch_ind += 1
+ elif counter >= nframes:
+ # in case we reach the end of the video
+ if batch_ind > 0:
+ preds = []
+ for scale_id, batched_frames in enumerate(multi_scale_batched_frames):
+ D = predict.predict_batched_peaks_and_costs(
+ test_cfg,
+ batched_frames,
+ sess,
+ inputs,
+ outputs,
+ )
+
+ preds.append(D)
+
+ ind_start = inds[0]
+ for i in range(batchsize):
+ ind = ind_start + i
+ if ind >= nframes:
+ break
+ PredicteData["frame" + str(ind).zfill(strwidth)] = []
+ for scale_id in range(len(scale_list)):
+ if i >= len(preds[scale_id]):
+ pred = []
+ else:
+ pred = preds[scale_id][i]
+ if pred != []:
+ pred = _project_pred_to_original_size(pred, old_shape, frame_shapes[scale_id])
+ PredicteData["frame" + str(ind).zfill(strwidth)].append(pred)
+
+ break
+
+ counter += 1
+ pbar.update(1)
+
+ cap.close()
+ pbar.close()
+
+ for k, v in PredicteData.items():
+ if v != []:
+ PredicteData[k] = _average_multiple_scale_preds(v, scale_list, num_kpts)
+
+ PredicteData["metadata"] = {
+ "nms radius": test_cfg.get("nmsradius", None),
+ "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"]))),
+ "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"]))],
+ "nframes": nframes,
+ }
+
+ return PredicteData, nframes
+
+
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
+def video_inference(
+ videos,
+ project_name,
+ model_name,
+ scale_list=None,
+ video_extensions: str | Sequence[str] | None = None,
+ destfolder=None,
+ batchsize=1,
+ robust_nframes=False,
+ allow_growth=False,
+ init_weights="",
+ customized_test_config="",
+):
+ if scale_list is None:
+ scale_list = []
+ dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
+
+ if customized_test_config == "":
+ project_cfg = load_config(dlc_root_path / "modelzoo" / "project_configs" / f"{project_name}.yaml")
+ model_cfg = load_config(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)
+
+ else:
+ test_cfg = customized_test_config
+
+ # add a temp folder for checkpoint
+ weight_folder = str(dlc_root_path / "modelzoo" / "checkpoints" / f"{project_name}_{model_name}")
+ snapshots = list(Path(weight_folder).glob("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 = str(auxiliaryfunctions.safe_resolve(snapshots[0].with_suffix("")))
+ 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)
+ DLCscorer = "DLC_" + Path(test_cfg["init_weights"]).stem
+ videos = collect_video_paths(videos, extensions=video_extensions)
+
+ datafiles = []
+ for video in videos:
+ vname = Path(video).stem
+
+ videofolder = str(Path(video).parents[0])
+ if destfolder is None:
+ destfolder = videofolder
+ auxiliaryfunctions.attempt_to_make_folder(destfolder)
+
+ dataname = str(Path(destfolder) / (vname + DLCscorer + ".h5"))
+ datafiles.append(dataname)
+
+ if Path(dataname).is_file():
+ print("Video already analyzed!", dataname)
+ else:
+ print("Loading ", video)
+ vid = VideoWriter(video)
+ if len(scale_list) == 0:
+ # spatial pyramid can still be useful for reducing jittering and quantization error
+ scale_list = [vid.height - 50, vid.height, vid.height + 50]
+ 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")
+
+ # extra data
+ PredicteData, nframes = _video_inference(
+ test_cfg,
+ sess,
+ inputs,
+ outputs,
+ vid,
+ nframes,
+ int(test_cfg["batch_size"]),
+ scale_list=scale_list,
+ )
+
+ stop = time.time()
+
+ coords = [0, nx, 0, ny]
+
+ 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": (ny, nx),
+ "nframes": nframes,
+ "iteration (active-learning)": 0,
+ "cropping": False,
+ "training set fraction": 70,
+ "cropping_parameters": coords,
+ }
+ metadata = {"data": dictionary}
+ print(f"Saving results in {destfolder}...")
+
+ metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
+
+ with Path(metadata_path).open("wb") as f:
+ pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
+
+ xyz_labs = ["x", "y", "likelihood"]
+ scorer = DLCscorer
+ keypoint_names = test_cfg["all_joints_names"]
+ product = [[scorer], keypoint_names, xyz_labs]
+ names = ["scorer", "bodyparts", "coords"]
+ columnindex = pd.MultiIndex.from_product(product, names=names)
+ imagenames = [k for k in PredicteData.keys() if k != "metadata"]
+
+ data = np.full((len(imagenames), len(columnindex)), np.nan)
+ for i, imagename in enumerate(imagenames):
+ dict_ = PredicteData[imagename]
+ if dict_ == [] or dict_ == [[]]:
+ data[i, 2::3] = 0
+ else:
+ keypoints = dict_["coordinates"][0]
+ confidence = dict_["confidence"]
+ temp = np.full((len(keypoints), 3), np.nan)
+ 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
+ data[i] = temp.flatten()
+ df = pd.DataFrame(data, columns=columnindex, index=imagenames)
+ 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,
+):
+ """Internal utility for super-animal model video inference.
+
+ WARNING: Do not call directly. Used by ``deeplabcut.modelzoo.api.video_inference``.
+
+ Makes predictions based on a super animal model. Currently only single-animal
+ video inference is supported.
+
+ The index of the trained network is specified by parameters in the config file
+ (in particular the variable ``snapshotindex``).
+
+ Output labels are stored as a MultiIndex Pandas DataFrame containing the network
+ name, body part name, (x, y) label position in pixels, and likelihood for each
+ frame per body part. These arrays are stored in HDF format in the same directory
+ as the video.
+
+ Args:
+ videos (list): Full paths to videos for analysis, or a directory containing
+ videos with the same extension.
+ project_name (str): SuperAnimal project name (e.g. ``superanimal``).
+ model_name (str): SuperAnimal model name (e.g. ``topviewmouse``).
+ Combined with ``project_name`` as ``project_name + "_" + model_name``.
+ scale_list (list, optional): Target heights for multi-scale test-time
+ augmentation. By default uses the original size.
+ video_extensions (string or Sequence[str], optional): When ``videos`` is a directory, only videos with
+ these extensions are analyzed. Defaults to ``"mp4"``.
+ video_adapt (bool, optional): If True, apply video adaptation for less
+ jittering results. Defaults to False.
+ plot_trajectories (bool, optional): Plot trajectories of body parts.
+ Defaults to True.
+ pcutoff (float, optional): Keypoints with confidence below ``pcutoff`` are
+ hidden in the output video. Defaults to 0.1.
+ adapt_iterations (int, optional): Iterations for adaptation training.
+ Defaults to 1000.
+ pseudo_threshold (float, optional): Video adaptation uses predictions above
+ this threshold. Defaults to 0.1.
+ create_labeled_video (bool, optional): Whether to create a labeled video.
+ Defaults to True.
+
+ Examples:
+ Given a list of scales for the spatial pyramid, perform inference:
+
+ scale_list = [200, 300, 400]
+ deeplabcut.video_inference_superanimal(
+ video,
+ "superanimal_topviewmouse",
+ 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 cd2450611c..ab0aca78fb 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/__init__.py
@@ -1,23 +1,20 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-pose_estimation_tensorflow is based of Eldar's repository:
-https://github.com/eldar/pose-tensorflow
-
-"""
-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 ca2ebae8b4..1737ba22c4 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/base.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/base.py
@@ -1,7 +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/master/AUTHORS
+#
+# 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
@@ -11,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])
@@ -33,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"]
@@ -76,7 +84,10 @@ def test(self, inputs):
return self.add_inference_layers(heads)
def prediction_layers(
- self, features, scope="pose", reuse=None,
+ self,
+ features,
+ scope="pose",
+ reuse=None,
):
out = {}
n_joints = self.cfg["num_joints"]
@@ -89,27 +100,31 @@ def prediction_layers(
)
if self.cfg["location_refinement"]:
out["locref"] = prediction_layer(
- self.cfg, features, "locref_pred", n_joints * 2,
+ self.cfg,
+ features,
+ "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,
+ 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, "pairwise_pred", self.cfg["num_limbs"] * 2,
+ self.cfg,
+ features,
+ "pairwise_pred",
+ self.cfg["num_limbs"] * 2,
)
out["features"] = features
return out
def inference(self, inputs):
"""Direct TF inference on GPU.
+
Added with: https://arxiv.org/abs/1909.11229
"""
heads = self.get_net(inputs)
@@ -123,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])
@@ -151,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))
@@ -170,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))
@@ -178,16 +183,22 @@ def add_inference_layers(self, heads):
# https://openaccess.thecvf.com/content_CVPR_2020/papers/Huang_The_Devil_Is_in_the_Details_Delving_Into_Unbiased_Data_CVPR_2020_paper.pdf
scmaps = tf.gather(prob, tf.range(self.cfg["num_joints"]), axis=3)
kernel = make_2d_gaussian_kernel(
- sigma=self.cfg.get("sigma", 1), size=nms_radius * 2 + 1,
+ sigma=self.cfg.get("sigma", 1),
+ size=nms_radius * 2 + 1,
)
kernel = kernel[:, :, tf.newaxis, tf.newaxis]
kernel_sc = tf.tile(kernel, [1, 1, tf.shape(scmaps)[3], 1])
scmaps = tf.nn.depthwise_conv2d(
- scmaps, kernel_sc, strides=[1, 1, 1, 1], padding="SAME",
+ scmaps,
+ kernel_sc,
+ strides=[1, 1, 1, 1],
+ padding="SAME",
)
peak_inds = predict_multianimal.find_local_peak_indices_maxpool_nms(
- scmaps, nms_radius, self.cfg.get("minconfidence", 0.01),
+ scmaps,
+ nms_radius,
+ self.cfg.get("minconfidence", 0.01),
)
outputs = {"part_prob": prob, "peak_inds": peak_inds}
if self.cfg["location_refinement"]:
@@ -195,7 +206,10 @@ def add_inference_layers(self, heads):
if self.cfg.get("locref_smooth", False):
kernel_loc = tf.tile(kernel, [1, 1, tf.shape(locref)[3], 1])
locref = tf.nn.depthwise_conv2d(
- locref, kernel_loc, strides=[1, 1, 1, 1], padding="SAME",
+ locref,
+ kernel_loc,
+ strides=[1, 1, 1, 1],
+ padding="SAME",
)
outputs["locref"] = locref
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py b/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
index e6d3729559..f588b295bf 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/conv_blocks.py
@@ -1,3 +1,4 @@
+#
# Copyright 2018 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,8 +12,9 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-# ==============================================================================
+#
"""Convolution blocks for mobilenet."""
+
import contextlib
import functools
@@ -79,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.
@@ -126,7 +129,6 @@ def split_separable_conv2d(
Returns:
output tesnor
"""
-
with _v1_compatible_scope_naming(scope) as scope:
dw_scope = scope + "depthwise"
endpoints = endpoints if endpoints is not None else {}
@@ -170,7 +172,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),
@@ -235,21 +237,20 @@ def expanded_conv(
Tensor of depth num_outputs
Raises:
- TypeError: on inval
+ TypeError: If arguments are invalid.
"""
- 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,
@@ -365,8 +366,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 5d4041348e..3da6d3cbbf 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/efficientnet.py
@@ -1,23 +1,23 @@
-"""
-DeepLabCut 2.1.9 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
-
-Adopted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-
-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
-
+#
+# 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
+#
+"""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 4cd9f4be1d..61501353b3 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/factory.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/factory.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import warnings
@@ -8,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 feff5544ac..fc0d4d2c0d 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/layers.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/layers.py
@@ -1,7 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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):
@@ -26,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
@@ -56,5 +63,10 @@ def prediction_layer_stage(cfg, input, name, num_outputs):
weights_regularizer=slim.l2_regularizer(cfg["weight_decay"]),
):
with tf.compat.v1.variable_scope(name):
- pred = slim.conv2d(input, num_outputs, kernel_size=[3, 3], stride=1,)
+ pred = slim.conv2d(
+ input,
+ num_outputs,
+ kernel_size=[3, 3],
+ stride=1,
+ )
return pred
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py b/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
index 4223b08a54..1d4f4adafb 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/mobilenet.py
@@ -1,12 +1,15 @@
-"""
-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
+#
+# 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
+#
+"""
Also see our paper:
Pretraining boosts out-of-domain robustness for pose estimation
by Alexander Mathis, Mert YĂŒksekgönĂŒl, Byron Rogers, Matthias Bethge, Mackenzie W. Mathis
@@ -20,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": (
@@ -58,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"]]
@@ -69,9 +72,17 @@ def extract_features(self, inputs):
return net, end_points
def prediction_layers(
- self, features, end_points, scope="pose", reuse=None,
+ self,
+ features,
+ end_points,
+ scope="pose",
+ reuse=None,
):
- out = super(PoseMobileNet, self).prediction_layers(features, scope, reuse,)
+ out = super().prediction_layers(
+ features,
+ scope,
+ reuse,
+ )
with tf.compat.v1.variable_scope(scope, reuse=reuse):
if self.cfg["intermediate_supervision"]:
out["part_pred_interm"] = prediction_layer(
diff --git a/deeplabcut/pose_estimation_tensorflow/nnets/multi.py b/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
index f6558d45d6..28c08fdbde 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/multi.py
@@ -1,34 +1,31 @@
-"""
-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
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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 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 = {
@@ -97,7 +94,10 @@ def prediction_layer(cfg, input, name, num_outputs):
):
with tf.compat.v1.variable_scope(name):
pred = slim.conv2d_transpose(
- input, num_outputs, kernel_size=[3, 3], stride=2,
+ input,
+ num_outputs,
+ kernel_size=[3, 3],
+ stride=2,
)
return pred
@@ -105,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"]
@@ -116,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()):
@@ -141,21 +139,22 @@ def extract_features(self, inputs):
return net, end_points
def prediction_layers(
- self, features, end_points, input_shape, scope="pose", reuse=None,
+ self,
+ features,
+ end_points,
+ input_shape,
+ scope="pose",
+ reuse=None,
):
net_type = self.cfg["net_type"]
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)
@@ -220,7 +219,6 @@ def prediction_layers(
weights_regularizer=slim.l2_regularizer(self.cfg["weight_decay"]),
):
with tf.compat.v1.variable_scope("upsampled_features"):
-
concat_3_s16 = tf.concat([bank_1_s16, bank_2_s16, features], 3)
if self.cfg["stride"] == 8:
@@ -264,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
@@ -295,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(
@@ -316,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,
@@ -335,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,
@@ -358,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]
@@ -370,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(
@@ -386,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(
@@ -399,22 +374,19 @@ def prediction_layers(
scope="block4",
)
net = tf.concat([bank_3, upsampled_features], 3)
- out = super(PoseMultiNet, self).prediction_layers(net, scope, reuse,)
+ 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 51b69b4ed4..b97bb130b9 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/resnet.py
@@ -1,16 +1,19 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
import re
+
import tensorflow as tf
import tf_slim as slim
from tf_slim.nets import resnet_v1
@@ -19,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,
@@ -30,29 +32,38 @@
@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"]]
im_centered = self.center_inputs(inputs)
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,
+ im_centered,
+ global_pool=False,
+ output_stride=16,
+ is_training=False,
)
return net, end_points
def prediction_layers(
- self, features, end_points, scope="pose", reuse=None,
+ self,
+ features,
+ end_points,
+ scope="pose",
+ reuse=None,
):
- out = super(PoseResnet, self).prediction_layers(features, scope, reuse,)
+ out = super().prediction_layers(
+ features,
+ scope,
+ reuse,
+ )
out["features"] = features
with tf.compat.v1.variable_scope(scope, reuse=reuse):
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 b54cbfc029..5641ffc6c9 100644
--- a/deeplabcut/pose_estimation_tensorflow/nnets/utils.py
+++ b/deeplabcut/pose_estimation_tensorflow/nnets/utils.py
@@ -1,3 +1,4 @@
+#
# Copyright 2019 The TensorFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -11,14 +12,16 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
-# ==============================================================================
+#
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):
@@ -51,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,
@@ -85,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)
@@ -104,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,
@@ -136,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
@@ -166,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):
@@ -175,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
@@ -219,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):
@@ -242,5 +213,3 @@ def drop_connect(inputs, is_training, drop_connect_rate):
class DepthwiseConv2D(tf.keras.layers.DepthwiseConv2D, tf.compat.v1.layers.Layer):
"""Wrap keras DepthwiseConv2D to tf.layers."""
-
- pass
diff --git a/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py b/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
index 4867c9c0d4..f2107ed445 100644
--- a/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
+++ b/deeplabcut/pose_estimation_tensorflow/predict_multianimal.py
@@ -1,13 +1,14 @@
-"""
-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
+#
+# 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 pickle
import shelve
import time
@@ -19,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(
@@ -38,68 +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.attempttomakefolder(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 (destfolder / f"{basename}_assemblies.pickle").open("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(
@@ -116,123 +109,120 @@ def AnalyzeMultiAnimalVideo(
use_shelve=False,
):
"""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.attempttomakefolder(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 (destfolder / f"{basename}_meta.pickle").open("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,
+ 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}
@@ -249,7 +239,6 @@ def GetPoseandCostsF_from_assemblies(
feature_dict,
extra_dict,
):
-
"""Batchwise prediction of pose"""
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
@@ -258,14 +247,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"])
@@ -282,7 +269,6 @@ def GetPoseandCostsF_from_assemblies(
inds.append(counter)
if batch_ind == batchsize - 1:
-
preds = predict.predict_batched_peaks_and_costs(
dlc_cfg, frames, sess, inputs, outputs, extra_dict=extra_dict
)
@@ -290,14 +276,16 @@ 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
fname = "frame" + str(ind).zfill(strwidth)
feature_dict[fname] = _get_features_dict(
- raw_coords, features[i], dlc_cfg["stride"],
+ raw_coords,
+ features[i],
+ dlc_cfg["stride"],
)
batch_ind = 0
@@ -307,7 +295,6 @@ def GetPoseandCostsF_from_assemblies(
batch_ind += 1
elif counter >= nframes:
if batch_ind > 0:
-
preds = predict.predict_batched_peaks_and_costs(
dlc_cfg, frames, sess, inputs, outputs, extra_dict=extra_dict
)
@@ -315,14 +302,16 @@ 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
fname = "frame" + str(ind).zfill(strwidth)
feature_dict[fname] = _get_features_dict(
- raw_coords, features[i], dlc_cfg["stride"],
+ raw_coords,
+ features[i],
+ dlc_cfg["stride"],
)
break
@@ -332,25 +321,29 @@ 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(
- cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, batchsize, shelf_path,
+ cfg,
+ dlc_cfg,
+ sess,
+ inputs,
+ outputs,
+ cap,
+ nframes,
+ batchsize,
+ shelf_path,
):
"""Batchwise prediction of pose"""
strwidth = int(np.ceil(np.log10(nframes))) # width for strings
@@ -360,15 +353,16 @@ 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 = []
if shelf_path:
- db = shelve.open(shelf_path, protocol=pickle.DEFAULT_PROTOCOL,)
+ db = shelve.open(
+ shelf_path,
+ protocol=pickle.DEFAULT_PROTOCOL,
+ )
else:
db = dict()
db["metadata"] = {
@@ -376,13 +370,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():
@@ -399,9 +389,13 @@ def GetPoseandCostsF(
inds.append(counter)
if batch_ind == batchsize - 1:
D = predict.predict_batched_peaks_and_costs(
- dlc_cfg, frames, sess, inputs, outputs,
+ dlc_cfg,
+ frames,
+ sess,
+ 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
@@ -412,9 +406,13 @@ def GetPoseandCostsF(
elif counter >= nframes:
if batch_ind > 0:
D = predict.predict_batched_peaks_and_costs(
- dlc_cfg, frames, sess, inputs, outputs,
+ dlc_cfg,
+ frames,
+ sess,
+ 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
@@ -437,7 +435,10 @@ def GetPoseandCostsS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, shelf_pa
cap.set_bbox(cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
if shelf_path:
- db = shelve.open(shelf_path, protocol=pickle.DEFAULT_PROTOCOL,)
+ db = shelve.open(
+ shelf_path,
+ protocol=pickle.DEFAULT_PROTOCOL,
+ )
else:
db = dict()
db["metadata"] = {
@@ -445,13 +446,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)
@@ -467,8 +464,14 @@ def GetPoseandCostsS(cfg, dlc_cfg, sess, inputs, outputs, cap, nframes, shelf_pa
if frame.shape[-1] == 4:
frame = rgba2rgb(frame)
dets = predict.predict_batched_peaks_and_costs(
- dlc_cfg, np.expand_dims(frame, axis=0), sess, inputs, outputs,
+ dlc_cfg,
+ np.expand_dims(frame, axis=0),
+ sess,
+ inputs,
+ outputs,
)
+ if not dets:
+ continue
db[key] = dets[0]
del dets
elif counter >= nframes:
diff --git a/deeplabcut/pose_estimation_tensorflow/predict_videos.py b/deeplabcut/pose_estimation_tensorflow/predict_videos.py
index b845b02224..ddb2845ece 100644
--- a/deeplabcut/pose_estimation_tensorflow/predict_videos.py
+++ b/deeplabcut/pose_estimation_tensorflow/predict_videos.py
@@ -1,12 +1,14 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
####################################################
# Dependencies
@@ -14,11 +16,11 @@
import argparse
import os
-import os.path
import pickle
import re
import time
import warnings
+from collections.abc import Sequence
from pathlib import Path
import cv2
@@ -29,28 +31,29 @@
from skimage.util import img_as_ubyte
from tqdm import tqdm
+from deeplabcut.core import inferenceutils, trackingutils
+from deeplabcut.core.deprecation import renamed_parameter
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
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
####################################################
# 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,
@@ -66,26 +69,28 @@ 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:
del os.environ["TF_CUDNN_USE_AUTOTUNE"] # was potentially set during training
if gputouse is not None: # gpu selection
- os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
+ auxfun_models.set_visible_devices(gputouse)
tf.compat.v1.reset_default_graph()
- start_path = os.getcwd() # record cwd to return to this directory in the end
+ start_path = Path.cwd() # record cwd to return to this directory in the end
cfg = auxiliaryfunctions.read_config(config)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
@@ -96,64 +101,45 @@ def create_tracking_dataset(
print("Overwriting cropping parameters:", cropping)
print("These are used for all videos, but won't be save to the cfg file.")
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / 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]
- )
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
+ dlc_cfg["init_weights"] = str(Path(modelfolder) / "train" / Snapshots[snapshotindex])
+ trainingsiterations = Path(dlc_cfg["init_weights"]).name.split("-")[-1]
# Update number of output and batchsize
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
- if batchsize == None:
+ if batchsize is None:
# update batchsize (based on parameters in config.yaml)
dlc_cfg["batch_size"] = cfg["batch_size"]
else:
@@ -171,11 +157,13 @@ 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:
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
trainFraction,
@@ -185,7 +173,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")
@@ -197,15 +186,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"],
)
@@ -213,7 +200,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:
@@ -235,7 +222,11 @@ def create_tracking_dataset(
sess.close()
tf.keras.backend.clear_session()
create_triplets_dataset(
- Videos, DLCscorer, track_method, n_triplets=n_triplets, destfolder=destfolder,
+ Videos,
+ DLCscorer,
+ track_method,
+ n_triplets=n_triplets,
+ destfolder=destfolder,
)
else:
@@ -244,29 +235,34 @@ 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,
save_as_csv=False,
+ in_random_order=True,
destfolder=None,
batchsize=None,
cropping=None,
@@ -278,6 +274,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,
@@ -287,191 +284,153 @@ def analyze_videos(
The index of the trained network is specified by parameters in the config file
(in particular the variable 'snapshotindex').
- 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.
-
- 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.
-
- 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
- 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.
-
- 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.
-
- batchsize: int or None, optional, default=None
- 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
- 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, 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. >> deeplabcut.analyze_videos(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'],
- )
+ 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.
+
+ Args:
+ 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): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for
+ training the network. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml). Defaults to 0.
+ gputouse (int or None, optional): 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. Defaults to None.
+ save_as_csv (bool, optional): Saves the predictions in a .csv file. Defaults to False.
+ in_random_order (bool, optional): Whether or not to analyze videos in a random order.
+ This is only relevant when specifying a video directory in `videos`. Defaults to True.
+ destfolder (string or None, optional): Destination folder for analysis data. If ``None``, uses the
+ video path. Pass this folder for subsequent analysis too. Defaults to None.
+ batchsize (int or None, optional): Batch size for inference; overwrites ``pose_cfg.yaml`` if set.
+ Defaults to None.
+ cropping (list or None, optional): 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. Defaults to None.
+ TFGPUinference (bool, optional): 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. Defaults to True.
+ dynamic (tuple[bool, float, int], optional): Triple containing (state,
+ detection_threshold, margin). If state is True, dynamic cropping is
+ performed: when any body part exceeds detection_threshold, object boundaries
+ are computed from min/max x/y positions, expanded by margin, and only the
+ posture within this crop is analyzed until the object is lost. Defaults to
+ (False, 0.5, 10).
+ 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. Defaults to "".
+ robust_nframes (bool, optional): 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. Defaults to False.
+ allow_growth (bool, optional): 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. Defaults to False.
+ use_shelve (bool, optional): 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. Defaults to False.
+ auto_track (bool, optional): For multi-animal projects, automatically perform
+ tracking and stitching to produce the final h5 file. If False, run
+ ``convert_detections2tracklets`` and ``stitch_tracklets`` afterwards.
+ Defaults to True.
+ identity_only (bool, optional): If True and animal identity was learned by the
+ model, assembly and tracking rely exclusively on identity prediction.
+ Defaults to False.
+ calibrate (bool, optional): If True, use training data to calibrate the animal
+ assembly procedure. Defaults to False.
+ n_tracks (int or None, optional): Number of tracks to reconstruct. By default from config.yaml.
+ Pass another value if animal count differs from training. Defaults to None.
+ animal_names (list[str], 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.
+ use_openvino (str, optional): Use "CPU" for inference if OpenVINO is available in the Python environment.
+ Defaults to "CPU" when OpenVINO is available, otherwise None.
+
+ Returns:
+ str: DLCScorer; the scorer used to analyze the videos.
+
+ Examples:
+ Analyzing a single video on Windows:
+
+ deeplabcut.analyze_videos(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'],
+ )
- Analyzing a single video on Linux/MacOS
+ Analyzing a single video on Linux/MacOS:
- >>> deeplabcut.analyze_videos(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos/reachingvideo1.avi'],
- )
+ deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/reachingvideo1.avi'],
+ )
- Analyze all videos of type ``avi`` in a folder
+ Analyze all videos of type ``avi`` in a folder:
- >>> deeplabcut.analyze_videos(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos'],
- videotype='.avi',
- )
+ deeplabcut.analyze_videos(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos'],
+ video_extensions='.avi',
+ )
- Analyze multiple videos
+ Analyze multiple videos:
- >>> deeplabcut.analyze_videos(
- '/analysis/project/reaching-task/config.yaml',
- [
- '/analysis/project/videos/reachingvideo1.avi',
- '/analysis/project/videos/reachingvideo2.avi',
- ],
- )
+ 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``
+ 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,
- )
+ 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
+ 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,
- )
+ 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 "TF_CUDNN_USE_AUTOTUNE" in os.environ:
del os.environ["TF_CUDNN_USE_AUTOTUNE"] # was potentially set during training
if gputouse is not None: # gpu selection
- os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
+ auxfun_models.set_visible_devices(gputouse)
tf.compat.v1.reset_default_graph()
- start_path = os.getcwd() # record cwd to return to this directory in the end
+ start_path = Path.cwd() # record cwd to return to this directory in the end
cfg = auxiliaryfunctions.read_config(config)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
@@ -483,64 +442,46 @@ def analyze_videos(
print("Overwriting cropping parameters:", cropping)
print("These are used for all videos, but won't be save to the cfg file.")
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / 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]
- )
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
+ dlc_cfg["init_weights"] = str(Path(modelfolder) / "train" / Snapshots[snapshotindex])
+ trainingsiterations = Path(dlc_cfg["init_weights"]).name.split("-")[-1]
# Update number of output and batchsize
dlc_cfg["num_outputs"] = cfg.get("num_outputs", dlc_cfg.get("num_outputs", 1))
- if batchsize == None:
+ if batchsize is None:
# update batchsize (based on parameters in config.yaml)
dlc_cfg["batch_size"] = cfg["batch_size"]
else:
@@ -558,11 +499,12 @@ 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:
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
trainFraction,
@@ -572,7 +514,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")
@@ -584,17 +527,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],
@@ -604,7 +541,7 @@ def analyze_videos(
##################################################
# Looping over videos
##################################################
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ 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 (
@@ -629,7 +566,7 @@ def analyze_videos(
convert_detections2tracklets(
config,
[video],
- videotype,
+ video_extensions,
shuffle,
trainingsetindex,
destfolder=destfolder,
@@ -640,12 +577,14 @@ 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,
)
else:
for video in Videos:
@@ -670,28 +609,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"]
@@ -712,19 +656,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))
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))
@@ -736,9 +676,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)
@@ -768,9 +706,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))
@@ -782,15 +718,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:
@@ -806,9 +738,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
@@ -821,9 +751,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)
@@ -833,9 +761,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:
@@ -847,61 +773,51 @@ 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
- ny, nx = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
+ ny = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ nx = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
if cfg["cropping"]:
ny, nx = checkcropping(cfg, cap)
- pose_tensor = predict.extract_GPUprediction(
- outputs, dlc_cfg
- ) # extract_output_tensor(outputs, dlc_cfg)
- frames = np.empty(
- (batchsize, ny, nx, 3), dtype="ubyte"
- ) # this keeps all frames in a batch
+ # Flip x, y, confidence and reshape
+ pose_tensor = predict.extract_GPUprediction(outputs, dlc_cfg)
+ pose_tensor = tf.gather(pose_tensor, [1, 0, 2], axis=1)
+ pose_tensor = tf.reshape(pose_tensor, (batchsize, -1))
+
+ frames = np.empty((batchsize, ny, nx, 3), dtype="ubyte")
pbar = tqdm(total=nframes)
- counter = 0
- step = max(10, int(nframes / 100))
+ counter = -1
inds = []
- while cap.isOpened():
- if counter != 0 and counter % step == 0:
- pbar.update(step)
+ while cap.isOpened() and counter < nframes - 1:
ret, frame = cap.read()
- 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"]]
- )
- else:
- frames[batch_ind] = img_as_ubyte(frame)
- inds.append(counter)
- if batch_ind == batchsize - 1:
- # pose = predict.getposeNP(frames,dlc_cfg, sess, inputs, outputs)
- pose = sess.run(pose_tensor, feed_dict={inputs: frames})
- pose[:, [0, 1, 2]] = pose[
- :, [1, 0, 2]
- ] # change order to have x,y,confidence
- pose = np.reshape(
- pose, (batchsize, -1)
- ) # bring into batchsize times x,y,conf etc.
- PredictedData[inds] = pose
- batch_ind = 0
- inds.clear()
- batch_num += 1
- else:
- batch_ind += 1
- elif counter >= nframes:
- if batch_ind > 0:
- # pose = predict.getposeNP(frames, dlc_cfg, sess, inputs, outputs) #process the whole batch (some frames might be from previous batch!)
- pose = sess.run(pose_tensor, feed_dict={inputs: frames})
- pose[:, [0, 1, 2]] = pose[:, [1, 0, 2]]
- pose = np.reshape(pose, (batchsize, -1))
- PredictedData[inds[:batch_ind]] = pose[:batch_ind]
- break
counter += 1
+ if not ret:
+ warnings.warn(f"Could not decode frame #{counter}.", stacklevel=2)
+ continue
+
+ if cfg["cropping"]:
+ frame = img_as_ubyte(frame[cfg["y1"] : cfg["y2"], cfg["x1"] : cfg["x2"]])
+ else:
+ frame = img_as_ubyte(frame)
+ frames[batch_ind] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ inds.append(counter)
+ if batch_ind == batchsize - 1:
+ pose = sess.run(pose_tensor, feed_dict={inputs: frames})
+ PredictedData[inds] = pose
+ batch_ind = 0
+ batch_num += 1
+ inds.clear()
+ pbar.update(batchsize)
+ else:
+ batch_ind += 1
+
+ if batch_ind > 0:
+ pose = sess.run(pose_tensor, feed_dict={inputs: frames})
+ PredictedData[inds[:batch_ind]] = pose[:batch_ind]
+ pbar.update(batch_ind)
pbar.close()
return PredictedData, nframes
@@ -915,10 +831,10 @@ 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:
@@ -940,9 +856,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])
@@ -961,19 +875,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
@@ -1008,22 +919,23 @@ def AnalyzeVideo(
if destfolder is None:
destfolder = str(Path(video).parents[0])
- auxiliaryfunctions.attempttomakefolder(destfolder)
+ auxiliaryfunctions.attempt_to_make_folder(destfolder)
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))
duration = nframes * 1.0 / fps
- size = (int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)))
+ size = (
+ int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
+ int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
+ )
ny, nx = size
print(
"Duration of video [s]: ",
@@ -1076,16 +988,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]
@@ -1103,14 +1011,14 @@ 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}
print(f"Saving results in {destfolder}...")
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
- auxiliaryfunctions.SaveData(
+ dataname = Path(destfolder) / (vname + DLCscorer + ".h5")
+ auxiliaryfunctions.save_data(
PredictedData[:nframes, :],
metadata,
dataname,
@@ -1118,18 +1026,15 @@ 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")
- im = imread(os.path.join(directory, framelist[0]), mode="skimage")
+ im = imread(Path(directory) / framelist[0], mode="skimage")
ny, nx, nc = np.shape(im)
print(
@@ -1140,27 +1045,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!")
@@ -1171,56 +1071,44 @@ def GetPosesofFrames(
if batchsize == 1:
for counter, framename in enumerate(framelist):
- im = imread(os.path.join(directory, framename), mode="skimage")
+ im = imread(Path(directory) / framename, mode="skimage")
if counter != 0 and counter % step == 0:
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")
+ im = imread(Path(directory) / framename, mode="skimage")
if counter != 0 and counter % step == 0:
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
@@ -1236,117 +1124,97 @@ 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.
-
- 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.
-
- 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. 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
-
- 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``
-
- Examples
- --------
- If you want to analyze all frames in /analysis/project/timelapseexperiment1
- >>> deeplabcut.analyze_videos('/analysis/project/reaching-task/config.yaml','/analysis/project/timelapseexperiment1')
- --------
+ """Analyze all images (of type ``frametype``) in a folder and store 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 labels are stored as a MultiIndex Pandas DataFrame containing the network
+ name, body part name, (x, y) label position in pixels, and likelihood for each
+ frame per body part. These arrays are stored in HDF format in the same directory
+ as the images. If ``save_as_csv`` is True, data can also be exported as CSV.
+
+ Args:
+ 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. Defaults to ``.png``.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ Defaults to 0.
+ 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. Defaults to False.
+ modelprefix (str, optional): Directory containing the deeplabcut models to use.
+ Defaults to "".
+
+ Examples:
+ If you want to analyze all frames in /analysis/project/timelapseexperiment1:
+
+ deeplabcut.analyze_images(
+ "/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
if gputouse is not None: # gpu selection
- os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
+ auxfun_models.set_visible_devices(gputouse)
tf.compat.v1.reset_default_graph()
- start_path = os.getcwd() # record cwd to return to this directory in the end
+ start_path = Path.cwd() # record cwd to return to this directory in the end
cfg = auxiliaryfunctions.read_config(config)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / 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:
- 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:
+ 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 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]
- )
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
+ dlc_cfg["init_weights"] = str(Path(modelfolder) / "train" / Snapshots[snapshotindex])
+ trainingsiterations = Path(dlc_cfg["init_weights"]).name.split("-")[-1]
# update batchsize (based on parameters in config.yaml)
dlc_cfg["batch_size"] = cfg["batch_size"]
# Name for scorer:
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
trainFraction,
@@ -1369,21 +1237,19 @@ def analyze_time_lapse_frames(
)
if gputouse is not None: # gpu selectinon
- os.environ["CUDA_VISIBLE_DEVICES"] = str(gputouse)
+ auxfun_models.set_visible_devices(gputouse)
##################################################
# Loading the images
##################################################
# checks if input is a directory
- if os.path.isdir(directory) == True:
- """
- Analyzes all the frames in the directory.
- """
+ if Path(directory).is_dir():
+ """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)])
+ framelist = np.sort([fn.name for fn in Path(directory).iterdir() if (frametype in fn.name)])
vname = Path(directory).stem
- notanalyzed, dataname, DLCscorer = auxiliaryfunctions.CheckifNotAnalyzed(
+ notanalyzed, dataname, DLCscorer = auxiliaryfunctions.check_if_not_analyzed(
directory, vname, DLCscorer, DLCscorerlegacy, flag="framestack"
)
if notanalyzed:
@@ -1404,7 +1270,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]
@@ -1424,9 +1290,9 @@ def analyze_time_lapse_frames(
}
metadata = {"data": dictionary}
- print("Saving results in %s..." % (directory))
+ print(f"Saving results in {directory}...")
- auxiliaryfunctions.SaveData(
+ auxiliaryfunctions.save_data(
PredictedData[:nframes, :],
metadata,
dataname,
@@ -1435,19 +1301,21 @@ 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))
def _convert_detections_to_tracklets(
- cfg, inference_cfg, data, metadata, output_path, greedy=False, calibrate=False,
+ cfg,
+ inference_cfg,
+ data,
+ metadata,
+ output_path,
+ greedy=False,
+ calibrate=False,
):
track_method = cfg.get("default_track_method", "ellipse")
if track_method not in trackingutils.TRACK_METHODS:
@@ -1455,6 +1323,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"]
@@ -1463,7 +1334,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(
@@ -1480,7 +1351,7 @@ def _convert_detections_to_tracklets(
)
tracklets = {}
- ass = inferenceutils.Assembler(
+ assembly_builder = inferenceutils.Assembler(
data,
max_n_individuals=inference_cfg["topktoretain"],
n_multibodyparts=len(cfg["multianimalbodyparts"]),
@@ -1489,30 +1360,29 @@ 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.GetTrainingSetFolder(cfg)
- train_data_file = os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ train_data_file = (
+ Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5")
)
- ass.calibrate(train_data_file)
- ass.assemble()
+ assembly_builder.calibrate(train_data_file)
+ assembly_builder.assemble()
- output_path, _ = os.path.splitext(output_path)
+ output_path = str(Path(output_path).with_suffix(""))
output_path += ".pickle"
- ass.to_pickle(output_path.replace(".pickle", "_assemblies.pickle"))
+ assembly_builder.to_pickle(output_path.replace(".pickle", "_assemblies.pickle"))
if cfg["uniquebodyparts"]:
tracklets["single"] = {}
- tracklets["single"].update(ass.unique)
+ tracklets["single"].update(assembly_builder.unique)
- for i, imname in tqdm(enumerate(ass.metadata["imnames"])):
- assemblies = ass.assemblies.get(i)
+ for i, imname in tqdm(enumerate(assembly_builder.metadata["imnames"])):
+ assemblies = assembly_builder.assemblies.get(i)
if assemblies is None:
continue
- animals = np.stack([ass.data[:, :3] for ass 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)
@@ -1531,14 +1401,15 @@ def _convert_detections_to_tracklets(
names=["scorer", "bodyparts", "coords"],
)
tracklets["header"] = pdindex
- with open(output_path, "wb") as f:
+ with Path(output_path).open("wb") as f:
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,
@@ -1552,86 +1423,80 @@ def convert_detections2tracklets(
identity_only=False,
track_method="",
):
- """
- 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.
-
- 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.
-
- 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).
-
- 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.
-
-
- Examples
- --------
- If you want to convert detections to tracklets:
- >>> deeplabcut.convert_detections2tracklets('/analysis/project/reaching-task/config.yaml',[]'/analysis/project/video1.mp4'], videotype='.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')
+ """This should be called at the end of deeplabcut.analyze_videos for multianimal
+ projects!
+
+ Args:
+ 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): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network.
+ Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ Defaults to 0.
+ overwrite (bool, optional): Overwrite tracks file; recompute tracks from full
+ detections. Defaults to False.
+ destfolder (string, optional): 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 (list, optional): List of body part names to ignore during tracking.
+ By default, all body parts are used. Defaults to None.
+ inferencecfg (dict, optional): Configuration for inference (assembly of individuals).
+ Ideally obtained from cross validation. By default loaded from inference_cfg.yaml.
+ Defaults to None.
+ modelprefix (str, optional): Directory containing the deeplabcut models to use.
+ Defaults to "".
+ greedy (bool, optional): Use greedy assembly instead of default method.
+ Defaults to False.
+ calibrate (bool, optional): 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. Defaults to False.
+ window_size (int, optional): 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. Defaults to 0.
+ identity_only (bool, optional): If True and animal identity was learned by the model,
+ assembly and tracking rely exclusively on identity prediction. Defaults to False.
+ 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. Defaults to "".
+
+ Examples:
+ If you want to convert detections to tracklets:
+
+ 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'],
+ 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)
trainFraction = cfg["TrainingFraction"][trainingsetindex]
- start_path = os.getcwd() # record cwd to return to this directory in the end
+ start_path = Path.cwd() # record cwd to return to this directory in the end
# TODO: add cropping as in video analysis!
# if cropping is not None:
@@ -1640,22 +1505,16 @@ def convert_detections2tracklets(
# print("Overwriting cropping parameters:", cropping)
# print("These are used for all videos, but won't be save to the cfg file.")
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / 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!")
@@ -1667,45 +1526,33 @@ 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]
- )
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split("-")[-1]
+ print(f"Using {Snapshots[snapshotindex]}", "for model", modelfolder)
+ dlc_cfg["init_weights"] = str(Path(modelfolder) / "train" / Snapshots[snapshotindex])
+ trainingsiterations = Path(dlc_cfg["init_weights"]).name.split("-")[-1]
# Name for scorer:
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
shuffle,
trainFraction,
@@ -1716,16 +1563,16 @@ 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)
videofolder = str(Path(video).parents[0])
if destfolder is None:
destfolder = videofolder
- auxiliaryfunctions.attempttomakefolder(destfolder)
+ auxiliaryfunctions.attempt_to_make_folder(destfolder)
vname = Path(video).stem
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
+ dataname = Path(destfolder) / (vname + DLCscorer + ".h5")
data, metadata = auxfun_multianimal.LoadFullMultiAnimalData(dataname)
if track_method == "ellipse":
method = "el"
@@ -1733,12 +1580,10 @@ def convert_detections2tracklets(
method = "bx"
else:
method = "sk"
- trackname = dataname.split(".h5")[0] + f"_{method}.pickle"
+ trackname = dataname.with_name(f"{dataname.stem}_{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 Path(trackname).is_file() and not overwrite: # TODO: check if metadata are identical (same parameters!)
print("Tracklets already computed", trackname)
print("Set overwrite = True to overwrite.")
else:
@@ -1749,10 +1594,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(
@@ -1766,7 +1610,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(
@@ -1783,7 +1627,7 @@ def convert_detections2tracklets(
)
tracklets = {}
multi_bpts = cfg["multianimalbodyparts"]
- ass = inferenceutils.Assembler(
+ assembly_builder = inferenceutils.Assembler(
data,
max_n_individuals=inferencecfg["topktoretain"],
n_multibodyparts=len(multi_bpts),
@@ -1792,29 +1636,33 @@ def convert_detections2tracklets(
min_affinity=inferencecfg.get("pafthreshold", 0.05),
window_size=window_size,
identity_only=identity_only,
+ min_n_links=inferencecfg["minimalnumberofconnections"],
)
- if calibrate:
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- train_data_file = os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
- ass.calibrate(train_data_file)
- ass.assemble()
- ass.to_pickle(dataname.split(".h5")[0] + "_assemblies.pickle")
+ assemblies_filename = dataname.with_name(dataname.stem + "_assemblies.pickle")
+ if not Path(assemblies_filename).exists() or overwrite:
+ if calibrate:
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ train_data_file = (
+ Path(cfg["project_path"])
+ / str(trainingsetfolder)
+ / ("CollectedData_" + cfg["scorer"] + ".h5")
+ )
+ assembly_builder.calibrate(train_data_file)
+ assembly_builder.assemble()
+ assembly_builder.to_pickle(assemblies_filename)
+ else:
+ assembly_builder.from_pickle(assemblies_filename)
+ print(f"Loading assemblies from {assemblies_filename}")
try:
data.close()
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):
- single_detection = ass.unique.get(index)
+ single_detection = assembly_builder.unique.get(index)
if single_detection is None:
continue
imindex = int(re.findall(r"\d+", imname)[0])
@@ -1824,7 +1672,7 @@ def convert_detections2tracklets(
if inferencecfg["topktoretain"] == 1:
tracklets[0] = {}
for index, imname in tqdm(enumerate(imnames)):
- assemblies = ass.assemblies.get(index)
+ assemblies = assembly_builder.assemblies.get(index)
if assemblies is None:
continue
tracklets[0][imname] = assemblies[0].data
@@ -1832,10 +1680,10 @@ def convert_detections2tracklets(
keep = set(multi_bpts).difference(ignore_bodyparts or [])
keep_inds = sorted(multi_bpts.index(bpt) for bpt in keep)
for index, imname in tqdm(enumerate(imnames)):
- assemblies = ass.assemblies.get(index)
+ assemblies = assembly_builder.assemblies.get(index)
if assemblies is None:
continue
- animals = np.stack([ass.data for ass 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(
@@ -1847,26 +1695,23 @@ 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:
+ with Path(trackname).open("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'."
+ "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/training.py b/deeplabcut/pose_estimation_tensorflow/training.py
index 31dd792b3e..5e08abb960 100644
--- a/deeplabcut/pose_estimation_tensorflow/training.py
+++ b/deeplabcut/pose_estimation_tensorflow/training.py
@@ -1,31 +1,31 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
import os
from pathlib import Path
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
- Parameters
- ----------
- config : string
- Full path of the config.yaml file as a string.
+ """Returns the training and test pose config file names as well as the folder where
+ the snapshot is.
- shuffle: int
- Integer value specifying the shuffle index to select for training.
+ Args:
+ 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): Which TrainingsetFraction to use. By default the first
+ (TrainingFraction is a list in config.yaml). Defaults to 0.
- trainingsetindex: int, optional
- 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
+ Returns:
+ tuple: trainposeconfigfile, testposeconfigfile, snapshotfolder.
"""
from deeplabcut.utils import auxiliaryfunctions
@@ -33,17 +33,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(cfg["project_path"]) / str(modelfoldername) / "train" / "pose_cfg.yaml"
+ testposeconfigfile = Path(cfg["project_path"]) / str(modelfoldername) / "test" / "pose_cfg.yaml"
+ snapshotfolder = Path(cfg["project_path"]) / str(modelfoldername) / "train"
return trainposeconfigfile, testposeconfigfile, snapshotfolder
@@ -61,131 +53,155 @@ def train_network(
autotune=False,
keepdeconvweights=True,
modelprefix="",
+ superanimal_name="",
+ superanimal_transfer_learning=False,
):
"""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 interation 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
-
- displayiters: 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!
-
- saveiters: 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!
-
- maxiters: 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!
-
- allow_growth: bool, optional, default=True.
- 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
- 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
- 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.
-
- 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,
- )
+ Args:
+ 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. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ 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 (int, optional): 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! Defaults to None.
+ saveiters (int, optional): 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! Defaults to None.
+ maxiters (int, optional): 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! Defaults to None.
+ allow_growth (bool, optional): 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. Defaults to 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. Defaults to None.
+ autotune (bool, optional): Property of TensorFlow, somehow faster if ``False``
+ (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). Defaults to False.
+ keepdeconvweights (bool, optional): Restores deconvolution layer (and backbone) weights when
+ training from a snapshot. Set to false if bodypart count changes. Defaults to True.
+ 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. Defaults to "".
+ superanimal_name (str, optional): Specified if transfer learning with superanimal is desired. Defaults to "".
+ superanimal_transfer_learning (bool, optional): If true, transfer learning (new decoding layer).
+ If false and superanimal_name is set, fine-tuning (reuse decoding layer). Defaults to False.
+
+ 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,
+ )
"""
- import tensorflow as tf
+ if allow_growth:
+ os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
# reload logger.
import importlib
import logging
+ import tensorflow as tf
+
importlib.reload(logging)
logging.shutdown()
from deeplabcut.utils import auxiliaryfunctions
tf.compat.v1.reset_default_graph()
- start_path = os.getcwd()
+ start_path = Path.cwd()
# Read file path for pose_config file. >> pass it on
cfg = auxiliaryfunctions.read_config(config)
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(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)
try:
cfg_dlc = auxiliaryfunctions.read_plainconfig(poseconfigfile)
- if "multi-animal" in cfg_dlc["dataset_type"]:
+
+ if superanimal_name != "":
+ from dlclibrary.dlcmodelzoo.modelzoo_download import (
+ MODELOPTIONS,
+ download_huggingface_model,
+ )
+
+ from deeplabcut.modelzoo.utils import parse_available_supermodels
+
+ dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
+ parse_available_supermodels()
+ weight_folder = str(
+ Path(dlc_root_path)
+ / "pose_estimation_tensorflow"
+ / "models"
+ / "pretrained"
+ / (superanimal_name + "_weights")
+ )
+
+ if superanimal_name in MODELOPTIONS:
+ if not Path(weight_folder).exists():
+ download_huggingface_model(superanimal_name, weight_folder)
+ else:
+ print(f"{weight_folder} exists, using the downloaded weights")
+ else:
+ print(
+ f"{superanimal_name} not available. Available ones are: ",
+ MODELOPTIONS,
+ )
+
+ snapshots = list(Path(weight_folder).glob("snapshot-*.index"))
+ init_weights = auxiliaryfunctions.safe_resolve(snapshots[0].with_suffix(""))
+
+ from deeplabcut.pose_estimation_tensorflow.core.train_multianimal import (
+ train,
+ )
+
+ print("Selecting multi-animal trainer")
+ train(
+ str(poseconfigfile),
+ displayiters,
+ saveiters,
+ maxiters,
+ max_to_keep=max_snapshots_to_keep,
+ keepdeconvweights=keepdeconvweights,
+ allow_growth=allow_growth,
+ init_weights=str(init_weights),
+ 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"]:
from deeplabcut.pose_estimation_tensorflow.core.train_multianimal import (
train,
)
diff --git a/deeplabcut/pose_estimation_tensorflow/util/__init__.py b/deeplabcut/pose_estimation_tensorflow/util/__init__.py
index f9b7f58495..a9b1d07db7 100644
--- a/deeplabcut/pose_estimation_tensorflow/util/__init__.py
+++ b/deeplabcut/pose_estimation_tensorflow/util/__init__.py
@@ -1,3 +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/master/AUTHORS
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
Adapted from DeeperCut by Eldar Insafutdinov:
https://github.com/eldar/pose-tensorflow
diff --git a/deeplabcut/pose_estimation_tensorflow/util/logging.py b/deeplabcut/pose_estimation_tensorflow/util/logging.py
index 238c20981d..18a18b8c99 100644
--- a/deeplabcut/pose_estimation_tensorflow/util/logging.py
+++ b/deeplabcut/pose_estimation_tensorflow/util/logging.py
@@ -1,15 +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/master/AUTHORS
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
Adapted from DeeperCut by Eldar Insafutdinov
https://github.com/eldar/pose-tensorflow
"""
+
import logging
-import os
def setup_logging():
FORMAT = "%(asctime)-15s %(message)s"
logging.basicConfig(
- filename=os.path.join("log.txt"),
+ filename="log.txt",
filemode="a",
datefmt="%Y-%m-%d %H:%M:%S",
level=logging.INFO,
diff --git a/deeplabcut/pose_estimation_tensorflow/util/visualize.py b/deeplabcut/pose_estimation_tensorflow/util/visualize.py
index e660ac2ebf..c84a11ed07 100644
--- a/deeplabcut/pose_estimation_tensorflow/util/visualize.py
+++ b/deeplabcut/pose_estimation_tensorflow/util/visualize.py
@@ -1,3 +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/master/AUTHORS
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
Adapted from DeeperCut by Eldar Insafutdinov
https://github.com/eldar/pose-tensorflow
@@ -5,6 +18,7 @@
import math
+import cv2
import matplotlib.pyplot as plt
import numpy as np
@@ -17,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")
@@ -78,7 +89,7 @@ def show_heatmaps(cfg, img, scmap, pose, cmap="jet"):
plot_j = (pidx + 1) // subplot_width
plot_i = (pidx + 1) % subplot_width
scmap_part = np.sum(scmap[:, :, part], axis=2)
- scmap_part = imresize(scmap_part, 8.0, interp="bicubic")
+ scmap_part = imresize(scmap_part, 8.0, interpolationmethod=cv2.INTER_CUBIC)
scmap_part = np.lib.pad(scmap_part, ((4, 0), (4, 0)), "minimum")
curr_plot = axarr[plot_j, plot_i]
curr_plot.set_title(all_joints_names[pidx])
diff --git a/deeplabcut/pose_estimation_tensorflow/vis_dataset.py b/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
index df5332f876..a3414a50f3 100644
--- a/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
+++ b/deeplabcut/pose_estimation_tensorflow/vis_dataset.py
@@ -1,10 +1,21 @@
-"""
-Adapted from DeeperCut by Eldar Insafutdinov
-https://github.com/eldar/pose-tensorflow
-"""
+#
+# 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
+#
+# Adapted from DeeperCut by Eldar Insafutdinov
+# https://github.com/eldar/pose-tensorflow
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+
import logging
+import cv2
import matplotlib.pyplot as plt
import numpy as np
@@ -55,10 +66,10 @@ def display_dataset():
continue
scmap_part = scmap[:, :, j]
- scmap_part = imresize(scmap_part, 8.0, interp="nearest")
+ 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 0fd8a8ff0c..64d886c26f 100644
--- a/deeplabcut/pose_estimation_tensorflow/visualizemaps.py
+++ b/deeplabcut/pose_estimation_tensorflow/visualizemaps.py
@@ -1,18 +1,25 @@
-"""
-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
-"""
-
+#
+# 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 os
+from pathlib import Path
+
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,
@@ -23,55 +30,60 @@ def extract_maps(
Indices=None,
modelprefix="",
):
+ """Extracts the scoremap, locref, partaffinityfields (if available).
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ shuffle (integer): Integer specifying shuffle index of the training dataset. Defaults to 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". Defaults to 0.
+ gputouse (int, optional): GPU index (see nvidia-smi). Use None if no GPU.
+ Defaults to None.
+ rescale (bool, optional): 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! Defaults to False.
+ Indices (list, optional): Image indices for which to extract maps. Defaults to None.
+ modelprefix (str, optional): Directory containing the deeplabcut models to use.
+ Defaults to "".
+
+ Returns:
+ dict: Dictionary indexed by trainingsetfraction, snapshotindex, and imageindex;
+ each item contains (image, scmap, locref, paf, bpt names, partaffinity graph,
+ imagename, True/False if this image was in trainingset).
+
+ 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])
"""
- 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".
-
- 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!
- 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])
+ import numpy as np
+ import pandas as pd
+ import tensorflow as tf
+ from tqdm import tqdm
- """
- from deeplabcut.utils.auxfun_videos import imread, imresize
+ 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" #
# tf.logging.set_verbosity(tf.logging.WARN)
- start_path = os.getcwd()
+ start_path = Path.cwd()
# Read file path for pose_config file. >> pass it on
cfg = auxiliaryfunctions.read_config(config)
@@ -92,19 +104,11 @@ def extract_maps(
)
# Loading human annotatated data
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- Data = pd.read_hdf(
- os.path.join(
- cfg["project_path"],
- str(trainingsetfolder),
- "CollectedData_" + cfg["scorer"] + ".h5",
- )
- )
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ Data = pd.read_hdf(Path(cfg["project_path"]) / str(trainingsetfolder) / ("CollectedData_" + cfg["scorer"] + ".h5"))
# Make folder for evaluation
- auxiliaryfunctions.attempttomakefolder(
- str(cfg["project_path"] + "/evaluation-results/")
- )
+ auxiliaryfunctions.attempt_to_make_folder(Path(cfg["project_path"]) / "evaluation-results")
Maps = {}
for trainFraction in TrainingFractions:
@@ -112,17 +116,12 @@ def extract_maps(
##################################################
# Load and setup CNN part detector
##################################################
- datafn, metadatafn = auxiliaryfunctions.GetDataandMetaDataFilenames(
+ datafn, metadatafn = auxiliaryfunctions.get_data_and_metadata_filenames(
trainingsetfolder, trainFraction, shuffle, cfg
)
- modelfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_model_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ modelfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
path_test_config = Path(modelfolder) / "test" / "pose_cfg.yaml"
# Load meta data
@@ -131,50 +130,26 @@ def extract_maps(
trainIndices,
testIndices,
trainFraction,
- ) = auxiliaryfunctions.LoadMetadata(
- os.path.join(cfg["project_path"], metadatafn)
- )
+ ) = auxiliaryfunctions.load_metadata(Path(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.
# Create folder structure to store results.
- evaluationfolder = os.path.join(
- cfg["project_path"],
- str(
- auxiliaryfunctions.get_evaluation_folder(
- trainFraction, shuffle, cfg, modelprefix=modelprefix
- )
- ),
+ evaluationfolder = Path(cfg["project_path"]) / str(
+ auxiliaryfunctions.get_evaluation_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)
)
- auxiliaryfunctions.attempttomakefolder(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)
- )
+ auxiliaryfunctions.attempt_to_make_folder(evaluationfolder, recursive=True)
- 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]
@@ -183,38 +158,33 @@ 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]
+ dlc_cfg["init_weights"] = str(
+ Path(modelfolder) / "train" / Snapshots[snapindex]
) # setting weights to corresponding snapshot.
- trainingsiterations = (dlc_cfg["init_weights"].split(os.sep)[-1]).split(
- "-"
- )[
- -1
- ] # read how many training siterations that corresponds to.
+ Path(dlc_cfg["init_weights"]).name.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)
@@ -224,9 +194,7 @@ def extract_maps(
DATA = {}
for imageindex, imagename in tqdm(Indices):
- image = imread(
- os.path.join(cfg["project_path"], *imagename), mode="skimage"
- )
+ image = imread(Path(cfg["project_path"]).joinpath(*imagename), mode="skimage")
if scale != 1:
image = imresize(image, scale)
@@ -237,9 +205,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)
@@ -282,84 +248,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)
@@ -376,73 +267,66 @@ def extract_save_all_maps(
modelprefix="",
dest_folder=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".
-
- 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).
-
- 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.
-
- 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
-
- 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])
+ """Extract scoremap, location refinement field and part affinity field predictions.
+
+ Maps are rescaled to the size of the input image and stored in the corresponding
+ model folder in /evaluation-results.
+
+ Args:
+ config (string): Full path of the config.yaml file as a string.
+ shuffle (integer): Integer specifying shuffle index of the training dataset. Defaults to 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". Defaults to 0.
+ comparisonbodyparts (list of bodyparts): Average error for those body parts only
+ (subset of all body parts). Defaults to "all".
+ extract_paf (bool, optional): Extract part affinity fields by default.
+ Note that turning it off will make the function much faster. Defaults to True.
+ all_paf_in_one (bool, optional): By default, all part affinity fields are displayed on a single frame.
+ If false, individual fields are shown on separate frames. Defaults to True.
+ gputouse (int, optional): GPU index (see nvidia-smi). Use None if no GPU.
+ Defaults to None.
+ rescale (bool, optional): Evaluate at global_scale from pose_config.yaml.
+ Defaults to False.
+ Indices (list, optional): Image indices for which to compute scmap/locref/paf.
+ Defaults to None.
+ modelprefix (str, optional): Directory containing the deeplabcut models to use.
+ Defaults to "".
+ dest_folder (string, optional): Destination folder for saved maps. Defaults to None.
+
+ 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]
+ )
"""
+ from tqdm import tqdm
from deeplabcut.utils.auxiliaryfunctions import (
- read_config,
- attempttomakefolder,
+ attempt_to_make_folder,
get_evaluation_folder,
- IntersectionofBodyPartsandOnesGivenbyUser,
+ 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 = IntersectionofBodyPartsandOnesGivenbyUser(
- cfg, comparisonbodyparts
- )
+ comparisonbodyparts = intersection_of_body_parts_and_ones_given_by_user(cfg, comparisonbodyparts)
print("Saving plots...")
for frac, values in data.items():
if not dest_folder:
- dest_folder = os.path.join(
- cfg["project_path"],
- str(get_evaluation_folder(frac, shuffle, cfg, modelprefix=modelprefix)),
- "maps",
+ dest_folder = (
+ Path(cfg["project_path"])
+ / str(get_evaluation_folder(frac, shuffle, cfg, modelprefix=modelprefix))
+ / "maps"
)
- attempttomakefolder(dest_folder)
+ attempt_to_make_folder(dest_folder)
filepath = "{imname}_{map}_{label}_{shuffle}_{frac}_{snap}.png"
- dest_path = os.path.join(dest_folder, filepath)
+ dest_path = str(Path(dest_folder) / filepath)
for snap, maps in values.items():
for imagenr in tqdm(maps):
(
@@ -460,18 +344,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)
@@ -512,7 +390,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,
@@ -527,7 +405,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 6c13fe16d1..f5e3b9925a 100644
--- a/deeplabcut/pose_tracking_pytorch/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/__init__.py
@@ -1,13 +1,15 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-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 51d7d8f002..f7f97f27fc 100644
--- a/deeplabcut/pose_tracking_pytorch/apis.py
+++ b/deeplabcut/pose_tracking_pytorch/apis.py
@@ -1,86 +1,91 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
+from collections.abc import Sequence
+from deeplabcut.core.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.
-
- Outputs: The tracklet file is saved in the same folder where the non-transformer tracklet file is stored.
-
- 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.
-
- 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.
-
- shuffle : int, optional
- which shuffle to use
-
- trainingsetindex : int. optional
- which training fraction to use, identified by its index
-
- track_method: str, optional
- track method from which tracklets are sampled
-
- n_tracks: int
- number of tracks to be formed in the videos.
- TODO: handling videos with different number of tracks
-
- n_triplets: (optional) int
- number of triplets to be mined from the videos
-
- train_epochs: (optional), int
- number of epochs to train the transformer
-
- train_frac: (optional), fraction
- fraction of triplets used for training/testing of the transformer
-
- Examples
- --------
-
- Training model for one video based on ellipse-tracker derived tracklets
- >>> deeplabcut.transformer_reID(path_config_file,[''/home/alex/video.mp4'],track_method="ellipse")
-
- --------
-
+ - 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.
+
+ Args:
+ 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): 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). Defaults to None.
+ shuffle (int, optional): Which shuffle to use. Defaults to 1.
+ trainingsetindex (int, optional): Which training fraction to use, identified by
+ its index. Defaults to 0.
+ track_method (str, optional): Track method from which tracklets are sampled.
+ Defaults to "ellipse".
+ n_tracks (int | None, optional): Number of tracks to be formed in the videos.
+ Defaults to None.
+ n_triplets (int, optional): Number of triplets to be mined from the videos.
+ Defaults to 1000.
+ train_epochs (int, optional): Number of epochs to train the transformer.
+ Defaults to 100.
+ train_frac (float, optional): Fraction of triplets used for training/testing of
+ the transformer. Defaults to 0.8.
+ modelprefix (str, optional): Directory containing the deeplabcut models to use.
+ Defaults to "".
+ destfolder (str, optional): Destination folder for analysis data. Defaults to
+ None.
+
+ Examples:
+ Training a model for one video based on ellipse-tracker derived tracklets:
+
+ 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",
+ )
"""
+ from pathlib import Path
+
import deeplabcut
- import os
from deeplabcut.utils import auxiliaryfunctions
# calling create_tracking_dataset, train_tracking_transformer, stitch_tracklets
@@ -94,11 +99,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 +126,7 @@ def transformer_reID(
config,
DLCscorer,
videos,
- videotype=videotype,
+ video_extensions=video_extensions,
train_frac=train_frac,
modelprefix=modelprefix,
train_epochs=train_epochs,
@@ -129,17 +134,15 @@ def transformer_reID(
destfolder=destfolder,
)
- transformer_checkpoint = os.path.join(
- snapshotfolder, f"dlc_transreid_{train_epochs}.pth"
- )
+ transformer_checkpoint = Path(snapshotfolder) / f"dlc_transreid_{train_epochs}.pth"
- if not os.path.exists(transformer_checkpoint):
+ if not transformer_checkpoint.exists():
raise FileNotFoundError(f"checkpoint {transformer_checkpoint} not found")
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 b8310bdfb8..933769bfb2 100644
--- a/deeplabcut/pose_tracking_pytorch/config/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/config/__init__.py
@@ -1,19 +1,21 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-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")
+reid_config = 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 294e5c2854..9c92897124 100644
--- a/deeplabcut/pose_tracking_pytorch/create_dataset.py
+++ b/deeplabcut/pose_tracking_pytorch/create_dataset.py
@@ -1,19 +1,23 @@
-"""
-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 numpy as np
-import os
+#
+# 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 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)
@@ -31,52 +35,41 @@ 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:
-
anchor, pos, neg = triplet[0], triplet[1], triplet[2]
anchor_coord, anchor_frame = anchor
pos_coord, pos_frame = pos
neg_coord, neg_frame = neg
- # if animal == 'pup' and anchor_coord.shape[0]!=5:
- # continue
-
anchor_frame = "frame" + str(anchor_frame).zfill(zfill_width)
pos_frame = "frame" + str(pos_frame).zfill(zfill_width)
neg_frame = "frame" + str(neg_frame).zfill(zfill_width)
- 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
- )
+ 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)
- ret_vecs.append([anchor_vec, pos_vec, neg_vec])
+ ret_vecs.append([anchor_vec, pos_vec, neg_vec])
ret_vecs = np.array(ret_vecs)
- with open(out_name, "wb") as f:
+ with Path(out_name).open("wb") as f:
np.save(f, ret_vecs)
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):
-
# 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
@@ -85,13 +78,16 @@ def create_triplets_dataset(videos, dlcscorer, track_method, n_triplets=1000, de
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 = str(Path(destfolder) / (vname + dlcscorer + "_bpt_features.pickle"))
method = trackingutils.TRACK_METHODS[track_method]
- track_file = os.path.join(destfolder, vname + dlcscorer + f"{method}.pickle")
- 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
- )
+ track_file = Path(destfolder) / (vname + dlcscorer + f"{method}.pickle")
+ if not 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 = str(Path(destfolder) / (vname + dlcscorer + "_triplet_vector.npy"))
+ create_train_using_pickle(feature_fname, track_file, out_fname, n_triplets=n_triplets)
diff --git a/deeplabcut/pose_tracking_pytorch/datasets/__init__.py b/deeplabcut/pose_tracking_pytorch/datasets/__init__.py
index 23a7c818ce..e367a92c17 100644
--- a/deeplabcut/pose_tracking_pytorch/datasets/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/datasets/__init__.py
@@ -1,10 +1,12 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
from .make_dataloader import make_dlc_dataloader
diff --git a/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py b/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
index b372d0dfdb..ae707771ba 100644
--- a/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
+++ b/deeplabcut/pose_tracking_pytorch/datasets/dlc_vec.py
@@ -1,19 +1,20 @@
-"""
-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
-"""
-from torch.utils.data import Dataset
+#
+# 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 numpy as np
+from torch.utils.data import Dataset
class TripletDataset(Dataset):
def __init__(self, datasource, transform=None):
-
self.x = datasource
# normalize vectors here
diff --git a/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py b/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
index b7c432c5df..660b960003 100644
--- a/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
+++ b/deeplabcut/pose_tracking_pytorch/datasets/make_dataloader.py
@@ -1,13 +1,16 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
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 529bb78617..debea750a4 100644
--- a/deeplabcut/pose_tracking_pytorch/inference.py
+++ b/deeplabcut/pose_tracking_pytorch/inference.py
@@ -1,24 +1,26 @@
-"""
-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
-"""
+#
+# 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 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}
@@ -28,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)
@@ -41,7 +41,6 @@ def __init__(self, checkpoint):
self.model.eval()
def _load_params(self, params):
-
self.model.load_state_dict(params)
def _get_vec(self, inp_a, inp_b, zfill_width, feature_dict):
@@ -59,7 +58,7 @@ def _get_vec(self, inp_a, inp_b, zfill_width, feature_dict):
def __call__(self, inp_a, inp_b, zfill_width, feature_dict, return_features=False):
# tracklets
- device = default_device('cuda')
+ device = default_device("cuda")
_tuple = self._get_vec(inp_a, inp_b, zfill_width, feature_dict)
if _tuple is None:
diff --git a/deeplabcut/pose_tracking_pytorch/loss/__init__.py b/deeplabcut/pose_tracking_pytorch/loss/__init__.py
index 181528fdda..71a92651bf 100644
--- a/deeplabcut/pose_tracking_pytorch/loss/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/loss/__init__.py
@@ -1 +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 .make_loss import easy_triplet_loss
diff --git a/deeplabcut/pose_tracking_pytorch/loss/make_loss.py b/deeplabcut/pose_tracking_pytorch/loss/make_loss.py
index d21a1b88ff..546ad68ab1 100644
--- a/deeplabcut/pose_tracking_pytorch/loss/make_loss.py
+++ b/deeplabcut/pose_tracking_pytorch/loss/make_loss.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import torch
diff --git a/deeplabcut/pose_tracking_pytorch/model/__init__.py b/deeplabcut/pose_tracking_pytorch/model/__init__.py
index 761344cf09..573d1ecd61 100644
--- a/deeplabcut/pose_tracking_pytorch/model/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/model/__init__.py
@@ -1,10 +1,12 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-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/__init__.py b/deeplabcut/pose_tracking_pytorch/model/backbones/__init__.py
index fba7515bc6..3c3f8f3483 100644
--- a/deeplabcut/pose_tracking_pytorch/model/backbones/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/model/backbones/__init__.py
@@ -1 +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 .vit_pytorch import dlc_base_kpt_TransReID
diff --git a/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py b/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
index 8b16b83198..e00258baf3 100644
--- a/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
+++ b/deeplabcut/pose_tracking_pytorch/model/backbones/vit_pytorch.py
@@ -1,4 +1,14 @@
-""" Vision Transformer (ViT) in PyTorch
+#
+# 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
+#
+"""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
@@ -20,6 +30,7 @@
Hacked together by / Copyright 2020 Ross Wightman
"""
+
import math
from functools import partial
@@ -29,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
@@ -51,10 +60,12 @@ 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):
@@ -101,7 +112,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)
@@ -110,11 +121,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],
@@ -263,14 +270,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)
@@ -321,21 +325,17 @@ 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}"
)
-def resize_pos_embed(posemb, posemb_new, hight, width):
+def resize_pos_embed(posemb, posemb_new, height, width):
# Rescale the grid of position embeddings when loading from state_dict. Adapted from
# https://github.com/google-research/vision_transformer/blob/00883dd691c63a6830751563748663526e811cee/vit_jax/checkpoint.py#L224
ntok_new = posemb_new.shape[1]
@@ -345,13 +345,12 @@ def resize_pos_embed(posemb, posemb_new, hight, 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, hight, 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=(hight, width), mode="bilinear")
- posemb_grid = posemb_grid.permute(0, 2, 3, 1).reshape(1, hight * width, -1)
+ posemb_grid = F.interpolate(posemb_grid, size=(height, width), mode="bilinear")
+ posemb_grid = posemb_grid.permute(0, 2, 3, 1).reshape(1, height * width, -1)
posemb = torch.cat([posemb_token, posemb_grid], dim=1)
return posemb
@@ -366,7 +365,6 @@ def dlc_base_kpt_TransReID(
kpt_num=12,
**kwargs,
):
-
embed_dim = 128
depth = 4
num_heads = 4
@@ -430,12 +428,14 @@ 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
best when :math:`a \leq \text{mean} \leq b`.
+
Args:
tensor: an n-dimensional `torch.Tensor`
mean: the mean of the normal distribution
@@ -443,7 +443,9 @@ def trunc_normal_(tensor, mean=0.0, std=1.0, a=-2.0, b=2.0):
a: the minimum cutoff value
b: the maximum cutoff value
Examples:
- >>> w = torch.empty(3, 5)
- >>> nn.init.trunc_normal_(w)
+ Fill tensor with values drawn from a truncated normal distribution:
+
+ w = torch.empty(3, 5)
+ nn.init.trunc_normal_(w)
"""
return _no_grad_trunc_normal_(tensor, mean, std, a, b)
diff --git a/deeplabcut/pose_tracking_pytorch/model/make_model.py b/deeplabcut/pose_tracking_pytorch/model/make_model.py
index 4da95bf1a1..51142aa16d 100644
--- a/deeplabcut/pose_tracking_pytorch/model/make_model.py
+++ b/deeplabcut/pose_tracking_pytorch/model/make_model.py
@@ -1,20 +1,23 @@
-"""
-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
-"""
+#
+# 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 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
@@ -45,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 = {
@@ -57,7 +62,6 @@ def load_param(self, trained_path):
def make_dlc_model(cfg, feature_dim, kpt_num):
-
model = build_dlc_transformer(cfg, feature_dim, kpt_num, __factory_T_type)
return model
diff --git a/deeplabcut/pose_tracking_pytorch/processor/__init__.py b/deeplabcut/pose_tracking_pytorch/processor/__init__.py
index 0228e939c9..2a49144cdf 100644
--- a/deeplabcut/pose_tracking_pytorch/processor/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/processor/__init__.py
@@ -1,10 +1,17 @@
-"""
-DeepLabCut2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-from .processor import do_dlc_train, do_dlc_inference, do_dlc_pair_inference, default_device
+from .processor import (
+ default_device,
+ do_dlc_inference,
+ do_dlc_pair_inference,
+ do_dlc_train,
+)
diff --git a/deeplabcut/pose_tracking_pytorch/processor/processor.py b/deeplabcut/pose_tracking_pytorch/processor/processor.py
index 3c56865fff..5d3302314c 100644
--- a/deeplabcut/pose_tracking_pytorch/processor/processor.py
+++ b/deeplabcut/pose_tracking_pytorch/processor/processor.py
@@ -1,39 +1,41 @@
-"""
-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
-"""
+#
+# 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 logging
-import os
+import pickle
import time
+from pathlib import Path
+
+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)
def calc_cos_correct(vec1, gt1, vec2, gt2, threshold=0.5):
-
cos = nn.CosineSimilarity(dim=1, eps=1e-6)
confidence = cos(vec1, vec2)
@@ -44,12 +46,14 @@ def calc_cos_correct(vec1, gt1, vec2, gt2, threshold=0.5):
n_correct = torch.sum(torch.eq(pred_mask, gt_mask))
return n_correct
-#TODO: maybe find a better spot for this.
-def default_device(device = "cuda"): #setting CPU, if no GPU available
- #dev = device if torch.cuda.is_available() else "cpu"
+
+# TODO: maybe find a better spot for this.
+def default_device(device="cuda"): # setting CPU, if no GPU available
+ # dev = device if torch.cuda.is_available() else "cpu"
dev = torch.device(device) if torch.cuda.is_available() else torch.device("cpu")
return dev
+
def do_dlc_train(
cfg,
model,
@@ -64,7 +68,6 @@ def do_dlc_train(
total_epochs=300,
ckpt_folder="",
):
-
log_period = cfg["log_period"]
checkpoint_period = cfg["checkpoint_period"]
eval_period = 10
@@ -77,7 +80,6 @@ def do_dlc_train(
if device:
model.to(device)
-
loss_meter = AverageMeter()
acc_meter = AverageMeter()
@@ -99,7 +101,6 @@ def do_dlc_train(
total_n = 0.0
total_correct = 0.0
for n_iter, (anchor, pos, neg) in enumerate(train_loader):
-
optimizer.zero_grad()
anchor = anchor.to(device)
@@ -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,22 +142,21 @@ 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(
{
"state_dict": model.state_dict(),
"num_kpts": num_kpts,
"feature_dim": feature_dim,
},
- os.path.join(ckpt_folder, model_name + "_{}.pth".format(epoch)),
+ Path(ckpt_folder) / (model_name + f"_{epoch}.pth"),
)
if epoch % eval_period == 0:
@@ -181,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
@@ -196,14 +193,11 @@ 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 (Path(ckpt_folder) / "dlc_transreid_results.pickle").open("wb") as handle:
pickle.dump(plot_dict, handle, protocol=pickle.HIGHEST_PROTOCOL)
def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
-
device = default_device(cfg["device"])
logger = logging.getLogger("transreid.test")
logger.info("Enter inferencing")
@@ -214,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)
@@ -225,9 +219,8 @@ 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)
neg = neg.to(device)
@@ -238,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]
@@ -254,17 +247,16 @@ def do_dlc_inference(cfg, model, triplet_loss, val_loader, num_query):
val_loss += loss.item()
features_list = np.vstack(features_list)
- with open("video_trans_features.npy", "wb") as f:
+ with Path("video_trans_features.npy").open("wb") as f:
np.save(f, features_list)
- with open("labels.npy", "wb") as f:
+ with Path("labels.npy").open("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 ")
def do_dlc_pair_inference(cfg, model, val_loader, num_query):
-
device = default_device(cfg["device"])
logger = logging.getLogger("transreid.test")
logger.info("Enter inferencing")
@@ -275,18 +267,16 @@ 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)
vec1 = vec1.to(device)
@@ -298,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/__init__.py b/deeplabcut/pose_tracking_pytorch/solver/__init__.py
index 920843dd08..7175205e40 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/__init__.py
@@ -1 +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 .make_optimizer import make_easy_optimizer
diff --git a/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py b/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
index 4173bd958d..246360d5b5 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/cosine_lr.py
@@ -1,16 +1,27 @@
-""" Cosine Scheduler
+#
+# 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
+#
+"""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__)
@@ -68,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]
@@ -83,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:
@@ -127,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 76e277703d..076ea071b7 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/make_optimizer.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import torch
@@ -19,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 5b690905fb..8da7aa5ce8 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/scheduler.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/scheduler.py
@@ -1,11 +1,21 @@
-from typing import Dict, Any
+#
+# 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 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
@@ -39,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
@@ -63,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):
@@ -94,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):
@@ -113,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 2d485faa7d..ed42cc8849 100644
--- a/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
+++ b/deeplabcut/pose_tracking_pytorch/solver/scheduler_factory.py
@@ -1,6 +1,23 @@
-""" Scheduler Factory
-Hacked together by / Copyright 2020 Ross Wightman
-"""
+#
+# Copyright 2019 Ross Wightman
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# 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."""
+
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 e43d384148..1ee383d0e4 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/__init__.py
@@ -1,5 +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/master/AUTHORS
+#
+# 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 b72370ea8c..3b56c2643d 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/meter.py
@@ -1,5 +1,15 @@
-class AverageMeter(object):
- """Computes and stores the average and current value"""
+#
+# 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
+#
+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 8d47b34962..37eab5fc38 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/metrics.py
@@ -1,5 +1,16 @@
-import torch
+#
+# 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 numpy as np
+import torch
+
from ..tracking_utils.reranking import re_ranking
@@ -37,10 +48,10 @@ 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
+ # 0, 2, 1, 3
+ # 1, 2, 3, 0
matches = (g_pids[indices] == q_pids[:, np.newaxis]).astype(np.int32)
# compute cmc curve for each query
all_cmc = []
@@ -91,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 42460af2ec..0eff0ad96a 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/preprocessing.py
@@ -1,10 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import numpy as np
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, :]
@@ -23,15 +32,9 @@ 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
@@ -39,18 +42,17 @@ def convert_coord_from_img_space_to_feature_space(arr, stride):
# take care of difference between feature map space and original image space
- arr = (arr - (stride//2) ) // stride
+ arr = (arr - (stride // 2)) // stride
return arr.astype(np.int64)
def query_feature_by_coord_in_img_space(feature_dict, frame_id, ref_coord):
-
features = feature_dict[frame_id]["features"]
coordinates = feature_dict[frame_id]["coordinates"]
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 2184c756a0..e4117b7da6 100644
--- a/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py
+++ b/deeplabcut/pose_tracking_pytorch/tracking_utils/reranking.py
@@ -1,34 +1,29 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-Created on Fri, 25 May 2018 20:29:09
+#
+# 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 numpy as np
+import torch
-"""
+def re_ranking(probFea, galFea, k1, k2, lambda_value, local_distmat=None, only_local=False):
+ """
-"""
-CVPR2017 paper:Zhong Z, Zheng L, Cao D, et al. Re-ranking Person Re-identification with k-reciprocal Encoding[J]. 2017.
-url:http://openaccess.thecvf.com/content_cvpr_2017/papers/Zhong_Re-Ranking_Person_Re-Identification_CVPR_2017_paper.pdf
-Matlab version: https://github.com/zhunzhong07/person-re-ranking
-"""
+ probFea: all feature vectors of the query set (torch tensor)
+ galFea: all feature vectors of the gallery set (torch tensor)
+ k1,k2,lambda: parameters, the original paper uses (k1=20,k2=6,lambda=0.3)
-"""
-API
+ Code adapted from https://github.com/zhunzhong07/person-re-ranking
-probFea: all feature vectors of the query set (torch tensor)
-probFea: all feature vectors of the gallery set (torch tensor)
-k1,k2,lambda: parameters, the original paper is (k1=20,k2=6,lambda=0.3)
-MemorySave: set to 'True' when using MemorySave mode
-Minibatch: available when 'MemorySave' is 'True'
-"""
+ Zhong Z, Zheng L, Cao D, et al. Re-ranking Person Re-identification with k-reciprocal Encoding CVPR 2017.
-import numpy as np
-import torch
-
-
-def re_ranking(
- probFea, galFea, k1, k2, lambda_value, local_distmat=None, only_local=False
-):
+ """
# if feature vector is numpy, you should use 'torch.tensor' transform it to tensor
query_num = probFea.size(0)
all_num = query_num + galFea.size(0)
@@ -44,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))
@@ -61,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 918ee4ec94..cbfd5d7061 100644
--- a/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py
+++ b/deeplabcut/pose_tracking_pytorch/train_dlctransreid.py
@@ -1,31 +1,35 @@
-"""
-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
-"""
+#
+# 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 random
+
try:
import torch
-except ModuleNotFoundError:
- raise ModuleNotFoundError(
- "Unsupervised identity learning requires PyTorch. Please run `pip install torch`."
- )
-import numpy as np
-import os
-import glob
-from deeplabcut.utils import auxiliaryfunctions
+except ModuleNotFoundError as e:
+ raise ModuleNotFoundError("Unsupervised identity learning requires PyTorch. Please run `pip install torch`.") from e
+from collections.abc import Sequence
from pathlib import Path
+
+import numpy as np
+
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.utils.auxfun_videos import collect_video_paths
+
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):
@@ -42,7 +46,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 = []
@@ -63,11 +66,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,
@@ -76,14 +80,14 @@ 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:
destfolder = videofolder
video_name = Path(video).stem
# video_name = '.'.join(video.split("/")[-1].split(".")[:-1])
- files = glob.glob(os.path.join(destfolder, video_name + dlcscorer + "*.npy"))
+ files = [str(p) for p in Path(destfolder).glob(video_name + dlcscorer + "*.npy")]
# assuming there is only one match
npy_list.append(files[0])
diff --git a/deeplabcut/post_processing/__init__.py b/deeplabcut/post_processing/__init__.py
index a137a2ad24..56ad1689fa 100644
--- a/deeplabcut/post_processing/__init__.py
+++ b/deeplabcut/post_processing/__init__.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
diff --git a/deeplabcut/post_processing/analyze_skeleton.py b/deeplabcut/post_processing/analyze_skeleton.py
index f2073fc494..6e552d40f4 100644
--- a/deeplabcut/post_processing/analyze_skeleton.py
+++ b/deeplabcut/post_processing/analyze_skeleton.py
@@ -1,49 +1,54 @@
-"""
-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
+#
+# 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
+#
-Written by Federico Claudi - https://github.com/FedeClaudi
+"""
+Contributed by Federico Claudi - https://github.com/FedeClaudi
"""
import argparse
+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.core.deprecation import renamed_parameter
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions
+from deeplabcut.utils.auxfun_videos import collect_video_paths
# utility functions
def calc_distance_between_points_two_vectors_2d(v1, v2):
- """calc_distance_between_points_two_vectors_2d [pairwise distance between vectors points]
+ """Calculate pairwise distance between vector points.
- Arguments:
- v1 {[np.array]} -- [description]
- v2 {[type]} -- [description]
+ Args:
+ v1 (np.array): First array of 2D points.
+ v2 (np.array): Second array of 2D points.
Raises:
- ValueError -- [description]
- ValueError -- [description]
- ValueError -- [description]
+ ValueError: If input arguments have invalid data format, shape, or length.
Returns:
- [type] -- [description]
+ list: Pairwise Euclidean distances between corresponding points.
- testing:
- >>> v1 = np.zeros((2, 5))
- >>> v2 = np.zeros((2, 5))
- >>> v2[1, :] = [0, 10, 25, 50, 100]
- >>> d = calc_distance_between_points_two_vectors_2d(v1.T, v2.T)
- """
+ Examples:
+ Calculate pairwise Euclidean distances between corresponding points:
+ v1 = np.zeros((2, 5))
+ v2 = np.zeros((2, 5))
+ v2[1, :] = [0, 10, 25, 50, 100]
+ d = calc_distance_between_points_two_vectors_2d(v1.T, v2.T)
+ """
# Check dataformats
if not isinstance(v1, np.ndarray) or not isinstance(v2, np.ndarray):
raise ValueError("Invalid argument data format")
@@ -53,31 +58,36 @@ 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.]
+ """Determine 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]
- p2 {[np.ndarray, list]} -- np.array or list [ with the X and Y coordinates of the point]
+ Args:
+ p1 (np.ndarray or list): Array or list with the X and Y coordinates of the point.
+ p2 (np.ndarray or list): Array or list with the X and Y coordinates of the point.
Returns:
- [int] -- [clockwise angle between p1, p2 using the inner product and the deterinant of the two vectors]
-
- Testing: - to check: print(zero, ninety, oneeighty, twoseventy)
- >>> zero = angle_between_points_2d_clockwise([0, 1], [0, 1])
- >>> ninety = angle_between_points_2d_clockwise([1, 0], [0, 1])
- >>> oneeighty = angle_between_points_2d_clockwise([0, -1], [0, 1])
- >>> twoseventy = angle_between_points_2d_clockwise([-1, 0], [0, 1])
- >>> ninety2 = angle_between_points_2d_clockwise([10, 0], [10, 1])
- >>> print(ninety2)
- """
+ float: Clockwise angle between p1 and p2 using the inner product and the
+ determinant of the two vectors.
+
+ Examples:
+ Calculate the clockwise angle between points:
+
+ zero = angle_between_points_2d_clockwise([0, 1], [0, 1])
+ ninety = angle_between_points_2d_clockwise([1, 0], [0, 1])
+ oneeighty = angle_between_points_2d_clockwise([0, -1], [0, 1])
+ twoseventy = angle_between_points_2d_clockwise([-1, 0], [0, 1])
+ ninety2 = angle_between_points_2d_clockwise([10, 0], [10, 1])
+ print(ninety2)
+ """
"""
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
@@ -94,36 +104,35 @@ 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]
+ """Calculate the clockwise angle between each set of points for two 2d arrays.
- Arguments:
- v1 {[np.ndarray]} -- [2d array with X,Y position at each timepoint]
- v2 {[np.ndarray]} -- [2d array with X,Y position at each timepoint]
+ Args:
+ v1 (np.ndarray): 2d array with X,Y position at each timepoint.
+ v2 (np.ndarray): 2d array with X,Y position at each timepoint.
Returns:
- [np.ndarray] -- [1d array with clockwise angle between pairwise points in v1,v2]
+ np.ndarray: 1d array with clockwise angle between pairwise points in v1,v2.
Testing:
- >>> v1 = np.zeros((2, 4))
- >>> v1[1, :] = [1, 1, 1, 1, ]
- >>> v2 = np.zeros((2, 4))
- >>> v2[0, :] = [0, 1, 0, -1]
- >>> v2[1, :] = [1, 0, -1, 0]
- >>> a = calc_angle_between_vectors_of_points_2d(v2, v1)
+ Calculate the clockwise angle:
+
+ v1 = np.zeros((2, 4))
+ v1[1, :] = [
+ 1,
+ 1,
+ 1,
+ 1,
+ ]
+ v2 = np.zeros((2, 4))
+ v2[0, :] = [0, 1, 0, -1]
+ v2[1, :] = [1, 0, -1, 0]
+ a = calc_angle_between_vectors_of_points_2d(v2, v1)
"""
-
# 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)
@@ -139,11 +148,11 @@ def calc_angle_between_vectors_of_points_2d(v1, v2):
# Process single bone
def analyzebone(bp1, bp2):
- """[Computes length and orientation of the bone at each frame]
+ """Compute length and orientation of the bone at each frame.
- Arguments:
- bp1 {[type]} -- [description]
- bp2 {[type]} -- [description]
+ Args:
+ bp1: First body part data.
+ bp2: Second body part data.
"""
bp1_pos = np.vstack([bp1.x.values, bp1.y.values]).T
bp2_pos = np.vstack([bp2.x.values, bp2.y.values]).T
@@ -153,23 +162,22 @@ def analyzebone(bp1, bp2):
bone_orientation = calc_angle_between_vectors_of_points_2d(bp1_pos.T, bp2_pos.T)
# keep the smallest of the two likelihoods
- likelihoods = np.vstack([bp2.likelihood.values, bp2.likelihood.values]).T
+ likelihoods = np.vstack([bp1.likelihood.values, bp2.likelihood.values]).T
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="",
+ config: str | Path,
+ videos: list[str | Path],
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtered=False,
@@ -177,112 +185,120 @@ def analyzeskeleton(
destfolder=None,
modelprefix="",
track_method="",
+ return_data=False,
+ **kwargs,
):
"""Extracts length and orientation of each "bone" of the skeleton.
The bone and skeleton information is defined in the config file.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- videos: list[str]
- 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.
-
- 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.
-
- trainingsetindex: int, optional, default=0
- Integer specifying which TrainingsetFraction to use.
- Note that TrainingFraction is a list in config.yaml.
-
- filtered: bool, optional, default=False
- Boolean variable indicating if filtered output should be plotted rather than
- frame-by-frame predictions. Filtered version can be calculated with
- ``deeplabcut.filterpredictions``.
-
- save_as_csv: bool, optional, default=False
- Saves the predictions in a .csv file.
-
- 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.
-
- 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.
-
- track_method: string, optional, default=""
- Specifies the tracker used to generate the data.
- Empty by default (corresponding to a single animal project).
- For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
- be taken from the config.yaml file if none is given.
-
- Returns
- -------
- None
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): The shuffle index of training dataset. The extracted
+ frames will be stored in the labeled-dataset for the corresponding shuffle
+ of training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction
+ to use. Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ filtered (bool, optional): Boolean variable indicating if filtered output should
+ be plotted rather than frame-by-frame predictions. Filtered version can be
+ calculated with ``deeplabcut.filterpredictions``. Defaults to False.
+ save_as_csv (bool, optional): Saves the predictions in a .csv file. Defaults to
+ False.
+ destfolder (string or None, optional): 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. Defaults to None.
+ 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. Defaults to "".
+ track_method (string, optional): Specifies the tracker used to generate the
+ data. Empty by default (corresponding to a single animal project). For
+ multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
+ be taken from the config.yaml file if none is given. Defaults to "".
+ return_data (bool, optional): If True, returns a dictionary of the filtered data
+ keyed by video names. Defaults to False.
+ **kwargs: Additional arguments. For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ dict: Dictionary mapping video filepaths to skeleton dataframes.
+
+ * If no videos exist, the dictionary will be empty.
+ * If a video is not analyzed, the corresponding value in the dictionary will be
+ None.
"""
# Load config file, scorer and videos
cfg = auxiliaryfunctions.read_config(config)
if not cfg["skeleton"]:
raise ValueError("No skeleton defined in the config.yaml.")
+ video_to_skeleton_df = {}
+
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
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))
- if destfolder is None:
- destfolder = str(Path(video).parents[0])
+ print(f"Processing {video}")
+ videofolder = destfolder
+ if videofolder is None:
+ videofolder = str(Path(video).parents[0])
vname = Path(video).stem
try:
df, filepath, scorer, _ = auxiliaryfunctions.load_analyzed_data(
- destfolder, vname, DLCscorer, filtered, track_method
+ videofolder, vname, DLCscorer, filtered, track_method
)
- output_name = filepath.replace(".h5", f"_skeleton.h5")
- if os.path.isfile(output_name):
- print(f"Skeleton in video {vname} already processed. Skipping...")
- continue
-
- bones = {}
- if "individuals" in df.columns.names:
- for animal_name, df_ in df.groupby(level="individuals", axis=1):
- temp = df_.droplevel(["scorer", "individuals"], axis=1)
- if animal_name != "single":
- for bp1, bp2 in cfg["skeleton"]:
- name = "{}_{}_{}".format(animal_name, bp1, bp2)
- bones[name] = analyzebone(temp[bp1], temp[bp2])
- else:
- for bp1, bp2 in cfg["skeleton"]:
- name = "{}_{}".format(bp1, bp2)
- bones[name] = analyzebone(df[scorer][bp1], df[scorer][bp2])
-
- skeleton = pd.concat(bones, axis=1)
- skeleton.to_hdf(output_name, "df_with_missing", format="table", mode="w")
- if save_as_csv:
- skeleton.to_csv(output_name.replace(".h5", ".csv"))
-
except FileNotFoundError as e:
print(e)
+ video_to_skeleton_df[video] = None
continue
+ output_name = filepath.replace(".h5", "_skeleton.h5")
+ if Path(output_name).is_file():
+ print(f"Skeleton in video {vname} already processed. Skipping...")
+ video_to_skeleton_df[video] = pd.read_hdf(output_name, "df_with_missing")
+ continue
+
+ bones = {}
+ if "individuals" in df.columns.names:
+ for animal_name, df_ in df.T.groupby(level="individuals"):
+ df_ = df_.T
+ temp = df_.droplevel(["scorer", "individuals"], axis=1)
+ if animal_name != "single":
+ for bp1, bp2 in cfg["skeleton"]:
+ name = f"{animal_name}_{bp1}_{bp2}"
+ bones[name] = analyzebone(temp[bp1], temp[bp2])
+ else:
+ for bp1, bp2 in cfg["skeleton"]:
+ 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, key="df_with_missing", format="table", mode="w")
+ if save_as_csv:
+ skeleton.to_csv(output_name.replace(".h5", ".csv"))
+
+ if return_data:
+ return video_to_skeleton_df
+
if __name__ == "__main__":
parser = argparse.ArgumentParser()
diff --git a/deeplabcut/post_processing/filtering.py b/deeplabcut/post_processing/filtering.py
index 32dd89c79e..2f013aa833 100644
--- a/deeplabcut/post_processing/filtering.py
+++ b/deeplabcut/post_processing/filtering.py
@@ -1,13 +1,16 @@
-"""
-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
-"""
+#
+# 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 argparse
+from collections.abc import Sequence
from pathlib import Path
import numpy as np
@@ -15,26 +18,22 @@
from scipy import signal
from scipy.interpolate import CubicSpline
+from deeplabcut.core.deprecation import renamed_parameter
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
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.
-
- Parameters
- ----------
- data : array_like
- 2D matrix of data.
- max_gap : int, optional
- Maximum gap size to fill. By default, all gaps are interpolated.
-
- Returns
- -------
- interpolated data with same shape as *data*
+ """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.
+
+ Args:
+ data (array_like): 2D matrix of data.
+ max_gap (int, optional): Maximum gap size to fill. By default, all gaps are interpolated.
+
+ Returns:
+ ndarray: Interpolated data with the same shape as *data*.
"""
if np.ndim(data) < 2:
data = np.expand_dims(data, axis=1)
@@ -44,9 +43,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:
@@ -54,7 +51,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
@@ -64,10 +61,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="",
+ config: str | Path,
+ video: str | Path,
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtertype="median",
@@ -80,201 +78,203 @@ def filterpredictions(
destfolder=None,
modelprefix="",
track_method="",
+ return_data=False,
+ **kwargs,
):
"""Fits frame-by-frame pose predictions.
The pose predictions are fitted with ARIMA model (filtertype='arima') or median
filter (default).
- Parameters
- ----------
- config : string
- Full path of the config.yaml file.
-
- video : string
- Full path of the video to extract the frame from. Make sure that this video is
- already analyzed.
-
- 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.
-
- trainingsetindex: int, optional, default=0
- Integer specifying which TrainingsetFraction to use.
- Note that TrainingFraction is a list in config.yaml.
-
- filtertype: string, optional, default="median".
- The filter type - 'arima', 'median' or 'spline'.
-
- windowlength: int, optional, default=5
- For filtertype='median' filters the input array using a local window-size given
- by windowlength. The array will automatically be zero-padded.
- https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.medfilt.html.
- The windowlenght should be an odd number.
- If filtertype='spline', windowlength is the maximal gap size to fill.
-
- p_bound: float between 0 and 1, optional, default=0.001
- For filtertype 'arima' this parameter defines the likelihood below,
- below which a body part will be consided as missing data for filtering purposes.
-
- ARdegree: int, optional, default=3
- For filtertype 'arima' Autoregressive degree of Sarimax model degree.
- see https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html
-
- MAdegree: int, optional, default=1
- For filtertype 'arima' Moving Average degree of Sarimax model degree.
- See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html
-
- alpha: float, optional, default=0.01
- Significance level for detecting outliers based on confidence interval of fitted SARIMAX model.
-
- save_as_csv: bool, optional, default=True
- Saves the predictions in a .csv file.
-
- destfolder: string, optional, default=None
- Specifies the destination folder for analysis data. If ``None``, the path of
- the video is used by default. 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.
-
- track_method: string, optional, default=""
- Specifies the tracker used to generate the data.
- Empty by default (corresponding to a single animal project).
- For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
- be taken from the config.yaml file if none is given.
-
- Returns
- -------
- None
-
- Examples
- --------
-
- Arima model:
-
- >>> deeplabcut.filterpredictions(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\myproject\\trailtracking-task\\test.mp4'],
- shuffle=3,
- filterype='arima',
- ARdegree=5,
- MAdegree=2,
- )
-
- Use median filter over 10 bins:
-
- >>> deeplabcut.filterpredictions(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\myproject\\trailtracking-task\\test.mp4'],
- shuffle=3,
- windowlength=10,
- )
-
- One can then use the filtered rather than the frame-by-frame predictions by calling:
-
- >>> deeplabcut.plot_trajectories(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\myproject\\trailtracking-task\\test.mp4'],
- shuffle=3,
- filtered=True,
- )
-
- >>> deeplabcut.create_labeled_video(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\myproject\\trailtracking-task\\test.mp4'],
- shuffle=3,
- filtered=True,
- )
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ video (str | Path): Full path of the video to filter. Make sure that this video is
+ already analyzed.
+ video_extensions (str | Sequence[str] | None, optional): 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). Defaults to None.
+ shuffle (int, optional): The shuffle index of training dataset. The extracted frames will be stored in
+ the labeled-dataset for the corresponding shuffle of training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ filtertype (string, optional): The filter type - 'arima', 'median' or 'spline'. Defaults to "median".
+ windowlength (int, optional): For filtertype='median' filters the input array using a local window-size given
+ by windowlength. The array will automatically be zero-padded.
+ https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.medfilt.html.
+ The windowlenght should be an odd number.
+ If filtertype='spline', windowlength is the maximal gap size to fill. Defaults to 5.
+ p_bound (float, optional): For filtertype 'arima' this parameter defines the likelihood below,
+ below which a body part will be consided as missing data for filtering purposes.
+ Defaults to 0.001.
+ ARdegree (int, optional): For filtertype 'arima' Autoregressive degree of Sarimax model degree.
+ see https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html.
+ Defaults to 3.
+ MAdegree (int, optional): For filtertype 'arima' Moving Average degree of Sarimax model degree.
+ See https://www.statsmodels.org/dev/generated/statsmodels.tsa.statespace.sarimax.SARIMAX.html.
+ Defaults to 1.
+ alpha (float, optional): Significance level for detecting outliers based on the
+ confidence interval of the fitted SARIMAX model. Defaults to 0.01.
+ save_as_csv (bool, optional): Saves the predictions in a .csv file. Defaults to True.
+ destfolder (string, optional): Specifies the destination folder for analysis data. If ``None``, the path of
+ the video is used by default. Note that for subsequent analysis this folder
+ also needs to be passed. Defaults to None.
+ 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. Defaults to "".
+ track_method (string, optional): Specifies the tracker used to generate the data.
+ Empty by default (corresponding to a single animal project).
+ For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
+ be taken from the config.yaml file if none is given. Defaults to "".
+ return_data (bool, optional): If True, returns a dictionary of the filtered data keyed by video names.
+ Defaults to False.
+ kwargs (dict, optional): Additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ dict | None: If ``return_data`` is True, returns a dictionary mapping video
+ filepaths to filtered dataframes. Otherwise returns None.
+
+ Examples:
+ Arima model:
+
+ deeplabcut.filterpredictions(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\myproject\\trailtracking-task\\test.mp4'],
+ shuffle=3,
+ filtertype='arima',
+ ARdegree=5,
+ MAdegree=2,
+ )
+
+ Use median filter over 10 bins:
+
+ deeplabcut.filterpredictions(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\myproject\\trailtracking-task\\test.mp4'],
+ shuffle=3,
+ windowlength=10,
+ )
+
+ One can then use the filtered rather than the frame-by-frame predictions by calling:
+
+ deeplabcut.plot_trajectories(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\myproject\\trailtracking-task\\test.mp4'],
+ shuffle=3,
+ filtered=True,
+ )
+
+ deeplabcut.create_labeled_video(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\myproject\\trailtracking-task\\test.mp4'],
+ shuffle=3,
+ filtered=True,
+ )
"""
cfg = auxiliaryfunctions.read_config(config)
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
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'.")
- return
+ print("No video(s) were found. Please check your paths and/or extensions filter.")
+ if return_data:
+ return video_to_filtered_df
for video in Videos:
- if destfolder is None:
- destfolder = str(Path(video).parents[0])
+ videofolder = destfolder
+ if videofolder is None:
+ videofolder = 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:
- _ = auxiliaryfunctions.load_analyzed_data(
- destfolder, vname, DLCscorer, True, track_method
+ df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(
+ videofolder, vname, DLCscorer, True, track_method
)
print(f"Data from {vname} were already filtered. Skipping...")
- except FileNotFoundError: # Data haven't been filtered yet
- try:
- df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(
- destfolder, vname, DLCscorer, track_method=track_method
- )
- nrows = df.shape[0]
- if filtertype == "arima":
- temp = df.values.reshape((nrows, -1, 3))
- 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[0] = x[0]
- meany[0] = y[0]
- placeholder[:, i] = np.c_[meanx, meany, p]
- data = pd.DataFrame(
- placeholder.reshape((nrows, -1)),
- columns=df.columns,
- index=df.index,
- )
- 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
- )
- elif filtertype == "spline":
- data = df.copy()
- mask_data = data.columns.get_level_values("coords").isin(("x", "y"))
- xy = data.loc[:, mask_data].values
- prob = data.loc[:, ~mask_data].values
- missing = np.isnan(xy)
- xy_filled = columnwise_spline_interp(xy, windowlength)
- filled = ~np.isnan(xy_filled)
- xy[filled] = xy_filled[filled]
- inds = np.argwhere(missing & filled)
- if inds.size:
- # Retrieve original individual label indices
- inds[:, 1] //= 2
- inds = np.unique(inds, axis=0)
- prob[inds[:, 0], inds[:, 1]] = 0.01
- data.loc[:, ~mask_data] = prob
- data.loc[:, mask_data] = xy
- else:
- raise ValueError(f"Unknown filter type {filtertype}")
-
- outdataname = filepath.replace(".h5", "_filtered.h5")
- data.to_hdf(outdataname, "df_with_missing", format="table", mode="w")
- if save_as_csv:
- print("Saving filtered csv poses!")
- data.to_csv(outdataname.split(".h5")[0] + ".csv")
- except FileNotFoundError as e:
- print(e)
- continue
+ video_to_filtered_df[video] = df
+ # Data has been filtered so continue to the next video
+ continue
+ except FileNotFoundError:
+ pass
+
+ # Data haven't been filtered yet
+ try:
+ df, filepath, _, _ = auxiliaryfunctions.load_analyzed_data(
+ videofolder, vname, DLCscorer, track_method=track_method
+ )
+ except FileNotFoundError as e:
+ video_to_filtered_df[video] = None
+ print(e)
+ continue
+
+ nrows = df.shape[0]
+ if filtertype == "arima":
+ temp = df.values.reshape((nrows, -1, 3))
+ 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[0] = x[0]
+ meany[0] = y[0]
+ placeholder[:, i] = np.c_[meanx, meany, p]
+ data = pd.DataFrame(
+ placeholder.reshape((nrows, -1)),
+ columns=df.columns,
+ index=df.index,
+ )
+ 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)
+ elif filtertype == "spline":
+ data = df.copy()
+ mask_data = data.columns.get_level_values("coords").isin(("x", "y"))
+ xy = data.loc[:, mask_data].values
+ prob = data.loc[:, ~mask_data].values
+ missing = np.isnan(xy)
+ xy_filled = columnwise_spline_interp(xy, windowlength)
+ filled = ~np.isnan(xy_filled)
+ xy[filled] = xy_filled[filled]
+ inds = np.argwhere(missing & filled)
+ if inds.size:
+ # Retrieve original individual label indices
+ inds[:, 1] //= 2
+ inds = np.unique(inds, axis=0)
+ prob[inds[:, 0], inds[:, 1]] = 0.01
+ data.loc[:, ~mask_data] = prob
+ data.loc[:, mask_data] = xy
+ else:
+ raise ValueError(f"Unknown filter type {filtertype}")
+
+ video_to_filtered_df[video] = data
+
+ outdataname = filepath.replace(".h5", "_filtered.h5")
+ 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")
+
+ if return_data:
+ return video_to_filtered_df
if __name__ == "__main__":
diff --git a/deeplabcut/refine_training_dataset/__init__.py b/deeplabcut/refine_training_dataset/__init__.py
index ca013626e5..9dc09adde2 100644
--- a/deeplabcut/refine_training_dataset/__init__.py
+++ b/deeplabcut/refine_training_dataset/__init__.py
@@ -1,13 +1,14 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-
-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 ce5298a4e0..63efbb1c4b 100644
--- a/deeplabcut/refine_training_dataset/outlier_frames.py
+++ b/deeplabcut/refine_training_dataset/outlier_frames.py
@@ -1,17 +1,19 @@
-"""
-DeepLabCut2.0 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
-Please see AUTHORS for contributors.
+#
+# 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
+#
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
import argparse
-import os
import pickle
import re
+from collections.abc import Sequence
from pathlib import Path
import matplotlib.pyplot as plt
@@ -20,55 +22,49 @@
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.core.deprecation import renamed_parameter
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
def find_outliers_in_raw_data(
- config,
- pickle_file,
- video_file,
+ config: str | Path,
+ pickle_file: str | Path,
+ video_file: str | Path,
pcutoff=0.1,
percentiles=(5, 95),
with_annotations=True,
extraction_algo="kmeans",
+ copy_videos=False,
):
- """
- Extract outlier frames from either raw detections or assemblies of multiple animals.
-
- Parameter
- ----------
- config : str
- Absolute path to the project config.yaml.
-
- pickled_file : str
- Path to a *_full.pickle or *_assemblies.pickle.
-
- video_file : str
- Path to the corresponding video file for frame extraction.
-
- pcutoff : float, optional (default=0.1)
- Detection confidence threshold below which frames are flagged as
- containing outliers. Only considered if raw detections are passed in.
-
- percentiles : tuple, optional (default=(5, 95))
- Assemblies are considered outliers if their areas are beyond the 5th
- and 95th percentiles. Must contain a lower and upper bound.
-
- with_annotations : bool, optional (default=True)
- If true, extract frames and the corresponding network predictions.
- Otherwise, only the frames are extracted.
-
- extraction_algo : string, optional (default="kmeans")
- Outlier detection algorithm. Must be either ``uniform`` or ``kmeans``.
-
+ """Extract outlier frames from either raw detections or assemblies of multiple
+ animals.
+
+ Args:
+ config (str | Path): Absolute path to the project config.yaml.
+ pickle_file (str | Path): Path to a *_full.pickle or *_assemblies.pickle.
+ video_file (str | Path): Path to the corresponding video file for frame extraction.
+ pcutoff (float, optional): Detection confidence threshold below which frames are
+ flagged as containing outliers. Only considered if raw detections are
+ passed in. Defaults to 0.1.
+ percentiles (tuple, optional): Assemblies are considered outliers if their areas
+ are beyond the 5th and 95th percentiles. Must contain a lower and upper
+ bound. Defaults to (5, 95).
+ with_annotations (bool, optional): If true, extract frames and the corresponding
+ network predictions. Otherwise, only the frames are extracted. Defaults to
+ True.
+ extraction_algo (string, optional): Outlier detection algorithm. Must be either
+ ``uniform`` or ``kmeans``. Defaults to "kmeans".
+ copy_videos (bool, optional): If True, newly-added videos (from which outlier
+ frames are extracted) are copied to the project folder. By default, symbolic
+ links are created instead. Defaults to False.
"""
if extraction_algo not in ("kmeans", "uniform"):
raise ValueError(f"Unsupported extraction algorithm {extraction_algo}.")
@@ -78,7 +74,7 @@ def find_outliers_in_raw_data(
if not pickle_name.startswith(video_name):
raise ValueError("Video and pickle files do not match.")
- with open(pickle_file, "rb") as file:
+ with Path(pickle_file).open("rb") as file:
data = pickle.load(file)
if pickle_file.endswith("_full.pickle"):
inds, data = find_outliers_in_raw_detections(data, threshold=pcutoff)
@@ -96,7 +92,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(
@@ -108,39 +104,31 @@ def find_outliers_in_raw_data(
config=config,
savelabeled=False,
with_annotations=with_annotations,
+ copy_videos=copy_videos,
)
-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
- ----------
- pickled_data : dict
- Data in the *_full.pickle file obtained after `analyze_videos`.
-
- algo : string, optional (default="uncertain")
- Outlier detection algorithm. Currently, only 'uncertain' is supported
- for multi-animal raw detections.
-
- threshold: float, optional (default=0.1)
- Detection confidence threshold below which frames are flagged as
- containing outliers. Only considered if `algo`==`uncertain`.
-
- kept_keypoints : list, optional (default=None)
- Indices in the list of labeled body parts to be kept of the analysis.
- By default, all keypoints are used for outlier search.
-
- Returns
- -------
- candidates : list
- Indices of video frames containing potential outliers
+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.
+
+ Args:
+ pickled_data (dict): Data in the *_full.pickle file obtained after
+ `analyze_videos`.
+ algo (string, optional): Outlier detection algorithm. Currently, only 'uncertain'
+ is supported for multi-animal raw detections. Defaults to "uncertain".
+ threshold (float, optional): Detection confidence threshold below which frames
+ are flagged as containing outliers. Only considered if `algo`==`uncertain`.
+ Defaults to 0.1.
+ kept_keypoints (list, optional): Indices in the list of labeled body parts to
+ be kept of the analysis. By default, all keypoints are used for outlier
+ search. Defaults to None.
+
+ Returns:
+ tuple: Indices of video frames containing potential outliers, and the processed
+ data dictionary.
"""
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")
@@ -167,13 +155,40 @@ 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="",
+ config: str | Path,
+ videos: list[str | Path],
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
outlieralgorithm="jump",
+ frames2use=None,
comparisonbodyparts="all",
epsilon=20,
p_bound=0.01,
@@ -186,9 +201,11 @@ def extract_outlier_frames(
cluster_color=False,
opencv=True,
savelabeled=False,
+ copy_videos=False,
destfolder=None,
modelprefix="",
track_method="",
+ **kwargs,
):
"""Extracts the outlier frames.
@@ -198,165 +215,152 @@ def extract_outlier_frames(
Another crucial parameter in config.yaml is how many frames to extract
``numframes2extract``.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- videos : list[str]
- 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.
-
- 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.
-
- trainingsetindex: int, optional, default=0
- Integer specifying which TrainingsetFraction to use.
- Note that TrainingFraction is a list in config.yaml.
-
- 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
- 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
-
- comparisonbodyparts: list[str] or str, optional, default="all"
- This selects the body parts for which the comparisons with the outliers are
- carried out. If ``"all"``, then all body parts from config.yaml are used. If 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 body
- parts.
-
- p_bound: float between 0 and 1, optional, default=0.01
- For outlieralgorithm ``'uncertain'`` this parameter defines the likelihood
- below which a body part will be flagged as a putative outlier.
-
- epsilon: float, optional, default=20
- If ``'outlieralgorithm'`` is ``'fitting'``, this is the float bound according
- to which frames are picked when the (average) body part estimate deviates from
- model fit.
-
- If ``'outlieralgorithm'`` is ``'jump'``, this is the float bound specifying the
- distance by which body points jump from one frame to next (Euclidean distance).
-
- ARdegree: int, optional, default=3
- For outlieralgorithm ``'fitting'``: Autoregressive 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
-
- MAdegree: int, optional, default=1
- For outlieralgorithm ``'fitting'``: MovingAvarage 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
-
- alpha: float, optional, default=0.01
- Significance level for detecting outliers based on confidence interval of
- fitted ARIMA model. Only the distance is used however.
-
- extractionalgorithm : str, optional, default="kmeans"
- String specifying the algorithm to use for selecting the frames from the
- identified putatative outlier frames. Currently, deeplabcut supports either
- ``kmeans`` or ``uniform`` based selection (same logic as for extract_frames).
-
- automatic : bool, optional, default=False
- If ``True``, extract outliers without being asked for user feedback.
-
- cluster_resizewidth: number, default=30
- If ``"extractionalgorithm"`` is ``"kmeans"``, one can change the width to which
- the images are downsampled (aspect ratio is fixed).
-
- cluster_color: bool, optional, default=False
- If ``False``, each downsampled image is treated as a grayscale vector
- (discarding color information). If ``True``, then the color channels are
- considered. This increases the computational complexity.
-
- opencv: bool, optional, default=True
- Uses openCV for loading & extractiong (otherwise moviepy (legacy)).
-
- savelabeled: bool, optional, default=False
- If ``True``, frame are saved with predicted labels in each folder.
-
- destfolder: str or None, optional, default=None
- Specifies the destination folder that was used for storing analysis data. If
- ``None``, the path of the video is used.
-
- 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.
-
- track_method: str, optional, default=""
- Specifies the tracker used to generate the data.
- Empty by default (corresponding to a single animal project).
- For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
- be taken from the config.yaml file if none is given.
-
- Returns
- -------
- None
-
- Examples
- --------
-
- Extract the frames with default settings on Windows.
-
- >>> deeplabcut.extract_outlier_frames(
- 'C:\\myproject\\reaching-task\\config.yaml',
- ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'],
- )
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): The shuffle index of training dataset. The extracted
+ frames will be stored in the labeled-dataset for the corresponding shuffle
+ of training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction
+ to use. Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ outlieralgorithm (str, optional): String specifying the algorithm used to detect
+ the outliers.
+
+ * ``'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.'``
+
+ Defaults to "jump".
+ frames2use (list[str], optional): If ``'outlieralgorithm'`` is ``'list'``,
+ provide the list of frames here. Defaults to None.
+ comparisonbodyparts (list[str] or str, optional): This selects the body parts for
+ which the comparisons with the outliers are carried out. If ``"all"``, then
+ all body parts from config.yaml are used. If 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 body parts.
+ Defaults to "all".
+ p_bound (float, optional): For outlieralgorithm ``'uncertain'`` this parameter
+ defines the likelihood below which a body part will be flagged as a putative
+ outlier. Defaults to 0.01.
+ epsilon (float, optional): If ``'outlieralgorithm'`` is ``'fitting'``, this is
+ the float bound according to which frames are picked when the (average) body
+ part estimate deviates from model fit. If ``'outlieralgorithm'`` is
+ ``'jump'``, this is the float bound specifying the distance by which body
+ points jump from one frame to next (Euclidean distance). Defaults to 20.
+ ARdegree (int, optional): For outlieralgorithm ``'fitting'``: Autoregressive
+ 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
+ Defaults to 3.
+ MAdegree (int, optional): 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
+ Defaults to 1.
+ alpha (float, optional): Significance level for detecting outliers based on
+ confidence interval of fitted ARIMA model. Only the distance is used
+ however. Defaults to 0.01.
+ extractionalgorithm (str, optional): String specifying the algorithm to use for
+ selecting the frames from the identified putatative outlier frames.
+ Currently, deeplabcut supports either ``kmeans`` or ``uniform`` based
+ selection (same logic as for extract_frames). Defaults to "kmeans".
+ automatic (bool, optional): If ``True``, extract outliers without being asked
+ for user feedback. Defaults to False.
+ cluster_resizewidth (number, optional): If ``"extractionalgorithm"`` is
+ ``"kmeans"``, one can change the width to which the images are downsampled
+ (aspect ratio is fixed). Defaults to 30.
+ cluster_color (bool, optional): If ``False``, each downsampled image is treated
+ as a grayscale vector (discarding color information). If ``True``, then the
+ color channels are considered. This increases the computational complexity.
+ Defaults to False.
+ opencv (bool, optional): Uses openCV for loading & extractiong (otherwise moviepy
+ (legacy)). Defaults to True.
+ savelabeled (bool, optional): If ``True``, frame are saved with predicted labels
+ in each folder. Defaults to False.
+ copy_videos (bool, optional): If True, newly-added videos (from which outlier
+ frames are extracted) are copied to the project folder. By default, symbolic
+ links are created instead. Defaults to False.
+ destfolder (str or None, optional): Specifies the destination folder that was
+ used for storing analysis data. If ``None``, the path of the video is used.
+ Defaults to None.
+ 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. Defaults to "".
+ track_method (str, optional): Specifies the tracker used to generate the data.
+ Empty by default (corresponding to a single animal project). For multiple
+ animals, must be either 'box', 'skeleton', or 'ellipse' and will be taken
+ from the config.yaml file if none is given. Defaults to "".
+ **kwargs: Additional arguments. For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ None
+
+ Examples:
+ Extract the frames with default settings on Windows.
+
+ deeplabcut.extract_outlier_frames(
+ 'C:\\myproject\\reaching-task\\config.yaml',
+ ['C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi'],
+ )
- Extract the frames with default settings on Linux/MacOS.
+ Extract the frames with default settings on Linux/MacOS.
- >>> deeplabcut.extract_outlier_frames(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/video/reachinvideo1.avi'],
- )
+ deeplabcut.extract_outlier_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/video/reachinvideo1.avi'],
+ )
- Extract the frames using the "kmeans" algorithm.
+ Extract the frames using the "kmeans" algorithm.
- >>> deeplabcut.extract_outlier_frames(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/video/reachinvideo1.avi'],
- extractionalgorithm='kmeans',
- )
+ deeplabcut.extract_outlier_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/video/reachinvideo1.avi'],
+ extractionalgorithm='kmeans',
+ )
- Extract the frames using the "kmeans" algorithm and ``"epsilon=5"`` pixels.
+ Extract the frames using the "kmeans" algorithm and ``"epsilon=5"`` pixels.
- >>> deeplabcut.extract_outlier_frames(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/video/reachinvideo1.avi'],
- epsilon=5,
- extractionalgorithm='kmeans',
- )
+ deeplabcut.extract_outlier_frames(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/video/reachinvideo1.avi'],
+ epsilon=5,
+ extractionalgorithm='kmeans',
+ )
"""
cfg = auxiliaryfunctions.read_config(config)
- bodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
- 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.")
track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
cfg,
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)
@@ -365,17 +369,28 @@ def extract_outlier_frames(
videofolder = str(Path(video).parents[0])
else:
videofolder = destfolder
- vname = os.path.splitext(os.path.basename(video))[0]
+ vname = Path(video).stem
try:
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)
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ if (metadata.get("data") or {}).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]
@@ -387,36 +402,47 @@ def extract_outlier_frames(
elif outlieralgorithm == "jump":
temp_dt = df_temp.diff(axis=0) ** 2
temp_dt.drop("likelihood", axis=1, level="coords", inplace=True)
- sum_ = temp_dt.sum(axis=1, level=1)
- ind = df_temp.index[(sum_ > epsilon ** 2).any(axis=1)].tolist()
+ sum_ = temp_dt.T.groupby(level="bodyparts").sum().T
+ 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)
elif outlieralgorithm == "manual":
- wd = Path(config).resolve().parents[0]
- os.chdir(str(wd))
- from deeplabcut.gui import outlier_frame_extraction_toolbox
-
- outlier_frame_extraction_toolbox.show(
- config,
- video,
- shuffle,
- df,
- savelabeled,
- cfg.get("multianimalproject", False),
+ from deeplabcut.gui.widgets import launch_napari
+
+ added_video = attempt_to_add_video(
+ config=config,
+ video=video,
+ copy_videos=copy_videos,
+ coords=None,
)
+ if added_video:
+ project_video_path = Path(cfg["project_path"]) / "videos" / Path(video).name
+ _ = launch_napari([project_video_path, dataname])
+ return
+
+ elif outlieralgorithm == "list":
+ if frames2use is not None:
+ try:
+ frames2use = np.array(frames2use).astype("int")
+ except ValueError:
+ print(
+ "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"!')
+ else:
+ raise ValueError(f"outlieralgorithm {outlieralgorithm} not recognized!")
# Run always except when the outlieralgorithm == manual.
if not outlieralgorithm == "manual":
@@ -450,12 +476,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,
@@ -468,16 +489,16 @@ def extract_outlier_frames(
cluster_resizewidth,
cluster_color,
savelabeled,
+ 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"
)
@@ -497,12 +518,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),
@@ -513,7 +535,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:
@@ -536,11 +559,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
@@ -552,29 +573,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
@@ -582,7 +605,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",
)
@@ -591,6 +614,49 @@ def compute_deviations(
return d, o
+def attempt_to_add_video(
+ config: str,
+ video: str,
+ copy_videos: bool,
+ coords: list | None,
+) -> bool:
+ """Add new videos to the config file at any stage of the project.
+
+ Args:
+ config (string): Full path of the config file in the project.
+ video (string): 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 videos will
+ be copied instead. The default is ``False``; if provided it must be either
+ ``True`` or ``False``.
+ coords (list, optional): A list containing the list of cropping coordinates of
+ the video. Defaults to None.
+
+ Returns:
+ bool: True iff the video was successfully added to the project.
+ """
+ from deeplabcut.create_project import add
+
+ # make sure coords and videos are a list
+ videos = [video]
+ if coords is not None:
+ coords = [coords]
+
+ try:
+ add.add_new_videos(config, videos, coords=coords, copy_videos=copy_videos)
+ 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(
+ "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)
+ return False
+
+ return True
+
+
def ExtractFramesbasedonPreselection(
Index,
extractionalgorithm,
@@ -603,21 +669,20 @@ def ExtractFramesbasedonPreselection(
cluster_color=False,
savelabeled=True,
with_annotations=True,
+ copy_videos=False,
):
- from deeplabcut.create_project import add
-
start = cfg["start"]
stop = cfg["stop"]
numframes2extract = cfg["numframes2pick"]
- bodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(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):
+ tmpfolder = Path(cfg["project_path"]) / "labeled-data" / vname
+ if tmpfolder.is_dir():
print("Frames from video", vname, " already extracted (more will be added)!")
else:
- auxiliaryfunctions.attempttomakefolder(tmpfolder, recursive=True)
+ auxiliaryfunctions.attempt_to_make_folder(str(tmpfolder), recursive=True)
nframes = len(data)
print("Loading video...")
@@ -633,37 +698,50 @@ def ExtractFramesbasedonPreselection(
duration = clip.duration
if cfg["cropping"]: # one might want to adjust
- coords = (cfg["x1"], cfg["x2"], cfg["y1"], cfg["y2"])
+ coords = (cfg["video_sets"].get(video) or {}).get("crop")
+ if coords is not None:
+ coords = list(map(int, coords.split(", ")))
else:
coords = None
+ print("Cropping coords:", coords)
print("Duration of video [s]: ", duration, ", recorded @ ", fps, "fps!")
print("Overall # of frames: ", nframes, "with (cropped) frame dimensions: ")
if extractionalgorithm == "uniform":
if opencv:
- frames2pick = frameselectiontools.UniformFramescv2(
- vid, numframes2extract, start, stop, Index
- )
+ if coords is not None:
+ vid.set_bbox(*coords)
+ frames2pick = frameselectiontools.UniformFramescv2(vid, numframes2extract, start, stop, Index)
else:
- frames2pick = frameselectiontools.UniformFrames(
- clip, numframes2extract, start, stop, Index
- )
+ if coords is not None:
+ clip = clip.crop(
+ y1=coords[2],
+ y2=coords[3],
+ x1=coords[0],
+ x2=coords[1],
+ )
+ frames2pick = frameselectiontools.UniformFrames(clip, numframes2extract, start, stop, Index)
elif extractionalgorithm == "kmeans":
if opencv:
+ if coords is not None:
+ vid.set_bbox(*coords)
frames2pick = frameselectiontools.KmeansbasedFrameselectioncv2(
vid,
numframes2extract,
start,
stop,
- cfg["cropping"],
- coords,
Index,
resizewidth=cluster_resizewidth,
color=cluster_color,
)
else:
- if cfg["cropping"]:
- clip = clip.crop(y1=cfg["y1"], y2=cfg["x2"], x1=cfg["x1"], x2=cfg["x2"])
+ if coords is not None:
+ clip = clip.crop(
+ y1=coords[2],
+ y2=coords[3],
+ x1=coords[0],
+ x2=coords[1],
+ )
frames2pick = frameselectiontools.KmeansbasedFrameselection(
clip,
numframes2extract,
@@ -675,12 +753,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
@@ -688,8 +765,6 @@ def ExtractFramesbasedonPreselection(
if opencv:
PlottingSingleFramecv2(
vid,
- cfg["cropping"],
- coords,
data,
bodyparts,
tmpfolder,
@@ -724,40 +799,44 @@ 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:
- try:
- if cfg["cropping"]:
- add.add_new_videos(
- config, [video], coords=[coords]
- ) # make sure you pass coords as a list
- else:
- add.add_new_videos(config, [video], coords=None)
- except: # can we make a catch here? - in fact we should drop indices from DataCombined if they are in CollectedData.. [ideal behavior; currently this is pretty unlikely]
- print(
- "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)
+ added_video = attempt_to_add_video(
+ config=config,
+ video=video,
+ copy_videos=copy_videos,
+ coords=coords,
+ )
+ if not added_video:
pass
if with_annotations:
- machinefile = os.path.join(
- tmpfolder, "machinelabels-iter" + str(cfg["iteration"]) + ".h5"
- )
+ machinefile = Path(tmpfolder) / ("machinelabels-iter" + str(cfg["iteration"]) + ".h5")
if isinstance(data, pd.DataFrame):
df = data.loc[frames2pick]
- df.index = pd.MultiIndex.from_tuples([
- ("labeled-data", vname, "img" + str(index).zfill(strwidth) + ".png")
- for index in df.index
- ]) # exchange index number by file names.
+ df.index = pd.MultiIndex.from_tuples(
+ [
+ (
+ "labeled-data",
+ vname,
+ "img" + str(index).zfill(strwidth) + ".png",
+ )
+ for index in df.index
+ ]
+ ) # exchange index number by file names.
elif isinstance(data, dict):
- idx = pd.MultiIndex.from_tuples([
- ("labeled-data", vname, "img" + str(index).zfill(strwidth) + ".png")
- for index in frames2pick
- ])
- filename = os.path.join(
- str(tmpfolder), f"CollectedData_{cfg['scorer']}.h5"
+ idx = pd.MultiIndex.from_tuples(
+ [
+ (
+ "labeled-data",
+ vname,
+ "img" + str(index).zfill(strwidth) + ".png",
+ )
+ for index in frames2pick
+ ]
)
+ filename = str(Path(tmpfolder) / f"CollectedData_{cfg['scorer']}.h5")
try:
df_temp = pd.read_hdf(filename, "df_with_missing")
columns = df_temp.columns
@@ -802,25 +881,18 @@ 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(
- os.path.join(tmpfolder, "machinelabels.csv")
+ Path(tmpfolder) / "machinelabels.csv"
) # this is always the most current one (as reading is from h5)
else:
df.to_hdf(machinefile, key="df_with_missing", mode="w")
- df.to_csv(os.path.join(tmpfolder, "machinelabels.csv"))
+ df.to_csv(Path(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.")
@@ -841,17 +913,13 @@ def PlottingSingleFrame(
"""Label frame and save under imagename / this is already cropped (for clip)"""
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"
- )
+ imagename1 = Path(tmpfolder) / ("img" + str(index).zfill(strwidth) + ".png")
+ imagename2 = Path(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 imagename1.is_file():
plt.axis("off")
image = img_as_ubyte(clip.get_frame(index * 1.0 / clip.fps))
- io.imsave(imagename1, image)
+ io.imsave(str(imagename1), image)
if savelabeled:
if np.ndim(image) > 2:
@@ -861,9 +929,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)))
@@ -879,7 +945,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,
)
@@ -894,8 +960,6 @@ def PlottingSingleFrame(
def PlottingSingleFramecv2(
cap,
- crop,
- coords,
Dataframe,
bodyparts2plot,
tmpfolder,
@@ -910,26 +974,17 @@ def PlottingSingleFramecv2(
"""Label frame and save under imagename / cap is not already cropped."""
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"
- )
+ imagename1 = Path(tmpfolder) / ("img" + str(index).zfill(strwidth) + ".png")
+ imagename2 = Path(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 imagename1.is_file():
plt.axis("off")
cap.set_to_frame(index)
- frame = cap.read_frame()
+ frame = cap.read_frame(crop=True)
if frame is None:
print("Frame could not be read.")
return
image = img_as_ubyte(frame)
- if crop:
- image = image[
- int(coords[2]) : int(coords[3]), int(coords[0]) : int(coords[1]), :
- ]
-
io.imsave(imagename1, image)
if savelabeled:
@@ -940,9 +995,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)))
@@ -958,7 +1011,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,
)
@@ -971,7 +1024,7 @@ def PlottingSingleFramecv2(
plt.close("all")
-def merge_datasets(config, forceiterate=None):
+def merge_datasets(config: str | Path, forceiterate=None):
"""Merge the original training dataset with the newly refined data.
Checks if the original training dataset can be merged with the newly refined
@@ -980,44 +1033,34 @@ def merge_datasets(config, forceiterate=None):
If this is the case then the ``"iteration"`` variable is advanced by 1.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ forceiterate (int or None, optional): If an integer is given the iteration
+ variable is set to this value. This is only done if all datasets were
+ labeled or refined. Defaults to None.
- forceiterate: int or None, optional, default=None
- If an integer is given the iteration variable is set to this value
- This is only done if all datasets were labeled or refined.
+ Examples:
- Examples
- --------
-
- >>> deeplabcut.merge_datasets('/analysis/project/reaching-task/config.yaml')
+ deeplabcut.merge_datasets("/analysis/project/reaching-task/config.yaml")
"""
cfg = auxiliaryfunctions.read_config(config)
config_path = Path(config).parents[0]
- bf = Path(str(config_path / "labeled-data"))
+ bf = 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(".")
+ p for p in bf.iterdir() if "_labeled" not in p.name and not p.name.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 (folder / "MachineLabelsRefine.h5").is_file(): # Folder that was manually refine...
pass
- elif os.path.isfile(
- os.path.join(folder, "CollectedData_" + cfg["scorer"] + ".h5")
- ): # Folder that contains human data set...
+ elif (folder / ("CollectedData_" + cfg["scorer"] + ".h5")).is_file(): # Folder that contains human data set...
pass
else:
print("The following folder was not manually refined,...", folder)
flagged = True
- pass # this folder does not contain a MachineLabelsRefine file (not updated...)
+ # this folder does not contain a MachineLabelsRefine file (not updated...)
if not flagged:
# updates iteration by 1
@@ -1029,14 +1072,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 96ea82ab36..a86c1d302b 100644
--- a/deeplabcut/refine_training_dataset/stitch.py
+++ b/deeplabcut/refine_training_dataset/stitch.py
@@ -1,35 +1,47 @@
-import matplotlib.pyplot as plt
-import networkx as nx
-import numpy as np
-import os
-import pandas as pd
+#
+# 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 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.deprecation import renamed_parameter
+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
+
class Tracklet:
def __init__(self, data, inds):
- """
- Create a Tracklet object.
+ """Create a Tracklet object.
Parameters
----------
@@ -43,13 +55,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]
@@ -89,10 +99,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):
@@ -101,18 +108,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
@@ -124,7 +130,12 @@ def likelihood(self):
def identity(self):
"""Return the average predicted identity of all Tracklet detections."""
try:
- return mode(self.data[..., 3], axis=None, nan_policy="omit")[0][0]
+ return mode(
+ self.data[..., 3],
+ axis=None,
+ nan_policy="omit",
+ keepdims=False,
+ )[0]
except IndexError:
return -1
@@ -177,8 +188,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)
@@ -187,39 +198,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:
@@ -233,58 +236,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):
@@ -351,13 +344,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)
@@ -369,12 +360,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.
@@ -388,20 +377,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)
@@ -461,7 +453,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
@@ -476,13 +468,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)
@@ -506,11 +493,9 @@ def from_pickle(
split_tracklets=True,
prestitch_residuals=True,
):
- with open(pickle_file, "rb") as file:
+ with Path(pickle_file).open("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_
@@ -527,7 +512,10 @@ def from_dict_of_dict(
header = dict_of_dict.pop("header", None)
single = None
for k, dict_ in dict_of_dict.items():
- inds, data = zip(*[(cls.get_frame_ind(k), v) for k, v in dict_.items()])
+ try:
+ inds, data = zip(*[(cls.get_frame_ind(k), v) for k, v in dict_.items()], strict=False)
+ except ValueError:
+ continue
inds = np.asarray(inds)
data = np.asarray(data)
try:
@@ -540,9 +528,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_
@@ -565,7 +551,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):
@@ -587,6 +573,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 = []
@@ -596,9 +585,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))
@@ -627,14 +614,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):
@@ -673,85 +658,66 @@ 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")
- )
- 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:
- # Pick the segment that minimizes "smoothness", computed here
- # with the coefficient of variation of the differences.
- if t1 in remaining_nodes:
- remaining_nodes.remove(t1)
- if t2 in remaining_nodes:
- remaining_nodes.remove(t2)
- track = sum(remaining_nodes)
- hyp1 = track + t1
- hyp2 = track + t2
- dx1 = np.diff(hyp1.centroid, axis=0)
- cv1 = dx1.std() / np.abs(dx1).mean()
- dx2 = np.diff(hyp2.centroid, axis=0)
- cv2 = dx2.std() / np.abs(dx2).mean()
- if cv1 < cv2:
- remaining_nodes.add(t1)
- self.residuals.append(t2)
- else:
- remaining_nodes.add(t2)
- self.residuals.append(t1)
- paths.append(list(remaining_nodes))
- elif incomplete_tracks > 1:
- # Rebuild a full graph from the remaining nodes without
- # temporal constraint on what tracklets can be stitched together.
- self.build_graph(list(remaining_nodes), max_gap=np.inf)
- self.G.nodes["source"]["demand"] = -incomplete_tracks
- self.G.nodes["sink"]["demand"] = incomplete_tracks
- _, self.flow = nx.capacity_scaling(self.G)
- paths += self.reconstruct_paths()
+ 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
+ # Verify whether there are overlapping tracklets
+ for t1, t2 in combinations(remaining_nodes, 2):
+ if t1 in t2:
+ # Pick the segment that minimizes "smoothness", computed here
+ # with the coefficient of variation of the differences.
+ if t1 in remaining_nodes:
+ remaining_nodes.remove(t1)
+ if t2 in remaining_nodes:
+ remaining_nodes.remove(t2)
+ track = sum(remaining_nodes)
+ hyp1 = track + t1
+ hyp2 = track + t2
+ dx1 = np.diff(hyp1.centroid, axis=0)
+ cv1 = dx1.std() / np.abs(dx1).mean()
+ dx2 = np.diff(hyp2.centroid, axis=0)
+ cv2 = dx2.std() / np.abs(dx2).mean()
+ if cv1 < cv2:
+ remaining_nodes.add(t1)
+ self.residuals.append(t2)
+ else:
+ remaining_nodes.add(t2)
+ self.residuals.append(t1)
+ paths.append(list(remaining_nodes))
+ elif incomplete_tracks > 1:
+ # Rebuild a full graph from the remaining nodes without
+ # temporal constraint on what tracklets can be stitched together.
+ self.build_graph(list(remaining_nodes), max_gap=np.inf)
+ self.G.nodes["source"]["demand"] = -incomplete_tracks
+ self.G.nodes["sink"]["demand"] = incomplete_tracks
+ _, self.flow = nx.capacity_scaling(self.G)
+ 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:
@@ -765,9 +731,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
@@ -799,9 +763,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
@@ -847,17 +809,28 @@ def concatenate_data(self):
self._first_frame = min(self.tracks, key=lambda t: t.start).start
self._last_frame = max(self.tracks, key=lambda t: t.end).end
data = []
- for track in self.tracks:
+ # Guarantee track data are sorted in the order defined in the config
+ for track in sorted(self.tracks, key=lambda t: t.identity):
flat_data = track.flat_data
temp = np.full((self.n_frames, flat_data.shape[1]), np.nan)
temp[track.inds - self._first_frame] = flat_data
data.append(temp)
+
+ # If there isn't a track for each animal, fill in the dataframe with NaNs
+ missing_tracks = self.n_tracks - len(self.tracks)
+ if missing_tracks > 0:
+ track_shape = self.tracks[0].flat_data.shape[1]
+ data += missing_tracks * [np.full((self.n_frames, track_shape), np.nan)]
+
return np.hstack(data)
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]
@@ -882,19 +855,19 @@ 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):
+ 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"))
@staticmethod
def calculate_edge_weight(tracklet1, tracklet2):
@@ -929,7 +902,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"):
@@ -942,7 +915,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"):
@@ -964,7 +937,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):
@@ -985,13 +958,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="",
+ config_path: str | Path,
+ videos: list[str | Path],
+ 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,
@@ -1002,105 +977,111 @@ def stitch_tracklets(
track_method="",
output_name="",
transformer_checkpoint="",
+ save_as_csv=False,
+ **kwargs,
):
+ """Stitch sparse tracklets into full tracks via a graph-based, minimum-cost flow
+ optimization problem.
+
+ Args:
+ config_path (str | Path): Path to the main project config.yaml file.
+ videos (list[str | Path]): Full paths to videos for analysis, or a directory where all videos
+ with the same extension are stored.
+ video_extensions (str | Sequence[str] | None, optional): 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). Defaults to None.
+ shuffle (int, optional): Shuffle index of the training dataset used for training the network. Defaults to 1.
+ trainingsetindex (int, optional): Which TrainingsetFraction to use. By default the
+ first (TrainingFraction is a list in config.yaml).
+ n_tracks (int, optional): 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 (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
+ in building the graph and finding the solution to the
+ optimization problem, but are rather added last after
+ "almost-complete" tracks are formed. The higher the value,
+ the lesser the computational cost, but the higher the chance of
+ discarding relatively long and reliable tracklets that are
+ essential to solving the stitching task.
+ Default is 10, and must be 3 at least.
+ split_tracklets (bool, optional): By default, tracklets whose time indices are not consecutive integers
+ are split in shorter tracklets whose time continuity is guaranteed.
+ This is for example very powerful to get rid of tracking errors
+ (e.g., identity switches) which are often signaled by a missing
+ time frame at the moment they occur. Note though that for long
+ occlusions where tracker re-identification capability can be trusted,
+ setting `split_tracklets` to False is preferable.
+ prestitch_residuals (bool, optional): Residuals will by default be grouped together according to their
+ temporal proximity prior to being added back to the tracks.
+ This is done to improve robustness and simultaneously reduce complexity.
+ max_gap (int, optional): Maximal temporal gap to allow between a pair of tracklets.
+ This is automatically determined by the TrackletStitcher by default.
+ weight_func (callable, optional): Function accepting two tracklets as arguments and returning a scalar
+ that must be inversely proportional to the likelihood that the tracklets
+ belong to the same track; i.e., the higher the confidence that the
+ tracklets should be stitched together, the lower the returned value.
+ destfolder (string, optional): 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): 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.
+ output_name (str, optional): Name of the output h5 file.
+ By default, tracks are automatically stored into the same directory
+ as the pickle file and with its name.
+ transformer_checkpoint (str, optional): Path to transformer checkpoint for re-ID
+ stitching. Defaults to "".
+ save_as_csv (bool, optional): Whether to write the tracks to a CSV file too (False by default).
+ kwargs (dict, optional): Additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ TrackletStitcher: A TrackletStitcher object.
"""
- Stitch sparse tracklets into full tracks via a graph-based,
- minimum-cost flow optimization problem.
-
- Parameters
- ----------
- config_path : str
- 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.
-
- 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).
-
- n_tracks : int, optional
- 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.
-
- min_length : int, optional
- Tracklets less than `min_length` frames of length
- are considered to be residuals; i.e., they do not participate
- in building the graph and finding the solution to the
- optimization problem, but are rather added last after
- "almost-complete" tracks are formed. The higher the value,
- the lesser the computational cost, but the higher the chance of
- discarding relatively long and reliable tracklets that are
- essential to solving the stitching task.
- Default is 10, and must be 3 at least.
-
- split_tracklets : bool, optional
- By default, tracklets whose time indices are not consecutive integers
- are split in shorter tracklets whose time continuity is guaranteed.
- This is for example very powerful to get rid of tracking errors
- (e.g., identity switches) which are often signaled by a missing
- time frame at the moment they occur. Note though that for long
- occlusions where tracker re-identification capability can be trusted,
- setting `split_tracklets` to False is preferable.
-
- prestitch_residuals : bool, optional
- Residuals will by default be grouped together according to their
- temporal proximity prior to being added back to the tracks.
- This is done to improve robustness and simultaneously reduce complexity.
-
- max_gap : int, optional
- Maximal temporal gap to allow between a pair of tracklets.
- This is automatically determined by the TrackletStitcher by default.
-
- weight_func : callable, optional
- Function accepting two tracklets as arguments and returning a scalar
- that must be inversely proportional to the likelihood that the tracklets
- belong to the same track; i.e., the higher the confidence that the
- 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.
-
- 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.
-
- output_name : str, optional
- Name of the output h5 file.
- By default, tracks are automatically stored into the same directory
- as the pickle file and with its name.
-
- 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:
@@ -1128,26 +1109,26 @@ 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))
videofolder = str(Path(video).parents[0])
dest = destfolder or videofolder
- deeplabcut.utils.auxiliaryfunctions.attempttomakefolder(dest)
+ 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 = str(Path(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()?'
- )
+ feature_dict = shelve.open(feature_dict_path, flag="r")
+ 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")
+ dataname = str(Path(dest) / (vname + DLCscorer + ".h5"))
method = TRACK_METHODS[track_method]
pickle_file = dataname.split(".h5")[0] + f"{method}.pickle"
@@ -1155,31 +1136,36 @@ 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:
stitcher.write_tracks(
- output_name=output_name, animal_names=animal_names, suffix="tr"
+ output_name=output_name,
+ animal_names=animal_names,
+ suffix="tr",
+ save_as_csv=save_as_csv,
)
else:
stitcher.write_tracks(
- output_name=output_name, animal_names=animal_names, suffix=""
+ output_name=output_name,
+ animal_names=animal_names,
+ suffix="",
+ save_as_csv=save_as_csv,
)
except FileNotFoundError as e:
print(e, "\nSkipping...")
diff --git a/deeplabcut/refine_training_dataset/tracklets.py b/deeplabcut/refine_training_dataset/tracklets.py
index a763997ae9..4e07e02d0f 100644
--- a/deeplabcut/refine_training_dataset/tracklets.py
+++ b/deeplabcut/refine_training_dataset/tracklets.py
@@ -1,42 +1,49 @@
-import numpy as np
-import pandas as pd
+#
+# 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 pickle
import re
-from deeplabcut.post_processing import columnwise_spline_interp
-from deeplabcut.utils.auxiliaryfunctions import read_config
+from pathlib import Path
+
+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
+
class TrackletManager:
def __init__(self, config, min_swap_len=2, min_tracklet_len=2, max_gap=0):
- """
+ """Manage tracklets for refinement and visualization.
- Parameters
- ----------
- config : str
- Path to a configuration file.
- min_swap_len : float, optional (default=2)
- Minimum swap length.
- Swaps shorter than 2 frames are discarded by default.
- min_tracklet_len : float, optional (default=2)
- Minimum tracklet length.
- Tracklets shorter than 2 frames are discarded by default.
- max_gap : int, optional (default = 0).
- Number of frames to consider when filling in missing data.
-
- Examples
- --------
-
- manager = TrackletManager(config_path, min_swap_frac=0, min_tracklet_frac=0)
-
- manager.load_tracklets_from_pickle(filename)
- # Alternatively
- manager.load_tracklets_from_h5(filename)
-
- manager.find_swapping_bodypart_pairs()
+ Args:
+ config (str): Path to a configuration file.
+ min_swap_len (int, optional): Minimum swap length.
+ Swaps shorter than 2 frames are discarded by default. Defaults to 2.
+ min_tracklet_len (int, optional): Minimum tracklet length.
+ Tracklets shorter than 2 frames are discarded by default. Defaults to 2.
+ max_gap (int, optional): Number of frames to consider when filling in missing data.
+ Defaults to 0.
+
+ Examples:
+
+ manager = TrackletManager(config_path, min_swap_len=0, min_tracklet_len=0)
+ manager.load_tracklets_from_pickle(filename)
+
+ # Alternatively:
+ manager.load_tracklets_from_hdf(filename)
+ manager.find_swapping_bodypart_pairs()
"""
self.config = config
- self.cfg = read_config(config)
+ self.cfg = auxiliaryfunctions.read_config(config)
self.min_swap_len = min_swap_len
self.min_tracklet_len = min_tracklet_len
self.max_gap = max_gap
@@ -62,22 +69,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 = []
@@ -95,12 +99,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]
@@ -116,15 +118,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]
@@ -139,21 +137,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
@@ -164,11 +156,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)
@@ -197,14 +187,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(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:
@@ -217,18 +205,14 @@ 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]
def load_tracklets_from_pickle(self, filename, auto_fill=True):
self.filename = filename
- with open(filename, "rb") as file:
+ with Path(filename).open("rb") as file:
tracklets = pickle.load(file)
self._load_tracklets(tracklets, auto_fill)
self._xy = self.xy.copy()
@@ -267,12 +251,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()
@@ -295,12 +277,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],
@@ -308,12 +286,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]
@@ -326,7 +300,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
@@ -339,7 +313,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
@@ -349,7 +323,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(
@@ -372,4 +346,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/__init__.py b/deeplabcut/utils/__init__.py
index 048505d8ce..f26cb4708b 100644
--- a/deeplabcut/utils/__init__.py
+++ b/deeplabcut/utils/__init__.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
from deeplabcut.utils.auxfun_multianimal import *
from deeplabcut.utils.auxfun_videos import *
from deeplabcut.utils.auxiliaryfunctions import *
@@ -6,4 +16,3 @@
from deeplabcut.utils.make_labeled_video import *
from deeplabcut.utils.plotting import *
from deeplabcut.utils.video_processor import *
-
diff --git a/deeplabcut/utils/auxfun_models.py b/deeplabcut/utils/auxfun_models.py
index 5245594dec..5043e825f7 100644
--- a/deeplabcut/utils/auxfun_models.py
+++ b/deeplabcut/utils/auxfun_models.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,84 +18,81 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import os
from pathlib import Path
from deeplabcut.utils import auxiliaryfunctions
-
-def Check4weights(modeltype, parent_path, num_shuffles):
- """ gets local path to network weights and checks if they are present. If not, downloads them from tensorflow.org """
- if "resnet_50" == modeltype:
- model_path = (
- parent_path
- / "pose_estimation_tensorflow/models/pretrained/resnet_v1_50.ckpt"
- )
- elif "resnet_101" == modeltype:
- model_path = (
- parent_path
- / "pose_estimation_tensorflow/models/pretrained/resnet_v1_101.ckpt"
- )
- elif "resnet_152" == modeltype:
- model_path = (
- parent_path
- / "pose_estimation_tensorflow/models/pretrained/resnet_v1_152.ckpt"
- )
- elif "mobilenet" in modeltype:
- model_path = Path(
- os.path.join(
- parent_path,
- "pose_estimation_tensorflow/models/pretrained/"
- + str(modeltype)
- + "_224.ckpt",
- )
- )
- elif "efficientnet" in modeltype:
- model_path = Path(
- os.path.join(
- parent_path,
- "pose_estimation_tensorflow/models/pretrained/"
- + modeltype.replace("_", "-"),
- )
- )
- else:
+# This dictionary maps the model types to the file locations where the models exist.
+MODEL_BASE_PATH = Path("pose_estimation_tensorflow") / "models" / "pretrained"
+MODELTYPE_FILEPATH_MAP = {
+ "resnet_50": MODEL_BASE_PATH / "resnet_v1_50.ckpt",
+ "resnet_101": MODEL_BASE_PATH / "resnet_v1_101.ckpt",
+ "resnet_152": MODEL_BASE_PATH / "resnet_v1_152.ckpt",
+ "mobilenet_v2_1.0": MODEL_BASE_PATH / "mobilenet_v2_1.0_224.ckpt",
+ "mobilenet_v2_0.75": MODEL_BASE_PATH / "mobilenet_v2_0.75_224.ckpt",
+ "mobilenet_v2_0.5": MODEL_BASE_PATH / "mobilenet_v2_0.5_224.ckpt",
+ "mobilenet_v2_0.35": MODEL_BASE_PATH / "mobilenet_v2_0.35_224.ckpt",
+ "efficientnet-b0": MODEL_BASE_PATH / "efficientnet-b0" / "model.ckpt",
+ "efficientnet-b1": MODEL_BASE_PATH / "efficientnet-b1" / "model.ckpt",
+ "efficientnet-b2": MODEL_BASE_PATH / "efficientnet-b2" / "model.ckpt",
+ "efficientnet-b3": MODEL_BASE_PATH / "efficientnet-b3" / "model.ckpt",
+ "efficientnet-b4": MODEL_BASE_PATH / "efficientnet-b4" / "model.ckpt",
+ "efficientnet-b5": MODEL_BASE_PATH / "efficientnet-b5" / "model.ckpt",
+ "efficientnet-b6": MODEL_BASE_PATH / "efficientnet-b6" / "model.ckpt",
+}
+
+
+# NOTE @deruyter92 2026-07-09: This is only used in TensorFlow code.
+def check_for_weights(modeltype, parent_path) -> str:
+ """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!"
)
- num_shuffles = -1 # thus the loop below is empty...
- model_path = parent_path
+ # Exit the function early if an unknown modeltype is provided.
+ return str(parent_path)
- if num_shuffles > 0:
+ exists = False
+ model_path = parent_path / MODELTYPE_FILEPATH_MAP[modeltype]
+ try:
+ for file in model_path.parent.iterdir():
+ if model_path.name in file.name:
+ exists = True
+ break
+ except FileNotFoundError:
+ pass
+
+ if not exists:
if "efficientnet" in modeltype:
- if not os.path.isdir(model_path):
- Downloadweights(modeltype, model_path)
- model_path = os.path.join(model_path, "model.ckpt")
+ download_weights(modeltype, model_path.parent)
else:
- if not model_path.is_file():
- Downloadweights(modeltype, model_path)
+ download_weights(modeltype, model_path)
- return str(model_path), num_shuffles
+ return str(model_path)
-def Downloadweights(modeltype, model_path):
- """
- Downloads the ImageNet pretrained weights for ResNets, MobileNets et al. from TensorFlow...
+def download_weights(modeltype, model_path):
+ """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)
@@ -94,20 +101,20 @@ def Downloadweights(modeltype, model_path):
print("Pick one of the following: ", neturls.keys())
-def DownloadModel(modelname, target_dir):
- """
- Downloads a DeepLabCut Model Zoo Project
- """
- import urllib.request
+def download_model(modelname, target_dir):
+ """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:
@@ -119,22 +126,12 @@ def tarfilenamecutting(tarf):
dlc_path = auxiliaryfunctions.get_deeplabcut_path()
neturls = auxiliaryfunctions.read_plainconfig(
- os.path.join(
- dlc_path,
- "pose_estimation_tensorflow",
- "models",
- "pretrained",
- "pretrained_model_urls.yaml",
- )
+ dlc_path / "pose_estimation_tensorflow" / "models" / "pretrained" / "pretrained_model_urls.yaml"
)
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)
@@ -142,32 +139,41 @@ def tarfilenamecutting(tarf):
tar.extractall(target_dir, members=tarfilenamecutting(tar))
else:
models = [
- fn
- for fn in neturls.keys()
- if "resnet_" 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 download_mpii_weights(wd):
- """ Downloads weights pretrained on human data from DeeperCut. """
- import urllib.request
- from pathlib import Path
-
- url = [
- "https://datasets.d2.mpi-inf.mpg.de/deepercut-models-tensorflow/mpii-single-resnet-101.data-00000-of-00001",
- "https://datasets.d2.mpi-inf.mpg.de/deepercut-models-tensorflow/mpii-single-resnet-101.meta",
- "https://datasets.d2.mpi-inf.mpg.de/deepercut-models-tensorflow/mpii-single-resnet-101.index",
- ]
- for i in url:
- file = str(Path(i).name)
- filename = file.replace("mpii-single-resnet-101", "snapshot-103000")
- filename = os.path.join(wd, filename)
- if os.path.isfile(filename):
- print("Weights already present!")
- break # not checking all the 3 files.
+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"
+ f"{list(range(n_devices))}."
+ )
+ tf.config.set_visible_devices(physical_devices[gputouse], "GPU")
+
+
+def smart_restore(restorer, sess, checkpoint_path, net_type):
+ """Restore pretrained weights, smartly redownloading them if missing."""
+ try:
+ restorer.restore(sess, checkpoint_path)
+ except ValueError as e: # The path may be wrong, or the weights no longer exist
+ dlcparent_path = auxiliaryfunctions.get_deeplabcut_path()
+ correct_model_path = str(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, dlcparent_path)
+ restorer.restore(sess, checkpoint_path)
else:
- urllib.request.urlretrieve(i, filename)
+ raise ValueError(e) from e
+
- return filename
+# Aliases for backwards-compatibility
+Check4Weights = check_for_weights
+Downloadweights = download_weights
+DownloadModel = download_model
diff --git a/deeplabcut/utils/auxfun_multianimal.py b/deeplabcut/utils/auxfun_multianimal.py
index d475d8336c..5c4e7b8ed6 100644
--- a/deeplabcut/utils/auxfun_multianimal.py
+++ b/deeplabcut/utils/auxfun_multianimal.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -9,7 +19,6 @@
"""
import math
-import os
import pickle
import random
import shelve
@@ -21,25 +30,19 @@
import numpy as np
import pandas as pd
+from deeplabcut.core.trackingutils import TRACK_METHODS
from deeplabcut.utils import auxiliaryfunctions, conversioncode
-from deeplabcut.generate_training_dataset import trainingsetmanipulation
-from deeplabcut.pose_estimation_tensorflow.lib.trackingutils import TRACK_METHODS
+
def reorder_individuals_in_df(df: pd.DataFrame, order: list) -> pd.DataFrame:
- """
- Reorders data of df to match the order given in a list
-
- Parameters:
- ----------
- df: pd.DataFrame
- Data from tracked .h5 file
- order: list of str
- Desired order of individuals
-
- Return:
- -------
- df: pd.DataFrame
- Reordered DataFrame
+ """Reorders data of df to match the order given in a list.
+
+ Args:
+ df (pd.DataFrame): Data from tracked .h5 file.
+ order (list of str): Desired order of individuals.
+
+ Returns:
+ pd.DataFrame: Reordered DataFrame.
"""
columns = df.columns
inds = df.index
@@ -49,6 +52,7 @@ def reorder_individuals_in_df(df: pd.DataFrame, order: list) -> pd.DataFrame:
return df
+
def extractindividualsandbodyparts(cfg):
individuals = cfg["individuals"].copy()
if len(cfg["uniquebodyparts"]) > 0:
@@ -61,21 +65,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
+ 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
@@ -83,7 +84,9 @@ 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]
@@ -109,17 +112,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)
@@ -135,7 +137,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
@@ -143,14 +145,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!
+ 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
@@ -178,43 +179,45 @@ 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 """
- data_path = dataname.split(".h5")[0] + suffix + ".pickle"
- metadata_path = dataname.split(".h5")[0] + "_meta.pickle"
-
+ """Save predicted data as h5 file and metadata as pickle file; created by
+ predict_videos.py.
+ """
+ dataname = Path(dataname)
+ data_path = dataname.with_name(dataname.stem + suffix + ".pickle")
+ metadata_path = dataname.with_name(dataname.stem + "_meta.pickle")
- with open(data_path, "wb") as f:
+ data_path.parent.mkdir(parents=True, exist_ok=True)
+ with data_path.open("wb") as f:
pickle.dump(data, f, pickle.HIGHEST_PROTOCOL)
- with open(metadata_path, "wb") as f:
+ with metadata_path.open("wb") as f:
pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
return data_path, metadata_path
-
def LoadFullMultiAnimalData(dataname):
- """ Save predicted data as h5 file and metadata as pickle file; created by predict_videos.py """
- data_file = dataname.split(".h5")[0] + "_full.pickle"
+ """Load predicted data and metadata from pickle files created by predict_videos.py."""
+ dataname = Path(dataname)
+ data_file = dataname.with_name(dataname.stem + "_full.pickle")
+ metadata_file = dataname.with_name(dataname.stem + "_meta.pickle")
try:
- with open(data_file, "rb") as handle:
+ with data_file.open("rb") as handle:
data = pickle.load(handle)
except (pickle.UnpicklingError, FileNotFoundError):
data = shelve.open(data_file, flag="r")
- with open(data_file.replace("_full.", "_meta."), "rb") as handle:
+ with metadata_file.open("rb") as handle:
metadata = pickle.load(handle)
return data, metadata
def returnlabelingdata(config):
- """ Returns a specific labeleing data set -- the user will be asked which one. """
+ """Returns a specific labeleing data set -- the user will be asked which one."""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
video_names = [Path(i).stem for i in videos]
@@ -222,51 +225,43 @@ 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 :)
- fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"] + ".h5")
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
+ fn = folder / ("CollectedData_" + cfg["scorer"] + ".h5")
Data = pd.read_hdf(fn)
return Data
-def convert2_maDLC(config, userfeedback=True, forceindividual=None):
- """
- Converts single animal annotation file into a multianimal annotation file,
- by introducing an individuals column with either the first individual
+def convert2_maDLC(config: str | Path, userfeedback=True, forceindividual=None):
+ """Convert a single-animal annotation file into a multianimal annotation file.
+
+ Introduces an individuals column with either the first individual
in individuals list in config.yaml or whatever is passed via "forceindividual".
- ----------
- config : string
- Full path of the config.yaml file as a string.
+ Args:
+ config (str | Path): Full path of the config.yaml file as a string.
+ userfeedback (bool, optional): If false, all folders are processed without prompting.
+ If true, the user is asked for each folder whether to convert. Use this, e.g. if you have already labeled
+ some folders and want to convert data for new videos only.
+ forceindividual (str | None, optional): If a string is given, that value is used
+ in the individuals column. Defaults to None.
- 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
- some folders and want to extract data for new videos.
+ Examples:
+ Convert multianimalbodyparts under the 'first individual' in individuals list in
+ `config.yaml` and uniquebodyparts under 'single':
- forceindividual: None default
- If a string is given that is used in the individuals column.
+ deeplabcut.convert2_maDLC("/socialrearing-task/config.yaml")
- Examples
- --------
- Converts mulianimalbodyparts under the 'first individual' in individuals list in config.yaml
- and uniquebodyparts under 'single'
- >>> deeplabcut.convert2_maDLC('/socialrearing-task/config.yaml')
+ Convert multianimalbodyparts under the individual label mus17 and uniquebodyparts
+ under 'single':
- --------
- Converts mulianimalbodyparts under the individual label mus17 and uniquebodyparts under 'single'
- >>> deeplabcut.convert2_maDLC('/socialrearing-task/config.yaml', forceindividual='mus17')
+ deeplabcut.convert2_maDLC("/socialrearing-task/config.yaml", forceindividual="mus17")
"""
-
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
- video_names = [trainingsetmanipulation._robust_path_split(i)[1] for i in videos]
+ video_names = [Path(i).stem 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:
@@ -278,24 +273,19 @@ 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 :)
-
- fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"])
- Data = pd.read_hdf(fn + ".h5")
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
+ fn = folder / ("CollectedData_" + cfg["scorer"])
+ Data = pd.read_hdf(fn.with_suffix(".h5"))
conversioncode.guarantee_multiindex_rows(Data)
imindex = Data.index
@@ -304,16 +294,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,
@@ -344,9 +330,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,
@@ -360,42 +344,40 @@ def convert2_maDLC(config, userfeedback=True, forceindividual=None):
dataFrame = pd.concat([dataFrame, frame], axis=1)
Data.to_hdf(
- fn + "singleanimal.h5", "df_with_missing",
+ fn.with_name(fn.name + "singleanimal.h5"),
+ key="df_with_missing",
)
- Data.to_csv(fn + "singleanimal.csv")
+ Data.to_csv(fn.with_name(fn.name + "singleanimal.csv"))
- dataFrame.to_hdf(fn + ".h5", "df_with_missing")
- dataFrame.to_csv(fn + ".csv")
+ dataFrame.to_hdf(fn.with_suffix(".h5"), key="df_with_missing")
+ dataFrame.to_csv(fn.with_suffix(".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 :)
- fn = os.path.join(str(folder), "CollectedData_" + cfg["scorer"])
- Data = pd.read_hdf(fn + ".h5")
+ if askuser == "y" or askuser == "yes" or askuser == "Ja" or askuser == "ha": # multilanguage support :)
+ fn = folder / ("CollectedData_" + cfg["scorer"])
+ Data = pd.read_hdf(fn.with_suffix(".h5"))
conversioncode.guarantee_multiindex_rows(Data)
imindex = Data.index
- if "individuals" in Data.columns.names and (
- target == 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":
@@ -434,18 +416,18 @@ def convert_single2multiplelegacyAM(config, userfeedback=True, target=None):
DataFrame = pd.concat([DataFrame, dataFrame], axis=1)
Data.to_hdf(
- fn + "multianimal.h5", "df_with_missing",
+ fn.with_name(fn.name + "multianimal.h5"),
+ key="df_with_missing",
)
- Data.to_csv(fn + "multianimal.csv")
+ Data.to_csv(fn.with_name(fn.name + "multianimal.csv"))
DataFrame.to_hdf(
- fn + ".h5", "df_with_missing",
- )
- DataFrame.to_csv(fn + ".csv")
- elif target == None or target == "multi":
- print(
- "This is a single animal data set, converting to multi...", folder
+ fn.with_suffix(".h5"),
+ key="df_with_missing",
)
+ DataFrame.to_csv(fn.with_suffix(".csv"))
+ elif target is None or target == "multi":
+ 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]:
@@ -521,28 +503,24 @@ def convert_single2multiplelegacyAM(config, userfeedback=True, target=None):
DataFrame = pd.concat([DataFrame, dataFrame], axis=1)
Data.to_hdf(
- fn + "singleanimal.h5", "df_with_missing",
+ fn.with_name(fn.name + "singleanimal.h5"),
+ key="df_with_missing",
)
- Data.to_csv(fn + "singleanimal.csv")
+ Data.to_csv(fn.with_name(fn.name + "singleanimal.csv"))
DataFrame.to_hdf(
- fn + ".h5", "df_with_missing",
+ fn.with_suffix(".h5"),
+ key="df_with_missing",
)
- DataFrame.to_csv(fn + ".csv")
+ DataFrame.to_csv(fn.with_suffix(".csv"))
def form_default_inferencecfg(cfg):
# load defaults
- inferencecfg = auxiliaryfunctions.read_plainconfig(
- os.path.join(auxiliaryfunctions.get_deeplabcut_path(), "inference_cfg.yaml")
- )
+ inferencecfg = auxiliaryfunctions.read_plainconfig(auxiliaryfunctions.get_deeplabcut_path() / "inference_cfg.yaml")
# set project specific parameters:
- inferencecfg["minimalnumberofconnections"] = (
- len(cfg["multianimalbodyparts"]) / 2
- ) # reasonable default
- inferencecfg["topktoretain"] = len(cfg["individuals"]) + 1 * (
- len(cfg["uniquebodyparts"]) > 0
- ) # reasonable default
+ inferencecfg["minimalnumberofconnections"] = len(cfg["multianimalbodyparts"]) / 2 # reasonable default
+ inferencecfg["topktoretain"] = len(cfg["individuals"])
return inferencecfg
@@ -550,16 +528,14 @@ 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):
"""Load inferencecfg or initialize it."""
try:
- inferencecfg = auxiliaryfunctions.read_plainconfig(str(path_inference_config))
+ inferencecfg = auxiliaryfunctions.read_plainconfig(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 b3e257c2c6..f16be2a400 100644
--- a/deeplabcut/utils/auxfun_videos.py
+++ b/deeplabcut/utils/auxfun_videos.py
@@ -1,4 +1,14 @@
#!/usr/bin/env python3
+#
+# 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.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -9,30 +19,34 @@
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
-# 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'
+from deeplabcut.core.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:
def __init__(self, video_path):
- if not os.path.isfile(video_path):
+ if not Path(video_path).is_file():
raise ValueError(f'Video path "{video_path}" does not point to a file.')
self.video_path = video_path
- self.video = cv2.VideoCapture(video_path)
+ self.video = cv2.VideoCapture(str(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
@@ -45,11 +59,11 @@ def __len__(self):
return self._n_frames
def check_integrity(self):
- dest = os.path.join(self.directory, f"{self.name}.log")
- command = f"ffmpeg -v error -i {self.video_path} -f null - 2>{dest}"
+ dest = str(Path(self.directory) / f"{self.name}.log")
+ 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.')
+ if Path(dest).stat().st_size != 0:
+ 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)
@@ -57,28 +71,24 @@ 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
def name(self):
- return os.path.splitext(os.path.split(self.video_path)[1])[0]
+ return Path(self.video_path).stem
@property
def format(self):
- return os.path.splitext(self.video_path)[1]
+ return Path(self.video_path).suffix
@property
def directory(self):
- return os.path.dirname(self.video_path)
+ return str(Path(self.video_path).parent)
@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:
@@ -89,21 +99,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
@@ -112,10 +115,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)
@@ -150,6 +150,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
@@ -177,9 +191,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)
@@ -190,62 +202,32 @@ 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.
-
- Parameter
- ----------
- start: str
- Time formatted in hours:minutes:seconds, where shortened video shall start.
-
- end: str
- Time formatted in hours:minutes:seconds, where shortened video shall end.
-
- suffix: str, optional
- String added to the name of the shortened video ('short' by default).
-
- dest_folder: str, optional
- Folder the video is saved into (by default, same as the original video)
-
- Returns
- -------
- str
- Full path to the shortened video
+ def shorten(self, start, end, suffix="short", dest_folder=None, validate_inputs=True):
+ """Shorten the video from start to end.
+
+ Args:
+ start (str): Time formatted in hours:minutes:seconds, where shortened video
+ shall start.
+ end (str): Time formatted in hours:minutes:seconds, where shortened video
+ shall end.
+ suffix (str, optional): String added to the name of the shortened video.
+ Defaults to 'short'.
+ dest_folder (str, optional): Folder the video is saved into. By default,
+ same as the original video.
+
+ Returns:
+ str: Full path to the shortened video.
"""
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
@@ -257,40 +239,33 @@ 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.
-
- Parameters
- ----------
- n_splits : int
- Number of shorter videos to produce
+ """Split a video into several shorter ones of equal duration.
- suffix: str, optional
- String added to the name of the splits ('short' by default).
+ Args:
+ n_splits (int): Number of shorter videos to produce.
+ suffix (str, optional): String added to the name of the splits.
+ Defaults to 'split'.
+ dest_folder (str, optional): Folder the video splits are saved into. By
+ default, same as the original video.
- dest_folder: str, optional
- Folder the video splits are saved into (by default, same as the original video)
-
- Returns
- -------
- list
- Paths of the video splits
+ Returns:
+ list: Paths of the video splits.
"""
if not n_splits > 1:
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),
@@ -313,6 +288,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,
@@ -323,17 +313,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)
@@ -346,7 +333,7 @@ def write_frame(frame, where):
def make_output_path(self, suffix, dest_folder):
if not dest_folder:
dest_folder = self.directory
- return os.path.join(dest_folder, f"{self.name}{suffix}{self.format}")
+ return str(Path(dest_folder) / f"{self.name}{suffix}{self.format}")
def check_video_integrity(video_path):
@@ -354,9 +341,12 @@ def check_video_integrity(video_path):
vid.check_integrity()
vid.check_integrity_robust()
+
def imread(image_path, mode="skimage"):
- ''' Read image either with skimage or cv2.
- Returns frame in uint with 3 color channels. '''
+ """Read image either with skimage or cv2.
+
+ 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:
@@ -366,8 +356,8 @@ 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
+ elif mode == "cv2":
+ return cv2.imread(str(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
@@ -380,44 +370,42 @@ 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).
-
- Returns the full path to the shortened video!
-
- Parameter
- ----------
- videos : string
- A string containing the full paths of the video.
+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).
- start: hours:minutes:seconds
- Time formatted in hours:minutes:seconds, where shortened video shall start.
+ Args:
+ vname (string): A string containing the full path of the video.
+ start (str): Time formatted in hours:minutes:seconds, where shortened video shall
+ start.
+ stop (str): Time formatted in hours:minutes:seconds, where shortened video shall
+ end.
+ outsuffix (str): Suffix for output videoname (see example).
+ outpath (str): Output path for saving video to (by default, same folder as the
+ video).
- stop: hours:minutes:seconds
- Time formatted in hours:minutes:seconds, where shortened video shall end.
+ Returns:
+ str: The full path to the shortened video.
- outsuffix: str
- Suffix for output videoname (see example).
+ Examples:
+ Linux/MacOs:
- outpath: str
- Output path for saving video to (by default will be the same folder as the video)
+ deeplabcut.ShortenVideo("/data/videos/mouse1.avi")
- Examples
- ----------
+ Extracts (sub)video from 1st second to 1st minutes (default values) and saves
+ it in /data/videos as mouse1short.avi
- Linux/MacOs
- >>> deeplabcut.ShortenVideo('/data/videos/mouse1.avi')
+ Windows:
- 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 saves it in
+ C:\\yourusername\\rig-95\\Videos as reachingvideo1brief.avi
"""
writer = VideoWriter(vname)
return writer.shorten(start, stop, outsuffix, outpath)
@@ -433,53 +421,48 @@ 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.
-
- Returns the full path to the downsampled video!
+ """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.
ffmpeg -i in.mp4 -filter:v "crop=out_w:out_h:x:y" out.mp4
- Parameter
- ----------
- vname : string
- A string containing the full path of the video.
-
- width: int
- width of output video
-
- height: int
- height of output video.
-
- origin_x, origin_y: int
- x- and y- axis origin of bounding box for cropping.
+ Args:
+ vname (string): A string containing the full path of the video.
+ width (int): Width of output video.
+ height (int): Height of output video.
+ origin_x (int): X-axis origin of bounding box for cropping.
+ origin_y (int): Y-axis origin of bounding box for cropping.
+ outsuffix (str): Suffix for output videoname (see example).
+ outpath (str): Output path for saving video to (by default, same folder as the
+ video).
- outsuffix: str
- Suffix for output videoname (see example).
+ Returns:
+ str: The full path to the cropped video.
- outpath: str
- Output path for saving video to (by default will be the same folder as the video)
+ Examples:
+ Linux/MacOs:
- Examples
- ----------
+ deeplabcut.CropVideo("/data/videos/mouse1.avi")
- Linux/MacOs
- >>> deeplabcut.CropVideo('/data/videos/mouse1.avi')
+ Crops the video using default values and saves it in /data/videos as
+ mouse1cropped.avi
- Crops the video using default values and saves it in /data/videos as mouse1cropped.avi
+ Windows:
- 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:
@@ -501,56 +484,92 @@ 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.
-
- Returns the full path to the downsampled video!
-
- Parameter
- ----------
- vname : string
- A string containing the full path of the video.
+ """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.
+
+ Args:
+ vname (string): A string containing the full path of the video.
+ width (int): Width of output video.
+ height (int): Height of output video.
+ outsuffix (str): Suffix for output videoname (see example).
+ outpath (str): Output path for saving video to (by default, same folder as the
+ video).
+ rotatecw (str): Default "No", rotates clockwise if "Yes", "Arbitrary" for
+ arbitrary rotation by specified angle.
+ angle (float): Angle to rotate by in degrees, default 0.0. Negative values
+ rotate counter-clockwise.
+
+ Returns:
+ str: The full path to the downsampled video.
+
+ Examples:
+ Linux/MacOs:
+
+ deeplabcut.DownSampleVideo("/data/videos/mouse1.avi")
+
+ Downsamples the video using default values and saves it in /data/videos as
+ mouse1downsampled.avi
+
+ Windows:
+
+ shortenedvideoname = deeplabcut.DownSampleVideo(
+ "C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi",
+ width=220,
+ height=320,
+ outsuffix="cropped",
+ )
- width: int
- width of output video
+ 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)
- height: int
- height of output video.
- outsuffix: str
- Suffix for output videoname (see example).
+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.
- outpath: str
- Output path for saving video to (by default will be the same folder as the video)
+ Args:
+ 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, same folder as the
+ video).
- rotatecw: str
- Default "No", rotates clockwise if "Yes", "Arbitrary" for arbitrary rotation by specified angle.
+ Returns:
+ str: The full path to the rotated video.
- angle: float
- Angle to rotate by in degrees, default 0.0. Negative values rotate counter-clockwise
+ Examples:
+ Linux/MacOs:
- Examples
- ----------
+ deeplabcut.rotate_video("/data/videos/mouse1.avi", angle=90)
- Linux/MacOs
- >>> deeplabcut.DownSampleVideo('/data/videos/mouse1.avi')
+ Rotates the video by 90 degrees and saves it in /data/videos as mouse1rotated.avi
- Downsamples the video using default values and saves it in /data/videos as mouse1cropped.avi
+ Windows:
- Windows:
- >>> shortenedvideoname=deeplabcut.DownSampleVideo('C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi', width=220,height=320,outsuffix='cropped')
+ shortenedvideoname = deeplabcut.rotate_video(
+ "C:\\yourusername\\rig-95\\Videos\\reachingvideo1.avi",
+ angle=180,
+ rotatecw="Yes",
+ )
- Downsamples the video to a width of 220 and height of 320 and saves it in C:\\yourusername\\rig-95\\Videos as reachingvideo1cropped.avi
+ Rotates the video by 180 degrees and saves it in
+ C:\\yourusername\\rig-95\\Videos as reachingvideo1rotated.avi
"""
writer = VideoWriter(vname)
- return writer.rescale(width, height, rotatecw, angle, outsuffix, outpath)
+ 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
@@ -569,12 +588,15 @@ 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()
ax = fig.add_subplot(111)
- ax.imshow(frame[:, :, ::-1])
+ ax.imshow(frame)
ax_help = fig.add_axes([0.9, 0.2, 0.1, 0.1])
ax_save = fig.add_axes([0.9, 0.1, 0.1, 0.1])
crop_button = Button(ax_save, "Crop")
@@ -582,17 +604,15 @@ def display_help(*args):
help_button = Button(ax_help, "Help")
help_button.on_clicked(display_help)
- rs = RectangleSelector(
+ RectangleSelector(
ax,
line_select_callback,
- drawtype="box",
minspanx=5,
minspany=5,
interactive=True,
spancoords="pixels",
- rectprops=dict(facecolor="red", edgecolor="black", alpha=0.3, fill=True),
)
- plt.show()
+ plt.show(block=False)
# import platform
# if platform.system() == "Darwin": # for OSX use WXAgg
@@ -603,3 +623,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.absolute() 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 b624bde9f7..a44a57905a 100644
--- a/deeplabcut/utils/auxiliaryfunctions.py
+++ b/deeplabcut/utils/auxiliaryfunctions.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,395 +18,182 @@
Licensed under GNU Lesser General Public License v3.0
"""
+from __future__ import annotations
+
+import logging
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
-
-
-def create_config_template(multianimal=False):
- """
- 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:
- """
- 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:
- """
-
- ruamelFile = YAML()
- cfg_file = ruamelFile.load(yaml_str)
- return cfg_file, ruamelFile
-
-
-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.
- """
- yaml_str = """\
-# Project definitions (do not edit)
- Task:
- scorer:
- date:
- \n
-# Project path (change when moving around)
- 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
-# 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
- """
- ruamelFile_3d = YAML()
- cfg_file_3d = ruamelFile_3d.load(yaml_str)
- return cfg_file_3d, ruamelFile_3d
+import pandas as pd
-def read_config(configname):
- """
- Reads structured config file defining a project.
- """
- ruamelFile = YAML()
- path = Path(configname)
- if os.path.exists(path):
- try:
- with open(path, "r") as f:
- cfg = ruamelFile.load(f)
- curr_dir = os.path.dirname(configname)
- 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:
- cfg = yaml.load(ymlfile, Loader=yaml.SafeLoader)
- write_config(configname, cfg)
- else:
- raise
+from deeplabcut.core import config as core_config
+from deeplabcut.core.deprecation import deprecated
+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
- 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!"
- )
- return cfg
+# NOTE @deruyter92 2026-01-29: Configuration I/O is now centralized in deeplabcut.core.config
+# These functions are exported here for backwards compatibility with existing code.
+create_config_template = core_config.create_config_template
+create_config_template_3d = core_config.create_config_template_3d
+read_config = core_config.read_config
+write_config = core_config.write_project_config
+logger = logging.getLogger(__name__)
-def write_config(configname, cfg):
- """
- Write structured config file.
- """
- with open(configname, "w") as cf:
- 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():
- cfg_file["skeleton"] = []
- cfg_file["skeleton_color"] = "black"
- ruamelFile.dump(cfg_file, cf)
+def safe_resolve(path: Path) -> Path:
+ """Return a resolved Path that is safe to use with str-based I/O.
+ Prefers Path.resolve() so that symlinks are followed (useful on Linux).
+ Falls back to Path.absolute() when resolve() fails or the resolved path
+ cannot be stat'd via its plain string form â e.g. on Windows 11 + SMB
+ network drives where resolve() may return an unusable
+ \\\\?\\Volume{GUID}\\... form.
-def edit_config(configname, edits, output_name=""):
- """
- Convenience function to edit and save a config file from a dictionary.
-
- Parameters
- ----------
- configname : string
- String containing the full path of the config file in the project.
- edits : dict
- Keyâvalue pairs to edit in config
- output_name : string, optional (default='')
- Overwrite the original config.yaml by default.
- If passed in though, new filename of the edited config.
-
- Examples
- --------
- config_path = 'my_stellar_lab/dlc/config.yaml'
-
- edits = {'numframes2pick': 5,
- 'trainingFraction': [0.5, 0.8],
- 'skeleton': [['a', 'b'], ['b', 'c']]}
-
- deeplabcut.auxiliaryfunctions.edit_config(config_path, edits)
+ See https://github.com/DeepLabCut/DeepLabCut/issues/3348
"""
- cfg = read_plainconfig(configname)
- for key, value in edits.items():
- cfg[key] = value
- if not output_name:
- output_name = configname
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."
+ resolved = path.resolve()
+ os.stat(os.fspath(resolved))
+ return resolved
+ except OSError as exc:
+ fallback = path.absolute()
+ logger.debug(
+ "safe_resolve: using absolute() fallback for %s (%s)",
+ path,
+ exc,
)
- for key in edits:
- cfg.pop(key)
- write_plainconfig(output_name, cfg)
- return cfg
+ return fallback
-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():
- cfg_file[key] = cfg[key]
- ruamelFile.dump(cfg_file, cf)
+def read_plainconfig(configname: str | Path) -> dict:
+ """Load a YAML config (alias for read_config_as_dict). See deeplabcut.core.config."""
+ return core_config.read_config_as_dict(config_path=configname)
-def write_config_3d_template(projconfigfile, cfg_file_3d, ruamelFile_3d):
- with open(projconfigfile, "w") as cf:
- ruamelFile_3d.dump(cfg_file_3d, cf)
+def write_plainconfig(configname: str | Path, cfg: dict, overwrite: bool = True) -> None:
+ """Write a config dict to YAML (alias for write_config). See deeplabcut.core.config."""
+ core_config.write_config(config_path=configname, config=cfg, overwrite=overwrite)
-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."
- )
- with open(configname) as file:
- return YAML().load(file)
-
+edit_config = core_config.edit_config
+write_config_3d = core_config.write_config_3d
+write_config_3d_template = core_config.write_config_3d_template
-def write_plainconfig(configname, cfg):
- with open(configname, "w") as file:
- YAML().dump(cfg, file)
+def get_bodyparts(cfg: dict) -> list[str]:
+ """Get the bodyparts.
-def attempttomakefolder(foldername, recursive=False):
- """ 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)
-
- if os.path.isdir(foldername):
- pass
- else:
- if recursive:
- os.makedirs(foldername)
- else:
- os.mkdir(foldername)
+ 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
-def read_pickle(filename):
- """ Read the pickle file """
- with open(filename, "rb") as handle:
- return pickle.load(handle)
+ return cfg["bodyparts"]
-def write_pickle(filename, data):
- """ Write the pickle file """
- with open(filename, "wb") as handle:
- pickle.dump(data, handle, protocol=pickle.HIGHEST_PROTOCOL)
+def get_unique_bodyparts(cfg: dict) -> list[str]:
+ """Get the unique bodyparts.
+ Args:
+ cfg: a project configuration file
-def get_list_of_videos(
- videos: typing.Union[typing.List[str], str],
- videotype: typing.Union[typing.List[str], str] = ""
-) -> typing.List[str]:
- """ Returns list of videos of videotype "videotype" in
- folder videos or for list of videos.
+ Returns: all unique bodyparts listed in the project
+ """
+ if cfg.get("multianimalproject", False):
+ (
+ _,
+ unique_bodyparts,
+ _,
+ ) = auxfun_multianimal.extractindividualsandbodyparts(cfg)
+ return unique_bodyparts
- NOTE: excludes keyword videos of the form:
+ return []
- *_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
+def attempt_to_make_folder(foldername, recursive=False):
+ """Attempts to create a folder with specified name.
- 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.
+ Does nothing if it already exists.
"""
- if isinstance(videos, str):
- videos = [videos]
+ foldername = Path(foldername)
- 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
+ if foldername.is_dir():
+ return
- from random import shuffle
+ if recursive:
+ foldername.mkdir(parents=True, exist_ok=True)
+ else:
+ foldername.mkdir(exist_ok=True, parents=True)
- 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)]
+def read_pickle(filename):
+ """Read the pickle file."""
+ with Path(filename).open("rb") as handle:
+ return pickle.load(handle)
- shuffle(videos) # this is useful so multiple nets can be used to analyze simultaneously
- if isinstance(videotype, str):
- videotype = [videotype]
+def write_pickle(filename, data):
+ """Write the pickle file."""
+ with Path(filename).open("wb") as handle:
+ pickle.dump(data, handle, protocol=pickle.HIGHEST_PROTOCOL)
- # 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
+@deprecated(replacement="deeplabcut.collect_video_paths", since="3.0.0")
+def get_list_of_videos(
+ videos: list[str] | str,
+ videotype: str | Sequence[str] | None = SUPPORTED_VIDEOS,
+ in_random_order: bool = True,
+) -> 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 SaveData(PredicteData, metadata, dataname, pdindex, imagenames, save_as_csv):
- """ Save predicted data as h5 file and metadata as pickle file; created by predict_videos.py """
+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.
+ """
+ dataname = Path(dataname)
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")
- with open(dataname.split(".h5")[0] + "_meta.pickle", "wb") as f:
+ DataMachine.to_csv(dataname.with_suffix(".csv"))
+ DataMachine.to_hdf(dataname, key="df_with_missing", format="table", mode="w")
+ with dataname.with_name(dataname.stem + "_meta.pickle").open("wb") as f:
# Pickle the 'data' dictionary using the highest protocol available.
pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
-def SaveMetadata(metadatafilename, data, trainIndices, testIndices, trainFraction):
- with open(metadatafilename, "wb") as f:
+def save_metadata(
+ metadatafilename: str | Path,
+ data,
+ trainIndices,
+ testIndices,
+ trainFraction,
+):
+ with Path(metadatafilename).open("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 LoadMetadata(metadatafile):
- with open(metadatafile, "rb") as f:
+def load_metadata(metadatafile: str | Path):
+ with Path(metadatafile).open("rb") as f:
[
trainingdata_details,
trainIndices,
@@ -406,22 +203,59 @@ def LoadMetadata(metadatafile):
return trainingdata_details, trainIndices, testIndices, testFraction_data
-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))
- ]
-
-
+@deprecated(replacement="deeplabcut.collect_video_paths", since="3.0.1")
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):
- if file.endswith(ext):
- yield file if relative else os.path.join(folder, file)
+ for file in Path(folder).iterdir():
+ if file.name.endswith(ext):
+ yield file.name if relative else str(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
-def GetVideoList(filename, videopath, videtype):
- """ Get list of videos in a path (if filetype == all), otherwise just a specific file."""
+@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.
+ """
videos = list(grab_files_in_folder(videopath, videtype))
if filename == "all":
return videos
@@ -435,88 +269,141 @@ def GetVideoList(filename, videopath, videtype):
## Various functions to get filenames, foldernames etc. based on configuration parameters.
-def GetTrainingSetFolder(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("training-datasets") / iterate / ("UnaugmentedDataSet_" + Task + date)
+
+
+def get_data_and_metadata_filenames(
+ trainingsetfolder: str | Path,
+ trainFraction: float,
+ shuffle: int,
+ cfg: dict,
+) -> tuple[Path, Path]:
+ """Paths to data and metadata files relative to the project root."""
+ base = Path(trainingsetfolder)
+ datafn = base / (
+ cfg["Task"] + "_" + cfg["scorer"] + str(int(100 * trainFraction)) + "shuffle" + str(shuffle) + ".mat"
)
-
-
-def GetDataandMetaDataFilenames(trainingsetfolder, trainFraction, shuffle, cfg):
- # Filename for metadata and data relative to project path for corresponding parameters
- metadatafn = os.path.join(
- str(trainingsetfolder),
- "Documentation_data-"
- + cfg["Task"]
- + "_"
- + str(int(trainFraction * 100))
- + "shuffle"
- + str(shuffle)
- + ".pickle",
- )
- datafn = os.path.join(
- str(trainingsetfolder),
- cfg["Task"]
- + "_"
- + cfg["scorer"]
- + str(int(100 * trainFraction))
- + "shuffle"
- + str(shuffle)
- + ".mat",
+ metadatafn = base / (
+ "Documentation_data-" + cfg["Task"] + "_" + str(int(trainFraction * 100)) + "shuffle" + str(shuffle) + ".pickle"
)
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:
+ """Get the model folder.
+
+ 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:
+ """Get the evaluation folder.
+
+ 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_deeplabcut_path():
- """ Get path of where deeplabcut is currently running """
+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() -> Path:
+ """Get path of where deeplabcut is currently running."""
import importlib.util
- return os.path.split(importlib.util.find_spec("deeplabcut").origin)[0]
+ return Path(importlib.util.find_spec("deeplabcut").origin).parent
-def IntersectionofBodyPartsandOnesGivenbyUser(cfg, comparisonbodyparts):
- """ Returns all body parts when comparisonbodyparts=='all', otherwise all bpts that are in the intersection of comparisonbodyparts and the actual bodyparts """
+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.
+ """
# if "MULTI!" in allbpts:
if cfg["multianimalproject"]:
allbpts = cfg["multianimalbodyparts"] + cfg["uniquebodyparts"]
@@ -526,17 +413,14 @@ def IntersectionofBodyPartsandOnesGivenbyUser(cfg, comparisonbodyparts):
if comparisonbodyparts == "all":
return list(allbpts)
else: # take only items in list that are actually bodyparts...
- cpbpts = []
+ cpbpts = [bp for bp in allbpts if bp in comparisonbodyparts]
# Ensure same order as in config.yaml
- for bp in allbpts:
- if bp in comparisonbodyparts:
- cpbpts.append(bp)
return cpbpts
-def get_labeled_data_folder(cfg, video):
- videoname = os.path.splitext(os.path.basename(video))[0]
- return os.path.join(cfg["project_path"], "labeled-data", videoname)
+def get_labeled_data_folder(cfg: dict, video: str | Path) -> Path:
+ videoname = Path(video).stem
+ return Path(cfg["project_path"]) / "labeled-data" / videoname
def form_data_containers(df, bodyparts):
@@ -548,45 +432,70 @@ def form_data_containers(df, bodyparts):
return df_x, df_y, df_likelihood
-def GetScorerName(
- cfg, shuffle, trainFraction, trainingsiterations="unknown", modelprefix=""
+def get_scorer_name(
+ 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.
- Returns tuple of DLCscorer, DLCscorerlegacy (old naming convention)
+ """Extract the scorer/network name for a particular shuffle, training fraction, etc.
+
+ If the engine is not specified, determines which to use from the project
+ configuration.
+
+ Args:
+ **kwargs: Additional arguments. For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ tuple: DLCscorer and 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 = Path(snapshot_name).parts[-1].split("-")[-1]
dlc_cfg = read_plainconfig(
- os.path.join(
- cfg["project_path"],
- str(get_model_folder(trainFraction, shuffle, cfg, modelprefix=modelprefix)),
- "train",
- "pose_cfg.yaml",
+ str(
+ Path(cfg["project_path"])
+ / get_model_folder(trainFraction, shuffle, cfg, engine=engine, modelprefix=modelprefix)
+ / "train"
+ / engine.pose_cfg_name
)
)
# ABBREVIATE NETWORK NAMES -- esp. for mobilenet!
@@ -599,30 +508,28 @@ def GetScorerName(
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 CheckifPostProcessing(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?
+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!)
+ """
+ folder = Path(folder)
+ outdataname = str(folder / (vname + DLCscorer + suffix + ".h5"))
+ sourcedataname = str(folder / (vname + DLCscorer + ".h5"))
+ if Path(outdataname).is_file(): # was data already processed?
if suffix == "filtered":
print("Video already filtered...", outdataname)
elif suffix == "_skeleton":
@@ -630,42 +537,42 @@ def CheckifPostProcessing(folder, vname, DLCscorer, DLCscorerlegacy, suffix="fil
return False, outdataname, sourcedataname, DLCscorer
else:
- odn = os.path.join(folder, vname + DLCscorerlegacy + suffix + ".h5")
- if os.path.isfile(odn): # was it processed by DLC <2.1 project?
+ odn = str(folder / (vname + DLCscorerlegacy + suffix + ".h5"))
+ if Path(odn).is_file(): # was it processed by DLC <2.1 project?
if suffix == "filtered":
print("Video already filtered...(with DLC<2.1)!", odn)
elif suffix == "_skeleton":
print("Skeleton in video already processed... (with DLC<2.1)!", odn)
return False, odn, odn, DLCscorerlegacy
else:
- sdn = os.path.join(folder, vname + DLCscorerlegacy + ".h5")
+ sdn = str(folder / (vname + DLCscorerlegacy + ".h5"))
tracks = sourcedataname.replace(".h5", "tracks.h5")
- if os.path.isfile(sourcedataname): # Was the video already analyzed?
+ if Path(sourcedataname).is_file(): # Was the video already analyzed?
return True, outdataname, sourcedataname, DLCscorer
- elif os.path.isfile(sdn): # was it analyzed with DLC<2.1?
+ elif Path(sdn).is_file(): # was it analyzed with DLC<2.1?
return True, odn, sdn, DLCscorerlegacy
- elif os.path.isfile(tracks): # May be a MA project with tracklets
+ elif Path(tracks).is_file(): # May be a MA project with tracklets
return True, tracks.replace(".h5", f"{suffix}.h5"), tracks, DLCscorer
else:
print("Video not analyzed -- Run analyze_videos first.")
return False, outdataname, sourcedataname, DLCscorer
-def CheckifNotAnalyzed(destfolder, vname, DLCscorer, DLCscorerlegacy, flag="video"):
- h5files = list(grab_files_in_folder(destfolder, "h5", relative=False))
+def check_if_not_analyzed(destfolder, vname, DLCscorer, DLCscorerlegacy, flag="video"):
+ h5files = collect_video_paths(destfolder, extensions=".h5")
if not len(h5files):
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
+ dataname = Path(destfolder) / (vname + DLCscorer + ".h5")
return True, dataname, DLCscorer
# Iterate over data files and stop as soon as one matching the scorer is found
for h5file in h5files:
- if vname + DLCscorer in Path(h5file).stem:
+ if vname + DLCscorer in h5file.stem:
if flag == "video":
print("Video already analyzed!", h5file)
elif flag == "framestack":
print("Frames already analyzed!", h5file)
return False, h5file, DLCscorer
- elif vname + DLCscorerlegacy in Path(h5file).stem:
+ elif vname + DLCscorerlegacy in h5file.stem:
if flag == "video":
print("Video already analyzed!", h5file)
elif flag == "framestack":
@@ -673,61 +580,69 @@ def CheckifNotAnalyzed(destfolder, vname, DLCscorer, DLCscorerlegacy, flag="vide
return False, h5file, DLCscorerlegacy
# If there was no match...
- dataname = os.path.join(destfolder, vname + DLCscorer + ".h5")
+ dataname = Path(destfolder) / (vname + DLCscorer + ".h5")
return True, dataname, DLCscorer
-def CheckifNotEvaluated(folder, DLCscorer, DLCscorerlegacy, snapshot):
- dataname = os.path.join(folder, DLCscorer + "-" + str(snapshot) + ".h5")
- if os.path.isfile(dataname):
+def check_if_not_evaluated(folder, DLCscorer, DLCscorerlegacy, snapshot):
+ dataname = str(Path(folder) / (DLCscorer + "-" + str(snapshot) + ".h5"))
+ if Path(dataname).is_file():
print("This net has already been evaluated!")
return False, dataname, DLCscorer
else:
- dn = os.path.join(folder, DLCscorerlegacy + "-" + str(snapshot) + ".h5")
- if os.path.isfile(dn):
+ dn = str(Path(folder) / (DLCscorerlegacy + "-" + str(snapshot) + ".h5"))
+ if Path(dn).is_file():
print("This net has already been evaluated (with DLC<2.1)!")
return False, dn, DLCscorerlegacy
else:
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, "")
candidates = []
- for file in grab_files_in_folder(folder, "h5"):
- stem = Path(file).stem
- starts_by_scorer = (
- file.startswith(videoname + scorer)
- or file.startswith(videoname + scorer_legacy)
- )
+ for file in collect_video_paths(folder, extensions=".h5"):
+ stem = file.stem.replace("_filtered", "")
+ starts_by_scorer = file.name.startswith((videoname + scorer, videoname + scorer_legacy))
if tracker:
matches_tracker = stem.endswith(tracker)
else:
@@ -735,17 +650,16 @@ def find_analyzed_data(folder, videoname, scorer, filtered=False, track_method="
if all(
(
starts_by_scorer,
- "skeleton" not in file,
+ "skeleton" not in file.name,
matches_tracker,
- (filtered and "filtered" in file)
- or (not filtered and "filtered" not in file),
+ (filtered and "filtered" in file.name) or (not filtered and "filtered" not in file.name),
)
):
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:
@@ -755,26 +669,22 @@ 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(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
def load_detection_data(video, scorer, track_method):
- folder = os.path.dirname(video)
- videoname = os.path.splitext(os.path.basename(video))[0]
+ video = Path(video)
+ folder = video.parent
+ videoname = video.stem
if track_method == "skeleton":
tracker = "sk"
elif track_method == "box":
@@ -784,18 +694,17 @@ def load_detection_data(video, scorer, track_method):
else:
raise ValueError(f"Unrecognized track_method={track_method}")
- filepath = os.path.splitext(video)[0] + scorer + f"_{tracker}.pickle"
- if not os.path.isfile(filepath):
+ filepath = str(video.with_suffix("")) + scorer + f"_{tracker}.pickle"
+ if not Path(filepath).is_file():
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)
def find_next_unlabeled_folder(config_path, verbose=False):
cfg = read_config(config_path)
- base_folder = Path(os.path.join(cfg["project_path"], "labeled-data"))
+ base_folder = Path(cfg["project_path"]) / "labeled-data"
h5files = sorted(
base_folder.rglob("*.h5"),
key=lambda p: p.lstat().st_mtime,
@@ -816,3 +725,31 @@ def find_next_unlabeled_folder(config_path, verbose=False):
frac = (~df.isna()).sum().sum() / df.size
print(f"{folder.name} | {int(100 * frac)} %")
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
+LoadMetadata = load_metadata
+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
+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 6b9c1a1657..15656ec826 100644
--- a/deeplabcut/utils/auxiliaryfunctions_3d.py
+++ b/deeplabcut/utils/auxiliaryfunctions_3d.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,7 +18,6 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import glob
import os
import pickle
from pathlib import Path
@@ -20,15 +29,13 @@
def Foldernames3Dproject(cfg_3d):
- """ 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"
- )
+ """Definitions of subfolders in 3D projects."""
+
+ img_path = str(Path(cfg_3d["project_path"]) / "calibration_images")
+ path_corners = str(Path(cfg_3d["project_path"]) / "corners")
+ path_camera_matrix = str(Path(cfg_3d["project_path"]) / "camera_matrix")
+ path_undistort = str(Path(cfg_3d["project_path"]) / "undistortion")
+ path_removed_images = str(Path(cfg_3d["project_path"]) / "removed_calibration_images")
return (
img_path,
@@ -58,7 +65,7 @@ def create_empty_df(dataframe, scorer, flag):
[[scorer], [bodypart], ["x", "y", "z"]],
names=["scorer", "bodyparts", "coords"],
)
- frame = pd.DataFrame(a, columns=pdindex, index=range(0, df.shape[0]))
+ frame = pd.DataFrame(a, columns=pdindex, index=range(df.shape[0]))
dataFrame = pd.concat([frame, dataFrame], axis=1)
return (dataFrame, scorer, bodyparts)
@@ -66,9 +73,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"]
@@ -83,7 +88,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")
@@ -96,7 +101,7 @@ def compute_triangulation_calibration_images(
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("Z")
- plt.savefig(os.path.join(str(path_undistort), "checkerboard_3d.png"))
+ plt.savefig(Path(path_undistort) / "checkerboard_3d.png")
return triangulate
@@ -105,39 +110,33 @@ 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
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 = [y for x in videos for y in x]
+ videos = [list(Path(path).glob("*" + cam_names[i] + "*" + videotype)) for i in range(len(cam_names))]
+ videos = [str(y) for x in videos for y in x]
# Exclude the labeled video files
if "." in 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 Path(v).is_file() 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)))
- ]
- ) # all videos with cam
+ vid.append([str(name) for name in Path(path).glob("*" + cam + "*" + videotype)]) # all videos with cam
# print("here is what I found",vid)
for k in range(len(vid[0])):
if cam in str(Path(vid[0][k]).stem):
@@ -148,64 +147,47 @@ def get_camerawise_videos(path, cam_names, videotype):
if suf == "":
print("Strange naming convention on your part. Respect.")
else:
- putativecam2name = os.path.join(path, cam_names[1] + suf + ending)
+ putativecam2name = str(Path(path) / (cam_names[1] + suf + ending))
else:
if suf == "":
- putativecam2name = os.path.join(path, pref + cam_names[1] + ending)
+ putativecam2name = str(Path(path) / (pref + cam_names[1] + ending))
else:
- 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):
+ putativecam2name = str(Path(path) / (pref + cam_names[1] + suf + ending))
+ # print([str(Path(path) / (pref+cam+suf+ending)), putativecam2name])
+ if Path(putativecam2name).is_file():
# found a pair!!!
- video_list.append(
- [os.path.join(path, pref + cam + suf + ending), putativecam2name]
- )
+ video_list.append([str(Path(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 = []
file_list = []
string_to_search = scorer_3d + ".h5"
# Checks if filepath is a directory
- if [os.path.isdir(i) for i in filepath] == [True]:
- """
- Analyzes all the videos in the directory.
- """
+ if [Path(i).is_dir() for i in filepath] == [True]:
+ """Analyzes all the videos in the directory."""
print("Analyzing all the videos in the directory")
videofolder = filepath[0]
- cwd = os.getcwd()
+ cwd = Path.cwd()
os.chdir(videofolder)
- triangulated_file_list = [
- fn for fn in os.listdir(os.curdir) if (string_to_search in fn)
- ]
+ triangulated_file_list = [fn.name for fn in Path(videofolder).iterdir() if (string_to_search in fn.name)]
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 == None:
+ if videofolder is None:
videofolder = str(Path(filepath[0]).parents[0])
video_list = get_camerawise_videos(videofolder, cam_names, videotype)
- # Get the filename of the triangulated file excluing the scorer name and remove any '-' or _ from it
+ # Get the filename of the triangulated file excluding the scorer name and remove any '-' or _ from it
filename = [i.split(string_to_search)[0] for i in triangulated_file_list]
for i in range(len(filename)):
if filename[i][-1] == "_" or filename[i][-1] == "-":
@@ -213,7 +195,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],
@@ -235,48 +218,46 @@ 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 (
suffix[j][0] in filename[k] and suffix[j][1] in filename[k]
):
- triangulated_file = glob.glob(
- os.path.join(
- triangulated_folder,
- str("*" + filename[k] + "*" + string_to_search),
- )
- )
+ triangulated_file = [
+ str(p) for p in Path(triangulated_folder).glob("*" + filename[k] + "*" + string_to_search)
+ ]
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
def SaveMetadata3d(metadatafilename, metadata):
- with open(metadatafilename, "wb") as f:
+ with Path(metadatafilename).open("wb") as f:
pickle.dump(metadata, f, pickle.HIGHEST_PROTOCOL)
def LoadMetadata3d(metadatafilename):
- with open(metadatafilename, "rb") as f:
+ with Path(metadatafilename).open("rb") as f:
metadata = pickle.load(f)
return metadata
+
def _reconstruct_tracks_as_tracklets(df):
"""
Parameters:
-----------
- df: DataFrame
+ df: DataFrame
loaded from an .h5 tracks file (obtained from `stitch_tracklets()`)
"""
from deeplabcut.refine_training_dataset.stitch import Tracklet
tracklets = []
- for _, group in df.groupby("individuals", axis=1):
+ for _, group in df.T.groupby("individuals"):
+ group = group.T
temp = group.dropna(how="all")
inds = temp.index.to_numpy()
track = Tracklet(temp.to_numpy().reshape((len(temp), -1, 3)), inds)
@@ -286,17 +267,17 @@ 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:
-----------
tracklets1/2: Tracklet() object (defined in stitch.py)
- F: nd.array
+ F: numpy.ndarray
Fundamental matrix between cam1 and cam2
"""
from scipy.optimize import linear_sum_assignment
+
# Initialize costs matrix
costs = np.zeros([len(tracklets1), len(tracklets2)])
@@ -310,31 +291,33 @@ 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))
-
- # Get average cost of the entire track
- cost = cost.mean()
+ 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
+
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
"""
-
tracks1 = _reconstruct_tracks_as_tracklets(df1)
tracks2 = _reconstruct_tracks_as_tracklets(df2)
costs, voting = _associate_paired_view_tracks(tracks1, tracks2, F)
- return costs, voting
\ No newline at end of file
+ return costs, voting
diff --git a/deeplabcut/utils/conversioncode.py b/deeplabcut/utils/conversioncode.py
index 14c93eb950..ec1414bebb 100644
--- a/deeplabcut/utils/conversioncode.py
+++ b/deeplabcut/utils/conversioncode.py
@@ -1,46 +1,51 @@
-"""
-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
+#
+# 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 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
+from deeplabcut.utils.auxfun_videos import collect_video_paths
SUPPORTED_FILETYPES = "csv", "nwb"
-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).
+def convertcsv2h5(config: str | Path, userfeedback=True, scorer=None):
+ """Convert annotation files in labeled-data from csv to h5.
+
+ 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.
+ Args:
+ config (str | Path): 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.
+ 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.
- 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.
+ Examples:
+ Convert csv annotation files for reaching-task project into hdf:
- 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.
+ deeplabcut.convertcsv2h5("/analysis/project/reaching-task/config.yaml")
- Examples
- --------
- Convert csv annotation files for reaching-task project into hdf.
- >>> 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')
- --------
+ deeplabcut.convertcsv2h5("/analysis/project/reaching-task/config.yaml", scorer="Albert")
"""
cfg = auxiliaryfunctions.read_config(config)
videos = cfg["video_sets"].keys()
@@ -58,12 +63,10 @@ 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 = 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:
+ with fn.open() as datafile:
head = list(islice(datafile, 0, 5))
if "individuals" in head[1]:
header = list(range(4))
@@ -82,91 +85,198 @@ 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: str | 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.
+
+ Args:
+ config_path (str): The path to the config.yaml file.
+ remove_old_bodyparts (bool): If True, old bodyparts not in the new project are
+ removed from the dataframe. Defaults to False.
+ other_scorer (bool): If True, the labels will be converted to the new scorer. Defaults to False.
+ userfeedback (bool): If true the user will be asked specifically
+ for each folder in labeled-data if the containing csv
+ shall be converted to hdf format. Defaults to True.
"""
- 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
- ----------
-
- video_folder : string
- Absolute path of a folder containing videos and the corresponding h5 data files.
-
- videotype: string, optional (default=.mp4)
- Only videos with this extension are screened.
-
- Examples
- --------
-
- 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')
+ # 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 = Path(project_path) / "labeled-data" / video_name
+ csv_files = [f.name for f in label_path.iterdir() if f.name.endswith(".csv")]
+ if not csv_files:
+ print("No csv file in the folder:", label_path)
+ else:
+ csv_path = str(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:]))
- 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)
+ 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 uses 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 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. 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.
+
+ Args:
+ video_folder (string): Absolute path of a folder containing videos and the corresponding h5 data files.
+ videotype (string, optional): Only videos with this extension are screened. Defaults to .mp4.
+
+ Examples:
+ 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 = collect_video_paths(Path(videos[0]).parent, extensions=".h5")
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 = collect_video_paths(video_folder, extensions=".h5")
+ videos = collect_video_paths(video_folder, extensions=videotype)
_convert_h5_files_to("csv", None, h5_files, videos)
+# TODO: @deruyter92 2026-05-20: this function uses videotype instead of video_extensions.
def analyze_videos_converth5_to_nwb(
- config,
- video_folder,
+ config: str | Path,
+ video_folder: str | Path,
videotype=".mp4",
listofvideos=False,
):
- """
- Convert all h5 output data files in `video_folder` to NWB format.
-
- Parameters
- ----------
- config : string
- Absolute path to the project YAML config file.
-
- video_folder : string
- Absolute path of a folder containing videos and the corresponding h5 data files.
-
- videotype: string, optional (default=.mp4)
- Only videos with this extension are screened.
+ """Convert all h5 output data files in `video_folder` to NWB format.
- Examples
- --------
+ Args:
+ config (string): Absolute path to the project YAML config file.
+ video_folder (string): Absolute path of a folder containing videos and the corresponding h5 data files.
+ videotype (string, optional): Only videos with this extension are screened. Defaults to .mp4.
- 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')
+ Examples:
+ Converts all pose-output files belonging to mp4 videos in the folder
+ '/media/alex/experimentaldata/cheetahvideos' to NWB files:
- """
- 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)
+ deeplabcut.analyze_videos_converth5_to_nwb(
+ config,
+ "/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 = collect_video_paths(Path(videos[0]).parent, extensions=".h5")
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 = collect_video_paths(video_folder, extensions=".h5")
+ videos = collect_video_paths(video_folder, extensions=videotype)
_convert_h5_files_to("nwb", config, h5_files, videos)
@@ -182,24 +292,22 @@ 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:
+ if "_labeled" in video.name:
continue
- vname = Path(video).stem
+ vname = video.stem
for file in h5_files:
- if vname in file:
- scorer = file.split(vname)[1].split(".h5")[0]
+ if vname in file.name:
+ scorer = file.stem.removeprefix(vname)
if "DLC" in scorer or "DeepCut" in scorer:
print("Found output file for scorer:", scorer)
print(f"Converting {file}...")
if filetype == "csv":
df = pd.read_hdf(file)
- df.to_csv(file.replace(".h5", ".csv"))
+ df.to_csv(file.with_suffix(".csv"))
else:
convert_h5_to_nwb(config, file)
@@ -207,19 +315,17 @@ 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")
- annotationfolders = []
- for elem in auxiliaryfunctions.grab_files_in_folder(data_path, relative=False):
- if os.path.isdir(elem):
- annotationfolders.append(elem)
+ annotationfolders = [d for d in data_path.iterdir() if d.is_dir()]
print("The following folders were found:", annotationfolders)
for folder in annotationfolders:
- filename = os.path.join(folder, "CollectedData_" + cfg["scorer"] + ".h5")
+ filename = str(Path(folder) / ("CollectedData_" + cfg["scorer"] + ".h5"))
try:
data = pd.read_hdf(filename)
guarantee_multiindex_rows(data)
@@ -233,15 +339,14 @@ def merge_windowsannotationdataONlinuxsystem(cfg):
def guarantee_multiindex_rows(df):
# Make paths platform-agnostic if they are not already
if not isinstance(df.index, pd.MultiIndex): # Backwards compatibility
- path = df.index[0]
try:
- sep = "/" if "/" in path else "\\"
- splits = tuple(df.index.str.split(sep))
- df.index = pd.MultiIndex.from_tuples(splits)
+ splits = df.index.str.replace("\\", "/").str.split("/")
+ df.index = pd.MultiIndex.from_tuples([tuple(s) for s in splits])
except TypeError: # Ignore numerical index of frame indices
pass
-
-def robust_split_path(s):
- sep = "/" if "/" in s else "\\"
- return tuple(s.split(sep))
+ # Ensure folder names are strings
+ try:
+ df.index = df.index.set_levels(df.index.levels[1].astype(str), level=1)
+ except AttributeError:
+ pass
diff --git a/deeplabcut/utils/frameselectiontools.py b/deeplabcut/utils/frameselectiontools.py
index 937cc6adec..b0eec99ca5 100644
--- a/deeplabcut/utils/frameselectiontools.py
+++ b/deeplabcut/utils/frameselectiontools.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,7 +18,6 @@
Licensed under GNU Lesser General Public License v3.0
"""
-
import math
import cv2
@@ -19,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.
"""
@@ -52,7 +61,7 @@ def UniformFrames(clip, numframes2pick, start, stop, Index=None):
else:
startindex = int(np.floor(clip.fps * clip.duration * start))
stopindex = int(np.ceil(clip.fps * clip.duration * stop))
- Index = np.array(Index, dtype=np.int)
+ Index = np.array(Index, dtype=int)
Index = Index[(Index > startindex) * (Index < stopindex)] # crop to range!
if len(Index) >= numframes2pick:
return list(np.random.permutation(Index)[:numframes2pick])
@@ -62,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.
"""
@@ -79,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)),
@@ -92,7 +99,7 @@ def UniformFramescv2(cap, numframes2pick, start, stop, Index=None):
else:
startindex = int(np.floor(nframes * start))
stopindex = int(np.ceil(nframes * stop))
- Index = np.array(Index, dtype=np.int)
+ Index = np.array(Index, dtype=int)
Index = Index[(Index > startindex) * (Index < stopindex)] # crop to range!
if len(Index) >= numframes2pick:
return list(np.random.permutation(Index)[:numframes2pick])
@@ -112,16 +119,19 @@ def KmeansbasedFrameselection(
max_iter=50,
color=False,
):
- """ This code downsamples the video to a width of resizewidth.
+ """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.
-
- Note: this method can return fewer images than numframes2pick."""
+ 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.
+ """
print(
"Kmeans-quantization based extracting of frames from",
round(start * clip.duration, 2),
@@ -155,25 +165,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,
)
)
@@ -182,9 +185,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
@@ -192,9 +193,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
@@ -208,8 +207,6 @@ def KmeansbasedFrameselectioncv2(
numframes2pick,
start,
stop,
- crop,
- coords,
Index=None,
step=1,
resizewidth=30,
@@ -217,16 +214,20 @@ 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
ratio = resizewidth * 1.0 / nx
@@ -253,7 +254,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
@@ -262,16 +265,8 @@ def KmeansbasedFrameselectioncv2(
if color:
for counter, index in tqdm(enumerate(Index)):
cap.set_to_frame(index) # extract a particular frame
- frame = cap.read_frame()
+ frame = cap.read_frame(crop=True)
if frame is not None:
- if crop:
- frame = frame[
- int(coords[2]) : int(coords[3]),
- int(coords[0]) : int(coords[1]),
- :,
- ]
-
- # image=img_as_ubyte(cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),None,fx=ratio,fy=ratio))
image = img_as_ubyte(
cv2.resize(
frame,
@@ -281,28 +276,12 @@ 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
- frame = cap.read_frame()
+ frame = cap.read_frame(crop=True)
if frame is not None:
- if crop:
- frame = frame[
- int(coords[2]) : int(coords[3]),
- int(coords[0]) : int(coords[1]),
- :,
- ]
- # image=img_as_ubyte(cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),None,fx=ratio,fy=ratio))
image = img_as_ubyte(
cv2.resize(
frame,
@@ -312,28 +291,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)
else:
print("Extracting and downsampling...", nframes, " frames from the video.")
if color:
for counter, index in tqdm(enumerate(Index)):
- frame = cap.read_frame()
+ frame = cap.read_frame(crop=True)
if frame is not None:
- if crop:
- frame = frame[
- int(coords[2]) : int(coords[3]),
- int(coords[0]) : int(coords[1]),
- :,
- ]
-
- # image=img_as_ubyte(cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),None,fx=ratio,fy=ratio))
image = img_as_ubyte(
cv2.resize(
frame,
@@ -343,27 +307,11 @@ 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()
+ frame = cap.read_frame(crop=True)
if frame is not None:
- if crop:
- frame = frame[
- int(coords[2]) : int(coords[3]),
- int(coords[0]) : int(coords[1]),
- :,
- ]
- # image=img_as_ubyte(cv2.resize(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),None,fx=ratio,fy=ratio))
image = img_as_ubyte(
cv2.resize(
frame,
@@ -373,22 +321,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
@@ -396,9 +335,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 762ee6d972..a50a483f31 100644
--- a/deeplabcut/utils/make_labeled_video.py
+++ b/deeplabcut/utils/make_labeled_video.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -11,33 +21,38 @@
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
####################################################
# Dependencies
####################################################
-import os.path
-from pathlib import Path
+from collections.abc import Callable, Iterable, Sequence
from functools import partial
-from multiprocessing import Pool
-from typing import Iterable
+from multiprocessing import Pool, get_start_method
+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
+from skimage.draw import disk, line_aa, rectangle_perimeter, set_color
from skimage.util import img_as_ubyte
from tqdm import trange
-from deeplabcut.utils import auxiliaryfunctions, auxfun_multianimal, visualization
+from deeplabcut.core.deprecation import renamed_parameter
+from deeplabcut.core.engine import Engine
+from deeplabcut.pose_estimation_pytorch.config import PoseConfig
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization
+from deeplabcut.utils.auxfun_videos import VideoWriter, collect_video_paths
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):
@@ -49,7 +64,8 @@ def get_segment_indices(bodyparts2connect, all_bpts):
*(
np.flatnonzero(all_bpts == bpt1),
np.flatnonzero(all_bpts == bpt2),
- )
+ ),
+ strict=False,
)
)
return bpts2connect
@@ -73,39 +89,38 @@ def CreateVideo(
draw_skeleton,
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)
if displaycropped:
ny, nx = y2 - y1, x2 - x1
else:
- ny, nx = clip.height(), clip.width()
+ ny, nx = clip.height, clip.width
- fps = clip.fps()
+ fps = clip.fps
+ if isinstance(fps, float):
+ if fps * 1000 > 65535:
+ fps = round(fps)
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:
df_x += x1
df_y += y1
@@ -114,15 +129,13 @@ def CreateVideo(
bplist = bpts.unique().to_list()
nbodyparts = len(bplist)
if Dataframe.columns.nlevels == 3:
- nindividuals = 1
- map2bp = list(range(len(all_bpts)))
- map2id = [0 for _ in map2bp]
+ nindividuals = int(len(all_bpts) / len(set(all_bpts)))
+ map2bp = list(np.repeat(list(range(len(set(all_bpts)))), nindividuals))
+ map2id = list(range(nindividuals)) * len(set(all_bpts))
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.T.groupby(level="individuals").size().values // 3
map2id = []
for i, j in enumerate(nbpts_per_ind):
map2id.extend([i] * j)
@@ -135,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)),
@@ -171,10 +207,12 @@ def CreateVideo(
shape=(ny, nx),
)
image[rr, cc] = color
- rr, cc = disk(
- (df_y[ind, index], df_x[ind, index]), dotsize, shape=(ny, nx)
- )
- image[rr, cc] = color
+ 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])
+
+ set_color(image, (rr, cc), color, alpha)
clip.save_frame(image)
clip.close()
@@ -204,33 +242,26 @@ 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
else:
- ny, nx = clip.height(), clip.width()
+ ny, nx = clip.height, clip.width
- fps = clip.fps()
+ fps = clip.fps
if outputframerate is None: # by def. same as input rate.
outputframerate = fps
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:
@@ -245,15 +276,13 @@ def CreateVideoSlow(
bplist = bpts.unique().to_list()
nbodyparts = len(bplist)
if Dataframe.columns.nlevels == 3:
- nindividuals = 1
- map2bp = list(range(len(all_bpts)))
- map2id = [0 for _ in map2bp]
+ nindividuals = int(len(all_bpts) / len(set(all_bpts)))
+ map2bp = list(np.repeat(list(range(len(set(all_bpts)))), nindividuals))
+ map2id = list(range(nindividuals)) * len(set(all_bpts))
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.T.groupby(level="individuals").size().values // 3
map2id = []
for i, j in enumerate(nbpts_per_ind):
map2id.extend([i] * j)
@@ -264,11 +293,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))
@@ -285,13 +315,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):
@@ -302,6 +354,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":
@@ -312,14 +365,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,
)
@@ -327,226 +380,294 @@ 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="",
- track_method="",
+ config: str | Path,
+ videos: list[str | Path],
+ 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.
Make sure the video is already analyzed by the function
``deeplabcut.analyze_videos``.
- Parameters
- ----------
- config : string
- 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.
-
- 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.
-
- shuffle : int, optional, default=1
- Number of shuffles of training dataset.
-
- trainingsetindex: int, optional, default=0
- Integer specifying which TrainingsetFraction to use.
- Note that TrainingFraction is a list in config.yaml.
-
- filtered: bool, optional, default=False
- Boolean variable indicating if filtered output should be plotted rather than
- frame-by-frame predictions. Filtered version can be calculated with
- ``deeplabcut.filterpredictions``.
-
- fastmode: bool, optional, default=True
- If ``True``, uses openCV (much faster but less customization of video) instead
- of matplotlib if ``False``. You can also "save_frames" individually or not in
- the matplotlib mode (if you set the "save_frames" variable accordingly).
- However, using matplotlib to create the frames it therefore allows much more
- flexible (one can set transparency of markers, crop, and easily customize).
-
- save_frames: bool, optional, default=False
- If ``True``, creates each frame individual and then combines into a video.
- Setting this to ``True`` is relatively slow as it stores all individual frames.
-
- keypoints_only: bool, optional, default=False
- By default, both video frames and keypoints are visible. If ``True``, only the
- keypoints are shown. These clips are an hommage to Johansson movies,
- see https://www.youtube.com/watch?v=1F5ICP9SYLU and of course his seminal
- paper: "Visual perception of biological motion and a model for its analysis"
- by Gunnar Johansson in Perception & Psychophysics 1973.
-
- Frames2plot: List[int] or None, optional, default=None
- If not ``None`` and ``save_frames=True`` then the frames corresponding to the
- index will be plotted. For example, ``Frames2plot=[0,11]`` will plot the first
- and the 12th frame.
-
- displayedbodyparts: list[str] or str, optional, default="all"
- This selects the body parts that are plotted in the video. If ``all``, then all
- body parts from config.yaml are used. If 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 body parts.
-
- displayedindividuals: list[str] or str, optional, default="all"
- Individuals plotted in the video.
- By default, all individuals present in the config will be showed.
-
- codec: str, optional, default="mp4v"
- Codec for labeled video. For available options, see
- http://www.fourcc.org/codecs.php. Note that this depends on your ffmpeg
- installation.
-
- outputframerate: int or None, optional, default=None
- Positive number, output frame rate for labeled video (only available for the
- mode with saving frames.) If ``None``, which results in the original video
- rate.
-
- destfolder: 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.
-
- draw_skeleton: bool, optional, default=False
- If ``True`` adds a line connecting the body parts making a skeleton on each
- frame. The body parts to be connected and the color of these connecting lines
- are specified in the config file.
-
- trailpoints: int, optional, default=0
- Number of previous frames whose body parts are plotted in a frame
- (for displaying history).
-
- displaycropped: bool, optional, default=False
- Specifies whether only cropped frame is displayed (with labels analyzed
- therein), or the original frame with the labels analyzed in the cropped subset.
-
- 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.
-
- 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.
-
- track_method: string, optional, default=""
- Specifies the tracker used to generate the data.
- Empty by default (corresponding to a single animal project).
- For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
- be taken from the config.yaml file if none is given.
-
- Returns
- -------
- None
-
- Examples
- --------
-
- Create the labeled video for a single video
-
- >>> deeplabcut.create_labeled_video(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos/reachingvideo1.avi'],
- )
-
- Create the labeled video for a single video and store the individual frames
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): Number of shuffles of training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ filtered (bool, optional): If True, plot filtered output rather than
+ frame-by-frame predictions. Filtered version can be calculated with
+ ``deeplabcut.filterpredictions``. Defaults to False.
+ fastmode (bool, optional): If ``True``, uses openCV (much faster but less customization of video) instead
+ of matplotlib if ``False``. You can also "save_frames" individually or not in
+ the matplotlib mode (if you set the "save_frames" variable accordingly).
+ However, using matplotlib to create the frames it therefore allows much more
+ flexible (one can set transparency of markers, crop, and easily customize). Defaults to True.
+ save_frames (bool, optional): If ``True``, creates each frame individual and then combines into a video.
+ Setting this to ``True`` is relatively slow as it stores all individual frames. Defaults to False.
+ keypoints_only (bool, optional): By default, both video frames and keypoints are visible. If ``True``, only the
+ keypoints are shown. These clips are an hommage to Johansson movies,
+ see https://www.youtube.com/watch?v=1F5ICP9SYLU and of course his seminal
+ paper: "Visual perception of biological motion and a model for its analysis"
+ by Gunnar Johansson in Perception & Psychophysics 1973. Defaults to False.
+ Frames2plot (List[int] or None, optional): If not ``None`` and ``save_frames=True``,
+ plot frames at the given indices. E.g. ``Frames2plot=[0,11]`` plots the first
+ and 12th frame. Defaults to None.
+ displayedbodyparts (list[str] or str, optional): Body parts plotted in the video. If ``all``, then all
+ body parts from config.yaml are used. If 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 body parts. Defaults to "all".
+ displayedindividuals (list[str] or str, optional): Individuals plotted in the video.
+ By default, all individuals present in the config will be shown. Defaults to "all".
+ codec (str, optional): Codec for labeled video. For available options, see
+ http://www.fourcc.org/codecs.php. Note that this depends on your ffmpeg
+ installation. Defaults to "mp4v".
+ outputframerate (int or None, optional): Output frame rate for labeled video (only
+ when saving frames). If ``None``, uses the original video rate. Defaults to None.
+ destfolder (Path, string or None, optional): Destination folder used for storing analysis data. If
+ ``None``, the path of the video file is used. Defaults to None.
+ draw_skeleton (bool, optional): If ``True`` adds a line connecting the body parts making a skeleton on each
+ frame. The body parts to be connected and the color of these connecting lines
+ are specified in the config file. Defaults to False.
+ trailpoints (int, optional): Number of previous frames whose body parts are plotted in a frame
+ (for displaying history). Defaults to 0.
+ displaycropped (bool, optional): Specifies whether only cropped frame is displayed (with labels analyzed
+ therein), or the original frame with the labels analyzed in the cropped subset. Defaults to False.
+ color_by (string, optional): Coloring rule. By default, each bodypart is colored differently.
+ If set to 'individual', points belonging to a single individual are colored the
+ same. Defaults to 'bodypart'.
+ 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. Defaults to "".
+ init_weights (str, optional): Checkpoint path to the super model. Defaults to "".
+ track_method (string, optional): Tracker used to generate the data.
+ Empty by default (corresponding to a single animal project).
+ For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
+ be taken from the config.yaml file if none is given. Defaults to "".
+ superanimal_name (str, optional): Name of the superanimal model. Defaults to "".
+ pcutoff (float, optional): Overrides the pcutoff set in the project configuration to plot the trajectories.
+ Defaults to None.
+ skeleton (list, optional): Skeleton definition for drawing. Defaults to None.
+ skeleton_color (string, optional): Color for the skeleton. Defaults to "white".
+ dotsize (int, optional): Size of label dots to use. Defaults to 8.
+ colormap (str, optional): Colormap to use for the labels. Defaults to "rainbow".
+ alphavalue (float, optional): Transparency of markers. Defaults to 0.5.
+ overwrite (bool, optional): If ``True`` overwrites existing labeled videos. Defaults to False.
+ confidence_to_alpha (bool | Callable[[float], float], optional): If False, all keypoints
+ use alpha=1. Otherwise, a function f: [0, 1] -> [0, 1] maps score to alpha.
+ When True, f(x) = max(0, (x - pcutoff)/(1 - pcutoff)). Defaults to False.
+ plot_bboxes (bool, optional): If using Pytorch and in Top-Down mode,
+ setting this to true will also plot the bounding boxes. Defaults to True.
+ bboxes_pcutoff (float, optional): If plotting bounding boxes, this overrides the bboxes_pcutoff
+ set in the model configuration. Defaults to None.
+ 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 (dict, optional): Additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ list[bool]: ``True`` if the video is successfully created for each item in ``videos``.
+
+ Examples:
+ Create the labeled video for a single video
+
+ deeplabcut.create_labeled_video(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/reachingvideo1.avi'],
+ )
+ Create the labeled video for a single video and store the individual frames
- >>> deeplabcut.create_labeled_video(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos/reachingvideo1.avi'],
- fastmode=True,
- save_frames=True,
- )
+ deeplabcut.create_labeled_video(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/reachingvideo1.avi'],
+ fastmode=True,
+ save_frames=True,
+ )
- Create the labeled video for multiple videos
+ Create the labeled video for multiple videos
- >>> deeplabcut.create_labeled_video(
- '/analysis/project/reaching-task/config.yaml',
- [
- '/analysis/project/videos/reachingvideo1.avi',
- '/analysis/project/videos/reachingvideo2.avi',
- ],
- )
+ deeplabcut.create_labeled_video(
+ '/analysis/project/reaching-task/config.yaml',
+ [
+ '/analysis/project/videos/reachingvideo1.avi',
+ '/analysis/project/videos/reachingvideo2.avi',
+ ],
+ )
- Create the labeled video for all the videos with an .avi extension in a directory.
+ Create the labeled video for all the videos with an .avi extension in a directory.
- >>> deeplabcut.create_labeled_video(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos/'],
- )
+ deeplabcut.create_labeled_video(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/'],
+ )
- Create the labeled video for all the videos with an .mp4 extension in a directory.
+ Create the labeled video for all the videos with an .mp4 extension in a directory.
- >>> deeplabcut.create_labeled_video(
- '/analysis/project/reaching-task/config.yaml',
- ['/analysis/project/videos/'],
- videotype='mp4',
- )
+ deeplabcut.create_labeled_video(
+ '/analysis/project/reaching-task/config.yaml',
+ ['/analysis/project/videos/'],
+ video_extensions='mp4',
+ )
"""
- cfg = auxiliaryfunctions.read_config(config)
- track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+ if config != "":
+ config = Path(config)
+ if destfolder is not None:
+ destfolder = Path(destfolder)
+ if skeleton is None:
+ skeleton = []
+ if config == "":
+ if pcutoff is None:
+ pcutoff = 0.6
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = 0.6
+
+ individuals = [""]
+ uniquebodyparts = []
+ else:
+ cfg = auxiliaryfunctions.read_config(config)
+ train_fraction = cfg["TrainingFraction"][trainingsetindex]
+ track_method = auxfun_multianimal.get_track_method(cfg, track_method=track_method)
+ if pcutoff is None:
+ pcutoff = cfg["pcutoff"]
- trainFraction = cfg["TrainingFraction"][trainingsetindex]
- DLCscorer, DLCscorerlegacy = auxiliaryfunctions.GetScorerName(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
- ) # automatically loads corresponding model (even training iteration based on snapshot index)
+ # 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 = PoseConfig.from_yaml(model_config_path)
+ if model_config.select("train_settings.weight_init.memory_replay"):
+ superanimal_name = model_config["train_settings"]["weight_init"]["dataset"]
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = model_config.select("detector.model.box_score_thresh") or 0.6
+ else:
+ if bboxes_pcutoff is None:
+ bboxes_pcutoff = 0.6
+
+ if init_weights == "":
+ 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
+ DLCscorerlegacy = "DLC_" + Path(init_weights).stem
if save_frames:
fastmode = False # otherwise one cannot save frames
keypoints_only = False
- bodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
- cfg, displayedbodyparts
- )
- individuals = auxfun_multianimal.IntersectionofIndividualsandOnesGivenbyUser(
- cfg, displayedindividuals
- )
+ # parse the alpha selection function
+ if isinstance(confidence_to_alpha, bool):
+ confidence_to_alpha = _get_default_conf_to_alpha(confidence_to_alpha, pcutoff)
+
+ if superanimal_name != "":
+ dlc_root_path = auxiliaryfunctions.get_deeplabcut_path()
+ test_cfg = auxiliaryfunctions.read_plainconfig(
+ dlc_root_path / "modelzoo" / "project_configs" / f"{superanimal_name}.yaml"
+ )
+
+ bodyparts = test_cfg["bodyparts"]
+ cfg = {
+ "skeleton": skeleton,
+ "skeleton_color": skeleton_color,
+ "pcutoff": pcutoff,
+ "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)
+
if draw_skeleton:
bodyparts2connect = cfg["skeleton"]
+ if displayedbodyparts != "all":
+ bodyparts2connect = [
+ pair for pair in bodyparts2connect if all(element in displayedbodyparts for element in pair)
+ ]
skeleton_color = cfg["skeleton_color"]
else:
bodyparts2connect = None
skeleton_color = None
- start_path = os.getcwd()
- Videos = auxiliaryfunctions.get_list_of_videos(videos, videotype)
+ start_path = Path.cwd()
+ Videos = collect_video_paths(videos, extensions=video_extensions)
if not Videos:
- return
+ return []
func = partial(
proc_video,
@@ -557,7 +678,7 @@ def create_labeled_video(
DLCscorerlegacy,
track_method,
cfg,
- individuals,
+ displayedindividuals,
color_by,
bodyparts,
codec,
@@ -571,12 +692,25 @@ def create_labeled_video(
displaycropped,
fastmode,
keypoints_only,
+ overwrite,
+ init_weights=init_weights,
+ pcutoff=pcutoff,
+ confidence_to_alpha=confidence_to_alpha,
+ plot_bboxes=plot_bboxes,
+ bboxes_pcutoff=bboxes_pcutoff,
)
- with Pool(min(os.cpu_count(), len(Videos))) as pool:
- pool.map(func, Videos)
+ if get_start_method() == "fork":
+ n_workers = max_workers or min(os.cpu_count(), len(Videos))
+ with Pool(n_workers) as pool:
+ results = pool.map(func, Videos)
+ else:
+ results = []
+ for video in Videos:
+ results.append(func(video))
os.chdir(start_path)
+ return results
def proc_video(
@@ -601,61 +735,90 @@ def proc_video(
displaycropped,
fastmode,
keypoints_only,
+ overwrite,
video,
+ init_weights="",
+ 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
-
- Parameters
- ----------
-
+ """Helper function for create_labeled_video.
+ Returns:
+ 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.attempttomakefolder(destfolder)
+ 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 != "":
+ DLCscorer = "DLC_" + Path(init_weights).stem
+ 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):
- 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")
- if os.path.isfile(videooutname):
+
+ videooutname = filepath.replace(".h5", f"{s}_p{int(100 * pcutoff)}_labeled.mp4")
+ if Path(videooutname).is_file() 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
@@ -670,19 +833,19 @@ def proc_video(
df,
videooutname,
inds,
- cfg["pcutoff"],
+ pcutoff,
cfg["dotsize"],
cfg["alphavalue"],
skeleton_color=skeleton_color,
color_by=color_by,
colormap=cfg["colormap"],
- fps=clip.fps(),
+ fps=clip.fps,
)
clip.close()
elif not fastmode:
- tmpfolder = os.path.join(str(videofolder), "temp-" + vname)
+ tmpfolder = str(Path(str(videofolder)) / ("temp-" + vname))
if save_frames:
- auxiliaryfunctions.attempttomakefolder(tmpfolder)
+ auxiliaryfunctions.attempt_to_make_folder(tmpfolder)
clip = vp(video)
CreateVideoSlow(
videooutname,
@@ -692,7 +855,7 @@ def proc_video(
cfg["dotsize"],
cfg["colormap"],
cfg["alphavalue"],
- cfg["pcutoff"],
+ pcutoff,
trailpoints,
cropping,
x1,
@@ -708,22 +871,21 @@ def proc_video(
draw_skeleton,
displaycropped,
color_by,
+ plot_bboxes=plot_bboxes,
+ bboxes_list=bboxes_list,
+ bboxes_pcutoff=bboxes_pcutoff,
)
clip.close()
else:
- if displaycropped: # then the cropped video + the labels is depicted
- bbox = x1, x2, y1, y2
- else: # then the full video + the (perhaps in cropped mode analyzed labels) are depicted
- bbox = None
- _create_labeled_video(
+ create_video(
video,
filepath,
keypoints2show=labeled_bpts,
animals2show=individuals,
- bbox=bbox,
+ 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,
@@ -731,28 +893,42 @@ def proc_video(
skeleton_color=skeleton_color,
trailpoints=trailpoints,
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:
print(e)
+ 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,
bbox=None,
+ display_cropped=False,
codec="mp4v",
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'.")
@@ -760,26 +936,22 @@ def _create_labeled_video(
if not output_path:
s = "_id" if color_by == "individual" else "_bp"
output_path = h5file.replace(".h5", f"{s}_labeled.mp4")
- try:
- x1, x2, y1, y2 = bbox
- display_cropped = True
- sw = x2 - x1
- sh = y2 - y1
- except TypeError:
- x1 = x2 = y1 = y2 = 0
- display_cropped = False
- 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.width, 0, clip.height)
+
+ x1, x2, y1, y2 = bbox if bbox is not None else (0, clip.width, 0, clip.height)
+
df = pd.read_hdf(h5file)
+
try:
animals = df.columns.get_level_values("individuals").unique().to_list()
if animals2show != "all" and isinstance(animals, Iterable):
@@ -787,9 +959,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,
@@ -798,7 +973,7 @@ def _create_labeled_video(
cmap,
kpts,
trailpoints,
- False,
+ cropping,
x1,
x2,
y1,
@@ -808,9 +983,18 @@ def _create_labeled_video(
bool(skeleton_edges),
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,
@@ -836,19 +1020,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}")
@@ -856,23 +1038,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)
@@ -884,88 +1062,112 @@ 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="",
+ config: str | Path,
+ videos: list[str | Path],
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
displayedbodyparts="all",
+ cropping: list[int] | None = None,
destfolder=None,
modelprefix="",
+ 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.
+
+ Args:
+ config (str | Path): Absolute path to the config.yaml file.
+ videos (list[str | Path]): Full paths to videos for analysis, or a directory where all
+ videos with the same extension are stored.
+ video_extensions (str | Sequence[str] | None, optional): 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). Defaults to None.
+ shuffle (int, optional): Number of shuffles of training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ By default the first (note that TrainingFraction is a list in config.yaml).
+ displayedbodyparts (list of strings, optional): Body parts 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): If passed in, [x1, x2, y1, y2] crop coordinates
+ shift detections appropriately. Defaults to None.
+ destfolder (string, optional): Destination folder used for storing analysis data
+ (default is the path of the video).
+ confidence_to_alpha (bool | Callable[[float], float], optional): If False, all keypoints
+ use alpha=1. Otherwise, a function f: [0, 1] -> [0, 1] maps score to alpha.
+ When True, f(x) = x. Defaults to False.
+ plot_bboxes (bool, optional): 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. Defaults to True.
+ kwargs (dict, optional): Additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
"""
- Create a video labeled with all the detections stored in a '*_full.pickle' file.
-
- Parameters
- ----------
- config : str
- Absolute path to the config.yaml file
-
- videos : list of 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.
-
- 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.
-
- 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).
-
- 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.
-
- destfolder: string, optional
- Specifies the destination folder that was used for storing analysis data (default is the path of the video).
-
- """
- 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.GetScorerName(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
+ DLCscorername, _ = auxiliaryfunctions.get_scorer_name(
+ 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
+ if isinstance(confidence_to_alpha, bool):
+ confidence_to_alpha = _get_default_conf_to_alpha(confidence_to_alpha, 0)
+
for video in videos:
- videofolder = os.path.splitext(video)[0]
+ videofolder = str(Path(video).with_suffix(""))
if destfolder is None:
- outputname = "{}_full.mp4".format(videofolder + DLCscorername)
- full_pickle = os.path.join(videofolder + DLCscorername + "_full.pickle")
+ outputname = f"{videofolder + DLCscorername}_full.mp4"
+ full_pickle = videofolder + DLCscorername + "_full.pickle"
else:
- auxiliaryfunctions.attempttomakefolder(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"
- )
+ auxiliaryfunctions.attempt_to_make_folder(destfolder)
+ outputname = str(Path(destfolder) / (Path(video).stem + DLCscorername + "_full.mp4"))
+ full_pickle = str(Path(destfolder) / (Path(video).stem + DLCscorername + "_full.pickle"))
- if not (os.path.isfile(outputname)):
- print("Creating labeled video for ", str(Path(video).stem))
+ if not Path(outputname).is_file():
+ 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, 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
+ # TODO @deruyter92: This pattern should be refactored throughout the codebase
+ # it is reading a config value that is supposed to be missing / None.
+ elif (metadata.get("data") or {}).get("cropping"):
+ x1, _, y1, _ = metadata["data"]["cropping_parameters"]
+
header = data.pop("metadata")
all_jointnames = header["all_joints_names"]
@@ -978,7 +1180,6 @@ def create_video_with_all_detections(
if bp in displayedbodyparts:
bpts.append(bptindex)
numjoints = len(bpts)
-
frame_names = list(data)
frames = [int(re.findall(r"\d+", name)[0]) for name in frame_names]
colorclass = plt.cm.ScalarMappable(cmap=cfg["colormap"])
@@ -988,7 +1189,12 @@ def create_video_with_all_detections(
pcutoff = cfg["pcutoff"]
dotsize = cfg["dotsize"]
clip = vp(fname=video, sname=outputname, codec="mp4v")
- ny, nx = clip.height(), clip.width()
+ ny, nx = clip.height, clip.width
+
+ bboxes_pcutoff = 0.6
+ if pytorch_cfg := (metadata.get("data") or {}).get("pytorch-config"):
+ bboxes_pcutoff = PoseConfig.from_any(pytorch_cfg).select("detector.model.box_score_thresh") or 0.6
+ bboxes_color = (255, 0, 0)
for n in trange(clip.nframes):
frame = clip.load_frame()
@@ -996,21 +1202,54 @@ def create_video_with_all_detections(
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))
- frame[rr, cc] = colors[bpts.index(det.label)]
- except ValueError: # No data stored for that particular frame
- print(n, "no data")
- pass
+ alpha = 1
+ if confidence_to_alpha is not None:
+ alpha = confidence_to_alpha(det.confidence)
+
+ set_color(
+ frame,
+ (rr, cc),
+ colors[bpts.index(det.label)],
+ alpha,
+ )
+ except ValueError as err: # No data stored for that particular frame
+ print(n, f"no data: {err}")
try:
clip.save_frame(frame)
- except:
+ except Exception:
print(n, "frame writing error.")
- pass
clip.close()
else:
print("Detections already plotted, ", outputname)
@@ -1018,10 +1257,11 @@ 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):
- os.mkdir(destfolder)
+ if not Path(destfolder).is_dir():
+ Path(destfolder).mkdir(exist_ok=True, parents=True)
vid = VideoWriter(video)
nframes = len(vid)
@@ -1040,13 +1280,13 @@ def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, s
for index in tqdm(range(nframes)):
vid.set_to_frame(index)
imname = "frame" + str(index).zfill(strwidth)
- image_output = os.path.join(destfolder, imname + ".png")
+ image_output = str(Path(destfolder) / (imname + ".png"))
frame = vid.read_frame()
- if frame is not None and not os.path.isfile(image_output):
+ if frame is not None and not Path(image_output).is_file():
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([], [])
@@ -1068,20 +1308,38 @@ def _create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff, s
output_name,
]
)
+ # remove frames used for video creation
+ for p in Path(destfolder).iterdir():
+ if p.is_file() and "frame" in p.name:
+ p.unlink()
-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]
+ destfolder = str(Path(video).with_suffix(""))
if not output_name:
- video_name, ext = os.path.splitext(os.path.split(video)[1])
- output_name = video_name + "DLClabeled" + ext
+ video_path = Path(video)
+ output_name = video_path.stem + "DLClabeled" + video_path.suffix
tracks = auxiliaryfunctions.read_pickle(pickle_file)
_create_video_from_tracks(video, tracks, destfolder, output_name, pcutoff)
+def _get_default_conf_to_alpha(
+ confidence_to_alpha: bool,
+ pcutoff: float,
+) -> Callable[[float], float] | None:
+ """Creates the default confidence_to_alpha function."""
+ if not confidence_to_alpha:
+ return None
+
+ def default_confidence_to_alpha(x):
+ if pcutoff == 0:
+ return x
+ return np.clip((x - pcutoff) / (1 - pcutoff), 0, 1)
+
+ return default_confidence_to_alpha
+
+
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("config")
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/pandas_future_mode.py b/deeplabcut/utils/pandas_future_mode.py
new file mode 100644
index 0000000000..cfb9d952e7
--- /dev/null
+++ b/deeplabcut/utils/pandas_future_mode.py
@@ -0,0 +1,32 @@
+"""Opt-in pandas 2.3 future-behavior checks for CI/local DLC test runs."""
+
+from __future__ import annotations
+
+import os
+
+import pandas as pd
+from packaging.version import Version
+
+ENABLED_ENV = "DLC_PANDAS_FUTURE"
+
+
+def is_enabled() -> bool:
+ return os.environ.get(ENABLED_ENV, "").lower() in {"1", "true", "yes"}
+
+
+def configure_pandas_future_if_enabled() -> None:
+ if not is_enabled():
+ return
+
+ ver = Version(pd.__version__)
+ if ver < Version("2.3") or ver >= Version("3"):
+ raise RuntimeError(f"pandas future mode requires pandas 2.3.x, got {pd.__version__}")
+
+ pd.options.future.infer_string = True
+ pd.options.mode.copy_on_write = "warn"
+
+ print(
+ f"pandas future mode enabled: pandas={pd.__version__}, "
+ f"infer_string={pd.options.future.infer_string}, "
+ f"copy_on_write={pd.options.mode.copy_on_write}"
+ )
diff --git a/deeplabcut/utils/plotting.py b/deeplabcut/utils/plotting.py
index 58189d2760..48ae8715c2 100644
--- a/deeplabcut/utils/plotting.py
+++ b/deeplabcut/utils/plotting.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,21 +18,25 @@
Licensed under GNU Lesser General Public License v3.0
"""
+from __future__ import annotations
+
import argparse
-import os
-import pickle
####################################################
# 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.core.deprecation import renamed_parameter
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions, visualization
+from deeplabcut.utils.auxfun_videos import collect_video_paths
def Histogram(vector, color, bins, ax=None, linewidth=1.0):
@@ -45,13 +59,18 @@ 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)
@@ -80,12 +99,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(),
@@ -133,25 +148,19 @@ def PlottingResults(
cbar.set_ticklabels(bodyparts2plot)
fig1.savefig(
- os.path.join(tmpfolder, "trajectory" + suffix),
+ Path(tmpfolder) / ("trajectory" + suffix),
bbox_inches="tight",
dpi=resolution,
)
- fig2.savefig(
- os.path.join(tmpfolder, "plot" + suffix), bbox_inches="tight", dpi=resolution
- )
+ fig2.savefig(Path(tmpfolder) / ("plot" + suffix), bbox_inches="tight", dpi=resolution)
fig3.savefig(
- os.path.join(tmpfolder, "plot-likelihood" + suffix),
+ Path(tmpfolder) / ("plot-likelihood" + suffix),
bbox_inches="tight",
dpi=resolution,
)
- fig4.savefig(
- os.path.join(tmpfolder, "hist" + suffix), bbox_inches="tight", dpi=resolution
- )
+ fig4.savefig(Path(tmpfolder) / ("hist" + suffix), bbox_inches="tight", dpi=resolution)
- if not showfigures:
- plt.close("all")
- else:
+ if showfigures:
plt.show()
@@ -160,10 +169,11 @@ def PlottingResults(
##################################################
+@renamed_parameter(old="videotype", new="video_extensions", since="3.0.0")
def plot_trajectories(
- config,
- videos,
- videotype="",
+ config: str | Path,
+ videos: list[str | Path],
+ video_extensions: str | Sequence[str] | None = None,
shuffle=1,
trainingsetindex=0,
filtered=False,
@@ -176,105 +186,91 @@ def plot_trajectories(
resolution=100,
linewidth=1.0,
track_method="",
+ pcutoff: float | None = None,
+ **kwargs,
):
"""Plots the trajectories of various bodyparts across the video.
- Parameters
- ----------
- config: str
- Full path of the config.yaml file.
-
- videos: list[str]
- 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.
-
- shuffle: int, optional, default=1
- Integer specifying the shuffle index of the training dataset.
-
- trainingsetindex: int, optional, default=0
- Integer specifying which TrainingsetFraction to use.
- Note that TrainingFraction is a list in config.yaml.
-
- filtered: bool, optional, default=False
- Boolean variable indicating if filtered output should be plotted rather than
- frame-by-frame predictions. Filtered version can be calculated with
- ``deeplabcut.filterpredictions``.
-
- displayedbodyparts: list[str] or str, optional, default="all"
- This select 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.
-
- showfigures: bool, optional, default=False
- If ``True`` then plots are also displayed.
-
- destfolder: string or None, optional, default=None
- Specifies the destination folder that was used for storing analysis data. If
- ``None``, the path of the video is used.
-
- 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.
-
- imagetype: string, optional, default=".png"
- Specifies the output image format - '.tif', '.jpg', '.svg' and ".png".
-
- resolution: int, optional, default=100
- Specifies the resolution (in dpi) of saved figures.
- Note higher resolution figures take longer to generate.
-
- linewidth: float, optional, default=1.0
- Specifies width of line for line and histogram plots.
-
- track_method: string, optional, default=""
- Specifies the tracker used to generate the data.
- Empty by default (corresponding to a single animal project).
- For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
- be taken from the config.yaml file if none is given.
-
- Returns
- -------
- None
-
- Examples
- --------
-
- To label the frames
-
- >>> deeplabcut.plot_trajectories(
- 'home/alex/analysis/project/reaching-task/config.yaml',
- ['/home/alex/analysis/project/videos/reachingvideo1.avi'],
- )
+ Args:
+ config (str | Path): Full path of the config.yaml file.
+ videos (list[str | Path]): 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): 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). Defaults to None.
+ shuffle (int, optional): Integer specifying the shuffle index of the training dataset. Defaults to 1.
+ trainingsetindex (int, optional): Integer specifying which TrainingsetFraction to use.
+ Note that TrainingFraction is a list in config.yaml. Defaults to 0.
+ filtered (bool, optional): Boolean variable indicating if filtered output should be plotted rather than
+ frame-by-frame predictions. Filtered version can be calculated with
+ ``deeplabcut.filterpredictions``. Defaults to False.
+ displayedbodyparts (list[str] or str, optional): This select 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. Defaults to "all".
+ showfigures (bool, optional): If ``True`` then plots are also displayed. Defaults to False.
+ destfolder (string or None, optional): Destination folder for analysis data. If
+ ``None``, the path of the video is used. Defaults to None.
+ 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. Defaults to "".
+ imagetype (string, optional): Output image format: '.tif', '.jpg', '.svg',
+ ".png". Defaults to ".png".
+ resolution (int, optional): Specifies the resolution (in dpi) of saved figures.
+ Note higher resolution figures take longer to generate. Defaults to 100.
+ linewidth (float, optional): Specifies width of line for line and histogram plots. Defaults to 1.0.
+ track_method (string, optional): Specifies the tracker used to generate the data.
+ Empty by default (corresponding to a single animal project).
+ For multiple animals, must be either 'box', 'skeleton', or 'ellipse' and will
+ be taken from the config.yaml file if none is given. Defaults to "".
+ pcutoff (float | None, optional): Overrides project pcutoff for plotting trajectories. Defaults to None.
+ kwargs (dict, optional): Additional arguments.
+ For torch-based shuffles, can be used to specify:
+ - snapshot_index
+ - detector_snapshot_index
+
+ Returns:
+ None
+
+ Examples:
+ To label the frames
+
+ deeplabcut.plot_trajectories(
+ 'home/alex/analysis/project/reaching-task/config.yaml',
+ ['/home/alex/analysis/project/videos/reachingvideo1.avi'],
+ )
"""
+ config = Path(config)
+ if destfolder is not None:
+ destfolder = Path(destfolder)
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.GetScorerName(
- cfg, shuffle, trainFraction, modelprefix=modelprefix
+ DLCscorer, DLCscorerlegacy = auxiliaryfunctions.get_scorer_name(
+ cfg,
+ shuffle,
+ trainFraction,
+ modelprefix=modelprefix,
+ **kwargs,
) # automatically loads corresponding model (even training iteration based on snapshot index)
- bodyparts = auxiliaryfunctions.IntersectionofBodyPartsandOnesGivenbyUser(
- 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
- failed = []
+ failures, multianimal_errors = [], []
for video in Videos:
if destfolder is None:
videofolder = str(Path(video).parents[0])
@@ -284,80 +280,127 @@ def plot_trajectories(
vname = str(Path(video).stem)
print("Loading ", video, "and data.")
try:
- df, _, _, suffix = auxiliaryfunctions.load_analyzed_data(
+ df, filepath, _, suffix = auxiliaryfunctions.load_analyzed_data(
videofolder, vname, DLCscorer, filtered, track_method
)
- failed.append(False)
- tmpfolder = os.path.join(videofolder, "plot-poses", vname)
- auxiliaryfunctions.attempttomakefolder(tmpfolder, 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
- ]
- # Either display the animals defined in the config if they are found
- # in the dataframe, or all the trajectories regardless of their names
- try:
- animals = set(df.columns.get_level_values("individuals"))
- except KeyError:
- animals = {""}
- for animal in animals.intersection(individuals) or animals:
- PlottingResults(
- tmpfolder,
- df,
- cfg,
- labeled_bpts,
- animal,
- showfigures,
- suffix + animal + imagetype,
- resolution=resolution,
- linewidth=linewidth,
- )
+ tmpfolder = str(Path(videofolder) / "plot-poses" / vname)
+ _plot_trajectories(
+ filepath,
+ bodyparts,
+ individuals,
+ showfigures,
+ resolution,
+ linewidth,
+ cfg["colormap"],
+ cfg["alphavalue"],
+ pcutoff,
+ suffix,
+ imagetype,
+ tmpfolder,
+ )
except FileNotFoundError as e:
- failed.append(True)
print(e)
- try:
- _ = auxiliaryfunctions.load_detection_data(
- video, DLCscorer, track_method
- )
- print(
- 'Call "deeplabcut.stitch_tracklets()"'
- " prior to plotting the trajectories."
- )
- except FileNotFoundError as e:
- print(e)
- print(
- f"Make sure {video} was previously analyzed, and that "
- f'detections were successively converted to tracklets using "deeplabcut.convert_detections2tracklets()" '
- f'and "deeplabcut.stitch_tracklets()".'
- )
-
- if not all(failed):
+ failures.append(video)
+ if track_method != "":
+ # In a multi animal scenario, show more verbose errors.
+ try:
+ _ = 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)
+ error_message = (
+ f"Make sure {video} was previously analyzed, and that "
+ "detections were successively converted to tracklets using "
+ '"deeplabcut.convert_detections2tracklets()" and "deeplabcut.stitch_tracklets()".'
+ )
+ multianimal_errors.append(error_message)
+
+ if len(failures) > 0:
+ # Some videos were not evaluated.
+ failed_videos = ",".join(failures)
+ if len(multianimal_errors) > 0:
+ verbose_error = ": " + " ".join(multianimal_errors)
+ else:
+ verbose_error = "."
print(
- 'Plots created! Please check the directory "plot-poses" within the video directory'
+ f"Plots could not be created for {failed_videos}. "
+ f"Videos were not evaluated with the current scorer {DLCscorer}" + verbose_error
)
else:
- print(
- f"Plots could not be created! "
- f"Videos were not evaluated with the current scorer {DLCscorer}."
+ print('Plots created! Please check the directory "plot-poses" within the video directory')
+
+
+def _plot_trajectories(
+ h5file,
+ bodyparts=None,
+ individuals=None,
+ show=False,
+ resolution=100,
+ linewidth=1.0,
+ colormap="viridis",
+ alpha=1.0,
+ pcutoff=0.01,
+ suffix="",
+ image_type=".png",
+ dest_folder=None,
+):
+ df = pd.read_hdf(h5file)
+ if bodyparts is None:
+ bodyparts = list(df.columns.get_level_values("bodyparts").unique())
+ if individuals is None:
+ try:
+ individuals = set(df.columns.get_level_values("individuals"))
+ except KeyError:
+ individuals = [""]
+ if dest_folder is None:
+ vname = Path(h5file).name.split("DLC")[0]
+ vid_folder = Path(h5file).parent
+ dest_folder = str(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]
+ # Either display the animals defined in the config if they are found
+ # in the dataframe, or all the trajectories regardless of their names
+ try:
+ animals = set(df.columns.get_level_values("individuals"))
+ except KeyError:
+ animals = {""}
+ cfg = {
+ "colormap": colormap,
+ "alphavalue": alpha,
+ "pcutoff": pcutoff,
+ }
+ for animal in animals.intersection(individuals) or animals:
+ PlottingResults(
+ dest_folder,
+ df,
+ cfg,
+ labeled_bpts,
+ animal,
+ show,
+ suffix + animal + image_type,
+ resolution=resolution,
+ linewidth=linewidth,
)
def _plot_paf_performance(
- within, between, nbins=51, kde=True, colors=None, ax=None,
+ within,
+ between,
+ nbins=51,
+ kde=True,
+ colors=None,
+ ax=None,
):
import seaborn as sns
bins = np.linspace(0, 1, nbins)
if colors is None:
- colors = '#EFC9AF', '#1F8AC0'
+ colors = "#EFC9AF", "#1F8AC0"
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(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)
return ax
@@ -367,46 +410,39 @@ def plot_edge_affinity_distributions(
output_name="",
figsize=(10, 7),
):
+ """Display the distribution of affinity costs of within- and between-animal edges.
+
+ Args:
+ eval_pickle_file (str | Path): Path to a *_full.pickle from the evaluation-results folder.
+ include_bodyparts (list of strings, optional): A list of body part names whose edges are to be shown.
+ By default, all body parts and their corresponding edges are analyzed.
+ We recommend only passing a subset of body parts for projects with large graphs. Defaults to "all".
+ output_name (string, optional): Path where the plot is saved. By default, it is stored as costdist.png.
+ figsize (tuple): Figure size in inches.
"""
- Display the distribution of affinity costs of within- and between-animal edges.
-
- Parameters
- ----------
- eval_pickle_file : string
- Path to a *_full.pickle from the evaluation-results folder.
- include_bodyparts : list of strings, optional
- A list of body part names whose edges are to be shown.
- By default, all body parts and their corresponding edges are analyzed.
- We recommend only passing a subset of body parts for projects with large graphs.
-
- output_name: string, optional
- Path where the plot is saved. By default, it is stored as costdist.png.
-
- figsize: tuple
- Figure size in inches.
-
- """
-
- with open(eval_pickle_file, 'rb') as file:
+ eval_pickle_file = Path(eval_pickle_file)
+ with eval_pickle_file.open("rb") as file:
data = pickle.load(file)
- meta_pickle_file = eval_pickle_file.replace('_full.', '_meta.')
- with open(meta_pickle_file, 'rb') as file:
+ meta_pickle_file = eval_pickle_file.with_name(eval_pickle_file.name.replace("_full.", "_meta."))
+ with meta_pickle_file.open("rb") as file:
metadata = pickle.load(file)
(w_train, _), (b_train, _) = crossvalutils._calc_within_between_pafs(
- data, metadata, train_set_only=True,
+ data,
+ metadata,
+ train_set_only=True,
)
- data.pop('metadata', None)
+ data.pop("metadata", None)
nonempty = set(i for i, vals in w_train.items() if vals)
- meta = metadata['data']['DLC-model-config file']
- bpts = list(map(str.lower, meta['all_joints_names']))
- inds_multi = set(b for edge in meta['partaffinityfield_graph'] for b in edge)
- if include_bodyparts == 'all':
+ meta = metadata["data"]["DLC-model-config file"]
+ bpts = list(map(str.lower, meta["all_joints_names"]))
+ inds_multi = set(b for edge in meta["partaffinityfield_graph"] for b in edge)
+ if include_bodyparts == "all":
include_bodyparts = inds_multi
else:
include_bodyparts = set(bpts.index(bpt) for bpt in include_bodyparts)
edges_to_keep = set()
- graph = meta['partaffinityfield_graph']
+ graph = meta["partaffinityfield_graph"]
for n, edge in enumerate(graph):
if not any(i in include_bodyparts for i in edge):
continue
@@ -415,18 +451,28 @@ def plot_edge_affinity_distributions(
nrows = int(np.ceil(np.sqrt(len(edge_inds))))
ncols = int(np.ceil(len(edge_inds) / nrows))
fig, axes_ = plt.subplots(
- nrows, ncols, figsize=figsize, tight_layout=True, squeeze=False,
+ nrows,
+ ncols,
+ figsize=figsize,
+ tight_layout=True,
+ squeeze=False,
)
axes = axes_.flatten()
for ax in axes:
- ax.axis('off')
+ ax.axis("off")
for n, ind in enumerate(edge_inds):
i1, i2 = graph[ind]
w_tr = w_train[ind]
b_tr = b_train[ind]
- sep, _ = crossvalutils._calc_separability(b_tr, w_tr, metric='auc')
- axes[n].text(0.5, 0.8, f'{bpts[i1]}â{bpts[i2]}\n{sep:.2f}', size=8,
- ha='center', transform=axes[n].transAxes)
+ sep, _ = crossvalutils._calc_separability(b_tr, w_tr, metric="auc")
+ axes[n].text(
+ 0.5,
+ 0.8,
+ f"{bpts[i1]}â{bpts[i2]}\n{sep:.2f}",
+ size=8,
+ ha="center",
+ transform=axes[n].transAxes,
+ )
_plot_paf_performance(w_tr, b_tr, ax=axes[n], kde=False)
axes[0].set_xticks([])
axes[0].set_yticks([])
diff --git a/deeplabcut/utils/pseudo_label.py b/deeplabcut/utils/pseudo_label.py
new file mode 100644
index 0000000000..d7f95f799f
--- /dev/null
+++ b/deeplabcut/utils/pseudo_label.py
@@ -0,0 +1,485 @@
+#
+# 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
+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,
+)
+
+
+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 = str(Path(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,
+ max_individuals=max_individuals,
+ device=device,
+ )
+ 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,
+ )
+
+ individuals = [f"animal{i}" for i in range(max_individuals)]
+ config["metadata"]["individuals"] = individuals
+ train_file_path = 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 train_file_path.open() 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 = Path(image["file_name"]).name
+ 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(str(p) for p in (memory_replay_folder / "images").glob(ext))
+
+ corresponded_images = []
+ for image in images_in_folder:
+ image_path = image
+ name = Path(image).name
+ if name in image_name_to_id:
+ corresponded_images.append(image_path)
+
+ images = corresponded_images
+ bbox_gts = [{"bboxes": np.array(image_name_to_bbox[Path(image).name])} 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 (memory_replay_folder / "pseudo_predictions.json").open("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 = Path(image_path).name
+ 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 = 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 = memory_replay_folder / "conversion_table.csv"
+ with conversion_table_out_path.open("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 = Path(dest_proj_folder) / "images"
+
+ # video_to_frames function by default outputs png or jpg
+ image_paths = sorted(str(p) for p in image_folder.glob("*.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(str(image_path))
+ height, width, channels = image_obj.shape
+ imagename = Path(image_path).name
+ 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 (Path(dest_proj_folder) / "annotations" / "test.json").open("w") as f:
+ json.dump(test_obj, f, indent=4)
+
+ with (Path(dest_proj_folder) / "annotations" / "train.json").open("w") as f:
+ json.dump(train_obj, f, indent=4)
diff --git a/deeplabcut/utils/skeleton.py b/deeplabcut/utils/skeleton.py
index 797f3f326d..3131d35c13 100644
--- a/deeplabcut/utils/skeleton.py
+++ b/deeplabcut/utils/skeleton.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.2 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,51 +18,46 @@
Licensed under GNU Lesser General Public License v3.0
"""
-import os
+import logging
import warnings
+from pathlib import Path
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt
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.core.config import read_config_as_dict, write_config
+from deeplabcut.generate_training_dataset.trainingsetmanipulation import drop_likelihood_columns
-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)
-
-
-def write_config(configname, cfg):
- with open(configname, "w") as file:
- YAML().dump(cfg, file)
+logger = logging.getLogger(__name__)
class SkeletonBuilder:
+ ### Usage parameters
+ lasso_select_size = 10
+ clear_button_axes = [0.85, 0.55, 0.1, 0.1]
+ clear_button_text = "Clear"
+ export_button_axes = [0.85, 0.45, 0.1, 0.1]
+ export_button_text = "Save"
+ ampl = 1.3 # Amplification factor for the zoomed-in view of the animal
+
def __init__(self, config_path):
self.config_path = config_path
- self.cfg = read_config(config_path)
+ self.cfg = read_config_as_dict(config_path)
# Find uncropped labeled data
+ self._ax = None
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')
- )
+ root = Path(self.cfg["project_path"]) / "labeled-data"
+ for folder in root.iterdir():
+ if folder.is_dir() and not any(folder.name.endswith(s) for s in ("cropped", "labeled")):
+ self.df = pd.read_hdf(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")
@@ -62,112 +67,176 @@ 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
- sep = "/" if "/" in row else "\\"
- if sep != os.path.sep:
- row = row.replace(sep, os.path.sep)
-
- self.image = io.imread(os.path.join(self.cfg["project_path"], row))
+ if isinstance(row, str):
+ sep = "/" if "/" in row else "\\"
+ row = row.split(sep)
+ self.image = io.imread(Path(self.cfg["project_path"]).joinpath(*row))
self.inds = set()
self.segs = set()
# Draw the skeleton if already existent
- if self.cfg["skeleton"]:
- for bone in self.cfg["skeleton"]:
+ skeleton = self.cfg.get("skeleton", [])
+ if skeleton:
+ for bone in 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, :])))
- 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()
+ self.build_ui()
+ self.display()
- 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)
- row, col = kept.pop()
- return row, col
-
- def show(self):
+ def build_ui(self):
self.fig = plt.figure()
- ax = self.fig.add_subplot(111)
- ax.axis("off")
+ self._ax = self.fig.add_subplot(111)
+ self._ax.axis("off")
lo = np.nanmin(self.xy, axis=0)
hi = np.nanmax(self.xy, axis=0)
center = (hi + lo) / 2
w, h = hi - lo
- ampl = 1.3
- w *= ampl
- h *= ampl
- ax.set_xlim(center[0] - w / 2, center[0] + w / 2)
- ax.set_ylim(center[1] - h / 2, center[1] + h / 2)
- ax.imshow(self.image)
- ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2)
- ax.add_collection(self.lines)
- ax.invert_yaxis()
-
- self.lasso = LassoSelector(ax, onselect=self.on_select)
- 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")
+ w *= self.ampl
+ h *= self.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)
+ self._ax.scatter(*self.xy.T, s=self.cfg["dotsize"] ** 2)
+ self._ax.add_collection(self.lines)
+ self._ax.invert_yaxis()
+
+ self.lasso = LassoSelector(self._ax, onselect=self.on_select)
+ ax_clear = self.fig.add_axes(self.clear_button_axes)
+ ax_export = self.fig.add_axes(self.export_button_axes)
+ self.clear_button = Button(ax_clear, self.clear_button_text)
self.clear_button.on_clicked(self.clear)
- self.export_button = Button(ax_export, "Export")
+ self.export_button = Button(ax_export, self.export_button_text)
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 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]
- write_config(self.config_path, self.cfg)
+ def read_config(self, config_path):
+ return read_config_as_dict(config_path)
+
+ def write_config(self, config_path, cfg):
+ write_config(config_path, cfg)
+
+ def _show_export_feedback(self):
+ if not hasattr(self, "export_button"):
+ return
+
+ button = self.export_button
+ canvas = self.fig.canvas
+
+ original_text = button.label.get_text()
+ original_color = button.ax.get_facecolor()
+
+ n_edges = len(self.cfg.get("skeleton") or [])
+ button.label.set_text(f"Saved {n_edges}")
+ button.ax.set_facecolor("#c8e6c9") # light green
+ canvas.draw_idle()
+
+ def reset_button():
+ button.label.set_text(original_text)
+ button.ax.set_facecolor(original_color)
+ canvas.draw_idle()
+ return False # stop Matplotlib timer
+
+ timer = canvas.new_timer(interval=1200)
+ timer.add_callback(reset_button)
+
+ # Keep a reference so the timer is not garbage-collected.
+ self._export_feedback_timer = timer
+ timer.start()
+
+ def export(self, *args) -> bool:
+ try:
+ 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 empty, mention we are saving an empty skeleton
+ if not self.inds:
+ logger.warning("No bodyparts are connected. Saving an empty skeleton.")
+ elif len(unconnected):
+ logger.warning(
+ "Not all bodyparts are connected. Note that connecting all bodyparts is not necessary.",
+ )
+ # 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)
+ self._show_export_feedback()
+ return True
+ except Exception as e:
+ logger.warning(f"Failed to export skeleton: {e}", stacklevel=2)
+ return False
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
- inds = self.tree.query_ball_point(verts, 5)
+ # Transform keypoints and lasso vertices from image/data coordinates
+ # into display coordinates. This makes the grab radius independent of
+ # the image resolution and current zoom level.
+ xy_display = self._ax.transData.transform(self.xy)
+ verts_display = self._ax.transData.transform(np.asarray(verts))
+
+ tree_display = KDTree(xy_display)
+ inds = tree_display.query_ball_point(
+ verts_display,
+ self.lasso_select_size,
+ )
+
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, :])))
- self.lines.set_segments(self.segs)
+
+ self.lines.set_segments(list(self.segs))
self.fig.canvas.draw_idle()
diff --git a/deeplabcut/utils/video_processor.py b/deeplabcut/utils/video_processor.py
index edca74ffc0..135375fc17 100644
--- a/deeplabcut/utils/video_processor.py
+++ b/deeplabcut/utils/video_processor.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
Author: Hao Wu
hwu01@g.harvard.edu
@@ -11,26 +21,77 @@
i.e. 'XVID'
"""
+import logging
+from abc import ABC, abstractmethod
+from typing import Literal
+
import cv2
import numpy as np
+logger = logging.getLogger(__name__)
-class VideoProcessor(object):
- """
- 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.
+class VideoProcessor(ABC):
+ """Abstract base class for video reading and writing.
+
+ Subclasses implement backend-specific video loading, metadata extraction,
+ output video creation, frame reading, frame writing, and cleanup.
+
+ Args:
+ fname (str): Path to the input video. If empty, no input video is opened.
+ sname (str): Path to the output video. If empty, no output video is created.
+ nframes (int): Number of frames to process. ``-1`` means all frames.
+ fps (float | None): Optional FPS override.
+ codec (str): FourCC codec string used for output videos.
+ sh (int | Literal[""] | None): Output video height. ``""`` and ``None``
+ mean use the input height.
+ sw (int | Literal[""] | None): Output video width. ``""`` and ``None``
+ mean use the input width.
+
+ Attributes:
+ fname (str): Input video path.
+ sname (str): Output video path.
+ nframes (int): Number of frames to process.
+ video_fps (float | None): Video frame rate.
+ FPS (float | None): Legacy alias for ``video_fps``.
+ h (int): Input video height.
+ w (int): Input video width.
+ nc (int): Number of channels.
+ i (int): Number of successfully loaded frames.
+ vid: Backend-specific input video object.
+ svid: Backend-specific output video object.
+ sh (int): Output video height.
+ sw (int): Output video width.
+
+ Notes:
+ ``height()``, ``width()``, ``fps()``, ``counter()``, and
+ ``frame_count()`` are retained as methods for backwards compatibility.
"""
def __init__(
- self, fname="", sname="", nframes=-1, fps=None, codec="X264", sh="", sw=""
+ self,
+ fname: str = "",
+ sname: str = "",
+ nframes: int = -1,
+ fps: float | None = None,
+ codec: str = "X264",
+ sh: int | Literal[""] | None = "",
+ sw: int | Literal[""] | None = "",
):
+ self._fname = None
+ self._sname = None
+ self._fps = None
+ self._nframes = None
+ self._h = 0
+ self._w = 0
+ self.vid = None
+ self.svid = None
+ self.sh = 0
+ self.sw = 0
self.fname = fname
self.sname = sname
- self.nframes = nframes
self.codec = codec
- self.h = 0
- self.w = 0
+ self.nframes = nframes
self.nc = 3
self.i = 0
@@ -41,19 +102,21 @@ def __init__(
self.sh = 0
self.sw = 0
if self.sname != "":
- if sh == "" and sw == "":
- self.sh = self.h
- self.sw = self.w
+ if sh in ("", None) and sw in (None, ""):
+ self.sh = self._h
+ self.sw = self._w
else:
self.sw = sw
self.sh = sh
self.svid = self.create_video()
except Exception as ex:
- print("Error: %s", ex)
+ logger.exception("VideoProcessor initialization failed: %s", ex)
if fps is not None: # Overwrite the video's FPS
- self.FPS = fps
+ # NOTE @C-Achard 2026-06-09 improving checks here might break old API
+ # same for raising on missing FPS
+ self.fps = fps
def load_frame(self):
frame = self._read_frame()
@@ -61,95 +124,201 @@ def load_frame(self):
self.i += 1
return frame
+ @property
+ def fname(self):
+ return self._fname
+
+ @fname.setter
+ def fname(self, value):
+ self._fname = "" if value in (None, "") else str(value)
+
+ @property
+ def sname(self):
+ return self._sname
+
+ @sname.setter
+ def sname(self, value):
+ self._sname = "" if value in (None, "") else str(value)
+
+ @property
def height(self):
- return self.h
+ return self._h
+ @property
def width(self):
- return self.w
+ return self._w
+
+ @height.setter
+ def height(self, value):
+ self._h = int(value)
+
+ @width.setter
+ def width(self, value):
+ self._w = int(value)
+ @property
def fps(self):
- return self.FPS
+ return self._fps
- def counter(self):
- return self.i
+ @fps.setter
+ def fps(self, value):
+ self._fps = None if value is None else float(value)
- def frame_count(self):
- return self.nframes
+ @property
+ def nframes(self):
+ return self._nframes
+ @nframes.setter
+ def nframes(self, value):
+ self._nframes = int(value)
+
+ @abstractmethod
def get_video(self):
- """
- implement your own
- """
- pass
+ raise NotImplementedError("Implement your own get_video method.")
+
+ @abstractmethod
def get_info(self):
- """
- implement your own
- """
- pass
+ """Implement your own."""
+ @abstractmethod
def create_video(self):
- """
- implement your own
- """
- pass
+ """Implement your own."""
+ @abstractmethod
def _read_frame(self):
- """
- implement your own
- """
- pass
+ """Implement your own."""
+ @abstractmethod
def save_frame(self, frame):
- """
- implement your own
- """
- pass
+ """Implement your own."""
+ @abstractmethod
def close(self):
- """
- implement your own
- """
- pass
+ """Implement your own."""
class VideoProcessorCV(VideoProcessor):
- """
- OpenCV implementation of VideoProcessor
- requires opencv-python==3.4.0.12
- """
+ """OpenCV-backed video reader and writer.
- def __init__(self, *args, **kwargs):
- super(VideoProcessorCV, self).__init__(*args, **kwargs)
+ This implementation uses cv2.VideoCapture for reading videos and
+ cv2.VideoWriter for writing videos. Frames returned by
+ `load_frame` are converted from OpenCV's native BGR channel order to
+ RGB channel order. Frames passed to `save_frame` are expected to be in
+ RGB order and are converted back to BGR before writing.
+
+ Attributes:
+ fname (str): Path to the input video. If empty, no input video is opened.
+ sname (str): Path to the output video. If empty, no output video is created.
+ nframes (int): Number of frames to process. If initialized as ``-1``,
+ it is replaced by the total number of frames reported by OpenCV.
+ codec (str): FourCC codec string used when creating the output video.
+ h (int): Input video height in pixels.
+ w (int): Input video width in pixels.
+ nc (int): Number of channels. This implementation uses ``3``.
+ i (int): Number of frames successfully loaded through ``load_frame()``.
+ FPS (float): Frames per second reported by OpenCV, or the user-provided
+ override.
+ sh (int): Output video height in pixels.
+ sw (int): Output video width in pixels.
+ vid (cv2.VideoCapture | None): OpenCV video reader.
+ svid (cv2.VideoWriter | None): OpenCV video writer.
+ """
def get_video(self):
+ """Open the input video with OpenCV.
+
+ Returns:
+ cv2.VideoCapture: OpenCV video capture object for ``self.fname``.
+ """
return cv2.VideoCapture(self.fname)
def get_info(self):
- self.w = int(self.vid.get(cv2.CAP_PROP_FRAME_WIDTH))
- self.h = int(self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
+ """Populate metadata from the OpenCV video reader.
+
+ Sets:
+ self.w: Frame width in pixels.
+ self.h: Frame height in pixels.
+ self.nframes: Number of frames to process.
+ self.FPS: Frames per second reported by OpenCV.
+ self.nc: Number of channels, always ``3``.
+
+ Notes:
+ If ``self.nframes`` is ``-1`` or greater than the total number of
+ frames reported by OpenCV, it is replaced by OpenCV's frame count.
+ """
+ self.width = int(self.vid.get(cv2.CAP_PROP_FRAME_WIDTH))
+ self.height = int(self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
all_frames = int(self.vid.get(cv2.CAP_PROP_FRAME_COUNT))
- self.FPS = self.vid.get(cv2.CAP_PROP_FPS)
+ self.fps = self.vid.get(cv2.CAP_PROP_FPS)
self.nc = 3
+
if self.nframes == -1 or self.nframes > all_frames:
self.nframes = all_frames
def create_video(self):
+ """Create an OpenCV video writer.
+
+ Returns:
+ cv2.VideoWriter: OpenCV video writer for ``self.sname``.
+
+ Notes:
+ ``self.sw`` and ``self.sh`` are expected to be set by the base class
+ before this method is called. The codec is interpreted as a FourCC
+ string, preserving the historical OpenCV behavior.
+ """
fourcc = cv2.VideoWriter_fourcc(*self.codec)
- return cv2.VideoWriter(self.sname, fourcc, self.FPS, (self.sw, self.sh), True)
+ return cv2.VideoWriter(self.sname, fourcc, self.fps, (self.sw, self.sh), True)
+
+ def _read_frame(self):
+ """Read the next video frame.
+
+ Returns:
+ numpy.ndarray | None: The next frame in RGB channel order, or
+ ``None`` if no frame could be read.
+
+ Notes:
+ OpenCV returns BGR frames. This method converts them to RGB using
+ ``np.flip(frame, 2)`` to preserve legacy behavior.
+ """
+ if self.vid is None:
+ return None
- def _read_frame(self): # return RGB (rather than BGR)!
- # return cv2.cvtColor(np.flip(self.vid.read()[1],2), cv2.COLOR_BGR2RGB)
success, frame = self.vid.read()
if not success:
return frame
+
return np.flip(frame, 2)
def save_frame(self, frame):
- self.svid.write(np.flip(frame, 2))
+ """Write one RGB frame to the output video.
+
+ Args:
+ frame (numpy.ndarray | None): RGB frame to write. ``None`` is ignored.
+
+ Notes:
+ This method preserves the historical behavior of silently ignoring
+ ``None`` frames. Non-``None`` frames are converted from RGB to BGR
+ before being passed to OpenCV.
+ """
+ if frame is None:
+ return
+ if self.svid is not None:
+ self.svid.write(np.flip(frame, 2))
+ else:
+ logger.warning(f"Could not write video because no output video writer is open for {self.sname}")
def close(self):
+ """Release OpenCV reader and writer resources.
+
+ This method is safe to call multiple times. After release, ``self.svid``
+ and ``self.vid`` are set to ``None`` to avoid accidental reuse of closed
+ OpenCV handles.
+ """
if hasattr(self, "svid") and self.svid is not None:
self.svid.release()
+ self.svid = None
+
if hasattr(self, "vid") and self.vid is not None:
self.vid.release()
+ self.vid = None
diff --git a/deeplabcut/utils/visualization.py b/deeplabcut/utils/visualization.py
index d6e52b51f2..c625f0968f 100644
--- a/deeplabcut/utils/visualization.py
+++ b/deeplabcut/utils/visualization.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -8,21 +18,34 @@
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.auxiliaryfunctions import attempttomakefolder
+from deeplabcut.utils import auxfun_videos, auxiliaryfunctions
+
+
+def get_cmap(n: int, name: str = "hsv") -> Colormap:
+ """Get the cmap.
+ 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)
@@ -35,12 +58,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
@@ -51,18 +77,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,
@@ -94,22 +119,74 @@ 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
@@ -149,8 +226,11 @@ def plot_and_save_labeled_frame(
ax,
scaling=1,
):
- image_path = os.path.join(cfg["project_path"], *DataCombined.index[ind])
- frame = io.imread(image_path)
+ if isinstance(DataCombined.index[ind], tuple):
+ image_path = Path(cfg["project_path"]).joinpath(*DataCombined.index[ind])
+ else:
+ image_path = Path(cfg["project_path"]) / DataCombined.index[ind]
+ frame = io.imread(os.fspath(image_path))
if np.ndim(frame) > 2: # color image!
h, w, numcolors = np.shape(frame)
else:
@@ -171,19 +251,23 @@ def plot_and_save_labeled_frame(
scaling=scaling,
ax=ax,
)
- save_labeled_frame(fig, image_path, foldername, ind in trainIndices)
+ save_labeled_frame(fig, image_path, Path(foldername), ind in trainIndices)
return ax
-def save_labeled_frame(fig, image_path, dest_folder, belongs_to_train):
- path = Path(image_path)
- imagename = path.parts[-1]
- imfoldername = path.parts[-2]
+def save_labeled_frame(
+ fig,
+ image_path: Path,
+ dest_folder: Path,
+ belongs_to_train: bool,
+) -> None:
+ imagename = image_path.parts[-1]
+ imfoldername = image_path.parts[-2]
if belongs_to_train:
dest = "-".join(("Training", imfoldername, imagename))
else:
dest = "-".join(("Test", imfoldername, imagename))
- full_path = os.path.join(dest_folder, dest)
+ full_path = os.fspath(dest_folder / dest)
# Windows throws error if file path is > 260 characters, can fix with prefix.
# See https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file#maximum-path-length-limitation
@@ -207,9 +291,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)
@@ -221,50 +303,42 @@ def prepare_figure_axes(width, height, scale=1.0, dpi=100):
def make_labeled_images_from_dataframe(
df,
cfg,
- destfolder="",
+ destfolder=None,
scale=1.0,
dpi=100,
keypoint="+",
draw_skeleton=True,
color_by="bodypart",
):
+ """Write labeled frames to disk from a DataFrame.
+
+ Args:
+ df (pd.DataFrame): DataFrame containing the labeled data. Typically, the
+ DataFrame is obtained through pandas.read_csv() or pandas.read_hdf().
+ cfg (dict): Project configuration.
+ destfolder (str or Path, optional): Destination folder into which images will be
+ stored. By default, same location as the labeled data. Note that the folder
+ will be created if it does not exist.
+ scale (float, optional): Up/downscale the output dimensions. By default, outputs
+ are of the same dimensions as the original images.
+ dpi (int, optional): Output resolution. 100 dpi by default.
+ keypoint (str, optional): Keypoint appearance. By default, keypoints are marked
+ by a + sign. Refer to https://matplotlib.org/3.2.1/api/markers_api.html for
+ a list of all possible options.
+ draw_skeleton (bool, optional): Whether to draw the animal skeleton as defined in
+ *cfg*. True by default.
+ color_by (str, optional): Color scheme of the keypoints. Must be either
+ 'bodypart' or 'individual'. By default, keypoints are colored relative to
+ the bodypart they represent.
"""
- Write labeled frames to disk from a DataFrame.
- Parameters
- ----------
- df : pd.DataFrame
- DataFrame containing the labeled data. Typically, the DataFrame is obtained
- through pandas.read_csv() or pandas.read_hdf().
- cfg : dict
- Project configuration.
- destfolder : string, optional
- Destination folder into which images will be stored. By default, same location as the labeled data.
- Note that the folder will be created if it does not exist.
- scale : float, optional
- Up/downscale the output dimensions.
- By default, outputs are of the same dimensions as the original images.
- dpi : int, optional
- Output resolution. 100 dpi by default.
- keypoint : str, optional
- Keypoint appearance. By default, keypoints are marked by a + sign.
- Refer to https://matplotlib.org/3.2.1/api/markers_api.html for a list of all possible options.
- draw_skeleton : bool, optional
- Whether to draw the animal skeleton as defined in *cfg*. True by default.
- color_by : str, optional
- Color scheme of the keypoints. Must be either 'bodypart' or 'individual'.
- By default, keypoints are colored relative to the bodypart they represent.
- """
-
bodyparts = df.columns.get_level_values("bodyparts")
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":
@@ -273,13 +347,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`.")
@@ -292,16 +364,16 @@ 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))
-
- 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"
- attempttomakefolder(tmpfolder)
+ bones.extend(zip(match1, match2, strict=False))
+ ind_bones = tuple(zip(*bones, strict=False))
+
+ images_list = [str(Path(cfg["project_path"]).joinpath(*tuple_)) for tuple_ in df.index.tolist()]
+ if destfolder is None:
+ destfolder = Path(images_list[0]).parent
+ else:
+ destfolder = Path(destfolder)
+ tmpfolder = destfolder.parent / (destfolder.name + "_labeled")
+ auxiliaryfunctions.attempt_to_make_folder(tmpfolder)
ic = io.imread_collection(images_list)
h, w = ic[0].shape[:2]
@@ -330,14 +402,14 @@ 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)
+ imagename = Path(filename).name
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
fig.savefig(
- os.path.join(tmpfolder, imagename.replace(".png", f"_{color_by}.png")),
+ tmpfolder / imagename.replace(".png", f"_{color_by}.png"),
dpi=dpi,
)
plt.close(fig)
@@ -351,17 +423,184 @@ 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)
+ imagename = Path(filename).name
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
fig.savefig(
- os.path.join(tmpfolder, imagename.replace(".png", f"_{color_by}.png")),
+ tmpfolder / imagename.replace(".png", f"_{color_by}.png"),
dpi=dpi,
)
plt.close(fig)
+
+
+def plot_evaluation_results(
+ df_combined: pd.DataFrame,
+ project_root: Path,
+ scorer: str,
+ model_name: str,
+ output_folder: Path,
+ 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 directory
+ 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 directory 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 = 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,
+ 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 dc3e52f597..2e77f0ac51 100644
--- a/deeplabcut/version.py
+++ b/deeplabcut/version.py
@@ -1,12 +1,13 @@
-"""
-DeepLabCut2.0-2.2 Toolbox (deeplabcut.org)
-© A. & M. Mathis Labs
-https://github.com/DeepLabCut/DeepLabCut
+#
+# 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
+#
-Please see AUTHORS for contributors.
-https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS
-Licensed under GNU Lesser General Public License v3.0
-"""
-
-__version__ = "2.2.1"
+__version__ = "3.0.1"
VERSION = __version__
diff --git a/dev-docs/docs/assets/favicon.ico b/dev-docs/docs/assets/favicon.ico
new file mode 100644
index 0000000000..e1bff26790
Binary files /dev/null and b/dev-docs/docs/assets/favicon.ico differ
diff --git a/dev-docs/docs/assets/javascripts/.gitkeep b/dev-docs/docs/assets/javascripts/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dev-docs/docs/assets/logo.png b/dev-docs/docs/assets/logo.png
new file mode 100644
index 0000000000..1c99d7d1c1
Binary files /dev/null and b/dev-docs/docs/assets/logo.png differ
diff --git a/dev-docs/docs/assets/stylesheets/.gitkeep b/dev-docs/docs/assets/stylesheets/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dev-docs/docs/developer-guides/configuration.md b/dev-docs/docs/developer-guides/configuration.md
new file mode 100644
index 0000000000..c1a656ad69
--- /dev/null
+++ b/dev-docs/docs/developer-guides/configuration.md
@@ -0,0 +1,221 @@
+# Model Configuration
+
+Model architectures in DeepLabCut PyTorch are defined using configuration files written in YAML format. These configuration files specify the model architecture, training hyperparameters, data augmentation settings, and more.
+
+## Configuration File Structure
+
+The primary configuration file is named `pytorch_cfg.yaml` and is stored in the model's training directory. This file is automatically generated during the standard DeepLabCut workflow, but can also be created manually for custom projects.
+
+## Creating Configuration Files
+
+The [`deeplabcut.pose_estimation_pytorch.config`][] module provides functions to create and manipulate configuration files.
+
+### Basic Configuration Creation
+
+Use [`make_pytorch_pose_config`][deeplabcut.pose_estimation_pytorch.config.make_pytorch_pose_config] to generate a model configuration:
+
+```python
+from pathlib import Path
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# Configuration for a DeepLabCut project
+project_cfg = {
+ "bodyparts": ["nose", "left_ear", "right_ear", "tail_base"],
+ "individuals": ["mouse1", "mouse2"],
+ # ... other project settings
+}
+
+pose_config_path = Path("/path/to/model/train")
+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, # Save the configuration to disk
+)
+```
+
+### Configuration for COCO Datasets
+
+For COCO-format datasets without a DeepLabCut project, use [`make_basic_project_config`][deeplabcut.pose_estimation_pytorch.config.make_basic_project_config]:
+
+```python
+from pathlib import Path
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# Create a minimal project configuration
+project_cfg = dlc_torch.config.make_basic_project_config(
+ dataset_path="/path/to/COCOProject",
+ bodyparts=["nose", "left_eye", "right_eye", "left_ear", "right_ear"],
+ max_individuals=2,
+ multi_animal=True,
+)
+
+# Generate model configuration
+model_cfg = dlc_torch.config.make_pytorch_pose_config(
+ project_config=project_cfg,
+ pose_config_path=Path("/path/to/experiment/train"),
+ net_type="dlcrnet_ms5",
+ top_down=False,
+ save=True,
+)
+```
+
+## Configuration File Components
+
+A complete `pytorch_cfg.yaml` file contains the following sections.
+
+### Model Architecture
+
+Specifies the backbone, optional neck, and head:
+
+```yaml
+model:
+ backbone:
+ type: HRNet
+ variant: w32
+ neck: null # omit or set to null for no neck
+ head:
+ type: HeatmapHead
+ weight_init: normal
+ predictor:
+ type: HeatmapPredictor
+ location_refinement: true
+ locref_std: 7.2801
+ target_generator:
+ type: HeatmapGaussianGenerator
+ num_heatmaps: "num_bodyparts"
+ pos_dist_thresh: 17
+ generate_locref: true
+ criterion:
+ heatmap:
+ type: WeightedMSECriterion
+ weight: 1.0
+ locref:
+ type: WeightedHuberCriterion
+ weight: 0.05
+```
+
+### Data Configuration
+
+Controls data loading, augmentation, and preprocessing. Augmentations under `train` are applied only during training; `inference` augmentations are applied during evaluation and video analysis:
+
+```yaml
+data:
+ colormode: RGB # RGB or GRAY
+ bbox_margin: 20 # pixels added around bounding boxes (top-down only)
+ train:
+ normalize_images: true
+ crop_sampling:
+ width: 448
+ height: 448
+ max_shift: 0.1
+ method: hybrid
+ affine:
+ p: 0.5
+ rotation: 30
+ scaling: [0.5, 1.25]
+ translation: 0
+ gaussian_noise: 12.75
+ motion_blur: true
+ hflip: true
+ inference:
+ normalize_images: true
+```
+
+### Training Settings
+
+Controls the training loop â batch size, number of epochs, data loading, and random seed:
+
+```yaml
+train_settings:
+ batch_size: 8
+ epochs: 200
+ seed: 42
+ dataloader_workers: 4
+ dataloader_pin_memory: true
+ display_iters: 500
+```
+
+### Runner Configuration
+
+The runner manages the training loop, optimisation, checkpointing, and evaluation. Use any optimizer from [`torch.optim`](https://pytorch.org/docs/stable/optim.html) and any scheduler from [`torch.optim.lr_scheduler`](https://pytorch.org/docs/stable/optim.html#how-to-adjust-learning-rate):
+
+```yaml
+runner:
+ type: PoseTrainingRunner
+ gpus: null # null = use device setting; list of ints for multi-GPU
+ key_metric: "test.mAP"
+ key_metric_asc: true # true if higher is better
+ eval_interval: 10 # evaluate every N epochs
+
+ optimizer:
+ type: AdamW
+ params:
+ lr: 0.0001
+ weight_decay: 0.01
+
+ scheduler:
+ type: LRListScheduler
+ params:
+ milestones: [160, 190]
+ lr_list: [[1e-5], [1e-6]]
+
+ snapshots:
+ max_snapshots: 5 # keep only the N most recent snapshots
+ save_epochs: 25 # save a snapshot every N epochs
+ save_optimizer_state: false
+
+ logger:
+ type: WandbLogger # omit or set to null for local-only logging
+ project_name: my-project
+ tags: ["model=hrnet_w32"]
+```
+
+### Resuming Training
+
+Resume from a specific snapshot by setting:
+
+```yaml
+resume_training_from: /path/to/model/train/snapshot-010.pt
+```
+
+### Inference Configuration
+
+Controls inference-specific behaviour, set independently of training augmentations:
+
+```yaml
+method: td # bu = bottom-up, td = top-down, ctd = conditional top-down
+device: auto # auto, cpu, cuda, or cuda:N
+```
+
+### Top-Down Detector Configuration
+
+Top-down models require a separate detector. The detector `pytorch_cfg.yaml` mirrors the pose model structure but uses `DetectorTrainingRunner`:
+
+```yaml
+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
+ epochs: 250
+ dataloader_workers: 0
+ display_iters: 500
+```
diff --git a/dev-docs/docs/developer-guides/custom_models.md b/dev-docs/docs/developer-guides/custom_models.md
new file mode 100644
index 0000000000..916bd0b865
--- /dev/null
+++ b/dev-docs/docs/developer-guides/custom_models.md
@@ -0,0 +1,201 @@
+# Adding Custom Models
+
+DeepLabCut's PyTorch backend uses a registry system that makes it easy to add custom model components. This guide explains how to extend the framework with your own backbones, necks, heads, predictors, and target generators.
+
+## Model Registry System
+
+The registry system allows you to register custom components that can be instantiated from configuration files. Five registries are available for different model building blocks:
+
+- [`BACKBONES`][deeplabcut.pose_estimation_pytorch.models.backbones] - Feature extraction networks
+- [`NECKS`][deeplabcut.pose_estimation_pytorch.models.necks] - Intermediate processing layers
+- [`HEADS`][deeplabcut.pose_estimation_pytorch.models.heads] - Task-specific output layers
+- [`PREDICTORS`][deeplabcut.pose_estimation_pytorch.models.predictors] - Output-to-keypoint converters
+- [`TARGET_GENERATORS`][deeplabcut.pose_estimation_pytorch.models.target_generators] - Annotation-to-target converters
+
+## Custom Backbones
+
+A backbone extracts features from input images. Subclass [`BaseBackbone`][deeplabcut.pose_estimation_pytorch.models.backbones.BaseBackbone] and decorate with `@BACKBONES.register_module`:
+
+```python
+import torch
+import torch.nn as nn
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone
+
+
+@BACKBONES.register_module
+class MyBackbone(BaseBackbone):
+ def __init__(self, out_channels: int = 256, pretrained: bool = False):
+ # stride is the total downsampling factor of the network
+ super().__init__(stride=32)
+ self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
+ self.conv2 = nn.Conv2d(64, out_channels, kernel_size=3, stride=2, padding=1)
+ # ... rest of architecture
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ x = self.conv1(x)
+ x = self.conv2(x)
+ return x
+```
+
+Use the custom backbone in a configuration file:
+
+```yaml
+model:
+ backbone:
+ type: MyBackbone
+ out_channels: 256
+ pretrained: false
+```
+
+## Custom Necks
+
+A neck processes the backbone's feature map before it reaches the head. Subclass [`BaseNeck`][deeplabcut.pose_estimation_pytorch.models.necks.BaseNeck] and register with `@NECKS.register_module`:
+
+```python
+import torch
+import torch.nn as nn
+from deeplabcut.pose_estimation_pytorch.models.necks import NECKS, BaseNeck
+
+
+@NECKS.register_module
+class MyNeck(BaseNeck):
+ def __init__(self, in_channels: int = 256, out_channels: int = 256):
+ super().__init__()
+ self.project = nn.Conv2d(in_channels, out_channels, kernel_size=1)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.project(x)
+```
+
+Reference in configuration:
+
+```yaml
+model:
+ neck:
+ type: MyNeck
+ in_channels: 256
+ out_channels: 128
+```
+
+## Custom Heads
+
+A head produces task-specific predictions from the neck's or backbone's output. Subclass [`BaseHead`][deeplabcut.pose_estimation_pytorch.models.heads.BaseHead] and register with `@HEADS.register_module`:
+
+```python
+import torch
+import torch.nn as nn
+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 MyHead(BaseHead):
+ def __init__(
+ self,
+ predictor: BasePredictor,
+ target_generator: BaseGenerator,
+ criterion: dict,
+ aggregator=None,
+ in_channels: int = 256,
+ num_keypoints: int = 17,
+ ):
+ super().__init__(
+ predictor=predictor,
+ target_generator=target_generator,
+ criterion=criterion,
+ aggregator=aggregator,
+ )
+ self.conv = nn.Conv2d(in_channels, num_keypoints, kernel_size=1)
+
+ def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
+ heatmap = self.conv(x)
+ return {"heatmap": heatmap}
+```
+
+## Custom Predictors
+
+A predictor converts a head's raw output tensors into keypoint coordinates. Subclass [`BasePredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.BasePredictor] and register with `@PREDICTORS.register_module`:
+
+```python
+import torch
+from deeplabcut.pose_estimation_pytorch.models.predictors import PREDICTORS, BasePredictor
+
+
+@PREDICTORS.register_module
+class MyPredictor(BasePredictor):
+ def forward(
+ self,
+ stride: float,
+ outputs: dict[str, torch.Tensor],
+ ) -> dict[str, torch.Tensor]:
+ heatmap = outputs["heatmap"]
+ # Locate the maximum value in each heatmap channel
+ batch, num_kpts, h, w = heatmap.shape
+ flat = heatmap.flatten(2)
+ idx = flat.argmax(dim=-1)
+ x = (idx % w).float() * stride
+ y = (idx // w).float() * stride
+ coords = torch.stack([x, y], dim=-1) # (batch, num_kpts, 2)
+ scores = flat.max(dim=-1).values
+ return {"coordinates": coords, "scores": scores}
+```
+
+## Custom Target Generators
+
+A target generator converts ground-truth annotations into training target tensors. Subclass [`BaseGenerator`][deeplabcut.pose_estimation_pytorch.models.target_generators.BaseGenerator] and register with `@TARGET_GENERATORS.register_module`:
+
+```python
+import torch
+import torch.nn as nn
+from deeplabcut.pose_estimation_pytorch.models.target_generators import (
+ TARGET_GENERATORS,
+ BaseGenerator,
+)
+
+
+@TARGET_GENERATORS.register_module
+class MyTargetGenerator(BaseGenerator):
+ def __init__(self, num_heatmaps: int, sigma: float = 2.0):
+ super().__init__()
+ self.num_heatmaps = num_heatmaps
+ self.sigma = sigma
+
+ def forward(
+ self,
+ annotations: dict,
+ stride: float,
+ output_size: tuple[int, int],
+ ) -> dict[str, torch.Tensor]:
+ # Build Gaussian heatmaps for each keypoint annotation
+ # Returns a dict matching the keys produced by the head
+ ...
+ return {"heatmap": target_heatmaps, "heatmap_mask": mask}
+```
+
+## Using Custom Components in Configuration
+
+Once registered, custom components are referenced by class name in `pytorch_cfg.yaml`. Make sure the module containing your class is imported before the configuration is loaded (e.g. at the top of your training script):
+
+```python
+# my_components.py â import this before calling dlc_torch.train()
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone
+import torch.nn as nn
+
+
+@BACKBONES.register_module
+class MyBackbone(BaseBackbone):
+ ...
+```
+
+Then reference it in the configuration:
+
+```yaml
+model:
+ backbone:
+ type: MyBackbone # matched by class name in the registry
+ out_channels: 256
+ head:
+ type: HeatmapHead # built-in head, paired with your backbone
+ ...
+```
diff --git a/dev-docs/docs/developer-guides/dataprep.md b/dev-docs/docs/developer-guides/dataprep.md
new file mode 100644
index 0000000000..d72c787884
--- /dev/null
+++ b/dev-docs/docs/developer-guides/dataprep.md
@@ -0,0 +1,142 @@
+# Data Preparation and Loading
+
+The [`deeplabcut.pose_estimation_pytorch.data`][] package provides comprehensive tools for dataset creation, train/test splitting, and data augmentation. This guide covers the data loaders, datasets, and transforms available in the PyTorch backend.
+
+## Data Loaders
+
+DeepLabCut provides two main data loader classes for different dataset formats:
+
+### DLCLoader
+
+The [`DLCLoader`][deeplabcut.pose_estimation_pytorch.data.DLCLoader] class loads labeled data from a DeepLabCut project. It handles train/test splitting, configuration loading, and dataset creation for a specific shuffle.
+
+**Basic Usage:**
+
+```python
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+loader = dlc_torch.DLCLoader(
+ config="/path/to/project/config.yaml",
+ trainset_index=0,
+ shuffle=1,
+)
+
+# Access loader properties
+print(loader.model_folder) # Path to model directory
+print(loader.evaluation_folder) # Path to evaluation directory
+print(loader.pose_task) # Task type (BOTTOM_UP, TOP_DOWN, etc.)
+
+# View the data
+print(loader.df) # Complete dataset as DataFrame
+print(loader.df_train) # Training split
+print(loader.df_test) # Test split
+```
+
+**Creating Datasets:**
+
+```python
+# Create training dataset
+train_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["train"]),
+ mode="train",
+ task=loader.pose_task,
+)
+
+# Create validation dataset
+valid_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["inference"]),
+ mode="test",
+ task=loader.pose_task,
+)
+
+# Check dataset size
+print(f"Training samples: {len(train_dataset)}")
+print(f"Validation samples: {len(valid_dataset)}")
+```
+
+### COCOLoader
+
+The [`COCOLoader`][deeplabcut.pose_estimation_pytorch.data.COCOLoader] class enables training on datasets in COCO format without creating a DeepLabCut project. This is useful for working with public datasets or custom data pipelines.
+
+**COCO Dataset Structure:**
+
+```
+COCOProject/
+âââ annotations/
+â âââ train.json
+â âââ test.json
+âââ images/
+ âââ img0000.png
+ âââ img0001.png
+ âââ ...
+```
+
+**Working with COCO Data:**
+
+```python
+from pathlib import Path
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+project_root = Path("/path/to/COCOProject")
+
+# Parse dataset information
+train_dict = dlc_torch.COCOLoader.load_json(
+ project_root,
+ filename="train.json"
+)
+max_num_individuals, bodyparts = dlc_torch.COCOLoader.get_project_parameters(train_dict)
+
+# Create model configuration
+model_cfg = dlc_torch.config.make_pytorch_pose_config(
+ project_config=dlc_torch.config.make_basic_project_config(
+ dataset_path=str(project_root),
+ bodyparts=bodyparts,
+ max_individuals=max_num_individuals,
+ multi_animal=True,
+ ),
+ pose_config_path=project_root / "experiments" / "hrnet_w32" / "train",
+ net_type="hrnet_w32",
+ top_down=True,
+ save=True,
+)
+
+# Create loader
+loader = dlc_torch.COCOLoader(
+ project_root=project_root,
+ model_config_path=project_root / "experiments" / "hrnet_w32" / "train" / "pytorch_cfg.yaml",
+ train_json_filename="train.json",
+ test_json_filename="test.json",
+)
+
+# Create datasets
+train_dataset = loader.create_dataset(
+ transform=dlc_torch.build_transforms(loader.model_cfg["data"]["train"]),
+ mode="train",
+ task=loader.pose_task,
+)
+```
+
+**Image Path Resolution:**
+
+COCO JSON files can specify image paths in two ways:
+
+1. **Relative paths**: Resolved to the `images/` folder
+
+ - `"file_name": "img0000.png"` â `/path/to/COCOProject/images/img0000.png`
+ - `"file_name": "subfolder/img0000.png"` â `/path/to/COCOProject/images/subfolder/img0000.png`
+
+1. **Absolute paths**: Used directly without resolution
+
+ - `"file_name": "/data/disk2/images/img0000.png"` â `/data/disk2/images/img0000.png`
+
+This allows you to keep images on different disks or reuse images across projects without duplication.
+
+## PoseDataset
+
+The [`PoseDataset`][deeplabcut.pose_estimation_pytorch.data.PoseDataset] class extends `torch.utils.data.Dataset` and converts raw images and keypoints into tensors for training and evaluation.
+
+- Loads images and annotations
+- Applies data augmentation transforms
+- Generates training targets using the model's target generator
+- Handles multi-animal and single-animal data
+- Supports dynamic cropping for top-down models
diff --git a/dev-docs/docs/developer-guides/index.md b/dev-docs/docs/developer-guides/index.md
new file mode 100644
index 0000000000..4714191dd8
--- /dev/null
+++ b/dev-docs/docs/developer-guides/index.md
@@ -0,0 +1,181 @@
+# Developer Guides
+
+This section provides comprehensive guides for developers working with the DeepLabCut PyTorch backend. Whether you're training models, running inference, or building custom architectures, these guides will help you understand and extend the framework.
+
+## Guide Overview
+
+### Model Architecture
+
+The [Model Architecture](models.md) guide explains the modular design of DeepLabCut models. Learn about:
+
+- **Backbones**: Feature extraction networks (ResNet, HRNet, CSPNext)
+- **Necks**: Optional intermediate processing layers
+- **Heads**: Task-specific output layers for pose estimation
+- **Predictors**: Converting model outputs to keypoint predictions
+- **Target Generators**: Creating training targets from annotations
+- **Available Models**: Pre-configured architectures for various tasks
+
+This guide is essential for understanding how components work together to form complete pose estimation systems.
+
+### Configuration Files
+
+The [Configuration Files](configuration.md) guide covers model and training configuration. Topics include:
+
+- Creating configuration files with [`make_pytorch_pose_config`][deeplabcut.pose_estimation_pytorch.config.make_pytorch_pose_config]
+- YAML configuration structure and parameters
+- Model architecture specification
+- Training hyperparameters (optimizer, scheduler, batch size)
+- Data augmentation settings
+- Reading and writing configurations
+- Using configuration templates
+
+Understanding configuration is crucial for customizing model behavior and training settings.
+
+### Data Preparation
+
+The [Data Preparation](dataprep.md) guide explains data loading and augmentation. Learn about:
+
+- [`DLCLoader`][deeplabcut.pose_estimation_pytorch.data.DLCLoader] for DeepLabCut projects
+- [`COCOLoader`][deeplabcut.pose_estimation_pytorch.data.COCOLoader] for COCO-format datasets
+- [`PoseDataset`][deeplabcut.pose_estimation_pytorch.data.PoseDataset] for PyTorch data loading
+- Data augmentation transforms
+- Train/test splitting
+- Custom collate functions for multi-animal scenarios
+- Snapshot management
+
+Proper data preparation is key to training robust models.
+
+### Training Models
+
+The [Training Models](training.md) guide covers the complete training workflow. Topics include:
+
+- High-level training APIs: [`train`][deeplabcut.pose_estimation_pytorch.apis.train] and [`train_network`][deeplabcut.pose_estimation_pytorch.apis.train_network]
+- Training on DeepLabCut projects and COCO datasets
+- [`PoseTrainingRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseTrainingRunner] for custom training loops
+- Optimizers and learning rate schedulers
+- Checkpointing and resuming training
+- Logging with Weights & Biases
+- Training top-down models (detector and pose estimation)
+- Distributed training across multiple GPUs
+- Performance optimization tips
+
+This guide helps you train models efficiently and monitor progress effectively.
+
+### Inference and Analysis
+
+The [Inference & Analysis](inference.md) guide explains how to run predictions. Learn about:
+
+- High-level APIs: [`analyze_videos`][deeplabcut.pose_estimation_pytorch.apis.analyze_videos] and [`analyze_images`][deeplabcut.pose_estimation_pytorch.apis.analyze_images]
+- Low-level [`video_inference`][deeplabcut.pose_estimation_pytorch.apis.video_inference] API
+- [`PoseInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseInferenceRunner] for pose estimation
+- [`DetectorInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.DetectorInferenceRunner] for object detection
+- [`VideoIterator`][deeplabcut.pose_estimation_pytorch.apis.VideoIterator] for efficient video processing
+- Running inference outside DeepLabCut projects
+- Using custom bounding boxes with top-down models
+- Model evaluation and metrics
+- Visualization and labeled video creation
+- Batch processing and performance optimization
+
+Master inference to efficiently analyze your data and evaluate model performance.
+
+### Adding Custom Models
+
+The [Adding Custom Models](custom_models.md) guide shows how to extend the framework. Topics include:
+
+- The model registry system (BACKBONES, NECKS, HEADS, PREDICTORS, TARGET_GENERATORS)
+- Creating custom backbones for feature extraction
+- Implementing custom heads for new output types
+- Building custom predictors for keypoint extraction
+- Designing custom target generators for training
+- Creating custom necks for feature processing
+- Using custom components in configuration files
+- Best practices for extensibility
+
+This guide empowers you to implement novel architectures and techniques.
+
+## Core Concepts
+
+### Modular Design
+
+DeepLabCut PyTorch uses a modular architecture where models are composed of interchangeable components.
+
+### Registry System
+
+Components are registered using decorators, making them available for instantiation from configuration files:
+
+```python
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES, BaseBackbone
+
+@BACKBONES.register_module
+class MyBackbone(BaseBackbone):
+ def __init__(self, ...):
+ super().__init__(stride=32)
+ # Implementation
+
+ def forward(self, x):
+ # Forward pass
+ return x
+```
+
+This pattern is used for all model components (backbones, necks, heads, predictors, target generators).
+
+### Configuration-Driven
+
+Models and training are controlled through YAML configuration files:
+
+```yaml
+model:
+ backbone:
+ type: "HRNet"
+ variant: "w32"
+ head:
+ type: "HeatmapHead"
+ num_bodyparts: 17
+
+train:
+ optimizer:
+ type: "AdamW"
+ lr: 0.0001
+ epochs: 200
+```
+
+This approach separates architecture definition from code, making experimentation easier.
+
+### Task-Based Design
+
+Different pose estimation approaches are handled through the [`Task`][deeplabcut.pose_estimation_pytorch.task.Task] enum:
+
+- `BOTTOM_UP`: Detect all keypoints then group into individuals
+- `TOP_DOWN`: Detect individuals then estimate pose for each
+- `COND_TOP_DOWN`: Conditional top-down approach
+
+The same APIs work across tasks, with the framework handling task-specific logic internally.
+
+## API Patterns
+
+### High-Level vs Low-Level APIs
+
+DeepLabCut provides both high-level and low-level APIs:
+
+- **High-Level**: Simple functions for common tasks (training, video analysis)
+- **Low-Level**: Detailed control through runners and components
+
+Start with high-level APIs and move to low-level when you need more control.
+
+### Loaders
+
+Data loaders abstract dataset access:
+
+- Use [`DLCLoader`][deeplabcut.pose_estimation_pytorch.data.DLCLoader] for standard DeepLabCut projects
+- Use [`COCOLoader`][deeplabcut.pose_estimation_pytorch.data.COCOLoader] for COCO-format data
+- Both provide consistent interfaces for dataset creation
+
+### Runners
+
+Runners encapsulate training and inference logic:
+
+- [`PoseTrainingRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseTrainingRunner]: Manages training loops
+- [`PoseInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseInferenceRunner]: Handles pose estimation
+- [`DetectorInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.DetectorInferenceRunner]: Handles object detection
+
+Runners can be created from configuration or instantiated directly for custom workflows.
diff --git a/dev-docs/docs/developer-guides/inference.md b/dev-docs/docs/developer-guides/inference.md
new file mode 100644
index 0000000000..34cb01df27
--- /dev/null
+++ b/dev-docs/docs/developer-guides/inference.md
@@ -0,0 +1,102 @@
+# Inference and Video Analysis
+
+The [`deeplabcut.pose_estimation_pytorch`][] package provides APIs for running inference on images and videos.
+
+## 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/dev-docs/docs/developer-guides/models.md b/dev-docs/docs/developer-guides/models.md
new file mode 100644
index 0000000000..12bb0924a9
--- /dev/null
+++ b/dev-docs/docs/developer-guides/models.md
@@ -0,0 +1,92 @@
+# Model Architecture
+
+The [`deeplabcut.pose_estimation_pytorch.models`][] package provides a modular framework for building pose estimation models. Models are composed of several key components that can be mixed and matched to create custom architectures.
+
+## Model Components
+
+DeepLabCut models are built from the following modular components:
+
+### Backbones
+
+Backbones are feature extraction networks that process input images and produce multi-scale feature representations. The [`deeplabcut.pose_estimation_pytorch.models.backbones`][] module provides several state-of-the-art backbone architectures.
+
+**Available Backbones:**
+
+- **ResNet** (ResNet-50, ResNet-101): Deep residual networks
+- **HRNet** (HRNet-W18, HRNet-W32, HRNet-W48): High-resolution networks that maintain high-resolution representations throughout
+- **CSPNext** (CSPNext-S, CSPNext-M, CSPNext-X): Cross Stage Partial networks for efficient feature extraction
+
+All backbones inherit from [`BaseBackbone`][deeplabcut.pose_estimation_pytorch.models.backbones.BaseBackbone] and must define a stride property indicating the downsampling factor.
+
+**Example:**
+
+```python
+from deeplabcut.pose_estimation_pytorch.models.backbones import BACKBONES
+
+# Build a backbone from configuration
+backbone_config = dict(type="HRNet", variant="w32")
+backbone = BACKBONES.build(backbone_config)
+```
+
+### Necks
+
+Necks are optional intermediate layers between the backbone and head. They typically perform feature aggregation, refinement, or transformation. The [`deeplabcut.pose_estimation_pytorch.models.necks`][] module provides various neck architectures.
+
+All necks inherit from [`BaseNeck`][deeplabcut.pose_estimation_pytorch.models.necks.BaseNeck].
+
+### Heads
+
+Heads are task-specific output layers that produce the final predictions. The [`deeplabcut.pose_estimation_pytorch.models.heads`][] module contains various head architectures for different pose estimation approaches.
+
+Each head contains:
+
+- A **predictor** to convert model outputs to keypoint locations
+- A **target generator** to create training targets from annotations
+- A **criterion** to compute the loss
+- An **aggregator** to combine multiple losses
+
+All heads inherit from [`BaseHead`][deeplabcut.pose_estimation_pytorch.models.heads.BaseHead] and output a dictionary mapping output names to tensors.
+
+**Example:**
+
+```python
+from deeplabcut.pose_estimation_pytorch.models.heads import HEADS
+
+head_config = dict(
+ type="HeatmapHead",
+ predictor=dict(type="HeatmapPredictor", location_refinement=True, locref_std=7.2801),
+ target_generator=dict(
+ type="HeatmapGaussianGenerator",
+ num_heatmaps=17,
+ pos_dist_thresh=17,
+ generate_locref=True,
+ ),
+ criterion=dict(type="WeightedMSECriterion"),
+ aggregator=None,
+ heatmap_config=dict(
+ num_input_channels=256,
+ num_output_channels=17,
+ num_deconv_layers=1,
+ ),
+)
+head = HEADS.build(head_config)
+```
+
+### Predictors
+
+Predictors transform model outputs into final keypoint predictions. The [`deeplabcut.pose_estimation_pytorch.models.predictors`][] module provides various prediction strategies.
+
+**Available Predictors:**
+
+- [`HeatmapPredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.HeatmapPredictor]: Extract keypoints from heatmaps
+- [`DEKRPredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.DEKRPredictor]: DEKR-style predictions with offsets
+- [`SimCCPredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.SimCCPredictor]: SimCC coordinate classification
+- [`PartAffinityFieldPredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.PartAffinityFieldPredictor]: Bottom-up PAF-based assembly
+
+All predictors inherit from [`BasePredictor`][deeplabcut.pose_estimation_pytorch.models.predictors.BasePredictor].
+
+### Target Generators
+
+Target generators create training targets from ground truth annotations. The [`deeplabcut.pose_estimation_pytorch.models.target_generators`][] module provides generators for different output types.
+
+All target generators inherit from [`BaseGenerator`][deeplabcut.pose_estimation_pytorch.models.target_generators.BaseGenerator].
diff --git a/dev-docs/docs/developer-guides/tracking.md b/dev-docs/docs/developer-guides/tracking.md
new file mode 100644
index 0000000000..2c8895288d
--- /dev/null
+++ b/dev-docs/docs/developer-guides/tracking.md
@@ -0,0 +1 @@
+# Tracking Guide
diff --git a/dev-docs/docs/developer-guides/training.md b/dev-docs/docs/developer-guides/training.md
new file mode 100644
index 0000000000..d47f527c95
--- /dev/null
+++ b/dev-docs/docs/developer-guides/training.md
@@ -0,0 +1,53 @@
+# Training Models
+
+The [`deeplabcut.pose_estimation_pytorch`][] package provides tools for training pose estimation models.
+
+## Training on a DeepLabCut Project
+
+For standard DeepLabCut projects, use [`train_network`][deeplabcut.pose_estimation_pytorch.apis.train_network]:
+
+```python
+import deeplabcut
+
+deeplabcut.train_network(
+ config="/path/to/project/config.yaml",
+ shuffle=1,
+ trainingsetindex=0,
+ max_epochs=200,
+ displayiters=100,
+ saveiters=10000,
+)
+```
+
+## Training on COCO Datasets
+
+You can train directly on COCO-format datasets:
+
+```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,
+)
+```
diff --git a/dev-docs/docs/examples/index.md b/dev-docs/docs/examples/index.md
new file mode 100644
index 0000000000..2cdc537d99
--- /dev/null
+++ b/dev-docs/docs/examples/index.md
@@ -0,0 +1,134 @@
+# Code Examples
+
+This guide provides practical examples for common DeepLabCut PyTorch workflows.
+
+## 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/dev-docs/docs/get-started/index.md b/dev-docs/docs/get-started/index.md
new file mode 100644
index 0000000000..7c0f4d7d39
--- /dev/null
+++ b/dev-docs/docs/get-started/index.md
@@ -0,0 +1,76 @@
+# DeepLabCut PyTorch API Documentation
+
+
+
+
+
+
+
+
+
+[](https://pypi.org/project/deeplabcut)
+
+[](https://github.com/DeepLabCut/DeepLabCut/blob/main/LICENSE)
+
+This documentation is designed for maintainers, developers, and expert users who want to understand and extend the PyTorch backend of DeepLabCut 3.0+. It provides detailed information about the architecture, APIs, and practical examples for building and training state-of-the-art pose estimation models.
+
+## Overview
+
+The [`deeplabcut.pose_estimation_pytorch`][] package provides a complete framework for training and deploying deep learning models for pose estimation. The API is designed to be modular, flexible, and extensible, allowing developers to easily add new models, customize training pipelines, and integrate with existing workflows.
+
+## Core Components
+
+The PyTorch DeepLabCut codebase is organized into four main components:
+
+### High-Level APIs
+
+The [`deeplabcut.pose_estimation_pytorch.apis`][] module contains high-level methods for training, evaluation, and inference. These methods work seamlessly with DeepLabCut projects but can also be used independently for maximum flexibility.
+
+Key functions include:
+
+- [`train`][deeplabcut.pose_estimation_pytorch.apis.train] - Train pose estimation models
+- [`evaluate`][deeplabcut.pose_estimation_pytorch.apis.evaluate] - Evaluate model performance
+- [`analyze_videos`][deeplabcut.pose_estimation_pytorch.apis.analyze_videos] - Run inference on video files
+- [`analyze_images`][deeplabcut.pose_estimation_pytorch.apis.analyze_images] - Run inference on image files
+- [`video_inference`][deeplabcut.pose_estimation_pytorch.apis.video_inference] - Low-level video inference API
+
+### Models
+
+The [`deeplabcut.pose_estimation_pytorch.models`][] package provides state-of-the-art pose estimation architectures including DLCRNet, HRNet, DEKR, BUCTD, and RTMPose. Models are built from modular components:
+
+- **Backbones**: Feature extraction networks like ResNet and HRNet (see [`deeplabcut.pose_estimation_pytorch.models.backbones`][])
+- **Necks**: Optional intermediate layers between backbone and head (see [`deeplabcut.pose_estimation_pytorch.models.necks`][])
+- **Heads**: Task-specific output layers for pose prediction (see [`deeplabcut.pose_estimation_pytorch.models.heads`][])
+- **Predictors**: Convert model outputs to keypoint locations (see [`deeplabcut.pose_estimation_pytorch.models.predictors`][])
+- **Target Generators**: Create training targets from annotations (see [`deeplabcut.pose_estimation_pytorch.models.target_generators`][])
+
+Object detection models for top-down pose estimation are available in [`deeplabcut.pose_estimation_pytorch.models.detectors`][].
+
+You can check available models programmatically:
+
+```python
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# List available pose estimation models
+print(dlc_torch.available_models())
+
+# List available object detection models
+print(dlc_torch.available_detectors())
+```
+
+### Data Loading
+
+The [`deeplabcut.pose_estimation_pytorch.data`][] package handles dataset creation, train/test splitting, and data augmentation. Two main data loaders are provided:
+
+- [`DLCLoader`][deeplabcut.pose_estimation_pytorch.data.DLCLoader] - Load data from DeepLabCut projects
+- [`COCOLoader`][deeplabcut.pose_estimation_pytorch.data.COCOLoader] - Load data in COCO format
+
+The [`PoseDataset`][deeplabcut.pose_estimation_pytorch.data.PoseDataset] class extends `torch.utils.data.Dataset` to provide tensor-based datasets for training and evaluation.
+
+### Training and Inference Runners
+
+The [`deeplabcut.pose_estimation_pytorch.runners`][] module provides classes for model training and inference:
+
+- [`PoseTrainingRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseTrainingRunner] - Handles the training loop
+- [`PoseInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.PoseInferenceRunner] - Runs pose estimation inference
+- [`DetectorInferenceRunner`][deeplabcut.pose_estimation_pytorch.runners.DetectorInferenceRunner] - Runs object detection for top-down models
diff --git a/dev-docs/docs/get-started/quickstart.md b/dev-docs/docs/get-started/quickstart.md
new file mode 100644
index 0000000000..9235e2781b
--- /dev/null
+++ b/dev-docs/docs/get-started/quickstart.md
@@ -0,0 +1,69 @@
+# Quick Start
+
+## Standard API (High-Level)
+
+The high-level API remains unchanged. PyTorch models work seamlessly with existing DeepLabCut workflows:
+
+```python
+import deeplabcut
+
+config = "/path/to/project/config.yaml"
+
+# Standard workflow works with both engines
+deeplabcut.create_training_dataset(config)
+deeplabcut.train_network(config, shuffle=1)
+deeplabcut.evaluate_network(config, shuffle=[1])
+deeplabcut.analyze_videos(config, videos=["/path/to/video.mp4"])
+```
+
+See the [standard guide](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide) for single-animal projects and [multi-animal guide](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide) for multi-animal tracking.
+
+## Low-Level PyTorch API
+
+For advanced usage, directly interact with the PyTorch backend:
+
+```python
+import deeplabcut.pose_estimation_pytorch as dlc_torch
+
+# Create a data loader
+loader = dlc_torch.DLCLoader(
+ config="/path/to/project/config.yaml",
+ trainset_index=0,
+ shuffle=1,
+)
+
+# Train with custom configuration
+dlc_torch.train(
+ loader=loader,
+ run_config=loader.model_cfg,
+ task=loader.pose_task,
+ device="cuda",
+ max_epochs=200,
+ batch_size=8,
+)
+
+# Run inference
+predictions = dlc_torch.analyze_videos(
+ config="/path/to/project/config.yaml",
+ videos=["/path/to/video.mp4"],
+ shuffle=1,
+)
+```
+
+## Listing Available Models
+
+Query supported architectures:
+
+```python
+from deeplabcut.pose_estimation_pytorch import available_models, available_detectors
+
+# List all pose estimation models
+models = available_models()
+print(models)
+# Output: ['dlcrnet_ms5', 'hrnet_w18', 'hrnet_w32', 'hrnet_w48', 'dekr_w32', ...]
+
+# List all object detectors (for top-down models)
+detectors = available_detectors()
+print(detectors)
+# Output: ['fasterrcnn_resnet50_fpn', 'fasterrcnn_mobilenet_v3_large_fpn', 'yolox_s', ...]
+```
diff --git a/dev-docs/docs/index.md b/dev-docs/docs/index.md
new file mode 100644
index 0000000000..b0b2ff8a3d
--- /dev/null
+++ b/dev-docs/docs/index.md
@@ -0,0 +1,5 @@
+# DeepLabCut Developer Documentation
+
+
+
+If you are not redirected automatically, go to [Get Started](get-started/).
diff --git a/dev-docs/docs/theme/main.html b/dev-docs/docs/theme/main.html
new file mode 100644
index 0000000000..7bb12f7cce
--- /dev/null
+++ b/dev-docs/docs/theme/main.html
@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+
+{% block outdated %}
+ You're not viewing the latest release.
+
+ Click here to go to the latest release.
+
+{% endblock %}
diff --git a/dev-docs/mkdocs.yml b/dev-docs/mkdocs.yml
new file mode 100644
index 0000000000..1ce7b36918
--- /dev/null
+++ b/dev-docs/mkdocs.yml
@@ -0,0 +1,139 @@
+site_name: DeepLabCut Developer Documentation
+site_description: Developer documentation for DeepLabCut
+site_url: https://deeplabcut.github.io/DeepLabCut/dev/
+site_dir: "../_build/dev"
+
+repo_name: DeepLabCut/DeepLabCut
+repo_url: https://github.com/DeepLabCut/DeepLabCut
+
+theme:
+ name: material
+ custom_dir: "docs/theme"
+ logo: assets/logo.png
+ favicon: assets/favicon.ico
+
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: deep purple
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: deep purple
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ features:
+ - announce.dismiss # allow dismissing announcements
+ - content.code.annotate # enable code annotations
+ - content.code.copy # add copy button to code blocks
+ - content.tabs.link # link content tabs across pages
+ - navigation.indexes # enable navigation index pages
+ - navigation.instant # enable instant navigation
+ - navigation.instant.prefetch # prefetch pages for instant navigation
+ - navigation.instant.preview # show instant previews
+ - navigation.instant.progress # show loading progress
+ - navigation.path # show breadcrumb navigation path
+ - navigation.sections # enable navigation sections
+ - navigation.tabs # enable navigation tabs
+ - navigation.top # add back-to-top button
+ - navigation.tracking # track navigation in URL
+ - search.highlight # highlight search results
+ - search.suggest # show search suggestions
+ - toc.follow # follow active heading in table of contents
+
+plugins:
+ - autorefs # link to a heading or attribute (id) from any other page in the site
+ - mike:
+ deploy_prefix: dev
+ - mkdocs-jupyter # adds support for rendering Jupyter Notebooks
+ - api-autonav: # automatically generates API reference navigation based on the file structure
+ modules:
+ - "../deeplabcut"
+ module_options: {}
+ exclude:
+ - "deeplabcut.version"
+ # - "deeplabcut.cli"
+ - "deeplabcut.benchmark"
+ - "deeplabcut.gui"
+ nav_section_title: "API Reference"
+ api_root_uri: "reference"
+ nav_item_prefix: ""
+ exclude_private: true
+ show_full_namespace: false
+ on_implicit_namespace_package: "warn"
+ - mkdocstrings:
+ default_handler: python
+ handlers:
+ python:
+ paths: [".."] # repo root, so `import deeplabcut` resolves correctly
+ options:
+ summary: true
+ show_root_heading: true
+ show_symbol_type_heading: true
+ parameter_headings: true
+ docstring_options:
+ returns_multiple_items: false
+ warn_missing_types: false
+ docstring_section_style: table
+ separate_signature: true
+ line_length: 120
+ docstring_style: google
+ show_signature_annotations: true
+ inventories:
+ - url: https://docs.python.org/3/objects.inv
+ domains: [py, std]
+ - url: https://typing-extensions.readthedocs.io/en/latest/objects.inv
+ - url: https://pytorch.org/docs/stable/objects.inv
+ - search # adds a search bar to the header
+ - social # generates social cards rendering as preview images on social media
+
+markdown_extensions:
+ - attr_list # adds support for setting attributes (ids) on elements
+ - pymdownx.superfences # allows for arbitrary nesting of code and content blocks inside each other
+ - pymdownx.highlight: # adds support for syntax highlighting of code blocks
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ default_lang: python
+ - pymdownx.arithmatex # adds support for rendering LaTeX math expressions
+ - pymdownx.inlinehilite # add support for syntax highlighting of inline code blocks
+ - pymdownx.snippets # adds the ability to embed content from arbitrary files into a document
+ - pymdownx.tabbed: # provides a syntax to easily add tabbed Markdown content.
+ alternate_style: true
+ - pymdownx.blocks.details # allows for the creation of collapsible details/summary constructs.
+ - pymdownx.blocks.admonition # allows for the creation of admonitions.
+ - pymdownx.blocks.definition # allows for the creation of definition lists.
+ - pymdownx.blocks.tab # allows for the creation of tab containers.
+ - pymdownx.blocks.html # allows for the arbitrary creation of HTML elements of various types.
+ - pymdownx.magiclink # adds support for magic links / URLs, e.g. https://example.com.
+
+extra:
+ main_docs_url: "https://deeplabcut.github.io/DeepLabCut/"
+ version:
+ provider: mike
+ default: latest-release
+ alias: true
+
+copyright: "Copyright © 2026 The DeepLabCut Team"
+
+nav:
+ - Get Started:
+ - Overview: get-started/index.md
+ - Quick Start: get-started/quickstart.md
+ - Developer Guides:
+ - Overview: developer-guides/index.md
+ - Model Architecture: developer-guides/models.md
+ # - Configuration Files: developer-guides/configuration.md
+ - Data Preparation: developer-guides/dataprep.md
+ - Training Models: developer-guides/training.md
+ - Inference & Analysis: developer-guides/inference.md
+ # - Adding Custom Models: developer-guides/custom_models.md
+ # - Multi-animal Tracking: developer-guides/tracking.md
+ - Examples:
+ - Practical Examples: examples/index.md
+ - API Reference
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..6e08a523e6
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,62 @@
+# syntax=docker/dockerfile:1
+
+ARG PYTORCH_VERSION=2.5.1
+ARG CUDA_VERSION=12.4
+ARG CUDNN_VERSION=9
+ARG DEEPLABCUT_VERSION=3.0.0
+
+# ââ core ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
+FROM pytorch/pytorch:${PYTORCH_VERSION}-cuda${CUDA_VERSION}-cudnn${CUDNN_VERSION}-runtime AS core
+
+ARG DEEPLABCUT_VERSION
+ARG CUDA_VERSION
+ARG PYTORCH_VERSION
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update -yy && \
+ apt-get install -yy --no-install-recommends \
+ libgl1 \
+ libglib2.0-0 \
+ ffmpeg \
+ build-essential \
+ git \
+ && pip install --upgrade pip \
+ && pip install "deeplabcut[modelzoo,wandb]==${DEEPLABCUT_VERSION}" \
+ && apt-get purge -yy --auto-remove build-essential git \
+ && rm -rf /var/lib/apt/lists/*
+
+# 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
+
+# Default Jupyter token (override with -e NOTEBOOK_TOKEN=).
+# An empty value (-e NOTEBOOK_TOKEN=) disables token auth â see docker/README.md.
+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 exec 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 d7c22e574b..0000000000
--- a/docker/Dockerfile.base
+++ /dev/null
@@ -1,12 +0,0 @@
-FROM nvidia/cuda:11.4.0-cudnn8-runtime-ubuntu20.04
-
-RUN DEBIAN_FRONTEND=noninteractive apt-get update -yy \
- && apt-get install -yy --no-install-recommends python3 python3-pip ffmpeg libsm6 libxext6 \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/*
-
-RUN pip install --no-cache-dir --upgrade pip \
- && pip install --no-cache-dir --upgrade "deeplabcut>=2.2.0.2" numpy==1.19.5 decorator==4.4.2 tensorflow==2.5.0
-
-# TODO required to fix permission errors when running the container with limited permission.
-RUN chmod a+rwx -R /usr/local/lib/python3.8/dist-packages/deeplabcut/pose_estimation_tensorflow/models/pretrained
diff --git a/docker/Dockerfile.latest-core b/docker/Dockerfile.latest-core
deleted file mode 100644
index ec624e09f7..0000000000
--- a/docker/Dockerfile.latest-core
+++ /dev/null
@@ -1,6 +0,0 @@
-FROM deeplabcut/deeplabcut:base
-
-ENV DLClight True
-
-COPY motd.sh /home/motd.sh
-RUN echo "source /home/motd.sh" >> /etc/profile
diff --git a/docker/Dockerfile.latest-gui b/docker/Dockerfile.latest-gui
deleted file mode 100644
index 8b7fb104c0..0000000000
--- a/docker/Dockerfile.latest-gui
+++ /dev/null
@@ -1,15 +0,0 @@
-FROM deeplabcut/deeplabcut:base
-
-RUN DEBIAN_FRONTEND=noninteractive apt-get update -yy \
- && DEBIAN_FRONTEND=noninteractive \
- 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
-
-RUN pip install --no-cache-dir --upgrade pip \
- && pip install --no-cache-dir --upgrade "deeplabcut[gui]>=2.2.0.2" numpy==1.19.5 decorator==4.4.2 tensorflow==2.5.0
-
-ENV DLClight=False
-CMD ["python3", "-m", "deeplabcut"]
diff --git a/docker/Dockerfile.latest-gui-jupyter b/docker/Dockerfile.latest-gui-jupyter
deleted file mode 100644
index 1603408f51..0000000000
--- a/docker/Dockerfile.latest-gui-jupyter
+++ /dev/null
@@ -1,7 +0,0 @@
-FROM deeplabcut/deeplabcut:latest-gui
-RUN pip install --no-cache-dir jupyter
-COPY ./examples /app/examples
-CMD ["jupyter", "notebook", \
- "--no-browser", \
- "--NotebookApp.password", "sha3_224:019dc9326f7c:689d6c568f840f6126fb438ba0cdb8ce94ddd0769399e152caf79931", \
- "--ip", "0.0.0.0" ]
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..89a29620ba 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,164 @@ 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`.
+Passing `-e NOTEBOOK_TOKEN=` (i.e. an empty string) **disables** token-authentication.
+```
-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 +213,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 b06e5ef103..0000000000
--- a/docker/build.sh
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/bin/bash
-# Build script for deeplabcut docker images.
-# > docker/build.sh [build|test|push]
-
-set -e
-
-DOCKER=${DOCKER:-'docker'}
-DOCKER_BUILD="$DOCKER build"
-BASENAME=deeplabcut/deeplabcut
-DOCKERDIR=docker
-
-if [[ ! -d ./${DOCKERDIR} ]]; then
- >&2 echo Run from repository root. Current pwd is
- >&2 pwd
- exit 1
-fi
-
-build_test_image() {
-image_id=$1
-${DOCKER_BUILD} -t deeplabcut:tmp - << EOF
-from ${image_id}
-run pip install --no-cache-dir pytest
-run mkdir -p /app
-run chmod a+rwx /app
-EOF
-}
-
-list_images() {
- $DOCKER images | grep '^deeplabcut ' | sed -s 's/\s\s\+/\t/g' | cut -f1,2 -d$'\t' --output-delimiter ':' | grep core
-}
-
-run_test() {
- test_image_id="$1"
- test_image_id=$(build_test_image $test_image_id)
-
- kwargs=(
- -u $(id -u) --tmpfs /.local --tmpfs /.cache -w /app
- --env DLClight=True
- -it deeplabcut:tmp
- )
-
- # Unit tests
- $DOCKER run -v $(pwd)/tests:/app ${kwargs[@]} python3 -m pytest -q .
-
- # Functional tests
- $DOCKER run \
- -v $(pwd)/testscript_cli.py:/app/testscript_cli.py:ro \
- -v $(pwd)/examples:/app/examples:ro \
- ${kwargs[@]} \
- python3 testscript_cli.py
-}
-
-for arg in "$@"; do
-case $1 in
- build)
- cp -r examples ${DOCKERDIR}
- for tag in base latest-core latest-gui latest-gui-jupyter; do
- (
- cd ${DOCKERDIR};
- ${DOCKER_BUILD} -t ${BASENAME}:${tag} -f Dockerfile.${tag} .
- )
- echo $tag
- done
- ;;
- test)
- for tag in latest-core; do
- run_test ${BASENAME}:${tag}
- done
- ;;
- push)
- for tag in base latest-core latest-gui latest-gui-jupyter; do
- ${DOCKER} push deeplabcut/deeplabcut:${tag}
- done
-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 c62087cde0..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} - << EOF
- from ${remote_name}
-
- # Create same user as on the host system
- run mkdir -p /home
- run mkdir -p /app
- run groupadd -g $gid $gname || groupmod -o -g $gid $gname
- run useradd -d /home -s /bin/bash -u $uid -g $gid $uname
- run chown -R $uname:$gname /home
- run chown -R $uname:$gname /app
-
- # Switch to the local user from now on
- user $uname
-EOF
- if [ $? -ne 0 ]; then
- err Build failed.
- exit 1
- fi
- err Build succeeded
-}
-
-### Start of CLI functions ###
-
-# Launch the UI version of DeepLabCut
-gui() {
- extra_args="$@"
- update gui || exit 1
- build gui || exit 1
- args="$(get_x11_args) $(get_mount_args) ${extra_args}"
- $DOCKER run -it --rm ${args} $(get_local_container_name gui) \
- || err "Failed to launch the DLC GUI. Used args: \"${args}\""
-}
-
-# Launch a Jupyter Server in the current directory
-notebook() {
- extra_args="$@"
- update gui-jupyter || exit 1
- build gui-jupyter || exit 1
- args="$(get_x11_args) $(get_mount_args) ${extra_args} -v /app/examples"
- err "Starting the notebook server."
- err "Open your browser at"
- err "http://127.0.0.1:${DLC_NOTEBOOK_PORT}"
- err "If prompted for a password, enter 'deeplabcut'."
- $DOCKER run -p 127.0.0.1:${DLC_NOTEBOOK_PORT}:8888 -it --rm ${args} $(get_local_container_name gui-jupyter) \
- || err "Failed to launch the notebook server. Used args: \"${args}\""
-}
-
-# Launch the command line, using DLC in light mode
-bash() {
- extra_args="$@"
- update core || exit 1
- build core || exit 1
- args="$(get_mount_args) ${extra_args}"
- $DOCKER run -it $args $(get_local_container_name core) bash
-}
-
-# Launch a custom docker image (for developers)
-# Takes a local image name as the first argument.
-custom() {
- image=$1
- shift 1
- extra_args="$@"
- _build $image "${image}-custom" || exit 1
- args="$(get_mount_args) ${extra_args}"
- $DOCKER run -it $args ${image}-custom bash
-}
-
-check_system
-subcommand=${1:-gui}
-shift 1
-case "${subcommand}" in
- gui) gui "$@" ;;
- notebook) notebook "$@" ;;
- bash) bash "$@" ;;
- custom) custom "$@" ;;
- *)
- echo "Usage"
- echo "$0 [gui|notebook|bash|help]"
- ;;
-esac
diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl
new file mode 100644
index 0000000000..c1498fd71a
--- /dev/null
+++ b/docker/docker-bake.hcl
@@ -0,0 +1,47 @@
+variable "REGISTRY" {
+ default = "deeplabcut/deeplabcut"
+}
+variable "DLC_VERSION" {
+ default = "3.0.0rc14"
+}
+variable "CUDA_VERSION" {
+ default = "12.4"
+}
+variable "CUDNN_VERSION" {
+ default = "9"
+}
+variable "PYTORCH_VERSION" {
+ default = "2.5.1"
+}
+variable "MARK_LATEST" {
+ default = false
+}
+target "_common" {
+ context = "."
+ dockerfile = "Dockerfile"
+ args = {
+ DEEPLABCUT_VERSION = DLC_VERSION
+ CUDA_VERSION = CUDA_VERSION
+ CUDNN_VERSION = CUDNN_VERSION
+ PYTORCH_VERSION = PYTORCH_VERSION
+ }
+}
+target "core" {
+ inherits = ["_common"]
+ target = "core"
+ tags = concat(
+ ["${REGISTRY}:${DLC_VERSION}-core-cuda${CUDA_VERSION}"],
+ MARK_LATEST ? ["${REGISTRY}:latest"] : []
+ )
+}
+target "jupyter" {
+ inherits = ["_common"]
+ target = "jupyter"
+ tags = concat(
+ ["${REGISTRY}:${DLC_VERSION}-jupyter-cuda${CUDA_VERSION}"],
+ MARK_LATEST ? ["${REGISTRY}:latest-jupyter"] : []
+ )
+}
+group "default" {
+ targets = ["core", "jupyter"]
+}
diff --git a/docker/motd.sh b/docker/motd.sh
index 1d1db5772e..4dc268ccc7 100644
--- a/docker/motd.sh
+++ b/docker/motd.sh
@@ -2,38 +2,38 @@
# DLC docker message of the day
check_root() {
- if [[ $(id -un) == "root" ]]; then
- echo !!! Warning: !!!
- echo It seems like you run the container as root, which is not recommended.
- echo If this is not intended, make sure to launch the container with the
- echo '-u $(id -u)' flag set or use the helper scripts in the main
- echo DLC repo, https://github.com/DeepLabCut/DeepLabCut
- fi
+ if [[ $(id -un) == "root" ]]; then
+ echo !!! Warning: !!!
+ echo It seems like you run the container as root, which is not recommended.
+ echo If this is not intended, make sure to launch the container with the
+ echo '-u $(id -u)' flag set or use the helper scripts in the main
+ echo DLC repo, https://github.com/DeepLabCut/DeepLabCut
+ fi
}
print_version() {
- DLC_VERSION=$(
- python3 -c "import deeplabcut; print(deeplabcut.__version__)" \
- 2>/dev/null \
- || echo [unknown version]
- )
- echo Welcome to DeepLabCut ${DLC_VERSION}!
- echo You are running the container as user $(id -un) \($(id -u)\).
+ DLC_VERSION=$(
+ python3 -c "import deeplabcut; print(deeplabcut.__version__)" \
+ 2>/dev/null ||
+ echo [unknown version]
+ )
+ echo Welcome to DeepLabCut ${DLC_VERSION}!
+ echo You are running the container as user $(id -un) \($(id -u)\).
}
-cat << "EOF"
+cat <<"EOF"
.--, .--,
( ( \.---./ ) )
'.__/o o\__.'
`{= ^ =}ÂŽ
> 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/changelog/v0.1.0.md b/docker/package/changelog/v0.1.0.md
new file mode 100644
index 0000000000..8208aeb2c5
--- /dev/null
+++ b/docker/package/changelog/v0.1.0.md
@@ -0,0 +1,46 @@
+# 0.1.0 â 2026-05-29
+
+This release modernizes the deeplabcut-docker helper. The vendored shell script
+(deeplabcut_docker.sh) has been replaced with a pure-Python implementation,
+and the package setup has been updated to use pyproject.toml.
+
+## Breaking changes / migration
+
+- The package now requires Python â„ 3.10.
+- `setup.cfg` / `MANIFEST.in` have been replaced by `pyproject.toml`.
+ Re-install from the new package to pick up the updated entry point.
+
+## New features
+
+- **Pure-Python rewrite** â replaced the vendored shell script with a single
+ `deeplabcut_docker.py` module; no shell dependency required.
+- **`--image` flag** â pass an arbitrary `repo:tag` or digest to override the
+ default image selection from `DLC_VERSION` / `CUDA_VERSION`.
+- **Local image support** â skips the registry pull when a matching image is
+ already present locally (or when a specific version/image is requested).
+- **Supplementary group forwarding** â all supplementary groups of the current
+ host user are forwarded into the container via `--group-add`.
+- **Jupyter image validation** â warns at startup when the pulled image does
+ not appear to have a Jupyter entrypoint.
+- **Configurable Docker binary** â override via `DOCKER` environment variable
+ (e.g. `DOCKER="sudo docker"`).
+- **Token privacy** â the notebook token is no longer echoed to the terminal by
+ default; users are instructed to use the value of `NOTEBOOK_TOKEN` instead.
+
+## Security
+
+- **Empty-token warning** â setting `NOTEBOOK_TOKEN=` (empty string) disables
+ Jupyter token authentication. `deeplabcut-docker notebook` now logs a
+ prominent warning in this case.
+
+## Bug fixes
+
+- Fixed broken Jupyter image support on the current Docker Hub tags.
+- Fixed default user home directory (`/home/{user}` instead of root).
+- Fixed notebook token not being propagated into the container environment.
+- Improved error logging and exit-status propagation from `docker` subprocesses.
+
+## Infrastructure
+
+- Replaced `setup.cfg` + `MANIFEST.in` with `pyproject.toml`.
+- Added `LICENSE` (LGPL-3.0) and `Makefile` for local build and PyPI release.
diff --git a/docker/package/deeplabcut_docker.py b/docker/package/deeplabcut_docker.py
new file mode 100644
index 0000000000..721ea8c249
--- /dev/null
+++ b/docker/package/deeplabcut_docker.py
@@ -0,0 +1,216 @@
+#!/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.1.0"
+
+_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 _supplementary_group_args() -> list[str]:
+ """Return --group-add flags for each supplementary group of the current user."""
+ primary_gid = os.getgid()
+ args = []
+ for gid in os.getgroups():
+ if gid != primary_gid:
+ args += ["--group-add", str(gid)]
+ return args
+
+
+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 _pull_image(remote: str, *, skip_if_local: bool = True) -> None:
+ """Pull the image; skip the pull if it already exists locally and skip_if_local is True."""
+ if skip_if_local:
+ r = subprocess.run(
+ _docker() + ["image", "inspect", remote],
+ capture_output=True,
+ text=True,
+ )
+ if r.returncode == 0:
+ _log(f"Using local image {remote!r} (skipping pull)")
+ return
+ if r.stderr:
+ _log(f"`docker image inspect` stderr: {r.stderr.strip()}")
+ _log(f"Pulling image {remote!r}...")
+ try:
+ subprocess.run(_docker() + ["pull", remote], check=True)
+ except subprocess.CalledProcessError as e:
+ _log(f"Failed to pull image {remote!r}. Verify the image name/tag and that Docker is running and accessible.")
+ sys.exit(e.returncode)
+
+
+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}"
+ try_local_image = bool(args.image or os.environ.get("DLC_VERSION", "").strip())
+ _pull_image(remote, skip_if_local=try_local_image)
+ if mode == "notebook":
+ _warn_if_not_jupyter_image(remote)
+ _build_user_image(remote, local)
+
+ run = _docker() + ["run", "-it", "--rm", "-v", f"{os.getcwd()}:/app", "-w", "/app"]
+ run += _supplementary_group_args()
+ if mode == "notebook":
+ port = os.environ.get("DLC_NOTEBOOK_PORT", "8888")
+ token = os.environ.get("NOTEBOOK_TOKEN", "deeplabcut")
+ _log("Starting the notebook server.")
+ _log(f"Open your browser at http://127.0.0.1:{port}")
+ if token == "":
+ _log("Warning: NOTEBOOK_TOKEN is empty â Jupyter token authentication is disabled.")
+ elif token == "deeplabcut":
+ _log(f"If prompted for a token, enter {token!r}.")
+ else:
+ _log("If prompted for a token, enter the value of NOTEBOOK_TOKEN.")
+ run += ["-p", f"127.0.0.1:{port}:8888", "-e", f"NOTEBOOK_TOKEN={token}"]
+ 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 3154b176ab..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/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 33460a678e..204d253608 100644
--- a/docs/Governance.md
+++ b/docs/Governance.md
@@ -1,5 +1,15 @@
+---
+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
@@ -29,7 +39,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
@@ -42,7 +52,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
@@ -73,7 +83,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:
@@ -84,7 +94,7 @@ The current steering council of DeepLabCut consists of the original developers:
The SC membership is revisited every January. SC members who do
not actively engage with the SC duties are expected to resign. New members are
added by nomination by a core developer. Nominees should have demonstrated
-long-term, continued commitment to the project and its [mission and values](https://github.com/DeepLabCut/DeepLabCut/docs/MISSION_AND_VALUES.md). A
+long-term, continued commitment to the project and its [mission and values](mission-and-values). A
nomination will result in discussion that cannot take more than a month and
then admission to the SC by consensus. During that time deadlocked votes of the SC will
be postponed until the new member has joined and another vote can be held.
@@ -98,14 +108,14 @@ members of the community. All non-sensitive project management discussion takes
place on the [issue tracker](https://github.com/deeplabcut/deeplabcut/issues). Occasionally,
sensitive discussions may occur on a private core developer medium.
-Decisions should be made in accordance with the [mission and values](https://github.com/DeepLabCut/DeepLabCut/docs/MISSION_AND_VALUES.md)
+Decisions should be made in accordance with the [mission and values](mission-and-values)
of the DeepLabCut project.
DeepLabCut uses a âconsensus seekingâ process for making decisions. The group
tries to find a resolution that has no open objections among core developers.
Core developers are expected to distinguish between fundamental objections to a
proposal and minor perceived flaws that they can live with, and not hold up the
-decision-making process for the latter. If no option can be found without
+decision-making process for the latter. If no option can be found without
objections, the decision is escalated to the SC, which will itself use
consensus seeking to come to a resolution. In the unlikely event that there is
still a deadlock, the proposal will move forward if it has the support of a
@@ -132,7 +142,7 @@ are made according to the following rules:
decision-making process outlined above.
- **Changes to this governance model or our mission, vision, and values**
- require a dedicated issue on our [issue tracker](https://github.com/DeepLabCut/DeepLabCut/issues)
+ require a dedicated issue on our [issue tracker](https://github.com/DeepLabCut/DeepLabCut/issues)
and follow the decision-making process outlined above,
*unless* there is unanimous agreement from core developers on the change in
which case it can move forward faster.
diff --git a/docs/HelperFunctions.md b/docs/HelperFunctions.md
index d900af83f0..05c08f8ad0 100644
--- a/docs/HelperFunctions.md
+++ b/docs/HelperFunctions.md
@@ -1,33 +1,45 @@
-## Helper & Advanced Optional Function Documentation
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: archive
+ notes: I would suggest using API docs over pages like this to avoid drift. The advice below promises updates that are not being made, and the content is already quite outdated. Automating API docs generation and putting usage info for obtaining commands info in ipython in a basic 'evergreen' page would be more sustainable than trying to maintain this page.
+---
-### There are additional functions that are not required, but can be extremely helpful.
+(helper-functions)=
-First off, if you are new to Python, you might not know this handy trick: you can see
-ALL the functions in deeplabcut by typing ``deeplabcut.`` then hitting "tab." You will see a massive list!
+# Helper & Advanced Optional Function Documentation
+
+There are additional functions that are not required, but can be extremely helpful.
+
+First off, if you are new to Python, you might not know this handy trick: you can see
+ALL the functions in deeplabcut by typing `deeplabcut.` then hitting "tab." You will see a massive list!
-Or perhaps you sort of know the name of the function, but not fully, then you can start typing the command, i.e. as in ``deeplabcut.a `` then hit tab:
+Or perhaps you sort of know the name of the function, but not fully, then you can start typing the command, i.e. as in `deeplabcut.a ` then hit tab:
+Now, for any of these functions, you type `deeplabcut.analyze_videos_converth5_to_csv?` you get:
-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)!
-
+
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
----------
-
+
videopath : string
A strings containing the full paths to videos for analysis or a path to the directory where all the videos with same extension are stored.
@@ -38,15 +50,14 @@ Only videos with this extension are analyzed. The default is ``.avi``
Examples
-----------
- 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')
+ 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')
```
-While some of the names are ridiculously long, we wanted them to be "self-explanatory." Here is a list
+While some of the names are ridiculously long, we wanted them to be "self-explanatory." Here is a list
(that is bound to be continually updated)
-of currently available helper functions. To see information about any of them, including HOW
-to use them, use the ``?`` at the end of the call, as described above.
-
+of currently available helper functions. To see information about any of them, including HOW
+to use them, use the `?` at the end of the call, as described above.
```python
deeplabcut.analyze_videos_converth5_to_csv
@@ -68,7 +79,7 @@ deeplabcut.adddatasetstovideolistandviceversa
deeplabcut.comparevideolistsanddatafolders
deeplabcut.dropannotationfileentriesduetodeletedimages
-
+
deeplabcut.dropduplicatesinannotatinfiles
deeplabcut.load_demo_data
@@ -79,7 +90,7 @@ deeplabcut.export_model
```
-### New! Model Export function:
+## Model Export function:
This function allows you to export a well-trained single animal model for real-time applications, etc. This function is part of [Kane et al, 2020 eLife](https://elifesciences.org/articles/61909). Please see the paper and related code-base on how to use this utility.
@@ -89,28 +100,35 @@ This function allows you to export a well-trained single animal model for real-t
deeplabcut.export_model(cfg_path, iteration=None, shuffle=1, trainingsetindex=0, snapshotindex=None, TFGPUinference=True, overwrite=False, make_tar=True)
```
-### New! Advanced Labeling across Cameras:
+## Advanced Labeling across Cameras:
+
+### If you have two cameras and you want to make a 3D Project from your data, you can leverage this in the Labeling GUI:
-#### If you have two cameras and you want to make a 3D Project from your data, you can leverage this in the Labeling GUI:
If you have multiple cameras, you may want to use epipolar lines projected on the images you are labeling to help you label the same position on the body in each camera angle. An epipolar line is a projection from one camera to all the possible points in the second camera's image that could match the labeled point in the first camera's image. A correctly labeled point will fall somewhere along this projected line.
In order to label with epipolar lines, you must complete two additional sets of steps **prior to labeling.**
-- First, you must create a 3d project and calibrate the cameras - to do so, complete steps 1-3: https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/Overviewof3D.md.
+- First, you must create a 3d project and calibrate the cameras - to do so, complete
+ steps 1-3 in [3D Overview](3D-overview).
- Second, you must extract imagr from `camera_1` first; here you would have run the standard `deeplabcut.extract_frames(config_path, userfeedback=True)`, but just extract files from 1 camera. Next, you need to extract matching frames from `camera_2`:
+
```python
deeplabcut.extract_frames(config_path, mode = 'match', config3d=config_path3d, extracted_cam=0)
```
-You can set `extracted_cam=0` to match all other camera images to the frame numbers in the `camera_1` folder, or change this to match to other cameras. If you `deeplabcut.extract_frames` with `mode='automatic'` before, it shouldn't matter which camera you pick. If you already extracted from both cameras, be warned this will overwrite the images for `camera_2`.
+
+You can set `extracted_cam=0` to match all other camera images to the frame numbers in the `camera_1` folder, or change this to match to other cameras. If you `deeplabcut.extract_frames` with `mode='automatic'` before, it shouldn't matter which camera you pick. If you already extracted from both cameras, be warned this will overwrite the images for `camera_2`.
- Three, now you can label with epipolar lines:
- - Here, label `camera_1` as you would normally, i.e.:
- ```python
- deeplabcut.label_frames(config_path)
- ```
- - Then for `camera_2` (now it will compute the epipolar lines based on camera_1 labels and project them onto the GUI):
- ```python
- deeplabcut.label_frames(config_path, config3d=config_path3d)
- ```
+ - Here, label `camera_1` as you would normally, i.e.:
+
+ ```python
+ deeplabcut.label_frames(config_path)
+ ```
+
+ - Then for `camera_2` (now it will compute the epipolar lines based on camera_1 labels and project them onto the GUI):
+
+ ```python
+ deeplabcut.label_frames(config_path, config3d=config_path3d)
+ ```
diff --git a/docs/MISSION_AND_VALUES.md b/docs/MISSION_AND_VALUES.md
index ec1da065b0..80630ed2fc 100644
--- a/docs/MISSION_AND_VALUES.md
+++ b/docs/MISSION_AND_VALUES.md
@@ -1,55 +1,83 @@
+---
+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](https://github.com/DeepLabCut/DeepLabCut/docs/Governance.md).
+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
- fast (GPU-powered)
- 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
-a welcoming place for all.
+- 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
-- 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.
+- [issue tracker](https://github.com/DeepLabCut/DeepLabCut/issues).
-- 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 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 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 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.
## 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
new file mode 100644
index 0000000000..c10220c313
--- /dev/null
+++ b/docs/ModelZoo.md
@@ -0,0 +1,220 @@
+---
+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, 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[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.
+
+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).
+
+## 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:
+
+- `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:
+
+- `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:
+
+- `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.
+
+### Practical example: Using SuperAnimal models for inference without training.
+
+You can simply call the model and run video inference.
+
+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)
+```
+
+### 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 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
+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,
+ 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:
+\* `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")
+
+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,
+ 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.
+
+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.
+
+### 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).
+
+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{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 beb7323dbd..4351e78f87 100644
--- a/docs/Overviewof3D.md
+++ b/docs/Overviewof3D.md
@@ -1,30 +1,51 @@
+---
+deeplabcut:
+ last_content_updated: '2025-10-14'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: update
+ notes: Contents seem up-to-date as the codebase has not evolved drastically for 3D, but formatting and organization could be improved. Separate basic/advanced sections could help, as well as more admonitions/dropdowns to streamline.
+---
+
+(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:**
-**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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/UseOverviewGuide.md). 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*.
+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\*.
C. You have calibration images taken (see details below!).
+### \***If you need more than 2 camera support:**
-### ***If you need more than 2 camera support:**
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:
@@ -38,83 +59,120 @@ 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 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 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'`.
- 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.
- - Cover several distances, and within each distance, cover all parts of the image view (all corners and center).
+- 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.
- - Use a checkerboard as big as possible, ideally with at least 8x6 squares.
+ - Cover several distances, and within each distance, cover all parts of the image view (all corners and center).
- - 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.
+ - Use a checkerboard as big as possible, ideally with at least 8x6 squares.
- - 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).
+ - 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).
-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:
@@ -123,56 +181,86 @@ 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:
@@ -203,8 +291,16 @@ 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 ;):
@@ -212,25 +308,40 @@ 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.
@@ -250,8 +361,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/PROJECT_GUI.md b/docs/PROJECT_GUI.md
deleted file mode 100644
index add4830d0d..0000000000
--- a/docs/PROJECT_GUI.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Interactive Project Manager GUI
-
-As of DeepLabCut 2.1+ now provide a full front-end user experience for DeepLabCut. As some users may be more comfortable working with an interactive interface, we wanted to provide an easy-entry point to the software. All the main functionality is now available in an easy-to-deploy GUI interface. Thus, while the many advanced features are not fully available in this Project GUI, we hope this gets more users up-and-running quickly.
-
-## Get Started:
-
-(1) Install DeepLabCut using the simple-install with Anaconda found [here!](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/installation.md)
-
-or
-
-if you already have deeplabcut installed, just go into your env then run `` pip install --upgrade deeplabcut``
-
-(2) Open the terminal and run: `python -m deeplabcut` or MacOS: `pythonw -m deeplabcut`
-
-
-
-
-
-
-Now, keep the terminal visible (as well as the GUI) so you can see the ongoing processes as you step through your project (i.e. you may need to answer yes/no in the terminal sometimes)!
-
-
-## VIDEO DEMOS: How to launch and run the Project Manager GUI:
-
-**Click on the images!**
-
-[](https://youtu.be/KcXogR-p5Ak)
-
-### Using the Project Manager GUI with the latest DLC code (single animals, plus objects): :arrow_down:
-
-[](https://www.youtube.com/watch?v=JDsa8R5J0nQ)
-
-[READ MORE HERE](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/UseOverviewGuide.md#important-information-on-using-deeplabcut)
-
-### 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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/UseOverviewGuide.md#important-information-on-using-deeplabcut)
-
-## VIDEO DEMO: How to benchmark your data with the new networks and data augmentation pipelines:
-
-[Watch the video](https://youtu.be/WXCVr6xAcCA)
diff --git a/docs/README.md b/docs/README.md
index 757d24fb46..00b76cff14 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,43 +1,14 @@
-# Welcome to Our Documentation Hub!
+---
+deeplabcut:
+ last_content_updated: '2022-08-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
-
+
-If you are new to DeepLabCut, we have 3 resources we recommend before jumping in. (1) Please first [read at the Nature Protocol paper](https://www.nature.com/articles/s41596-019-0176-0); (2) Check out the [quick video on navigating the docs](https://www.youtube.com/watch?v=A9qZidI7tL8); (3) Check out the [free DeepLabCut Course](https://github.com/DeepLabCut/DeepLabCut-Workshop-Materials/blob/master/DLCcourse.md): we have put together a "course" on the science of DeepLabCut and how to use it.
+
+Please see https://deeplabcut.github.io/DeepLabCut for documentation on how to use this software.
-
-**Otherwise, here are the links to all the key steps to get you up and running within a day!**
-
-- **[How to install DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/installation.md)**
-
-- 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)](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/UseOverviewGuide.md#important-information-on-using-deeplabcut)** Then pick:
-
-- **[How to use standard DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/standardDeepLabCut_UserGuide.md)**
-- **[How to use multi-animal DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/maDLC_UserGuide.md)**
-
-
-## More resources for help, additional docs, and roadmap of the project!
-
-[CLICK to launch an interactive graphic that walks you through more resources!](https://view.genial.ly/5fb40a49f8a0ef13943d4e5e/horizontal-infographic-review-learning-to-use-deeplabcut) (mini preview below)
-
-
-
-
-
- - [Real-time feedback with DeepLabCut: DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live)
-
- - [DeepLabCut 3D](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/Overviewof3D.md)
-
- - [Helper functions](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/HelperFunctions.md)
-
- - [Developer Road Map!](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/roadmap.md)
-
- - [How to Contribute to DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/blob/master/CONTRIBUTING.md) and the [Code of Conduct](https://github.com/DeepLabCut/DeepLabCut/blob/master/CODE_OF_CONDUCT.md)
-
- - [The Authors of DeepLabCut](https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS)
-
-A quick video to help you navigate the docs: **Click on the image!**
-
-[](https://www.youtube.com/watch?v=A9qZidI7tL8)
-
-- 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 to learn more about what we DO hope to provide you.
+This directory contains the source code for the docs.
diff --git a/docs/UseOverviewGuide.md b/docs/UseOverviewGuide.md
index 3ab727a528..f9ebc3a1a7 100644
--- a/docs/UseOverviewGuide.md
+++ b/docs/UseOverviewGuide.md
@@ -1,143 +1,242 @@
-## Documentation Overview:
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
-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.
+(overview)=
+
+# đ„ł Get started with DeepLabCut: our key recommendations
-- [How to install DeepLabCut](/docs/installation.md)
+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.
-- 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)](/docs/UseOverviewGuide.md#important-information-on-using-deeplabcut)** Then pick:
+```{hint}
+đĄđ If you are new to Python and DeepLabCut, you might consider checking our {ref}`beginner guide ` once you are ready to jump into using the DeepLabCut App!
+```
-- (1) [How to use standard DeepLabCut](/docs/standardDeepLabCut_UserGuide.md)
-- (2) [How to use multi-animal DeepLabCut](/docs/maDLC_UserGuide.md)
+## Introduction
- **Additional Learning Resources:**
+**DeepLabCut** is a software package for markerless pose estimation of animals performing various tasks. The software can manage multiple projects for various tasks. Each project is identified by the name of the project (e.g. TheBehavior), name of the experimenter (e.g. YourName), as well as the date at creation. This project folder holds a `config.yaml` (a text document) file containing various (project) parameters as well as links the data of the project.
- - [TUTORIALS:](https://www.youtube.com/channel/UC2HEbWpC_1v6i9RnDMy-dfA?view_as=subscriber) video tutorials that demonstrate various aspects of using the code base.
- - [HOW-TO-GUIDES:](https://deeplabcut.github.io/docs/UseOverviewGuide.md) 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
+
+
+
-Getting Started: [a video tutorial on navigating the documentation!](https://www.youtube.com/watch?v=A9qZidI7tL8)
+
+
+
+## {ref}`Installing DeepLabCut`
- ### What you need to get started:
+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!
- - **a set of videos that span the types of behaviors you want to track.** Having 10 videos that include different backgrounds, different individuals, and different postures is MUCH better than 1 or 2 videos of 1 or 2 different individuals (i.e. 10-20 frames from each of 10 videos is **much better** than 50-100 frames from 2 videos).
+## What we support
- - **minimally, a computer w/a CPU.** If you want to use DeepLabCut on your own computer for many experiments, then you should get an NVIDIA GPU. See technical specs [here](https://github.com/DeepLabCutDeepLabCut/wiki/FAQ). You can also use cloud computing resources, including COLAB ([see how](https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/README.md)).
+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"!
+### Main modes of DeepLabCut
- ### What you DON'T need to get started:
+- Decide on your needs: there are **two main modes, standard DeepLabCut or multi-animal DeepLabCut**.
- - 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).
+ - 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. See {ref}`important-info-regd-usage`.
+ - Then pick a user guide:
+ 1. {ref}`How to use standard DeepLabCut `
+ 1. {ref}`How to use multi-animal DeepLabCut `
- - no specific computer is required (but see recommendations above), our software works on Linux, Windows, and MacOS, although we recommend Ubuntu.
+- 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)
+ - 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.
-### Overview:
-**DeepLabCut** is a software package for markerless pose estimation of animals performing various tasks. The software can manage multiple projects for various tasks. Each project is identified by the name of the project (e.g. TheBehavior), name of the experimenter (e.g. YourName), as well as the date at creation. This project folder holds a ``config.yaml`` (a text document) file containing various (project) parameters as well as links the data of the project.
+
+
+
+- Here is more information on BUCTD:
-
+
+### Additional learning resources
-### 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.
+- [Video tutorials:](https://www.youtube.com/channel/UC2HEbWpC_1v6i9RnDMy-dfA?view_as=subscriber) video tutorials that demonstrate various aspects of using the code base.
-
-
+
-
-
+
-**NOTE:** There is a highly similar workflow for 2.2+ (and your 2.X projects are still fully compatible with this format!).
+- [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
+- {ref}`Beginner's guide to the GUI`: a step-by-step walkthrough of the GUI for new users.
-** 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)
+
-
-
-
+
+### What you need to get started
-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.
+- **A set of videos that span the types of behaviors you want to track.** Having 10 videos that include different backgrounds, different individuals, and different postures is MUCH better than 1 or 2 videos of 1 or 2 different individuals (i.e. 10-20 frames from each of 10 videos is **much better** than 50-100 frames from 2 videos).
+
+- **Ideally, a computer with a GPU.** If you want to use DeepLabCut on your own computer for training and/or for many experiments, then you should get an NVIDIA GPU.
+
+- You can also use cloud computing resources, including COLAB ([see how](https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/README.md)).
+
+### What you DON'T need to get started
+
+- 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.
+
+## Workflow overview
+
+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` 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 a {ref}`conda environment`; once you are finished, exit your terminal, and later re-activate your environment.
-# Specific Advice for Using DeepLabCut:
+When working on a given project, you just need to point to the correct `config.yaml` file to resume work; the documentation below will take you through the individual steps.
-### 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...
+(sec:important-info-regd-usage)=
-#### Additional information for getting started with maDeepLabCut (aka DeepLabCut 2.2):
+## Usage advice & project types
-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.
+```{tip}
+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-animal mode.
+```
-##### *What scenario do you have?*
+### First project: single or multi-animal?
+
+*Which 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.
+
+ - Quick start: when you `create_new_project` (and leave the default flag to False in `multianimal=False`). This is the typical work path for a single animal project.
- **I have single animal videos, but I want to use the updated network capabilities introduced for multi-animal projects:**
- - quick start: when you `create_new_project` just set the flag `multianimal=True`. This enables you to use maDLC features even though you have only one animal. To note, this is rarely required for single animal projects, and not the recommended path. Some tips for when you might want to use this: this is good for say, a hand or a mouse if you feel the "skeleton" during training would increase performance. DON'T do this for things that could be identified an individual objects. i.e., don't do whisker 1, whisker 2, whisker 3 as 3 individuals. Each whisker always has a specific spatial location, and by calling them individuals you will do WORSE than in single animal mode.
-[VIDEO TUTORIAL AVAILABLE!](https://youtu.be/JDsa8R5J0nQ)
+ - Quick start: when you `create_new_project` just set the flag `multianimal=True`.
+
+ - This enables you to use maDLC features even though you have only one animal. To note, this is rarely required for single animal projects, and not the recommended path.
+ - Some tips for when you might want to use this:
+ - This is good for e.g. a hand or a mouse if you feel the "skeleton" during training would increase performance.
+ - Do not do this for things that could be identified as an individual objects. i.e., don't do whisker 1, whisker 2, whisker 3 as 3 individuals.
+ Each whisker always has a specific spatial location, and by calling them individuals the network will perform worse than in single animal mode.
+
+ - [VIDEO TUTORIAL AVAILABLE!](https://youtu.be/JDsa8R5J0nQ)
- **I have multiple *identical-looking animals* in my videos:**
- - quick start: when you `create_new_project` set the flag `multianimal=True`. If you can't tell them apart, you can assign the "individual" ID to any animal in each frame. See this [labeling w/2.2 demo video](https://www.youtube.com/watch?v=_qbEqNKApsI)
-[VIDEO TUTORIAL AVAILABLE!](https://youtu.be/Kp-stcTm77g)
+ - Quick start: when you `create_new_project` set the flag `multianimal=True`.
+ - If you can't tell them apart, you can assign the "individual" ID to any animal in each frame. See this [labeling w/2.2 demo video](https://www.youtube.com/watch?v=_qbEqNKApsI)
+ - [VIDEO TUTORIAL AVAILABLE!](https://youtu.be/Kp-stcTm77g)
- **I have multiple animals, *but I can tell them apart,* in my videos and want to use DLC2.2:**
- - quick start: when you `create_new_project` set the flag `multianimal=True`. And always label the "individual" ID name the same; i.e. if you have mouse1 and mouse2 but mouse2 always has a miniscope, in every frame label mouse2 consistently. See this [labeling w/2.2 demo video](https://www.youtube.com/watch?v=_qbEqNKApsI). Then, you MUST put the following in the config.yaml file: `identity: true`
-[VIDEO TUTORIAL AVAILABLE!](https://youtu.be/Kp-stcTm77g) - ALSO, if you can tell them apart, label animals them consistently!
+ - Quick start: when you `create_new_project` set the flag `multianimal=True`.
+ - Always label the "individual" ID name the same; i.e. if you have mouse1 and mouse2 but mouse2 always has a miniscope, in every frame label mouse2 consistently. See this [labeling w/2.2 demo video](https://www.youtube.com/watch?v=_qbEqNKApsI).
+ - Then, you MUST put the following in the config.yaml file: `identity: true`
+ - [VIDEO TUTORIAL AVAILABLE!](https://youtu.be/Kp-stcTm77g)
+
+```{important}
+If you can tell them apart, label your animals consistently!
+```
- **I have a pre-2.2 single animal project, but I want to use 2.2:**
+ - Please read [the conversion to maDLC guide](convert-maDLC)
-Please read [this convert 2 maDLC guide](/docs/convert_maDLC.md)
+### Getting started with multi-animal (ma) DeepLabCut
-### The options for using DeepLabCut:
+We highly recommend using it first in the {ref}`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.
-Great - now that you get the overall workflow let's jump in! Here, you have several options.
+## How to run DeepLabCut
-[**Option 1**](/docs/UseOverviewGuide.md#option-1-demo-notebooks) DEMOs: for a quick introduction to DLC on our data.
+There are several options to use DeepLabCut, and we recommend you pick the one that best suits your needs and experience level. You can always switch between them, so don't worry about picking the "wrong" one.
-[**Option 2**](/docs/UseOverviewGuide.md#option-2-using-the-project-manager-gui) Standalone GUI: is the perfect place for beginners who want to start using DeepLabCut on your own data.
+- **Option 1**: [Demo notebooks](using-demo-notebooks): for a quick introduction to DLC on our data.
-[**Option 3**](/docs/UseOverviewGuide.md#option-3-using-the-program-terminal-start-ipython) In the terminal: is best for more advanced users, as with the terminal interface you get the most versatility and options.
+- **Option 2**: [Standalone GUI](using-project-manager-gui): is the perfect place for
+ beginners who want to start using DeepLabCut on your own data.
+- **Option 3**: [In the terminal](using-the-terminal): is best for more advanced users, as
+ with the terminal interface you get the most versatility and options.
+
+(using-demo-notebooks)=
+
+### Option 1: Demo Jupyter notebooks
-## 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
-## Option 2: using the Project Manager GUI:
[VIDEO TUTORIAL!](https://www.youtube.com/watch?v=KcXogR-p5Ak)
[VIDEO TUTORIAL#2!](https://youtu.be/Kp-stcTm77g)
-Start iPython, or if you are using MacOS, you must use ``pythonw`` vs. typing ``ipython`` or ``python``, but otherwise it's the same.
-If you are using DeepLabCut on the cloud, you cannot use the GUIs and you need to first set `DLClight=True`. If you use Windows, please always open the terminal with administrator privileges. 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).
+
+
+
+
+If you are using DeepLabCut on the cloud, you cannot use the GUIs.
+
+```{warning}
+On **Windows**: Open the terminal/cmd/anaconda prompt as **Administrator** (right click and select "Run as administrator") to avoid permission issues during usage when downloading models, and for symlink support when videos are not copied into the project folder.
+Admin mode is not required for installation.
+```
Simply open the terminal and type:
+
```python
python -m deeplabcut
```
+
That's it! Follow the GUI for details
+(using-the-terminal)=
+
+### Option 3: using the terminal
+
+1. Start iPython:
-## Option 3: using the program terminal, Start iPython*:
+ ```bash
+ ipython
+ ```
+
+1. Import DeepLabCut:
+
+ ```python
+ import deeplabcut
+ ```
+
+1. Follow the instructions in the user guides for either standard or multi-animal DeepLabCut (see below).
[VIDEO TUTORIAL AVAILABLE!](https://www.youtube.com/watch?v=7xwOhUcIGio)
-Please decide with mode you want to use DeepLabCut, and follow one of the following:
+Please decide which mode you want to use DeepLabCut with, and follow one of:
+
+- (1) {ref}`How to use standard DeepLabCut `
+- (2) {ref}`How to use multi-animal DeepLabCut `
+
+## Useful links
-- (1) [How to use standard DeepLabCut](/docs/standardDeepLabCut_UserGuide.md)
-- (2) [How to use multi-animal DeepLabCut](/docs/maDLC_UserGuide.md)
+Please read more in our Nature Protocols paper [here](https://www.nature.com/articles/s41596-019-0176-0).
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 0000000000..d0f1793c1a
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,379 @@
+/* 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;
+
+ /* Workflow dropdown/admonition colors */
+ --single-animal-border: #9b5de5;
+ --single-animal-bg: #f5edff;
+ --single-animal-title-bg: #ead7ff;
+ --single-animal-title-text: #4b236f;
+
+ --multi-animal-border: #57c4be;
+ --multi-animal-bg: #e8f8f7;
+ --multi-animal-title-bg: #21a197;
+ --multi-animal-title-text: #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;
+
+ /* Workflow dropdown/admonition colors */
+ --single-animal-border: #c084fc;
+ --single-animal-bg: rgba(72, 86, 107, 0.16);
+ --single-animal-title-bg: rgba(241, 83, 255, 0.32);
+ --single-animal-title-text: #f3e8ff;
+
+ --multi-animal-border: #57c4be;
+ --multi-animal-bg: rgba(72, 86, 107, 0.16);
+ --multi-animal-title-bg: #21a197;
+ --multi-animal-title-text: #000000;
+}
+
+/* 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;
+}
+
+/* ============================================================
+ Workflow-specific dropdown/admonition styling
+ Single animal = light purple
+ Multi animal = light green
+
+ Recommended MyST usage:
+
+ ```{dropdown}
+ :class-container: single-animal
+ :open:
+
+ Single-animal-specific instructions.
+ ```
+
+ ```{dropdown}
+ :class-container: multi-animal
+ :open:
+
+ Multi-animal-specific instructions.
+ ```
+ ============================================================ */
+
+/* ------------------------------------------------------------
+ Sphinx-design dropdowns
+ Usually rendered with .sd-dropdown
+ ------------------------------------------------------------ */
+
+.sd-dropdown.single-animal {
+ border-left: 0.35rem solid var(--single-animal-border);
+ background-color: var(--single-animal-bg);
+ border-radius: 0.5rem;
+ margin: 1rem 0;
+ overflow: hidden;
+}
+
+.sd-dropdown.single-animal>.sd-summary-title {
+ background-color: var(--single-animal-title-bg);
+ color: var(--single-animal-title-text);
+ font-weight: 700;
+}
+
+.sd-dropdown.multi-animal {
+ border-left: 0.35rem solid var(--multi-animal-border);
+ background-color: var(--multi-animal-bg);
+ border-radius: 0.5rem;
+ margin: 1rem 0;
+ overflow: hidden;
+}
+
+.sd-dropdown.multi-animal>.sd-summary-title {
+ background-color: var(--multi-animal-title-bg);
+ color: var(--multi-animal-title-text);
+ font-weight: 700;
+}
+
+/* ------------------------------------------------------------
+ Regular Sphinx/MyST admonitions as fallback
+
+ ```{admonition} Configuration
+ :class: single-animal
+
+ Single-animal-specific instructions.
+ ```
+ ------------------------------------------------------------ */
+
+div.admonition.single-animal {
+ border-left: 0.35rem solid var(--single-animal-border);
+ background-color: var(--single-animal-bg);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+div.admonition.single-animal>.admonition-title {
+ background-color: var(--single-animal-title-bg);
+ color: var(--single-animal-title-text);
+ font-weight: 700;
+}
+
+div.admonition.multi-animal {
+ border-left: 0.35rem solid var(--multi-animal-border);
+ background-color: var(--multi-animal-bg);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+div.admonition.multi-animal>.admonition-title {
+ background-color: var(--multi-animal-title-bg);
+ color: var(--multi-animal-title-text);
+ font-weight: 700;
+}
+
+/* ------------------------------------------------------------
+ Raw HTML fallback
+
+
+ Configuration
+
+ Single-animal-specific instructions.
+
+ ------------------------------------------------------------ */
+
+details.workflow-dropdown {
+ border: 1px solid transparent;
+ border-left-width: 0.35rem;
+ border-radius: 0.5rem;
+ margin: 1rem 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+details.workflow-dropdown>summary {
+ cursor: pointer;
+ font-weight: 700;
+ padding: 0.6rem 0.9rem;
+ list-style-position: inside;
+}
+
+details.workflow-dropdown>*:not(summary) {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+details.workflow-dropdown.single-animal {
+ border-left-color: var(--single-animal-border);
+ background-color: var(--single-animal-bg);
+}
+
+details.workflow-dropdown.single-animal>summary {
+ background-color: var(--single-animal-title-bg);
+ color: var(--single-animal-title-text);
+}
+
+details.workflow-dropdown.multi-animal {
+ border-left-color: var(--multi-animal-border);
+ background-color: var(--multi-animal-bg);
+}
+
+details.workflow-dropdown.multi-animal>summary {
+ background-color: var(--multi-animal-title-bg);
+ color: var(--multi-animal-title-text);
+}
+
+/* ------------------------------------------------------------
+ Compatibility fallback for dropdowns rendered as div.dropdown
+ ------------------------------------------------------------ */
+
+div.dropdown.single-animal {
+ border-left: 0.35rem solid var(--single-animal-border);
+ background-color: var(--single-animal-bg);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+div.dropdown.single-animal>.admonition-title,
+div.dropdown.single-animal>.sd-summary-title {
+ background-color: var(--single-animal-title-bg);
+ color: var(--single-animal-title-text);
+ font-weight: 700;
+}
+
+div.dropdown.multi-animal {
+ border-left: 0.35rem solid var(--multi-animal-border);
+ background-color: var(--multi-animal-bg);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+div.dropdown.multi-animal>.admonition-title,
+div.dropdown.multi-animal>.sd-summary-title {
+ background-color: var(--multi-animal-title-bg);
+ color: var(--multi-animal-title-text);
+ font-weight: 700;
+}
+
+/* ============================================================
+ Workflow dropdowns: title from CSS, default alignment,
+ custom left stripe, no hover color shift
+ ============================================================ */
+
+/* Hide sphinx-design's no-title kebab icon */
+details.sd-dropdown.sd-card.single-animal .sd-summary-text>svg.no-title,
+details.sd-dropdown.sd-card.multi-animal .sd-summary-text>svg.no-title {
+ display: none !important;
+}
+
+/* CSS-generated titles inside the default title span */
+details.sd-dropdown.sd-card.single-animal .sd-summary-text::before {
+ content: "đ Single animal";
+}
+
+details.sd-dropdown.sd-card.multi-animal .sd-summary-text::before {
+ content: "đđđ Multi animal";
+}
+
+/* Single animal colors */
+details.sd-dropdown.sd-card.single-animal>summary.sd-card-header {
+ --pst-sd-dropdown-color: var(--single-animal-border);
+ --pst-sd-dropdown-bg-color: var(--single-animal-title-bg);
+
+ color: var(--single-animal-title-text) !important;
+ background-color: var(--single-animal-title-bg) !important;
+}
+
+/* The body also needs the variable because pydata sets border-left there too */
+details.sd-dropdown.sd-card.single-animal>summary.sd-card-header+div.sd-summary-content {
+ --pst-sd-dropdown-color: var(--single-animal-border);
+
+ background-color: var(--single-animal-bg) !important;
+}
+
+/* Multi animal colors */
+details.sd-dropdown.sd-card.multi-animal>summary.sd-card-header {
+ --pst-sd-dropdown-color: var(--multi-animal-border);
+ --pst-sd-dropdown-bg-color: var(--multi-animal-title-bg);
+
+ color: var(--multi-animal-title-text) !important;
+ background-color: var(--multi-animal-title-bg) !important;
+}
+
+/* The body also needs the variable because pydata sets border-left there too */
+details.sd-dropdown.sd-card.multi-animal>summary.sd-card-header+div.sd-summary-content {
+ --pst-sd-dropdown-color: var(--multi-animal-border);
+
+ background-color: var(--multi-animal-bg) !important;
+}
+
+/* Keep text, emoji, chevron color stable */
+details.sd-dropdown.sd-card.single-animal .sd-summary-text,
+details.sd-dropdown.sd-card.single-animal .sd-summary-state-marker,
+details.sd-dropdown.sd-card.single-animal .sd-summary-state-marker svg {
+ color: var(--single-animal-title-text) !important;
+ fill: currentColor !important;
+}
+
+details.sd-dropdown.sd-card.multi-animal .sd-summary-text,
+details.sd-dropdown.sd-card.multi-animal .sd-summary-state-marker,
+details.sd-dropdown.sd-card.multi-animal .sd-summary-state-marker svg {
+ color: var(--multi-animal-title-text) !important;
+ fill: currentColor !important;
+}
+
+/* Disable pydata hover darken/lighten effect */
+details.sd-dropdown.sd-card.single-animal>summary.sd-card-header:hover,
+details.sd-dropdown.sd-card.single-animal>summary.sd-card-header:focus {
+ color: var(--single-animal-title-text) !important;
+ background-color: var(--single-animal-title-bg) !important;
+}
+
+details.sd-dropdown.sd-card.multi-animal>summary.sd-card-header:hover,
+details.sd-dropdown.sd-card.multi-animal>summary.sd-card-header:focus {
+ color: var(--multi-animal-title-text) !important;
+ background-color: var(--multi-animal-title-bg) !important;
+}
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/api/deeplabcut.video_inference_superanimal.rst b/docs/api/deeplabcut.video_inference_superanimal.rst
new file mode 100644
index 0000000000..442d0d97f9
--- /dev/null
+++ b/docs/api/deeplabcut.video_inference_superanimal.rst
@@ -0,0 +1 @@
+.. autofunction:: deeplabcut.video_inference_superanimal
diff --git a/docs/beginner-guides/Training-Evaluation.md b/docs/beginner-guides/Training-Evaluation.md
new file mode 100644
index 0000000000..c106b8982c
--- /dev/null
+++ b/docs/beginner-guides/Training-Evaluation.md
@@ -0,0 +1,79 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: As mentioned on other beginner-guides/ docs, this should be part of the GUI section.
+---
+
+(file:training-evaluation-gui)=
+
+# Neural network training and evaluation in the GUI
+
+
+
+## Network training
+
+### Creating a training dataset
+
+Before training your model, the first step is to assemble your training dataset.
+This involves:
+
+- Splitting labeled data into training and evaluation subsets
+- Creating each shuffle folder with the model configuration ready for training.
+
+**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 a good place to start.
+
+```{note}
+This guide assumes you have a (CUDA-enabled) GPU on your local machine. If you're CPU-bound and training is not feasible, 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!
+```
+
+### Starting 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!).
+
+
+
+## Network evaluation
+
+After training, it's time to see how well your model performs.
+
+### Step-by-step
+
+1. Find and click on the **`Evaluate Network`** tab.
+1. **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.
+1. 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`.
+```
+
+### Interpreting the 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 steps
+
+Head over the {ref}`file:video-analysis-gui` section to learn about applying your trained model to videos, and creating labeled videos with the results of your analysis!
diff --git a/docs/beginner-guides/beginners-guide.md b/docs/beginner-guides/beginners-guide.md
new file mode 100644
index 0000000000..9637651f6b
--- /dev/null
+++ b/docs/beginner-guides/beginners-guide.md
@@ -0,0 +1,198 @@
+---
+deeplabcut:
+ last_content_updated: '2026-03-03'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: move
+ notes: Move to GUI section.
+---
+
+(file:beginners-guide)=
+
+# Setting up a new project
+
+
+
+This guide and the related pages are intended as a beginner-friendly introduction to DeepLabCut for users who are new to Python. After you are comfortable with this material, we recommend then jumping into the more detailed user guides!
+
+
+
+
+
+## Installation
+
+Before you begin, make sure that DeepLabCut is installed on your system.
+Please see the {ref}`installation page` for detailed instructions on how to install DeepLabCut on your computer.
+
+
+
+
+
+## Starting the DeepLabCut GUI
+
+In the terminal, type:
+
+```bash
+python -m deeplabcut
+```
+
+This will open DeepLabCut.
+
+
+
+
+
+```{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.
+1. **Load Project:** Use this to resume your on-hold or past work.
+1. **Model Zoo:** Best suited for those who want to explore Model Zoo.
+
+
+
+
+
+### đŸ New project step-by-step
+
+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".
+
+ 
+
+ ```{note}
+ For most users, the engine will be PyTorch. See {ref}`sec:deeplabcut-with-tf-install` for TensorFlow support.
+ ```
+
+1. **Filling in Project Details:**
+
+ - **Naming Your Project:**
+
+ - Give a specific, easy-to-track name to your project.
+
+ ```{tip}
+ Avoid spaces in your project name.
+ ```
+
+ - **Fill in the name of the scorer/experimenter**. This name is used in data headers and directory names and it remains permanently associated with the project.
+
+1. **Determine Project Location:**
+
+ - By default, your project will be located on the **Desktop**.
+ - To pick a different location, browse as needed.
+
+1. **Multi-Animal or Single-Animal Project:**
+
+ - Tick the 'Multi-Animal' option in the menu if relevant to your experiment.
+ - Choose the 'Number of Cameras' as per your experiment.
+
+1. **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.
+ - Videos outside the project directory can be automatically copied into the project folder by selecting the "Copy videos to project folder" option. This is the recommended strategy for data management. External videos that are not copied are instead referenced via symbolic links. While using symbolic links avoids duplicating files and reduces storage usage, it is also more prone to issues, for example if the original files are moved or deleted.
+ - ```{tip}
+ By default, the GUI will look for a **directory** containing videos. Use the "Select individual files"
+ checkbox if you want to select individual videos instead of a whole folder.
+ ```
+
+1. **Define bodyparts and individuals:**
+
+ - Enter all the name, numbers or IDs of bodyparts you wish to track.
+ - **Example:** "head", "tail", "left paw", "right paw", etc.
+ - Less recommended: "L1", "L2", "L3", etc.
+ - **If you have multiple animals**:
+ - Enter the name, numbers or IDs of the individuals in your experiment.
+ - **Example:** "mouse1", "mouse2", "mouse3", etc.
+ - **Unique bodyparts**: If you wish to track "landmark" locations, such as the edges of a maze, or a specific object, you can add these as "unique bodyparts". These are not considered part of an individual, but are still tracked as part of the project.
+ - **Example**: "maze_left_edge", "maze_right_edge", "reward_port", etc.
+ - **Identity labeling**: if and only if you can tell individuals apart by their appearance (not their location), set this to Yes and consistently label your individuals in the same way across videos. This will allow DeepLabCut to learn to tell them apart, and assign consistent identities across frames and videos.
+
+1. **Create your project:**
+
+ - Click on the **`Create`** button on the bottom, right side of the main window.
+ - A new folder will be created in the location you chose above.
+
+## Video tutorial
+
+### đœ Video Tutorial: Setting Up Your Project in DeepLabCut
+
+
+
+## Next steps
+
+Next, head over to the beginner guide for {ref}`editing the configuration and managing the project `, which will show you how to edit the configuration file to edit your bodyparts and skeleton structure.
diff --git a/docs/beginner-guides/labeling.md b/docs/beginner-guides/labeling.md
new file mode 100644
index 0000000000..359e1f3817
--- /dev/null
+++ b/docs/beginner-guides/labeling.md
@@ -0,0 +1,82 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: Move to GUI section. Updated to link directly to the napari plugin docs. Making the link specific to the workflow section of the napari docs could help.
+---
+
+(file:labeling-gui)=
+
+# Labeling GUI
+
+## Selecting frames to label
+
+In DeepLabCut, choosing the right frames for labeling is a key step.
+
+```{important}
+Always aim to select the **most diverse data** you can for your model to be trained on. This implies picking a variety of good lighting, bad lighting, partial occlusions, and different poses.
+If relevant, label data across several experimental sessions, animals, and conditions.
+**Labeling 10 frames from several different videos is typically more effective than labeling 100 frames from a single video.**
+```
+
+To help you select "different" frames, DeepLabCut provides two main options:
+
+1. **Automated frame extraction** DeepLabCut can extract frames automatically for you.
+
+ - **Uniform:** Samples at regular time intervals. Does not guarantee diversity, but is simple and fast.
+ - **K-means clustering:** This one runs a k-means algorithm and picks images from different clusters. This is typically more robust in extracting a variety of actions and poses. Note, as it is a clustering tool, it will miss rare events, so after running this step, consider using the manual GUI to get some rare frames! You can do both within DLC.
+
+1. **Manual frame extraction** Pick frames yourself using the GUI. This is the most time-consuming, but allows you to have full control over the frames you pick, and can be useful to get rare events that automated tools might miss. You can also use this after running automated frame extraction to get some of those "rare" frames.
+
+### Example workflow
+
+1. Select **`automatic`** in the frame selection area.
+1. Choose **`k-means`** as a good default option for frame extraction, and set the number of frames you want to extract.
+1. Hit the **`Extract Frames`** button.
+
+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.
+With this, you are all set to start labeling!
+
+## Frame labeling workflow
+
+Alright, you've got your extracted frames ready. Now comes the labeling!
+
+### Launching the labeling GUI
+
+- **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 unlabeled folder to start, and then click **`Open`**.
+
+### napari-deeplabcut
+
+Please refer to the {ref}`file:napari-dlc-basic-usage` section for a detailed walkthrough of how to use the napari-DLC plugin for labeling your frames.
+
+### Completing the labeling
+
+Work through all the frames in the first folder and save them.
+
+```{tip}
+After saving, you can close napari and click **`Label Frames`** again to open the next folder
+**OR**
+Remove all layers in napari and drag-and-drop the next folder in the same napari session to keep going without needing to close and reopen napari.
+```
+
+## Checking labels
+
+After you've labeled all your frames, you may want to review their accuracy before moving on to training your model. This is a crucial step, as the quality of your labels will directly impact the performance of your model.
+
+- **Return to the DeepLabCut GUI:** 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.
+A clean foundation is essential for accurate analysis.
+
+## Next steps
+
+Head on to {ref}`file:training-evaluation-gui` to learn about training and evaluating your neural network with the labeled data you created!
diff --git a/docs/beginner-guides/manage-project.md b/docs/beginner-guides/manage-project.md
new file mode 100644
index 0000000000..fd091a2c44
--- /dev/null
+++ b/docs/beginner-guides/manage-project.md
@@ -0,0 +1,67 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: Move to a dedicated GUI section. Making the config edit tool slightly easier to work with and updating the docs below to include additional fields would be helpful.
+---
+
+(file:manage-project-gui)=
+
+# Editing and working with the configuration file
+
+
+
+The configuration file (`config.yaml`) is the central record of files in your project, as well as the settings for your models.
+As a YAML file, it can be edited manually, but the GUI provides an easy way to edit it without needing to know the YAML format. In this guide, we will show you how to edit the configuration file using the GUI.
+
+## Editing the configuration
+
+After creating your DeepLabCut project, you'll be shown the main GUI window, where you can manage your project from the Project Management Tab.
+
+- **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`**.
+ - A **`Configuration Editor`** window will open, displaying all the configuration details.
+ - You will need to modify some of these settings to align with your experiment.
+ - For example:
+ - Update or define the bodyparts you wish to track.
+ - *Optional:* Outline the skeleton structure.
+
+## Step-by-step configuration walkthrough
+
+### Defining & updating 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`**.
+
+
+
+
+
+### 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 list of bodypart pairs: i.e. the connections that define the skeleton structure of your model.
+ - In the list of bodypart pairs, each pair has an index. (ranging from 0 to the total number of pairs in the skeleton).
+ - Each item of the pair (also indexed; 0 or 1) has a value: the name of the bodypart.
+ - Each pair of two bodyparts represents a connection, where all connections together make the skeleton.
+
+
+
+```{tip}
+Spend some time visualizing how the chosen bodyparts can be connected effectively to form a coherent, visually helpful skeleton.
+```
+
+### Saving 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 steps
+
+Head over the guide for the {ref}`file:labeling-gui`, which will show you how to label your data using the napari-based labeling GUI.
diff --git a/docs/beginner-guides/video-analysis.md b/docs/beginner-guides/video-analysis.md
new file mode 100644
index 0000000000..842ee78506
--- /dev/null
+++ b/docs/beginner-guides/video-analysis.md
@@ -0,0 +1,55 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: As mentioned on other beginner-guides/ docs, this should be part of the GUI section.
+---
+
+(file:video-analysis-gui)=
+
+# Video analysis in the GUI
+
+
+
+After training and evaluating your model, the next step is to apply it to your videos.
+
+## Analyzing videos with your trained model
+
+### Step-by-step
+
+1. **Navigate to the 'Analyze Videos' Tab:** Begin applying your trained model to video data here.
+1. **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.
+1. **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.
+
+
+
+## Generating labeled videos
+
+### Create a labeled video
+
+1. **Go to 'Create Labeled Video' Tab:** The previously analyzed video should be selected.
+1. If not already selected, choose your video.
+1. Click **`Create Videos`**.
+
+### View the labeled video
+
+- Your labeled video will be in your video folder, named after the original video plus model details and 'labeled'.
+- Use it in your results, or perform downstream analyses with it!
+
+## Next steps
+
+
+
+Check our more advanced guides, and consider reading more about models, augmentations and other parameters to further optimize your model and analysis!
diff --git a/docs/benchmark.md b/docs/benchmark.md
new file mode 100644
index 0000000000..a9c3b6fc58
--- /dev/null
+++ b/docs/benchmark.md
@@ -0,0 +1,42 @@
+---
+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/).
+
+## High Level API
+
+When implementing your own benchmarks, the most important functions are directly accessible
+under the `deeplabcut.benchmark` package.
+
+```{eval-rst}
+.. automodule:: deeplabcut.benchmark
+ :members:
+ :show-inheritance:
+```
+
+## Available benchmark definitions
+
+See [the official benchmark page](https://benchmark.deeplabcut.org/datasets.html) for a full overview
+of the available datasets. A benchmark submission should contain a result for at least one of these
+benchmarks. For an example of how to implement a benchmark submission, refer to the baselines in the
+[DeepLabCut benchmark repo](https://github.com/DeepLabCut/benchmark/tree/main/benchmark/baselines).
+
+```{eval-rst}
+.. automodule:: deeplabcut.benchmark.benchmarks
+ :members:
+ :show-inheritance:
+```
+
+## Metric calculation
+
+```{eval-rst}
+.. automodule:: deeplabcut.benchmark.metrics
+ :members:
+ :show-inheritance:
+```
diff --git a/docs/citation.md b/docs/citation.md
new file mode 100644
index 0000000000..c015514b8c
--- /dev/null
+++ b/docs/citation.md
@@ -0,0 +1,147 @@
+---
+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 41409c5439..3eff9b10cf 100644
--- a/docs/convert_maDLC.md
+++ b/docs/convert_maDLC.md
@@ -1,9 +1,19 @@
-### How to convert a pre-2.2 project for use with DeepLabCut 2.2
+---
+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 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.
@@ -13,7 +23,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
@@ -22,15 +33,21 @@ uniquebodyparts: []
multianimalbodyparts:
identity: false/true
```
+
- Now, please name the animal you have a new name under individuals, i.e.:
+
```python
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
@@ -38,37 +55,50 @@ multianimalbodyparts:
- rightear
- tailbase
```
+
then you can set `bodyparts: MULTI!`
(3) Save the config.yaml (be sure to double check for spacing or typos first!) and then run:
+
```python
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. See function details [here](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/functionDetails.md#f-create-training-datasets)).
-**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).
+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).
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]
multianimalbodyparts: [rocket_tip,rocket_bottom]
skeleton: [[[rocket_tip,rocket_bottom]]]
```
+
In the unusual case, that your data also has multiple moons (e.g. is now carried out around Jupiter), but one rocket:
+
```
individuals: [Io, Europa, Ganymede, Callisto]
uniquebodyparts: [rocket_tip,rocket_bottom]
multianimalbodyparts: [moon]
```
+
Note you can use the single object tracker for this situation. What if you have multiple moons and rockets?
diff --git a/docs/course.md b/docs/course.md
new file mode 100644
index 0000000000..83d23e5848
--- /dev/null
+++ b/docs/course.md
@@ -0,0 +1,137 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: archive
+---
+
+# 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 the {ref}`file:beginners-guide` for help!
+
+- **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?]()
+- **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/dlc-live/deeplabcutlive.md b/docs/dlc-live/deeplabcutlive.md
new file mode 100644
index 0000000000..819c4eac98
--- /dev/null
+++ b/docs/dlc-live/deeplabcutlive.md
@@ -0,0 +1,25 @@
+---
+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..27ea5c7d0e
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/index.md
@@ -0,0 +1,84 @@
+---
+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
+1. **Configure cameras** (single or multi-camera)
+1. **Select a DeepLabCut Live model**
+1. **Start preview** and verify frame rate
+1. **Run pose inference** on a selected camera
+1. **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..ff9a009ca9
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/quickstart/install.md
@@ -0,0 +1,241 @@
+---
+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..e51fb670e8
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend.md
@@ -0,0 +1,350 @@
+---
+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
+ ```
+1. Check power, cabling, and network configuration
+1. 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..1350ec64f8
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend.md
@@ -0,0 +1,288 @@
+---
+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.
+1. 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)
+1. `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..007a8e0ac3
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support.md
@@ -0,0 +1,98 @@
+---
+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
+1. {ref}`GenTL ` - Industrial cameras via GenTL producers *(Windows, Linux)*
+ - Requires vendor-provided CTI files
+1. {ref}`Aravis ` - GenICam/GigE Vision cameras *(Linux, experimental on macOS)*
+1. {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..721ab4ac30
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend.md
@@ -0,0 +1,497 @@
+---
+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
+
+1. **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
+1. If `device_id` starts with `serial:`, match by exact serial number, then (if needed) substring
+1. Legacy serial keys (`serial_number` / `serial`) if present (exact then substring)
+1. 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`
+1. 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..18b0d7cbf9
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend.md
@@ -0,0 +1,311 @@
+---
+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)
+1. `properties.opencv.device_name` (substring match)
+1. `properties.opencv.device_vid` + `device_pid`
+1. `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..6ebe0d0a81
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing.md
@@ -0,0 +1,12 @@
+---
+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..9a64875687
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads.md
@@ -0,0 +1,126 @@
+---
+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.
+1. Downloads the correct model snapshot (weights) for the specified `super_animal` + `model_name`.
+1. 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..edd0ff9c21
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format.md
@@ -0,0 +1,101 @@
+---
+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..e94186a47d
--- /dev/null
+++ b/docs/dlc-live/dlc-live-gui/user_guide/overview.md
@@ -0,0 +1,319 @@
+---
+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.
+
+1. **Start Preview**
+
+ - Click **Start Preview** to begin streaming all selected configured cameras.
+ - If multiple cameras are active, the preview becomes a **tiled view**.
+
+1. **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.
+
+1. **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.
+
+1. **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/dlc-utils/XROMM/usage.md b/docs/dlc-utils/XROMM/usage.md
new file mode 100644
index 0000000000..f581ce61f8
--- /dev/null
+++ b/docs/dlc-utils/XROMM/usage.md
@@ -0,0 +1,64 @@
+(file:xamalab-dlc-integration)=
+
+# XROMM + DeepLabCut local integration
+
+These notes describe how this repository is used in the local 3-repo XROMM workflow together with `../XROMM_DLCTools` and `../xmalab`.
+
+> Contributed by [@homfunc](https://github.com/homfunc)
+
+## 1) Expected local layout
+
+Recommended sibling checkout layout:
+
+- `XROMM_DLCTools/`
+- `DeepLabCut/`
+- `xmalab/`
+ `XROMM_DLCTools/pyproject.toml` maps its optional `dlc` dependency group to this repository through `tool.uv.sources`.
+
+## 2) DeepLabCutâs role in the workflow
+
+Within the integrated workflow, DeepLabCut provides:
+
+- project creation / dataset generation support
+- video analysis / prediction entrypoints
+- the local import target used by `XROMM_DLCTools`
+- synthetic smoke coverage through the baseline harness
+ The current local integration suite also uses this repo to verify that the newer workflow service in `XROMM_DLCTools` still interoperates with a sibling DeepLabCut checkout.
+
+## 3) Local setup for this repo
+
+Standard developer setup:
+
+```bash
+uv sync --group dev
+```
+
+When working from `../XROMM_DLCTools`, enable the sibling import path there with:
+
+```bash
+uv sync --group dlc
+```
+
+## 4) Integration validation from XROMM_DLCTools
+
+Run these commands from `../XROMM_DLCTools`:
+
+```bash
+uv run python scripts/baseline_harness.py --scenario deeplabcut_repo_smoke --output-dir baseline_artifacts/deeplabcut_smoke --deeplabcut-repo ../DeepLabCut
+```
+
+Full multi-repo suite:
+
+```bash
+uv run python scripts/baseline_harness.py --scenario all --output-dir baseline_artifacts/integration_all --deeplabcut-repo ../DeepLabCut --xmalab-repo ../xmalab
+```
+
+True end-to-end local workflow scenario:
+
+```bash
+uv run python scripts/baseline_harness.py --scenario phase3_local_workflow_e2e --output-dir baseline_artifacts/e2e_local_workflow --deeplabcut-repo ../DeepLabCut --xmalab-repo ../xmalab
+```
+
+## 5) Compatibility notes
+
+The local workflow integration path expects this repo to remain importable in âlite modeâ when GUI dependencies are unavailable, and relies on the public `deeplabcut` import surface plus the synthetic project helpers under `examples/utils.py`.
diff --git a/docs/dlc-utils/index.md b/docs/dlc-utils/index.md
new file mode 100644
index 0000000000..36cdd16023
--- /dev/null
+++ b/docs/dlc-utils/index.md
@@ -0,0 +1,190 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-05-06'
+ last_verified: '2026-05-06'
+ verified_for: 3.0.0rc14
+ ignore: false
+---
+
+# DeepLabCut-Utils - Community contributions
+
+[](https://forum.image.sc/tags/deeplabcut)
+
+```{image} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1572296495650-Y4ZTJ2XP2Z9XF1AD74VW/ke17ZwdGBToddI8pDm48kMulEJPOrz9Y8HeI7oJuXxR7gQa3H78H3Y0txjaiv_0fDoOvxcdMmMKkDsyUqMSsMWxHk725yiiHCCLfrh8O1z5QPOohDIaIeljMHgDF5CVlOqpeNLcJ80NK65_fV7S1UZiU3J6AN9rgO1lHw9nGbkYQrCLTag1XBHRgOrY8YAdXW07ycm2Trb21kYhaLJjddA/DLC_logo_blk-01.png?format=1000w
+---
+alt: DLC Utils
+width: 350px
+align: right
+---
+```
+
+The DeepLabCut-Utils repository contains various scripts as well as links to other packages related to [DeepLabCut](https://github.com/DeepLabCut/DeepLabCut). Feel free to contribute your own analysis methods, and perhaps some short notebook of how to use it. Thanks!
+
+```{admonition} DLC-Utils
+---
+class: tip
+---
+[Link to repository](https://github.com/DeepLabCut/DLCutils)
+```
+
+```{caution}
+Please direct inquiries to the **contributors/code maintainers of that code**. Note that the software(s) are provided "as is", without warranty of any kind, express or implied.
+The DeepLabCut team is not responsible for the maintenance of these packages, and cannot guarantee that they will work with present & future versions of DeepLabCut.
+```
+
+## Example scripts for scaling up your DLC analysis & training
+
+These two scripts illustrate how to train, test, and analyze videos for multiple projects automatically (`scale_training_and_evaluation.py`) and how to analyze videos that are organized in subfolders automatically (`scale_analysis_oversubfolders.py`). Feel free to adjust them for your needs!
+
+- [Code: `scale_analysis_oversubfolders.py`](https://github.com/DeepLabCut/DLCutils/tree/master/SCALE_YOUR_ANALYSIS/scale_analysis_oversubfolders.py)
+- [Code: `scale_training_and_evaluation.py`](https://github.com/DeepLabCut/DLCutils/blob/master/SCALE_YOUR_ANALYSIS/scale_training_and_evaluation.py)
+
+Contributed by [Alexander Mathis](https://github.com/AlexEMG)
+
+## Using DLC + XROMM_DLCTools + xmalab
+
+> Contributed by [@homfunc](https://github.com/homfunc)
+
+The DeepLabCut repository can also be used as a sibling checkout together with:
+
+- `../XROMM_DLCTools`
+- `../xmalab`
+
+In that local layout, `XROMM_DLCTools` uses the optional `dlc` dependency group to import this checkout directly, and its baseline harness runs both a DeepLabCut smoke scenario and a broader end-to-end local workflow integration scenario.
+
+See {ref}`file:xamalab-dlc-integration` for the local integration notes and exact commands.
+
+## Using your DLC outputs, loading, simple ROI analysis, visualization examples
+
+### Time spent of a body part in a particular region of interest (ROI)
+
+You can compute time spent in particular ROIs in frames. This demo Jupyter Notebook shows you how to load the outputs of DLC and perform the analysis (plus other plotting functions):
+
+- [Code: `Demo_loadandanalyzeDLCdata.ipynb`](https://github.com/DeepLabCut/DLCutils/blob/master/Demo_loadandanalyzeDLCdata.ipynb)
+- [Code: `time_in_each_roi.py`](https://github.com/DeepLabCut/DLCutils/blob/master/time_in_each_roi.py)
+
+Contributed by [Federico Claudi](https://github.com/FedeClaudi) and Jupyter Notebook from [Alexander Mathis](https://github.com/AlexEMG)
+
+### DeepLabCut-Display GUI
+
+Open and view data to understand pose estimation errors and trends. Filter data by likelihood threshold.
+
+- [Code: `DeepLabCut-Display`](https://github.com/jakeshirey/DeepLabCut-Display)
+
+Contributed by [Jacob Shirey](https://github.com/jakeshirey)
+
+### A GUI-based ROI tool for time spent of a body part in a defined region of interest
+
+- [Code: `DLC_ROI_tool`](https://github.com/PolarBean/DLC_ROI_tool)
+
+Contributed by [Harry Carey](https://github.com/PolarBean)
+
+### Linear transformation and scaling of DLC output data (`transform_and_scale`)
+
+This package is designed for anyone who wants to know where a tracked marker is within a reference frame (i.e. behavioral context). DeepLabCut outputs coordinates in relation to the field of view of the recorded video. With this tool, these coordinates can be linearly transformed and scaled to the reference frame of the behavioral context, meaning that the output coordinates are distances [cm] to a corner of the behavioral context, instead of distances [px] to a corner of the video field of view.
+
+- [Code: `transform_and_scale`](https://github.com/DeepLabCut/DLCutils/tree/master/transform_and_scale/)
+- [Tutorial: `transform_and_scale_tutorial.ipynb`](https://github.com/DeepLabCut/DLCutils/tree/master/transform_and_scale/transform_and_scale_tutorial.ipynb)
+
+Contributed by [Michael Schellenberger](https://github.com/MSchellenberger)
+
+## Clustering tools (using the output of DLC)
+
+### Identifying Behavioral Structure from Deep Variational Embeddings of Animal Motion
+
+- [Paper](https://www.biorxiv.org/content/10.1101/2020.05.14.095430)
+- [Code: `VAME`](https://github.com/LINCellularNeuroscience/VAME)
+
+### Behavior clustering with MotionMapper
+
+- Adapted from [MotionMapper](https://github.com/gordonberman/MotionMapper)
+- [Code: `DLC_2_MotionMapper`](https://github.com/DeepLabCut/DLCutils/tree/master/DLC_2_MotionMapper)
+
+Contributed by [Mackenzie Mathis](https://github.com/MMathisLab)
+
+### Behavior clustering with B-SOiD
+
+B-SOiD is an open source unsupervised algorithm for discovery of spontaneous behaviors, and you can use the outputs of DLC to feed directly into B-SOiD in MATLAB.
+
+- [Paper](https://www.biorxiv.org/content/10.1101/770271v1.abstract)
+- [Code: `B-SOiD`](https://github.com/YttriLab/B-SOiD)
+
+## Machine-learning helper packages (using the output of DLC)
+
+### Behavior analysis with machine learning in R (`ETH-DLCAnalyzer`)
+
+Deep learning based behavioral analysis enables high precision rodent tracking and is capable of outperforming commercial solutions. Oliver Sturman, Lukas von Ziegler, Christa SchlÀppi, Furkan Akyol, Benjamin Grewe, Johannes Bohacek
+
+- [Paper](https://www.biorxiv.org/content/10.1101/2020.01.21.913624v1)
+- [Code: `DLCAnalyzer`](https://github.com/ETHZ-INS/DLCAnalyzer)
+
+### Behavior analysis with machine learning classifiers (SimBA)
+
+A pipeline for using pose estimation (i.e. DeepLabCut) then behavioral annotation and generation of supervised machine-learning-based classifiers. \<-- you can use the outputs of DLC to feed directly into SimBA (in Python).
+
+Code written by: [Simon Nilsson](https://github.com/sronilsson) (please direct use questions to Simon).
+
+- [Paper](https://www.biorxiv.org/content/10.1101/2020.04.19.049452v2)
+- [Code: `simba`](https://github.com/sgoldenlab/simba)
+
+## 3D DeepLabCut helper packages
+
+### A wrapper package for DeepLabCut 2.0 for 3D videos (`anipose`)
+
+- [Code: `anipose`](https://github.com/lambdaloop/anipose)
+
+Maintainer: [Pierre Karashchuk](https://github.com/lambdaloop)
+
+### 3D reconstruction with EasyWand/Argus DLT system with DeepLabCut data
+
+Written by [Brandon Jackson](https://github.com/haliaetus13), post our DLC workshop in Jan 2020:
+
+A small set of utilities that allow conversion between the data storage formats of DeepLabCut (DLC) and one of the DLT-based 3D tracking systems: either Ty Hedrick's DigitizingTools in MATLAB, or the Python-based Argus. These functions should allow you to use data previously digitized in a DLT system to create the files needed to train a DLC model, and to import DLC-tracked points back into a DLT 3D calibration to reconstruct 3D points.
+
+- [Code: `DLCconverterDLT`](https://github.com/haliaetus13/DLCconverterDLT)
+
+### Pupil Tracking
+
+- From Tom Vaissie - tvaissie@scripps.edu
+- Please see the [README.txt file](https://github.com/DeepLabCut/DLCutils/tree/master/pupilTracking) for details; this code makes the video in case study 7 [http://www.mousemotorlab.org/deeplabcut/](http://www.mousemotorlab.org/deeplabcut/).
+
+### Using DeepLabCut for USB-CGPIO feedback
+
+- [Paper](https://www.biorxiv.org/content/early/2018/11/28/482349)
+- [Code: `DeepCutRealTime`](https://github.com/bf777/DeepCutRealTime)
+
+Maintainer: [Brandon Forys](https://github.com/bf777)
+
+## Legacy utility functions (no longer required in DLC 2+)
+
+```{warning}
+These utilities are marked as legacy and are no longer required in DLC 2+.
+```
+
+### DLC 1 to DLC 2 conversion code
+
+This code allows you to import the labeled data from DLC 1 to DLC 2 projects. Note, it is not streamlined and should be used with care.
+
+- [Conversion scripts (`conversion_scripts_LEGACY`)](https://github.com/DeepLabCut/DLCutils/tree/master/conversion_scripts_LEGACY)
+
+Contributed by [Alexander Mathis](https://github.com/AlexEMG)
+
+### Running project created on Windows on Colaboratory
+
+```{note}
+**UPDATE:** as of DeepLabCut 2.0.4 onwards you no longer need to use this code! You can simply create the training set on the cloud and it will automatically convert your project for you.
+```
+
+- This solves a path problem when creating a project and annotating data on Windows (see [issue #172](https://github.com/AlexEMG/DeepLabCut/issues/172)). This functionality will be included in a later version of DLC 2 (DONE!)
+- [Conversion scripts (`conversion_scripts_LEGACY`)](https://github.com/DeepLabCut/DLCutils/tree/master/conversion_scripts_LEGACY)
+
+*Usage:* change in lines 70 and 71 of [`convertWin2Unix.py`](https://github.com/DeepLabCut/DLCutils/tree/master/conversion_scripts_LEGACY/convertWin2Unix.py)
+
+```python
+basepath='/content/drive/My Drive/DeepLabCut/examples/'
+projectname='Reaching-Mackenzie-2018-08-30'
+```
+
+then run this script on Colaboratory after uploading your labeled data to the drive. Thereby it will be converted to Unix format, then create a training set (with DeepLabCut) and proceed as usual...
+
+Contributed by [Alexander Mathis](https://github.com/AlexEMG)
diff --git a/docs/docker.md b/docs/docker.md
index f579d33bf9..c632a75602 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -1,108 +1,199 @@
-# 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.
+---
+deeplabcut:
+ last_content_updated: '2026-05-22'
+ last_metadata_updated: '2026-05-22'
+ ignore: false
+ visibility: online
+ status: viable
+ last_verified: '2026-05-22'
+ verified_for: 3.0.0
+---
+
+(docker-containers)=
+
+# DeepLabCut in Docker
+
+From DeepLabCut 2.2.0.2 onward, we provide container images on [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut).
+Using Docker is an alternative approach to installing DeepLabCut in a local conda or pip environment: the images bundle all dependencies needed to run DeepLabCut in a reproducible, self-contained environment.
+In a Docker container, DeepLabCut can be used from the terminal, or with Jupyter notebooks; the DeepLabCut GUI is not supported.
+The approach requires a local installation of [Docker / Docker Desktop](https://www.docker.com/), and is meant for users who need strict reproducibility, an isolated environment, or server-based automation.
+
+```{important}
+The napari-deeplabcut plugin **cannot be run in a Docker container**.
+To label your data, please {ref}`install napari-deeplabcut ` in a local, non-dockerized environment, e.g. using pip: `pip install napari-deeplabcut` .
+```
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
-``` bash
+```bash
$ pip install deeplabcut-docker
```
-on your machine (potentially in a virtual environment, or an existing Anaconda environment).
-Note that this will *not* disprupt or install Tensorflow, or any other DeepLabCut dependencies on your computer---the Docker containers are completely isolated from your existing software installation!
+on your machine (in any environment). `deeplabcut-docker` is just a lightweight package for setting up the Docker environment and it will *not* disrupt your existing software installation. The Docker container itself is completely isolated from your local environment!
-## Usage modes
+## Available images
+
+The following images are published to [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut). All images come with Python 3.11 and CUDA pre-installed.
+
+| Tag | Description |
+| ---------------------------------------------------- | -------------------------------------- |
+| `deeplabcut/deeplabcut:latest` | Default runtime image for terminal use |
+| `deeplabcut/deeplabcut:latest-jupyter` | Jupyter Notebook server |
+| `deeplabcut/deeplabcut:-core-cuda` | Versioned runtime image |
+| `deeplabcut/deeplabcut:-jupyter-cuda` | Versioned Jupyter image |
-With `deeplabcut-docker`, you can use the images in three modes.
+By default `deeplabcut-docker` pulls the `latest` / `latest-jupyter` tag. To select a specific DeepLabCut or CUDA version, set the `DLC_VERSION` and `CUDA_VERSION` environment variables:
-- *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 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*
+```bash
+DLC_VERSION=3.0.0 CUDA_VERSION=12.4 deeplabcut-docker bash --gpus all
+```
-`deeplabcut-docker bash -v /home/mackenzie/DEEPLABCUT:/home/mackenzie/DEEPLABCUT`
+To use a completely custom image instead of the default tags, pass `--image repo:tag`. Make sure the image supports Jupyter notebooks when using `deeplabcut-docker notebook`.
-(which will mount the full directory into the container in read/write mode)
+## Usage modes
-If read-only access is enough, `deeplabcut-docker bash -v /home/mackenzie/DEEPLABCUT:/home/mackenzie/DEEPLABCUT:ro`
+With `deeplabcut-docker`, you can use the images in two modes: terminal mode and Jupyter Notebook mode.
+
-### GUI mode
+```{note}
+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.
+```
-To run DeepLabCut in GUI mode and start the DeepLabCut GUI, simply run
+````{admonition} Choosing which directory to mount in the container
+---
+class: tip dropdown
+---
+For any mode below, you might want to set which directory is the base, so you can
+have read/write or read-only access.
-``` bash
-$ deeplabcut-docker gui
+If you want to mount the whole directory, you could e.g. pass:
+
+```bash
+deeplabcut-docker bash -v /home/mackenzie/DEEPLABCUT:/home/mackenzie/DEEPLABCUT
```
-which will pull the latest DeepLabCut version along with all dependencies, and afterwards opens the DeepLabCut GUI.
+This will mount the full directory into the container in read/write mode.
-The DeepLabCut version in this container is equivalent to the one you install with `pip install "deeplabcut[gui]"`.
+If read-only access is enough:
-### Terminal mode
+```bash
+deeplabcut-docker bash -v /home/mackenzie/DEEPLABCUT:/home/mackenzie/DEEPLABCUT:ro
+```
+````
-If you not need the GUI, you can run the light version of DeepLabCut and open a terminal by running
+### Terminal mode
-``` bash
+You can run the light version of DeepLabCut and open a terminal by running
+
+```bash
$ deeplabcut-docker bash
```
-Inside the terminal, you can confirm that DeepLabCut is correctly installed by running
+````{important}
+If you 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
+```
+````
-``` bash
+Inside the terminal, you can confirm that DeepLabCut is correctly installed by running and noting which version installs.
+
+```bash
$ ipython
>>> import deeplabcut
->>> print(deeplabcut.__version__)
-2.2.0.2
```
### 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
+```bash
+$ 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.
-The DeepLabCut version in this container is equivalent to the one you install with `pip install deeplabcut[gui]`. This means that you can start the DeepLabCut GUI with the appropriate commands in your notebook!
+The container comes with `deeplabcut[modelzoo,wandb]` pre-installed. Note that the DeepLabCut GUI is not available inside the container.
+
+```{danger}
+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 (see below).
+To use a custom token, pass `-e NOTEBOOK_TOKEN=` to `docker run`. You can pass an empty string to disable token-authentication: `-e NOTEBOOK_TOKEN=`.
+```
+
+#### Jupyter Notebooks on remote servers
+
+Sometimes you want to run Jupyter Notebooks on a remote server and connect from your local
+browser. This requires SSH port forwarding. For general guidance see
+[this StackOverflow post](https://stackoverflow.com/a/69244262) or the
+[Jupyter Notebook docs](https://jupyter-notebook.readthedocs.io/en/4.x/public_server.html).
+
+With `deeplabcut-docker` and `DLC_NOTEBOOK_PORT`, this is straightforward:
+
+```bash
+# Example: remote port XXXX=8889, local port YYYY=8890
+
+# 1. Connect to your server with port forwarding
+ssh -L localhost:8890:localhost:8889 you@your-server
+
+# 2. On the remote server, launch the container
+DLC_NOTEBOOK_PORT=8889 deeplabcut-docker notebook --gpus all
+
+# 3. Open http://127.0.0.1:8890 in your local browser
+```
### 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.
+It contains a single multi-stage Dockerfile covering all images, along with build instructions.
## 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
+```
- $ sudo docker run hello-world
-
- The output should be: ``Hello from Docker! This message shows that your installation appears to be working correctly.``
+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:
-
- $ sudo systemctl daemon-reload
- $ sudo systemctl 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
+```
+$ sudo groupadd docker
+```
+
Add your user to the docker group.
- $ sudo usermod -aG docker $USER
+```
+$ sudo usermod -aG docker $USER
+```
(perhaps restart your computer (best) or (at min) open a new terminal to make sure that you are added from now on)
-
## Notes and troubleshooting
-Running Docker with GUI support can vary across systems. The current images are confirmed to work with different Linux systems, but especially on MacOS additional configuration steps are necessary.
+We dropped GUI support in 2.3.5+ due to too many numerous issues supporting them. Also please note these are tested on unix systems.
When running containers on Linux, in some systems it might be necessary to run `host +local:docker` before starting the image via `deeplabcut-docker`.
-If you encounter errors while using the images, please open an issue in the DeepLabCut repo---especially the `deeplabcut-docker` is still in its alpha version, and we appreciate user feedback to make the tool robust to use across many operating systems!
+If you encounter errors while using the images, please open an issue in the DeepLabCut repo. We appreciate user feedback to make the tool robust across many operating systems!
diff --git a/docs/gui/PROJECT_GUI.md b/docs/gui/PROJECT_GUI.md
new file mode 100644
index 0000000000..ed53b4794d
--- /dev/null
+++ b/docs/gui/PROJECT_GUI.md
@@ -0,0 +1,74 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: update
+ notes: 'While the content is generally accurate, repeating installation instructions is not ideal. I would suggest linking to the installation guide instead of re-suggesting commands but then still saying to read the install page... Also, the GUI is likely used by the majority of users, so I would even consider making this a full section in the TOC, and maybe even having one file per GUI tab, which would make tracking code/docs sync easier. Addendum: it seems the beginner guide section is more of a GUI step-by-step, as mentioned earlier in this comment. I would suggest merging/moving and adding links in the present doc, which would make it less of a video list and more of a proper GUI guide.'
+---
+
+(project-manager-gui)=
+
+# Project Manager GUI
+
+As some users may be more comfortable working with an interactive interface, we wanted to provide an easy entry point to the software. All the main functionality is available in an easy-to-use GUI interface.
+While several advanced features are not fully available in this Project GUI, we hope this gets more users up-and-running quickly.
+
+## Getting started
+
+1. Install DeepLabCut following the instructions in the {ref}`installation page`.
+1. Open the terminal and run: `python -m deeplabcut`
+
+```{important}
+If you use Windows, please always open the terminal with administrator privileges! Right click, and "run as administrator".
+```
+
+
+
+
+
+Start at the Project Management Tab and work your way through the tabs to build 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 {ref}`napari gui` section.
+- To change from dark to light mode, set appearance at the top:
+
+
+
+
+
+## User guide
+
+```{important}
+See the dedicated {ref}`file:beginners-guide` section for a step-by-step walkthrough of the GUI.
+```
+
+## Video demos
+
+### How to launch and run the Project Manager GUI
+
+```{tip}
+**Click on the images!**
+```
+
+Note that currently the video demo is the wxPython version, but the logic is the same!
+
+[](https://youtu.be/KcXogR-p5Ak)
+
+### Using the Project Manager GUI with the latest DLC code (single animals, plus objects)
+
+[](https://www.youtube.com/watch?v=JDsa8R5J0nQ)
+
+{ref}`Read more here `
+
+### Using the Project Manager GUI with the latest DLC code (multiple identical-looking animals, plus objects)
+
+[](https://www.youtube.com/watch?v=Kp-stcTm77g)
+
+{ref}`Read more here `
+
+### How to benchmark your data with the new networks and data augmentation pipelines
+
+[Watch the video](https://youtu.be/WXCVr6xAcCA)
diff --git a/docs/gui/index.md b/docs/gui/index.md
new file mode 100644
index 0000000000..7219999f91
--- /dev/null
+++ b/docs/gui/index.md
@@ -0,0 +1,10 @@
+# GUIde
+
+```{toctree}
+---
+maxdepth: 2
+caption: GUI Workflows & Beginner Guides
+---
+ docs/gui/PROJECT_GUI
+ docs/gui/napari_GUI
+```
diff --git a/docs/gui/napari/advanced_usage.md b/docs/gui/napari/advanced_usage.md
new file mode 100644
index 0000000000..6e380c3257
--- /dev/null
+++ b/docs/gui/napari/advanced_usage.md
@@ -0,0 +1,79 @@
+---
+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)=
+
+# 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 bodyparts 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 only a rough estimate of relative labeling progress rather than an absolute measure of completeness: hidden/occluded keypoints are not counted, therefore projects with occlusions will not have 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 bodyparts 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
+
+To locate a bodypart label that is currently not visible in the viewer, enable "Show all bodyparts" in the color scheme list.
+Then, click on a bodypart entry in the color scheme list.
+The viewer will jump to the first instance of that body part and select it (when it exists).
+If the bodypart is already visible in the viewer, clicking on it in the color scheme will simply select all keypoints of that bodypart, as described above.
+
+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..1bddf910cf
--- /dev/null
+++ b/docs/gui/napari/basic_usage.md
@@ -0,0 +1,287 @@
+---
+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)=
+
+# 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.
+```
+
+## Using napari
+
+```{important}
+To familiarize yourself with napari, we recommend checking out the [official napari documentation and tutorials](https://napari.org/stable/usage.html).
+```
+
+(sec:napari-dlc-basic-workflow)=
+
+## 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 bodypart 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 bodypart 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 bodyparts 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:
+
+[Link to video](https://youtu.be/hsA9IB5r73E)
+
+```{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.
+
+```
diff --git a/docs/gui/napari/tracking/basic_usage.md b/docs/gui/napari/tracking/basic_usage.md
new file mode 100644
index 0000000000..6580f1942c
--- /dev/null
+++ b/docs/gui/napari/tracking/basic_usage.md
@@ -0,0 +1,353 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-05-08'
+ last_verified: '2026-05-08'
+ verified_for: 3.0.0rc14
+ ignore: false
+ last_content_updated: '2026-05-08'
+---
+
+# Automated annotation with point tracking
+
+```{seealso}
+For basic usage of the annotation plugin, see {ref}`file:napari-dlc-basic-usage` for the recommended workflow.
+```
+
+```{note}
+The plugin relies on third-party open-source tracking models.
+Please see {ref}`sec:napari-tracking-models-attribution` at the end of this page for information about the tracking models used in the plugin and their citation information.
+```
+
+## Overview
+
+The **Tracking Controls** widget is designed to help automate DeepLabCut annotation workflows:
+
+1. Manually annotate a small set of keypoints on a *reference frame*.
+1. Use a point tracking model to propagate those keypoints forward and/or backward in time.
+1. Inspect, refine, delete, and merge tracked results before exporting them back to DeepLabCut.
+
+> **Tracking is intended to accelerate annotation, and cannot replace manual review.**
+
+## Requirements
+
+```{tip}
+We recommend **having a GPU available for tracking**, as it can be computationally intensive and slow on CPU.
+Expect longer processing times on CPU, especially for longer videos or larger tracking ranges.
+```
+
+### In napari
+
+```{important}
+Before using tracking, you must:
+
+- Load a **video** or **extracted frames** as an `Image` layer with time as the first dimension.
+ - For DLC-integrated workflows, the **easiest starting point is often to drag-and-drop one of the `labeled-data` folders from your DLC project**.
+ - See {ref}`sec:napari-dlc-basic-workflow` for more details on how to prepare your data and annotations before tracking.
+- Ensure you have a **Points** layer containing DeepLabCut-style keypoints.
+ - If annotating from scratch, drag-and-drop the `config.yaml` file from your DLC project to create a new Points layer with the correct metadata.
+ - If loading a folder which already contains a `CollectedData_*.h5` file, the plugin will automatically create a Points layer with the existing annotations.
+- Annotate at least one frame with valid keypoints.
+- Tracking is most useful on temporally continuous image sequences or videos.
+
+See the workflow guides below for more details of the tracking process.
+```
+
+### In your Python environment
+
+**Skip this if you have already installed PyTorch or DeepLabCut**
+
+```{important}
+**By default, installing the `[tracking]` extra alone will not enable GPU support.**
+Check the [official PyTorch installation guide](https://pytorch.org/get-started/locally/) for GPU support and installation instructions for your system.
+```
+
+If you do not have PyTorch installed, or if you are using the plugin without the DeepLabCut package installed, install with:
+
+```bash
+pip install napari-deeplabcut[tracking]
+```
+
+## User interface
+
+
+
+```{figure} ../../../images/napari/tracking/controls.png
+---
+name: tracking-controls
+caption: Tracking Controls widget with annotated keypoints and tracking results.
+---
+Tracking Controls widget with annotated keypoints and tracking results.
+```
+
+### Showing the widget
+
+Use:
+
+> Plugins -> napari-deeplabcut -> Tracking controls
+
+### 1. Model selection
+
+| Control | Description |
+| --------------- | ------------------------------------------------------- |
+| **Tracker** | Selects the tracking backend from `AVAILABLE_TRACKERS`. |
+| **Info button** | Hover to see tracker-specific details. |
+
+
+
+```{note}
+Available models may depend on your installation and optional dependencies.
+```
+
+### 2. Layer selection
+
+| Control | Description |
+| ------------- | -------------------------------------------------------- |
+| **Keypoints** | Points layer containing manually annotated DLC keypoints |
+| **Video** | Image layer containing the video to track |
+
+The widget automatically updates based on layer changes.
+
+### 3. Reference frame selection
+
+- The **Current** spinbox always reflects the viewer's current time index.
+- This frame is used as the **query frame** for tracking.
+ - The model generates tracking predictions from the keypoints present on this frame and uses them as seeds to track forward and/or backward in time.
+
+```{note}
+Only keypoints present on the selected reference frame are used to initialize a tracking run.
+Neighboring frames or frames later in the video are never considered for initialization, even if they contain keypoints.
+```
+
+### 4. Frame range controls
+
+Tracking range can be specified **relative** or **absolute** to the reference frame.
+
+#### Backward (left)
+
+- Slider: relative negative offset
+- `<< Abs`: absolute frame index
+- `<< Rel`: relative frame offset
+
+#### Forward (right)
+
+- Slider: relative positive offset
+- `Abs >>`: absolute frame index
+- `Rel >>`: relative frame offset
+
+Changing the current frame updates the valid forward/backward range automatically.
+
+### 5. Tracking actions
+
+| Button | Action |
+| ------ | ----------------------------- |
+| â | Track backward |
+| ââ | Track backward to first frame |
+| â¶ | Track forward |
+| â¶â¶ | Track forward to last frame |
+| âł | Track both directions |
+| â | Stop tracking |
+
+```{note}
+Tracking runs in the background. You can continue navigating the viewer and editing layers while it runs; results will appear as a new layer once tracking is complete.
+```
+
+## Keyboard shortcuts
+
+Most tracking functions have keyboard shortcuts for easier usage.
+
+```{tip}
+You can see shortcuts and their status using:
+> Help -> Show napari-dlc shortcuts
+
+This is only available if the Keypoint controls widget has been opened at least once.
+```
+
+## Tracking results
+
+```{tip}
+**Being able to tell which results originate from which layer is very important for effectively using the plugin.**
+- Layers can be toggled (visible/invisible) with `V` by default or by clicking the eye icon next to the layer name in the layer list.
+- Grid mode (toggled with `Ctrl+G` by default) can also help visually separate different layers and their results.
+```
+
+Each tracking run creates a **new Points layer**:
+
+- Named automatically (`[Tracking v] Ref. layer name - t - Tracker name`)
+ - `XX` refers to the iteration number (if multiple tracking runs are performed from the same reference layer and model)
+ - `T` refers to the reference frame index used to generate the tracking result
+- **Visually distinct from manual annotations**:
+ - Cross symbol
+ - Slight transparency
+ - Green border
+
+```{note}
+The original annotation layer is never modified by tracking.
+To incorporate tracking results into your annotation data, use the merge workflow described below.
+```
+
+```{important}
+If you run into accessibility issues with the default visualization style, please [open an issue](https://github.com/DeepLabCut/napari-deeplabcut/issues).
+We would be happy to expand settings and provide more customization options if requested.
+```
+
+## Refinement and saving tools
+
+```{danger}
+There is **currently no undo option**. Any **deletion or merging action you perform on layers is irreversible**, so we recommend keeping track of your layers and using visibility toggles to compare before and after merge results.
+
+Overwrite warnings will be shown where relevant.
+```
+
+### Deleting tracked points in future frames
+
+**Tracking results are often satisfactory for a certain number of frames, then start to drift or produce errors.**
+For example, a tracked point may start following the background instead of the intended body part, or jump to a different body part or individual.
+Because of this sometimes unavoidable drift, we provide a way to delete future tracked points while keeping the current frame intact.
+
+1. Select a tracking result Points layer.
+ - This action is always disabled for the original annotation layer.
+1. Select one or more points on the **current frame**.
+1. Click **Delete selected points in future frames**.
+
+Only *exact identity matches* in future frames are removed.
+
+```{important}
+Points on the current frame are preserved so you can correct them and re-run tracking.
+```
+
+This allows you to run tracking, and iteratively progress through the frames, correcting keypoints as you go, and merging the final results back into the original annotation layer when you are satisfied, see below for more details on merging.
+
+### Merging tracked points
+
+The **Merge tracked points** workflow allows you to:
+
+- Combine multiple tracking passes
+- Decide how to handle overlaps or conflicts
+- Produce a clean final annotation layer
+
+This is especially useful when tracking was run from multiple reference frames.
+There are several merge options available to help you achieve the desired result:
+
+- **Fill missing only**: Existing keypoints are always preserved. Missing keypoints in frames are filled with tracked results.
+ - Intended for merging final tracking results into the original annotation layer.
+- **Overwrite existing target points**: Tracked keypoints overwrite existing ones in the target layer.
+ - Intended for replacing poor tracking results with a new, updated tracking pass.
+
+```{important}
+Tracking result layers are intermediate working layers.
+To save results back into the DeepLabCut project, first merge tracked points into a standard DLC annotation layer, then save that final annotation layer.
+Tracking layers will be saved as CSVs, which are not compatible with DLC project annotations and will not be written back to the `CollectedData_*.h5` workflow.
+```
+
+## Workflow example
+
+### Loading and annotating from scratch
+
+1. Create a DeepLabCut project and add the videos to label.
+1. Extract frames from the videos.
+ - Currently implemented trackers prefer continuous video frames. We recommend avoiding large gaps in frames ("jumpy" video), which can make tracking more difficult.
+ - For this reason, you may want to run tracking on the original video, then extract frames with tracking/refined annotations directly.
+1. Go to the `labeled-data` folder, then drag-and-drop a folder with extracted frames into napari.
+ - This creates an Image layer with the frames.
+1. Drag-and-drop the `config.yaml` file from your DLC project into napari.
+ - This creates an empty Points layer with the correct DLC metadata, ready for annotation.
+1. Annotate keypoints on a reference frame.
+
+> Go to {ref}`sec:tracking-workflow-guides`.
+
+### Loading and annotating from existing DLC annotations
+
+1. Go to the `labeled-data` folder, then drag-and-drop a subfolder with extracted frames into napari.
+ - This creates an Image layer with the frames.
+ - Existing annotations from the `CollectedData_*.h5` file are loaded as a Points layer.
+1. Inspect existing annotations, select a reference frame, and refine keypoints if needed.
+
+> Go to {ref}`sec:tracking-workflow-guides`.
+
+(sec:tracking-workflow-guides)=
+
+### Tracking
+
+1. Open the Tracking Controls widget (`Plugins -> napari-deeplabcut -> Tracking controls`).
+1. Go to the desired reference frame, with annotated keypoints visible.
+1. Select the forward/backward tracking range using the sliders and track forward/backward, or track to the beginning/end of the video using the seek buttons.
+1. Inspect the tracking results.
+ - You can use **Show trajectories** in the Keypoint Controls dock widget to visualize the trajectories of tracked points across frames, which can help identify where tracking starts to drift.
+ - The plot is filtered by selected keypoints, so you can select a subset of points to inspect their trajectories more closely.
+1. If there are problematic points:
+ 1. On the frame where tracking starts to drift, select the problematic point(s) and click **Delete selected points in future frames** to remove incorrect tracking results while preserving the tracked point(s) on the current frame.
+ 1. Refine the keypoint(s) on the current frame by correcting their position.
+ 1. Re-run tracking from that frame to propagate the correction forward or backward in time.
+1. Merge the new tracking result back into the previous tracking layer when appropriate (for example, using **Overwrite existing target points**).
+1. Repeat until satisfied with the tracking result, then merge into the original annotation layer using **Fill missing only** to preserve your original annotations and only add tracked keypoints in frames where you do not yet have manual annotations.
+1. **Save the final DLC annotation layer** (usually the original annotation layer after merging).
+ - Tracking result layers are intermediate working layers and are not written back directly as DLC project annotations.
+ - **Saving the final merged annotation layer is the step that writes back to the DLC project folder and updates the `CollectedData_*.h5` workflow.**
+
+```{note}
+The **Show trails** feature is currently not available for tracking result layers. Please [open an issue](https://github.com/DeepLabCut/napari-deeplabcut/issues) if this is something you would like to see in the future.
+```
+
+## Troubleshooting
+
+### No keypoints found on reference frame
+
+Ensure that:
+
+- **The correct Points layer is selected in the tracking controls dropdown menu.**
+- You are on the intended frame.
+- Points exist exactly on that frame index.
+
+### Tracking buttons do nothing
+
+Check that:
+
+- A video layer is selected.
+- A keypoint layer is selected.
+- Tracking is not already running.
+
+(sec:napari-tracking-models-attribution)=
+
+## Models information and citation info
+
+### CoTracker3
+
+> CoTracker is a fast transformer-based model that can track any point in a video. It brings to tracking some of the benefits of OpticalFlow.
+
+- [Link to GitHub repository](https://github.com/facebookresearch/co-tracker)
+- [Citation information](https://github.com/facebookresearch/co-tracker#citing-cotracker)
+
+```{admonition} Empirical observations
+---
+class: tip
+---
+This information is based on our own testing and experience with the model.
+Please share any feedback or insights you have with us!
+
+- **Strengths:** fast on GPU, can output 10-100 frames of satisfactory tracking results, depending on difficulty.
+- **Limitations:** strong preference for continuous video frames; struggles with large gaps in frame indices (for example, automated DLC frame extraction via clustering, or uniform extraction with a large step size).
+ Consider running tracking on the original video, then extracting frames with tracking/refined annotations directly.
+```
+
+## Limitations and future directions
+
+### Important considerations
+
+- As correcting labels can be time-consuming, annotating by hand may sometimes be faster than running tracking and heavily correcting its results.
+ - The benefits are mostly for long, continuous videos with many frames to annotate, where tracking can save time by propagating annotations across many frames at once.
+ - In very high-variability or very challenging videos, annotating by hand may still be more efficient than running tracking and correcting its results, especially if you only have a few frames to annotate.
+- Manual curation is still essential for good tracking results, and the tracking models do not fully replace the need for manual annotation.
+- In practice, a mix of hand-labeled hard frames and tracked easy frames should often works best.
+- Be mindful of training set imbalance: if you flood your training set with easy frames that are well tracked, and only have a few hand-picked frames with rare or difficult poses, your model may not learn to generalize well to those challenging poses.
+
+#### Future features
+
+- We currently only provide CoTracker3 as a model. It is, however, relatively easy to add new models to the plugin via the registry; feel free to ask if you would like to contribute a model or see a specific model added.
+- Generic napari saves or exports of tracking result layers are not part of the recommended DeepLabCut workflow. Tracking result layers are intermediate working layers; to preserve results in a DLC project-compatible way, merge them into a standard annotation layer and save that layer.
+- If there is demand, we may add support for saving and loading tracking layers as separate files in the DLC project folder.
+- If you have ideas for specific refinement tools, shortcuts, or other features that would be useful to add to the plugin, please share them with us.
+
+## Getting help and providing feedback
+
+- [GitHub issues](https://github.com/DeepLabCut/napari-deeplabcut/issues): for bug reports, feature requests, or general questions. We welcome your feedback and contributions.
+- [Discussion forum](https://forum.image.sc/tag/deeplabcut): for general discussion, questions, and sharing your work with the community. We also provide troubleshooting help and guidance here, but may open an issue for actual bugs or feature requests directly on GitHub, as well as request more information there.
diff --git a/docs/gui/napari_GUI.md b/docs/gui/napari_GUI.md
new file mode 100644
index 0000000000..2fefc50e82
--- /dev/null
+++ b/docs/gui/napari_GUI.md
@@ -0,0 +1,24 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-04-09'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: archive
+ notes: Being updated in a separate PR (#3280)
+ 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-rec.png b/docs/images/box1-multi-rec.png
new file mode 100644
index 0000000000..6b2cc2cb0c
Binary files /dev/null and b/docs/images/box1-multi-rec.png differ
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/images/box2-single.png b/docs/images/box2-single.png
new file mode 100644
index 0000000000..e680e64b7e
Binary files /dev/null and b/docs/images/box2-single.png differ
diff --git a/docs/images/dlc-workflow.png b/docs/images/dlc-workflow.png
new file mode 100644
index 0000000000..a1f8091cd9
Binary files /dev/null and b/docs/images/dlc-workflow.png differ
diff --git a/docs/images/napari/tracking/controls.png b/docs/images/napari/tracking/controls.png
new file mode 100644
index 0000000000..682f8e59e6
Binary files /dev/null and b/docs/images/napari/tracking/controls.png differ
diff --git a/docs/installation.md b/docs/installation.md
index 494786aca7..4628990add 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,136 +1,416 @@
-# How To Install DeepLabCut
+---
+deeplabcut:
+ last_content_updated: '2026-02-23'
+ last_metadata_updated: '2026-04-21'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: Could be moved to a core/installation folder for clarity.
+ last_verified: '2026-04-21'
+ verified_for: 3.0.0rc14
+---
-DeepLabCut can be run on Windows, Linux, or MacOS (see also [technical considerations](/docs/installation.md#technical-considerations)).
+(file:how-to-install)=
-## PIP:
+# Installing DeepLabCut
-- Everything you need to run DeepLabCut (i.e., our source code and our dependencies) can be installed with `pip install 'deeplabcut[gui]'` (for GUI support) or without: `pip install deeplabcut`.
+- **DeepLabCut can be run on Windows, Linux, or MacOS as long as you have Python 3.10-3.12 installed**
+ - See also {ref}`technical considerations `.
-- Please note, there are several modes of installation, and the user should decide to either use a **system-wide** (see [note below](/docs/installation.md#system-wide-considerations)), **Anaconda environment** based installation (**recommended**), or the supplied [**Docker container**](https://github.com/DeepLabCut/DeepLabCut/tree/master/docker.md) (recommended for Ubuntu advanced users). One can of course also use other Python distributions than Anaconda, but **Anaconda is the easiest route.**
+
-## CONDA: The installation process is as easy as this figure! -->
+
-
+- đ§ Please note, there are several possibilities for installation:
+ - **Recommended for most users**: Install in a {ref}`conda environment `
+ - Install with **{ref}`uv `** (recommended for developers)
+ - In the supplied **{ref}`Docker container `** (recommended for Ubuntu advanced users and reproducibility).
+- đ You will get the best performance when using a **GPU**!
+ - Please see the section on {ref}`GPU support ` to install your GPU driver and CUDA.
-### Step 1: You need to have Python installed
+````{hint}
+Familiar with python packages and conda?
-#### Simply download the appropriate files here: https://www.anaconda.com/distribution/
+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.
-- Anaconda is perhaps the easiest 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.
+```bash
+conda create -n DEEPLABCUT python=3.12
+conda activate DEEPLABCUT
-#### We recommend having a GPU.
+# Install PyTorch with your desired CUDA version (or CPU only)
+# Example: install GPU-enabled pytorch for CUDA 12.6
+pip install torch torchvision --index-url https://download.pytorch.org/whl/cu126
-- 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)).
+# install the latest version of DeepLabCut
+pip install deeplabcut # add --pre for pre-release versions!
+# or if you want to use the GUI
+pip install deeplabcut[gui]
- - **CPU?** Great, jump to the next section below!
+# 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())"
+```
+````
- - **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!**
+- If you're familiar with the command line and want TensorFlow support, look {ref}`below `.
-### Step 2: please use our supplied Anaconda environment
+(sec:installation-using-conda)=
-- **Click [HERE](http://www.mackenziemathislab.org/s/DEEPLABCUT.yaml) to download the conda file!**
-(or you can grab from github: 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)
+## Using Conda
-- Now, in Terminal (or Anaconda Command Prompt for windows users), go to the folder where you downloaded the file.
-For example, if you downloaded it from The CLICK HERE above, it likely went into your downloads folder: ``cd C:\Users\YourUserName\Downloads``
+
-If you cloned the repo onto your Desktop, the command may look like:
+**The installation process is as easy as the figure on the right!âïž**
-``cd C:\Users\YourUserName\Desktop\DeepLabCut\conda-environments``
+### đš Before you start...
-To get the location right, a cool trick is to drag the folder and drop it into Terminal. Alternatively, 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.
+Do you have a GPU? If yes, see the {ref}`GPU support section ` below for installation instructions.
-(C) Now, in the terminal run:
+If not, you can still install DeepLabCut and use it on your CPU, but it will be much slower for training and evaluation (but not for labeling or project management).
-``conda env create -f DEEPLABCUT.yaml``
+`````{admonition} đš Hardware 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**
+ ````{tab-set}
+ ```{tab-item} CPU
+ Great, jump to the next section below!
+ ```
+ ```{tab-item} 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 {ref}`sec:install-gpu-support` below carefully. **Note, DeepLabCut is up to date with the latest CUDA and PyTorch!**
+ ```
+ ```{tab-item} Apple M-chip GPU
+ Install miniconda and use the standard `DEEPLABCUT.yaml` conda environment â PyTorch will use your Apple GPU via Metal automatically. For TensorFlow, add the `tf` extra after install (see {ref}`TensorFlow Support `). More tips are on the {ref}`installation tips ` page.
+ ```
+ ````
-(D) You can now use this environment from anywhere on your comptuer (i.e. no need to go back into the conda- folder). Just enter your environment by running:
+- Note, you can also use the CPU-only install 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)).
+`````
-- Ubuntu/MacOS: ``source/conda activate nameoftheenv`` (i.e. on your Mac: ``conda activate DEEPLABCUT``)
-- Windows: ``activate nameoftheenv`` (i.e. ``activate DEEPLABCUT``)
+### Step 1: Install miniconda
-Now you should see (`nameofenv`) on the left of your teminal screen, i.e. ``(DEEPLABCUT) YourName-MacBook...``
-NOTE: no need to run pip install deeplabcut, as it is already installed!!! :)
+```{important}
+Download [miniconda](https://www.anaconda.com/docs/getting-started/miniconda/main) for your operating system
+```
-**Great, that's it! DeepLabCut is installed!**
+- miniconda is an easy way to install Python and additional packages across various operating systems
+- With miniconda, you can install all the dependencies in an [environment](https://conda.io/docs/user-guide/tasks/manage-environments.html) on your machine
+- Miniconda is a lightweight version of Anaconda that includes only conda and its dependencies.
-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/README.html)
+```{admonition} Wait, why are we mixing Anaconda, miniconda and conda?
+---
+class: dropdown tip
+---
+`conda` is the terminal-based environment management system that is included in both Anaconda and Miniconda. This is the actual workhorse that allows you to create and manage environments, and install packages.
-## DOCKER:
+**Anaconda** is a full-featured distribution that includes conda, Python, and a large number of scientific packages and their dependencies, plus some graphical user interfaces (GUIs) for managing environments and packages. It is a larger download and takes up more disk space.
+
+**Miniconda** is a minimal distribution that includes only conda and its dependencies, along with Python. It does not include any additional packages or GUIs. We recommend it as most GUIs and base packages provided by the full Anaconda distribution are not necessary for DeepLabCut.
+```
+
+(sec:conda-build-env)=
+
+### Step 2: Build a conda environment
+
+Use the `DEEPLABCUT.yaml` file to build a conda environment with all the dependencies for DeepLabCut.
+
+You simply need to have this `.yaml` file locally on your computer.
+
+```{warning}
+On **Windows**, make sure you have `git` installed: [Git for Windows](https://gitforwindows.org/)
+```
+
+- Follow the link âĄïž for the [conda file](https://github.com/DeepLabCut/DeepLabCut/blob/main/conda-environments/DEEPLABCUT.yaml#:~:text=Raw%20file%20content-,Download,-%E2%8C%98) and then click "..." and select Download
+
+
+
+- **Now, in Terminal (or Anaconda Command Prompt for Windows users)**:
+
+ - If you clicked to download, go to your downloads folder.
+
+ - Be sure you are in the folder that has the `.yaml` file, then run:
+
+ `conda env create -f DEEPLABCUT.yaml`
+
+- You can now use this environment from anywhere on your computer.
+ Just activate your environment by running: `conda activate DEEPLABCUT`
+
+Now you should see (`DEEPLABCUT`) on the left of your terminal screen:
+
+```
+(DEEPLABCUT) YourName-MacBook...
+```
+
+```{note}
+No need to run `pip install deeplabcut`, it's already in the conda file!
+```
+
+(sec:deeplabcut-with-tf-install)=
+
+#### TensorFlow support
+
+````{admonition} DeepLabCut TensorFlow Support
+---
+class: dropdown
+---
+đĄ **PyTorch and TensorFlow Support within DeepLabCut**
+
+As of June 2024 we have a PyTorch Engine backend and we will be deprecating the
+TensorFlow backend by version 3.2 latest (TBD).
+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 version 2.18.
+
+Installing TensorFlow manually and getting it to have access to the GPU can be a bit tricky.
+However, we try to simplify the installation procedure via optional dependencies.
+
+A specific note for **Windows users**: TensorFlowâs own docs state that **native Windows GPU** support
+ended after **2.10**. We recommend Windows users to install [The Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install)
+if they want GPU support.
+
+**Installation via the `tf` optional dependencies**
+We recommend installing DeepLabCut with TensorFlow by specifying one of the 'extra's': `tf`, `tf-cu11` or `tf-cu12`. E.g,
+
+```
+pip install deeplabcut[tf]
+```
+
+This table provides a more detailed summary on the available extras:
+
+| Extra | Version | Python | GPU backend | Role (summary) |
+|--------------|-----------------------------|-------------|--------------------------|-----------------------------------------------------------------------------|
+| tf | 2.12â2.18 (Python-dependent)| 3.10-3.12 | CUDA (Linux); Metal (macOS) | Default TensorFlow stack for most users. |
+| tf-cu11 | 2.14 | 3.10 / 3.11 | CUDA 11.8 | Pinned TF for CUDA 11.x-era stack |
+| tf-cu12 | 2.18 | 3.10-3.12 | CUDA 12.5 | Pinned TF for CUDA 12.x-era stack |
+| tf-latest | 2.18+ | 3.10-3.12 | CUDA 12.5+ | (Not recommended!) Newest TensorFlow â„ 2.18 |
+| apple_mchips | 2.12 - 2.18 | 3.10-3.12 | macOS Metal | (Not recommended!) Legacy extra; installs `tensorflow` + `tensorflow-metal`. Prefer `tf` instead. |
+
+
+Note that TensorFlow and PyTorch may try to install competing CUDA-toolkit dependencies.
+This is addressed in the listed extras by capping the PyTorch version to match the CUDA requirements.
+In case you experience problems with the above installation, you can try to let TensorFlow install their own CUDA-toolkit libraries.
+Please run the following installation command (in Linux), replacing with your TensorFlow version (see table above).
+Note that this may break PyTorch functionality.
+```
+pip install "tensorflow[and-cuda]=="
+```
+
+
+**Advanced manual setup (Linux):**
+if you do **not** use `deeplabcut[tf]`, you must align the following dependencies yourself:
+`tensorflow`, `tensorpack`, `tf-keras` / Keras, `tf-slim`, CUDA, the NVIDIA **driver**,
+and **PyTorch** yourself.
+
+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
+```
+````
+
+### Step 3: Let's run DeepLabCut!
+
+**DeepLabCut is installed!** đđ
+
+Launch the DeepLabCut GUI in your new conda env by running `python -m deeplabcut`
+
+Head over to the [User Guide Overview](https://deeplabcut.github.io/DeepLabCut/docs/UseOverviewGuide.html) for information.
+
+```{warning}
+On **Windows**: Open the terminal/cmd/anaconda prompt as **Administrator** (right click and select "Run as administrator") to avoid permission issues when downloading models, and for symlink support when videos are not copied into the project folder.
+```
+
+### Conda environment management tips
+
+Here are some conda environment management tips: [kapeli.com: Conda Cheat Sheet](https://kapeli.com/cheat_sheets/Conda.docset/Contents/Resources/Documents/index)
+
+
+
+
+
+Please see how to test your installation by following [this video](https://www.youtube.com/watch?v=IOWtKn3l33s).
+
+
+
+## Other ways to install DeepLabCut
+
+### git clone
+
+Recommended for users who want to modify the code, or want to be up-to-date with the latest code on GitHub.
+
+- To clone the repository run: `git clone https://github.com/DeepLabCut/DeepLabCut.git`
+- Then follow the same steps as in Step 2 above, adjusting for the `DEEPLABCUT.yaml` env file now being in the folder where you git cloned the repo.
+- Or use pip/uv to install from the cloned repo (see below).
+
+(sec:uv-install)=
+
+### `uv` (recommended for developers)
+
+- Clone the [repository](https://github.com/DeepLabCut/DeepLabCut)
+- Install `uv` following [instructions here](https://docs.astral.sh/uv/getting-started/installation/)
+- Run in the cloned repo:
+
+```bash
+uv venv -p 3.12
+uv pip install -e '.[gui]' # Change optional installs as needed
+source .venv/bin/activate # or & .venv\Scripts\activate.ps1 on Windows
+```
+
+- Add **`modelzoo`** for SuperAnimal models: `uv pip install -e '.[gui,modelzoo]'`.
+- Add **`tf`** (or `tf-cu11` / `tf-cu12` as appropriate) for the TensorFlow training engine â see {ref}`TensorFlow Support `.
+
+### `pip`
+
+If you already have a local environment, everything you need to use the project manager GUI, train and/or 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 **cloned the repo** and want to make edits to the code locally, navigate to the cloned repo folder and run `pip install -e .[gui]` to install the package in "editable" mode, which allows you to make changes to the code and have those changes reflected when you import the package.
+- If you want to use the SuperAnimal models, then please use `pip install 'deeplabcut[gui,modelzoo]'`.
+- If you need the **TensorFlow** training engine, add the **`tf`** extra (or `tf-cu11` / `tf-cu12` as appropriate): `pip install 'deeplabcut[tf]'` â see {ref}`TensorFlow Support `.
+
+### Docker
- We also have docker containers. Docker is the most reproducible way to use and deploy code. Please see our dedicated docker package and page [here](https://deeplabcut.github.io/DeepLabCut/docs/docker.html).
-### Pro Tips:
+### Creating your own conda environment
+
+
+
+
+
+```{tip}
+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.
+```
+
+Create a new conda environment with Python 3.10 (or 3.11, 3.12) by running:
-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.
+`conda create -n DLC python=3.10`
-Here are some conda environment management tips: https://kapeli.com/cheat_sheets/Conda.docset/Contents/Resources/Documents/index
+**Current version:** The only thing you then need to add to the env is deeplabcut (
+`pip install deeplabcut`) or `pip install 'deeplabcut[gui]'` if you are using the GUI, which includes the napari based labeling
+interface.
-**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](https://github.com/DeepLabCut/DeepLabCut/wiki/How-to-use-the-latest-GitHub-code) 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
+## Updating your installation
+If you ever want to update your DLC, just run `pip install --upgrade deeplabcut` (alongside optional needed requirements, e.g. `[gui]`) using your environment.
-### Creating your own customized conda env (recommended route for Linux: Ubuntu, CentOS, Mint, etc.)
+If you would like to use a specific release, then specify the version you want, such as `pip install deeplabcut==3.0` and optional requirements.
-*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.
+Once installed, you can
+check the version by running:
-Some users might want to create their own customize env. - Here is an example.
+```python
+import deeplabcut
+deeplabcut.__version__
+```
-In the terminal type:
+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 often.
-`conda create -n DLC python=3.8`
+### Data compatibility
+**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, check out our docs on [moving from
+TensorFlow to PyTorch](dlc3-user-guide)
-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).
+(sec:install-gpu-support)=
-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
-### **GPU Support:**
+### General GPU support
-The ONLY thing you need to do **first** if you have an NVIDIA GPU and the matching NVIDIA CUDA+driver installed.
-- CUDA: https://developer.nvidia.com/cuda-downloads (just follow the prompts here!)
-- DRIVERS: https://www.nvidia.com/Download/index.aspx
+Please ensure you have an NVIDIA GPU and the matching NVIDIA driver installed.
-#### The most common "new user" hurdle is installing and using your GPU, so don't get discouraged!
+```{warning}
+If you have a GPU, you should first **install an appropriate driver for
+your specific GPU**, then you can use the supplied conda file.
+```
-**CRITICAL:** If you have a GPU, you should FIRST then **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.
+- Drivers: see [NVIDIA Drivers](https://www.nvidia.com/Download/index.aspx)
+- CUDA: download [here](https://developer.nvidia.com/cuda-downloads) if needed. Installing the drivers usually allows you to skip installing CUDA; instead obtaining via the PyTorch installation process.
-- 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.
+### Installing CUDA and cuDNN for TensorFlow GPU support
+You will need an NVIDIA GPU that is compatible with CUDA.
-**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``.
+To see a list of CUDA-enabled NVIDIA GPUs, please [see their website](https://developer.nvidia.com/cuda-gpus).
-**SECOND**, install CUDA (versions up to CUDA11 are supported, together with TF2.5): 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).
+Here we provide notes on how to install and check your GPU use with TensorFlow, which is used by DeepLabCut.
-**THIRD:** Follow the steps above to get the `DEEPLABCUT` conda file and install it!
+1. Install a driver for your GPU, using the NVIDIA Drivers link above.
+ - Check which driver is installed by typing this into the terminal: `nvidia-smi`.
+1. 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.
+1. Follow the steps above to get the `DEEPLABCUT` conda file and install it!
-##### Notes:
+### 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:
+- **As of version 3.0+ the default engine is PyTorch.** TensorFlow remains optional via
+ `pip install "deeplabcut[tf]"` and related extras; **version ranges are defined in
+ `pyproject.toml`** (typically TensorFlow **2.12+** on supported Python versions). Upstream, **native
+ Windows GPU** for TensorFlow stopped after **2.10**. We advise Windows users to install [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). We do not guarantee every future TensorFlow release for all platforms.
+
+- 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).
+- 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 test scripts](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 used with these additional [tips](https://www.tensorflow.org/programmers_guide/using_gpu).
-- 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 DLC install on Ubuntu useful as well.
-- 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
-## Troubleshooting:
+### TensorFlow
-TensorFlow:
Here are some additional resources users have found helpful (posted without endorsement):
- https://stackoverflow.com/questions/30820513/what-is-the-correct-version-of-cuda-for-my-nvidia-driver/30820690
-
+
- https://www.tensorflow.org/install/source#gpu
@@ -139,43 +419,66 @@ 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
+- A few Windows users report needing to install re-install ffmpeg (after windows updates) as described here: https://video.stackexchange.com/questions/20495/how-do-i-set-up-and-use-ffmpeg-in-windows (A potential error could occur when making new videos). On Ubuntu, the command is: `sudo apt install ffmpeg`
-FFMPEG:
+### DeepLabCut
-- A few Windows users report needing to install re-install ffmpeg (after windows updates) as described here: https://video.stackexchange.com/questions/20495/how-do-i-set-up-and-use-ffmpeg-in-windows (A potential error could occur when making new videos). On Ubuntu, the command is: `sudo apt install ffmpeg`
+- If you git clone or download this folder, and are inside of it then `import deeplabcut` will import the package from the local folder rather than from the latest on PyPi!
+
+(sec:system-wide-considerations-during-install)=
+
+## System-wide installation considerations
+
+```{note}
+**What is a system-wide installation?**
+
+A system-wide installation, or a base environment installation, is when you install using the default Python environment/interpreter on your computer, instead of a compartmentalized, separate environment (e.g., a conda environment).
+
+This is often a source of conflicts between packages, user confusion and progressive "dependency hell" (where you have to keep installing and uninstalling packages to get the right versions for different applications).
+
+To avoid this, we recommend using a virtual environment (e.g., conda or uv managed environments) to keep your DeepLabCut installation separate from other Python packages and applications on your system.
+```
+
+If you perform a system-wide/base environment installation, and the computer has other Python packages or TensorFlow versions installed that conflict, this will overwrite them.
+
+If you have a dedicated machine for DeepLabCut, this may be *temporarily* fine, but will degrade over time as you try to install or update other packages.
+
+Indeed, if there are other applications that require different versions of libraries, then installing/updating anything would potentially break those applications.
+
+One way to manage virtual environments is to use conda environments (for which you need Anaconda/miniconda installed).
+An environment is a self-contained directory that contains a Python installation for a particular version of Python, plus additional packages, without any cross-talk with other environments (NVIDIA drivers being a notable exception, as they are system-wide by nature).
-DEEPLABCUT:
+(sec:hardware-considerations-during-install)=
-- if you git clone or download this folder, and are inside of it then ``import deeplabcut`` will import the package from there rather than from the latest on PyPi!
+## Hardware considerations
+- **Computer**:
-## System-wide considerations:
+ - For reference, we use e.g. Dell workstations (79xx series) with **Ubuntu 16.04 LTS, 18.04 LTS, 20.04 LTS, 22.04 LTS** and for versions prior to 2.2, we run a Docker container that has TensorFlow, etc. installed (https://github.com/DeepLabCut/Docker4DeepLabCut2.0). Now we use the new Docker containers supplied on this repo (linux support only), also available through [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut) or the [`deeplabcut-docker`](https://pypi.org/project/deeplabcut-docker/) helper script.
-If you perform the system-wide installation, and the computer has other Python packages or TensorFlow versions installed that conflict, this will overwrite them. If you have a dedicated machine for DeepLabCut, this is fine. If there are other applications that require different versions of libraries, then one would potentially break those applications. The solution to this problem is to create a virtual environment, a self-contained directory that contains a Python installation for a particular version of Python, plus additional packages. One way to manage virtual environments is to use conda environments (for which you need Anaconda installed).
+- **Computing Hardware**:
-## Technical Considerations:
+ - An NVIDIA GPU with *at least* 8GB VRAM (memory) is ideal.
+ - A GPU is not strictly necessary, but on a CPU the (training and evaluation) code is considerably slower (10x) for ResNets, but MobileNets are faster. You might also consider using cloud computing services like [Google cloud/amazon web services](https://github.com/DeepLabCut/DeepLabCut/issues/47) or Google Colaboratory.
-- Computer:
+- **Camera Hardware**:
- - For reference, we use e.g. Dell workstations (79xx series) with **Ubuntu 16.04 LTS, 18.04 LTS, or 20.04 LTS** and for versions prior to 2.2, we run a Docker container that has TensorFlow, etc. installed (https://github.com/DeepLabCut/Docker4DeepLabCut2.0). Now we use the new Docker containers supplied on this repo, also available through [DockerHub](https://hub.docker.com/r/deeplabcut/deeplabcut) or the [`deeplabcut-docker`](https://pypi.org/project/deeplabcut-docker/) helper script.
+ - The software is very robust to variations stemming from various cameras (cell phone cameras, grayscale, color; captured under infrared light, different manufacturers, etc.). See demos on our [website](https://www.mousemotorlab.org/deeplabcut/).
+ - Note that a model trained on certain data/camera may not generalize to data from a different camera however, so we recommend using the same camera for training and inference.
-- Computer Hardware:
- - Ideally, you will use a strong GPU with *at least* 8GB memory such as the [NVIDIA GeForce 1080 Ti or 2080 Ti](https://www.nvidia.com/en-us/shop/geforce/?page=1&limit=9&locale=en-us). A GPU is not necessary, but on a CPU the (training and evaluation) code is considerably slower (10x) for ResNets, but MobileNets are faster (see WIKI). You might also consider using cloud computing services like [Google cloud/amazon web services](https://github.com/DeepLabCut/DeepLabCut/issues/47) or Google Colaboratory.
+- **Software**:
-- Camera Hardware:
- - The software is very robust to track data from any camera (cell phone cameras, grayscale, color; captured under infrared light, different manufacturers, etc.). See demos on our [website](https://www.mousemotorlab.org/deeplabcut/).
+ - Operating System: Linux (Ubuntu), MacOS[^1] (Mojave), or Windows 10. However, we the authors strongly recommend Ubuntu!
+ - DeepLabCut is written in Python 3 (https://www.python.org/) and not compatible with Python 2.
-- Software:
- - Operating System: Linux (Ubuntu), MacOS* (Mojave), or Windows 10. However, the authors strongly recommend Ubuntu! *MacOS does not support NVIDIA GPUs (easily), so we only suggest this option for CPU use or a case where the user wants to label data, refine data, etc and then push the project to a cloud resource for GPU computing steps, or use MobileNets.
- - 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 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.
- - Docker: We highly recommend advanced users use the supplied [Docker container](https://github.com/DeepLabCut/DeepLabCut/tree/master/docker.md)
+
+
-Return to [readme](../README.md).
+[^1]: MacOS does not support NVIDIA GPUs (easily), so we only suggest this option for CPU use or a case where the user wants to label data, refine data, etc, and then push the project to a cloud resource for GPU computing steps, or use MobileNets
diff --git a/docs/intro.md b/docs/intro.md
index 58a377b261..c731cba264 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -1,201 +1,8 @@
-
-
-[](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!
-
-To get started, click on the **"Welcome to Our Documentation Hub!"** in the table of contents!
-
-**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+` release](https://github.com/DeepLabCut/DeepLabCut/releases) for what's new & how to install it, please see our new [preprint, Lauer et al 2021](https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1), and the new 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 on [github](docs/README.md)).
-
-- 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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/roadmap.md). 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 in beta mode, please contact us; if you use the 2.2rc1+, please cite [Lauer et al. 2021](https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1).
-
-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{Lauer2021MultianimalPE,
- title = {Multi-animal pose estimation and tracking with DeepLabCut},
- author = {Jessy Lauer and Mu Zhou and Shaokai Ye and William Menegas and Tanmay Nath and Mohammed Mostafizur Rahman and V. Di Santo and Daniel Soberanes and Guoping Feng and V. Murthy and G. Lauder and C. Dulac and M. Mathis and Alexander Mathis},
- journal = {bioRxiv},
- year = {2021}
- url = {https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1}}
-
- @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):
-
-- 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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/UseOverviewGuide.md). 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 691d460935..0ce799554f 100644
--- a/docs/maDLC_UserGuide.md
+++ b/docs/maDLC_UserGuide.md
@@ -1,14 +1,42 @@
-# DeepLabCut for Multi-Animal Projects
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: verify
+ notes: Could use a small formatting pass. Contents are 4-5y old in some places, recommend to review for accuracy.
+---
+
+(multi-animal-userguide)=
+
+# Multi-animal projects
+
+```{contents}
+---
+local:
+depth: 3
+---
+```
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!
-Note, we strongly encourage you to use the [Project Manager GUI](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/PROJECT_GUI.md) 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.
+
- (1) Curate annotation data that allows you to learn a model to track the objects/animals of interest.
- (2) Create a high-quality pose estimation model.
- (3) Track in space and time, i.e., assemble bodyparts to detected objects/animals and link across time. This step performs assembly and tracking (comprising first local tracking and then tracklet stitching by global reasoning).
@@ -16,87 +44,168 @@ You should think of maDLC being **four** parts.
Thus, you should always label, train, and evaluate the pose estimation performance first. If and when that performance is high, then you should go advance to the tracking step (and video analysis). There is a natural break point for this, as you will see below.
-
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1596370260800-SP2GWKDPJCOIR7LJ31VM/ke17ZwdGBToddI8pDm48kB4fL2ovSQh5dRlH2jCMtpoUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYxCRW4BPu10St3TBAUQYVKcSV94BuD0XUinmig_1P1RJNYVU597j3jgswapL4c_w92BJE9r6UgUperYhWQ2ubQ_/workflow.png?format=2500w
+---
+name: fig-madlc-workflow
+alt: Overview of the four-part multi-animal DeepLabCut workflow
+width: 550px
+align: center
+---
+Overview of the multi-animal DeepLabCut workflow.
+```
+
+## Getting started
+
+DeepLabCut offers two equivalent interfaces: a **GUI** for those who prefer a visual
+workflow (no Python knowledge required), and a **Python API** for users who want
+scripting flexibility or to integrate DeepLabCut into a larger pipeline. All workflow
+steps are available in both.
+
+We assume you have DeepLabCut installed (if not, see {ref}`file:how-to-install`).
+Open a terminal and activate your conda environment:
-## Install:
+```bash
+conda activate DEEPLABCUT
+```
+
+```{important}
+On Windows, always open the terminal with administrator privileges: right-click and
+select "Run as administrator".
+```
-**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]'`.
+Choose your interface below to launch DeepLabCut:
-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).
+### GUI (recommended for beginners)
-## Get started in the terminal or Project GUI:
+```bash
+python -m deeplabcut
+```
-**GUI:** simply open your conda env, and windows/linux type `python -m deeplabcut`. MacOS users: `pythonw -m deeplabcut.`
-Then follow the tabs! It might be useful to read the following, however, so you understand what each command does.
+Each workflow step has a corresponding tab in the Project Manager. It is useful to
+read the sections below so you understand what each step does.
-**TERMINAL:** To begin, (windows) navigate to anaconda prompt and right-click to "open as admin ", or (unix/MacOS) simply launch "terminal" on your computer. We assume you have DeepLabCut installed (if not, go [here](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/installation.md)). Next, launch your conda env (i.e., for example `conda activate DLC-CPU`).
+### Python API
-Start iPython, or if you are using MacOS, you must use ``pythonw`` vs. typing ``ipython`` or ``python``, but otherwise it's the same.
-If you use Windows, please always open the terminal with administrator privileges. 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).
+In an interactive Python session (e.g. `ipython`), import DeepLabCut:
-Open an ``ipython`` session and import the package by typing in the terminal:
```python
-ipython
import deeplabcut
```
-**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``.
+## Workflow
+
+DeepLabCut's full multi-animal workflow is described in steps (A)â(L) below.
+Every step can be completed either via the **GUI** or the **Python API** â both are
+fully equivalent. Code examples throughout this page use the Python API; if you are
+using the GUI, the same steps are available in the corresponding tabs of the Project
+Manager.
+
+### Phase 1 â Project setup
-### 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 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, 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:
-- 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
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:
+- 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**, **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:** 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.
+**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.
-**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.
+**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. Note: you neither need to use this folder for videos, nor is it required for analyzing videos (they can be anywhere).
+**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.
+\*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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/convert_maDLC.md)
+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)
+
+```{figure} images/box1-multi.png
+---
+name: pose-cfg-box1-multi
+alt: Box 1 â multi-animal project configuration file parameter glossary
+---
+**Box 1.** Multi-animal project `config.yaml` parameter glossary.
+```
+
+##### 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.
-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
+import deeplabcut
-````python
-edits = {'colormap': 'summer',
- 'individuals': ['mickey', 'minnie', 'bianca'],
- 'skeleton': [['snout', 'tailbase'], ['snout', 'rightear']]}
+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:
@@ -118,25 +227,50 @@ 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`.
+**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`.
**Multianimalbodyparts:** are the bodyparts of each individual (in the above list).
**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:
-
-**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!**
+### Phase 2 â Data preparation
+
+#### (C) Select Frames to Label
+
+```{important}
+A good training dataset should consist of a sufficient number of frames that capture
+the breadth of the behavior. Select frames from different behavioral sessions, different
+lighting conditions, and different animals if those vary substantially (to train an
+invariant, robust feature detector). The dataset should reflect the diversity of
+postures, luminance conditions, background conditions, and animal identities in the data
+to be analyzed. For simple lab behaviors such as 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,
+more or fewer frames may be needed depending on accuracy requirements, behavior
+complexity, and video quality (e.g. motion blur, poor lighting). **For maDLC, make sure
+you include 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,
+)
+```
+
+```{important}
+Keep frame sizes small â large frames increase training and inference time and may
+exceed GPU memory. When running `extract_frames` with `crop=True`, you will be asked to
+draw a bounding box in the GUI (this is saved to `config.yaml`).
```
-**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).
`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`.
@@ -150,217 +284,433 @@ video and clusters the frames using k-means, where each frame is treated as a ve
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
-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.
+```{important}
+Extract frames from video segments that contain the behaviors of interest rather than
+from the whole video. Use the `start` and `stop` parameters in `config.yaml` to limit
+the extraction window, and `numframes2extract` to control the number of frames per
+video.
+```
- **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.
+
+
+````{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.
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/192345a5-e411-4d56-b718-ef52f91e195e/Qwerty.png?format=2500w
+---
+name: fig-labeling-hotkeys
+alt: Keyboard shortcut reference for the DeepLabCut labeling GUI
+---
+Keyboard shortcuts for the labeling GUI.
+```
-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.
+```{important}
+**Label similar spots consistently** (e.g., on a large wrist, always click the same
+sub-location). Invisible or occluded points should generally not be labeled unless you
+intentionally want to teach the network to predict occluded locations â this is
+possible, but may reduce accuracy. Body parts that are not visible can simply be skipped
+by leaving them unlabeled.
+```
-**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.
+```{note}
+To add new labels to an existing dataset, first append the new body parts to
+`bodyparts` in `config.yaml`, then call `label_frames` again. A dialog will ask whether
+to display all parts or only the new ones. Saving will append the new labels to the
+existing dataset.
+```
-Note, we also highly recommend that you use more bodypoints that you might otherwise have (see the example below).
+```{important}
+**Multi-animal labeling and identity:** Unless you can visually distinguish the animals,
+you do not need to maintain a consistent ID across frames. For example, with a white and
+a black mouse, always label white as animal 1 and black as animal 2. With two
+indistinguishable black mice the ID assignment (1 or 2) may switch between frames â
+just be consistent *within* each frame. If one animal always has a distinguishing
+feature (e.g., an optical fiber), then label them consistently across all frames. The
+goal of maDLC is to train a model that groups body parts to individuals and then links
+those individuals across video frames using temporal information.
+```
-**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 {ref}`napari-deeplabcut docs ` 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.
-
-
-
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1586203062876-D9ZL5Q7NZ464FUQN95NA/ke17ZwdGBToddI8pDm48kKmw982fUOZVIQXHUCR1F55Zw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpx7krGdD6VO1HGZR3BdeCbrijc_yIxzfnirMo-szZRSL5-VIQGAVcQr6HuuQP1evvE/img1068_individuals.png?format=750w
+---
+name: fig-check-labels-individuals
+alt: Example check_labels output showing annotated individuals per frame
+width: 50%
+align: center
+---
+Example `check_labels` output with annotations shown per individual.
+```
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.
-
-### Create Training Dataset:
-
-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:
-
-```python
-deeplabcut.create_multianimaltraining_dataset(path_config_file)
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.check_labels.rst
```
+````
-- The set of arguments in the function will shuffle the combined labeled dataset and split it to create train and test
-sets. The subdirectory with suffix ``iteration#`` under the directory **training-datasets** stores the dataset and meta
-information, where the ``#`` is the value of ``iteration`` variable stored in the projectâs configuration file (this number
-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.
+### Phase 3 â Training and evaluation
-- 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)**).
+#### (F) Create Training Dataset
-**OPTIONAL POINTS:**
+At this point, you'll need to select your neural network type.
-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:
+For the **PyTorch engine**, please see [the PyTorch Model Architectures](dlc3-architectures) for options.
-```python
-my_better_graph = [[0, 1], [1, 2], [2, 3]] # These are indices in the list of multianimalbodyparts
-deeplabcut.create_multianimaltraining_dataset(path_config_file, paf_graph=my_better_graph)
-```
+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)**).
-Alternatively, the `skeleton` defined in the `config.yaml` file can also be used:
+Then run:
```python
-deeplabcut.create_multianimaltraining_dataset(path_config_file, paf_graph='config')
+deeplabcut.create_training_dataset(config_path)
```
-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).
+- The set of arguments in the function will shuffle the combined labeled dataset and split it to create train and test
+ sets. The subdirectory with suffix `iteration#` under the directory **training-datasets** stores the dataset and meta
+ information, where the `#` is the value of `iteration` variable stored in the projectâs configuration file (this number
+ keeps track of how often the dataset was refined).
+
+- To benchmark performance across multiple train/test splits, pass an integer to
+ `num_shuffles`; see the docstring for 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-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](), 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
+is now part of the augmentation pipeline. In other words, image crops are no longer stored in labeled-data/...\_cropped
folders. Crop size still defaults to (400, 400); if your images are very large (e.g. 2k, 4k pixels), consider increasing the crop size, but be aware unless you have a strong GPU (24 GB memory or more), you will hit memory errors. You can lower the batch size, but this may affect performance.
In addition, one can specify a crop sampling strategy: crop centers can either be taken at random over the image (`uniform`) or the annotated keypoints (`keypoints`); with a focus on regions of the scene with high body part density (`density`); last, combining `uniform` and `density` for a `hybrid` balanced strategy (this is the default strategy). Note that both parameters can be easily edited prior to training in the **pose_cfg.yaml** configuration file.
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).
+```{tip}
+To compare multiple model architectures on the same train/test split, select "Use an
+existing data split" in the Project Manager GUI. This also lets you compare PyTorch and
+TensorFlow models side by side.
+```
+
+````{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!
-### Train The Network:
+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.train_network(config_path, allow_growth=True)
+deeplabcut.create_training_dataset_from_existing_split(
+ config_path,
+ from_shuffle=0,
+ shuffles=[10, 11, 12],
+ net_type="resnet_50",
+)
```
+````
-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).
+````{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_training_dataset.rst
+```
+````
+
+````{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
-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.train_network(config_path, shuffle=1)
```
-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.
+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.
-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).
+````{admonition} Tips on training models with the PyTorch Engine
+---
+class: dropdown
+---
+Example parameters that one can call:
-**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).
+```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,
+)
+```
-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.
+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.
+
+```{important}
+Train the network **until the loss plateaus** â depending on the dataset, model
+architecture, and hyper-parameters this typically occurs after 100â250 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.
+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.
+````
-**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} Tips on training models with the TensorFlow Engine
+---
+class: dropdown
+---
+Example parameters that one can call:
-**Parameters:**
+```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,
+)
```
-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
+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.
-trainingsetindex: int, optional
- Integer specifying which TrainingsetFraction to use. By default the first (note that TrainingFraction is a list in config.yaml).
+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).
-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
+```{important}
+Train until the loss plateaus â typically around **500,000** iterations with batch
+size 1, or **50â100K** iterations with batch size 8 (the default).
+```
-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
+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.
-autotune: property of TensorFlow, somehow faster if 'false' (as Eldar found out, see https://github.com/tensorflow/tensorflow/issues/13317). Default: False
+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.
-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
+```{important}
+Multi-animal projects use different output layers, data augmentation, optimizers,
+learning rates, and batch defaults compared to single-animal projects. Use a lower
+`save_iters` and `maxiters`: save every 10Kâ15K iterations and stop training at
+50Kâ100K iterations. Monitor the loss curve carefully to avoid overfitting. Training
+time is correspondingly shorter.
+```
+````
-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
+````{admonition} Click the button to see API Docs for train_network
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.train_network.rst
+```
+````
-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
+#### (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).
```
-### Evaluate the Trained Network:
+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`.
+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"`.
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
@@ -368,37 +718,62 @@ 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
-âxâ for (likelihood <= `pcutoff`).
+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
@@ -411,53 +786,116 @@ 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!)
-### -------------------- DECISION POINT -------------------
+You can drop "Indices" to run this on all training/testing images (this is very slow!)
+
+### Phase 4 â Video analysis and tracking
+
+#### (I) Analyze new Videos
-#### 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:
+```{important}
+Before moving on, make a deliberate decision about whether the pose estimation quality is sufficient. If you do not have good pose estimation evaluation metrics at this point, please revisit the original labels, add more training data and refine the model rather than proceeding with the current results.
+```
+
+```{note}
+In prior versions of DeepLabCut, pose estimation and tracking were separate procedures. From version 3.0 onward, `deeplabcut.analyze_videos` runs the **full pose estimation + tracking pipeline** by default (`auto_track=True`), producing an .h5 file ready for downstream use. To inspect raw detections before any of this is applied, pass `auto_track=False` explicitly.
+```
-Please run:
+##### Pose estimation quality check
+
+To validate raw pose estimation performance on a video before committing to the tracking
+results, 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']
+deeplabcut.analyze_videos(config_path, videos_to_analyze, videotype='.mp4', auto_track=False)
+deeplabcut.create_video_with_all_detections(config_path, videos_to_analyze, videotype='.mp4')
+```
+
+With `auto_track=False`, no `.h5` file is produced â only a `*_full.pickle` file
+containing the raw detections, which is what `create_video_with_all_detections` uses to
+render all detections before any individual is assigned.
+
+```{versionadded} 3.0.0
+For conditional top-down (CTD) models, tracking can be performed **inside the model
+during inference**, using temporal context from previous frames to condition predictions
+on the current frame. This is a distinct mechanism from `auto_track`: pass
+`ctd_tracking=True` to `deeplabcut.analyze_videos` when using any model whose name
+starts with `ctd_`. When `ctd_tracking=True`, post-processing tracking (`auto_track`) is
+skipped automatically. To learn more, see the [
+`COLAB_BUCTD_and_CTD_tracking`](
+https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb)
+COLAB notebook.
```
-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!!!
+
+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.
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:
+1. 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 & TRACKING ACROSS FRAMES -------------------
+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.
+```
-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.
+##### Optimized Animal Assembly + Video Analysis:
-### 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:
@@ -471,33 +909,38 @@ max_age: 100
min_hits: 3
```
- - **IMPORTANT POINT FOR SUPERVISED IDENTITY TRACKING**
-
- If the network has been trained to learn the animals' identities (i.e., you set `identity=True` in config.yaml before training) this information can be leveraged both during: (i) animal assembly, where body parts are grouped based on the animal they are predicted to belong to (affinity between pairs of keypoints is no longer considered in that case); and (ii) animal tracking, where identity only can be utilized in place of motion trackers to form tracklets.
+If the network was trained with identity supervision (i.e., `identity=True` in
+`config.yaml` before training), this information can be leveraged during: (i) animal
+assembly, where body parts are grouped by predicted identity rather than keypoint
+affinity; and (ii) tracking, where identity alone can be used in place of motion
+trackers to form tracklets.
To use this ID information, simply pass:
+
```python
deeplabcut.convert_detections2tracklets(..., identity_only=True)
```
- **Note:** If only one individual is to be assembled and tracked, assembly and tracking are skipped, and detections are treated as in single-animal projects; i.e., it is the keypoints with highest confidence that are kept and accumulated over frames to form a single, long tracklet. No action is required from users, this is done automatically.
-
**Animal assembly and tracking quality** can be assessed via `deeplabcut.utils.make_labeled_video.create_video_from_pickled_tracks`. This function provides an additional diagnostic tool before moving on to refining tracklets.
-
If animal assemblies do not look pretty, an alternative to the outlier search described above is to pass the
`_assemblies.pickle` to `find_outliers_in_raw_data` in place of the `_full.pickle`.
This will focus the outlier search on unusual assemblies (i.e., animal skeletons that were oddly reconstructed). This may be a bit more sensitive with crowded scenes or frames where animals interact closely.
Note though that at that stage it is likely preferable anyway to carry on with the remaining steps, and extract outliers
from the final h5 file as was customary in single animal projects.
-
-**Next, tracklets are stitched to form complete tracks with:
+\*\*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`.
@@ -508,121 +951,365 @@ 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).
-## Using Unsupervised Identity Tracking:
+##### API Docs
-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:
+````{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:
```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:
+##### Refine Tracklets:
You can also optionally **refine the tracklets**. You can fix both "major" ID swaps, i.e. perhaps when animals cross, and you can micro-refine the individual body points. You will load the `...trackertype.pickle` or `.h5'` file that was created above, and then you can launch a GUI to interactively refine the data. This also has several options, so please check out the docstring. Upon saving the refined tracks you get an `.h5` file (akin to what you might be used to from standard DLC. You can also load (1) filter this to take care of small jitters, and (2) load this `.h5` this to refine (again) in case you find another issue, etc!
```python
-deeplabcut.refine_tracklets(path_config_file, pickle_or_h5_file, videofile_path, max_gap=0, min_swap_len=2, min_tracklet_len=2, trail_len=50)
+deeplabcut.refine_tracklets(config_path, pickle_or_h5_file, videofile_path, max_gap=0, min_swap_len=2, min_tracklet_len=2, trail_len=50)
```
If you use the GUI (or otherwise), here are some settings to consider:
-
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1619628014395-BQ09VLLTKCLQQGRB5T9A/ke17ZwdGBToddI8pDm48kLMj_XrWI9gi4tVeBdgcB8p7gQa3H78H3Y0txjaiv_0fDoOvxcdMmMKkDsyUqMSsMWxHk725yiiHCCLfrh8O1z4YTzHvnKhyp6Da-NYroOW3ZGjoBKy3azqku80C789l0lt53wR20brczws2A6XSGt3kSTbW7uM0ncVKHWPvgHR4kN5Ka1TcK96ljy4ji9jPkQ/TrackletGUI.png?format=1000w
+---
+name: fig-tracklet-gui
+alt: Tracklet refinement GUI showing key settings
+width: 950px
+align: center
+---
+Tracklet refinement GUI. Key settings to configure are described in the text.
+```
-*note, setting `max_gap=0` can be used to fill in all frames across the video; otherwise, 1-n is the # of frames you want to fill in, i.e. maybe you want to fill in short gaps of 5 frames, but 15 frames indicates another issue, etc. You can test this in the GUI very easy by editing the value and then re-launch pop-up GUI.
+\*note, setting `max_gap=0` can be used to fill in all frames across the video; otherwise, 1-n is the # of frames you want to fill in, i.e. maybe you want to fill in short gaps of 5 frames, but 15 frames indicates another issue, etc. You can test this in the GUI very easy by editing the value and then re-launch pop-up GUI.
If you fill in gaps, they will be associated to an ultra low probability, 0.01, so you are aware this is not the networks best estimate, this is the human-override! Thus, if you create a video, you need to set your pcutoff to 0 if you want to see these filled in frames.
[Read more here!](functionDetails.md#madeeplabcut-critical-point---assemble--refine-tracklets)
Short demo:
-
-
-
-### Once you have analyzed video data (and refined your maDeepLabCut tracklets):
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1588690928000-90ZMRIM8SN6QE20ZOMNX/ke17ZwdGBToddI8pDm48kJ1oJoOIxBAgRD2ClXVCmKFZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxBw7VlGKDQO2xTcc51Yv6DahHgScLwHgvMZoEtbzk_9vMJY_JknNFgVzVQ2g0FD_s/refineDEMO.gif?format=750w
+---
+name: fig-refine-tracklets-demo
+alt: Animated demonstration of the tracklet refinement workflow
+width: 70%
+align: center
+---
+Short demo of the tracklet refinement workflow.
+```
+
+#### (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
You can also filter the predicted bodyparts by:
+
```python
deeplabcut.filterpredictions(config_path,['/fullpath/project/videos/reachingvideo1.avi'])
```
-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:
+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?`
-- **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.
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.filterpredictions.rst
+```
+````
+#### (K) Plot Trajectories
-- You can also determine a good `pcutoff` value by looking at the likelihood plot created during `plot_trajectories`:
+Before creating labeled videos, set the `pcutoff` threshold in `config.yaml`. For a
+well-trained network this should be high, e.g. `0.8` or higher. If you filled in gaps,
+set it to `0` to make those interpolated points visible.
+
+You can determine a good `pcutoff` value by inspecting the likelihood plot produced by
+`plot_trajectories`:
-Plot the outputs:
```python
- deeplabcut.plot_trajectories(config_path,['/fullpath/project/videos/reachingvideo1.avi'],filtered = True)
+deeplabcut.plot_trajectories(config_path, ['/fullpath/project/videos/reachingvideo1.avi'], filtered=True)
```
-Create videos:
+````{admonition} Click the button to see API Docs for plot_trajectories
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.plot_trajectories.rst
+```
+````
+
+#### (L) Create Labeled Videos
+
+There are many options for controlling video quality and display style â check the
+docstring for the full list. More details are also available
+[here](functionDetails.md#i-video-analysis-and-plotting-results).
+
```python
- deeplabcut.create_labeled_video(config_path, [videos], videotype='avi', 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', track_method='')
+deeplabcut.create_labeled_video(
+ config_path, [videos], videotype='avi', 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', track_method='',
+)
+```
+
+````{admonition} Click the button to see API Docs for create_labeled_video
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.create_labeled_video.rst
```
-- **NOTE :bulb::mega::** You have a lot of options in terms of video plotting (quality, display type, etc). We recommend checking the docstring!
+````
+
+### Phase 5 â Refinement (optional)
+
+#### (M) Optional Active Learning - Network Refinement: Extract Outlier Frames
+
+##### Overview
-(more details [here](functionDetails.md#i-video-analysis-and-plotting-results))
+While DeepLabCut typically generalizes well across datasets, one might want to optimize its performance in various,
+perhaps unexpected, situations. For generalization to large datasets, images with insufficient labeling performance
+can be extracted, manually corrected by adjusting the labels to increase the training set and iteratively improve the
+feature detectors. Such an active learning framework can be used to achieve a predefined level of confidence for all
+images with minimal labeling cost (discussed in Mathis et al 2018). Then, due to the large capacity of the neural network that underlies the feature detectors, one can continue training the network with these additional examples. One does not
+necessarily need to correct all errors as common errors could be eliminated by relabeling a few examples and then
+re-training. A priori, given that there is no ground truth data for analyzed videos, it is challenging to find putative
+âoutlier framesâ. However, one can use heuristics such as the continuity of body part trajectories, to identify images
+where the decoder might make large errors.
-### HELP:
+All this can be done for a specific video by typing (see other optional inputs below):
-In ipython/Jupyter notebook:
+##### Code example
+```python
+deeplabcut.extract_outlier_frames(config_path, ["videofile_path"])
```
-deeplabcut.nameofthefunction?
+
+##### Frame-selection methods
+
+We provide various frame-selection methods for this purpose. In particular
+the user can set:
+
+```text
+outlieralgorithm: "fitting", "jump", or "uncertain"
```
-In python or pythonw:
+- `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).
+
+- `outlieralgorithm="jump"`: select frames where a particular body part or all body parts jumped more than `epsilon`
+ pixels from the last frame.
+
+- `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`).
+- `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")
```
-help(deeplabcut.nameofthefunction)
+
+##### Selection after detection
+
+In general, depending on the parameters, these methods might return many 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="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}
+.. include:: ./api/deeplabcut.extract_outlier_frames.rst
```
+````
+
+______________________________________________________________________
+
+#### (N) Refine Labels: Augmentation of the Training Dataset
+
+##### Overview
-### Tips for "daily" use:
+Based on the performance of DeepLabCut, four scenarios are possible:
-
-
-
+- (A) Visible body part with accurate DeepLabCut prediction. These labels do not need any modifications.
-You can always exit an conda environment and easily jump back into a project by simply:
+- (B) Visible body part but wrong DeepLabCut prediction. Move the labelâs location to the actual position of the
+ body part.
-Linux/MacOS formatting example:
+- (C) Invisible, occluded body part. Remove the predicted label by DeepLabCut with a middle click. Every predicted
+ label is shown, even when DeepLabCut is uncertain. This is necessary, so that the user can potentially move
+ the predicted label. However, to help the user to remove all invisible body parts the low-likelihood predictions
+ are shown as open circles (rather than disks).
+
+- (D) Invalid images: In the unlikely event that there are any invalid images, the user should remove such an image
+ and their corresponding predictions, if any. Here, the GUI will prompt the user to remove an image identified
+ as invalid.
+
+The labels for extracted putative outlier frames can be refined by opening the GUI:
+
+##### Code example
+
+```python
+deeplabcut.refine_labels(config_path)
```
-source activate yourdeeplabcutEnvName
-ipython or pythonw
-import deeplabcut
-config_path ='/home/yourprojectfolder/config.yaml'
+
+This will launch a GUI where the user can refine the labels.
+
+Please refer to the {ref}`napari-deeplabcut docs ` for more information about the labelling workflow.
+
+##### Merge datasets
+
+After correcting the labels for all the frames in each of the subdirectories, the users should merge the dataset to
+create a new dataset. In this step the iteration parameter in the config.yaml file is automatically updated.
+
+```python
+deeplabcut.merge_datasets(config_path)
```
-Windows formatting example:
+
+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 new 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).
+
+Now you can run `create_training_dataset`, then `train_network`, etc. If your original labels were adjusted at all,
+start from fresh weights (which is generally recommended), otherwise consider using your already trained network
+weights (see {ref}`Box 2 `).
+
+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
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.refine_labels.rst
```
-activate yourdeeplabcutEnvName
-ipython
-import deeplabcut
-config_path = r'C:\home\yourprojectfolder\config.yaml'
+````
+
+##### API Docs for deeplabcut.merge_datasets
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.merge_datasets.rst
```
+````
+
+## Resources and further reading
-Now, you can run any of the functions described in this documentation.
+### Getting function help
-# Getting help with maDLC:
+In an interactive Python session or Jupyter notebook, append `?` to any function name:
-- If you have a detailed question about how to use the code, or you hit errors that are not "bugs" but you want code assistance, please post on the [](https://forum.image.sc/tags/deeplabcut)
+```python
+deeplabcut.nameofthefunction?
+```
+
+Or use the built-in `help()`:
-- If you have a quick, short question that fits a "chat" format:
-[](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+```python
+help(deeplabcut.nameofthefunction)
+```
-- If you want to share some results, or see others:
-[](https://twitter.com/DeepLabCut)
+### Tips for daily use
-- 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
+You can always exit a conda environment and pick up where you left off:
-- 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.
+Linux/macOS:
-**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](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/MISSION_AND_VALUES.md) to learn more about what we DO hope to provide you.
+```bash
+source activate yourdeeplabcutEnvName
+ipython
+import deeplabcut
+config_path = '/home/yourprojectfolder/config.yaml'
+```
+
+Windows:
+
+```bash
+activate yourdeeplabcutEnvName
+ipython
+import deeplabcut
+config_path = r'C:\home\yourprojectfolder\config.yaml'
+```
+
+### Getting help and support
+
+- **Forum** â for detailed usage questions or errors that are not bugs, please post on the
+ [Image.sc forum](https://forum.image.sc/tags/deeplabcut).
+- **Chat** â for short questions:
+ [](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
+- **Social** â share results or follow updates:
+ [](https://x.com/DeepLabCut)
+- **Bug reports** â please open an issue with a minimal reproducible example:
+
+
+```{note}
+We are not able to provide individual support for experiment design or custom data
+analysis â the volume of such requests is too large to sustain. We welcome these
+discussions on the forum, where the community can benefit collectively. Please read our
+[Missions & Values statement](mission-and-values) to learn more.
+```
diff --git a/docs/main-workflows/multi-animal-tracking.md b/docs/main-workflows/multi-animal-tracking.md
new file mode 100644
index 0000000000..8e638c3768
--- /dev/null
+++ b/docs/main-workflows/multi-animal-tracking.md
@@ -0,0 +1,229 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+---
+
+(file:multi-animal-tracking)=
+
+# Multi-animal tracking
+
+```{contents}
+---
+local:
+depth: 3
+---
+```
+
+The workflow for multi-animal projects is fully aligned with single animal projects. However, for multi-animal projects,
+the video analysis is slightly more complex: besides bare keypoint estimation, the keypoints need to be assigned to one
+of the different individuals and coherenty tracked across frames. This guide zooms in on the details of the tracking
+step in the video analysis for multi-animal projects. The full workflow is discussed in the
+{ref}`user guide `.
+
+```{important}
+From version 3.0 onward, `deeplabcut.analyze_videos` runs the **full pose estimation + tracking pipeline** by default (`auto_track=True`), producing an .h5 file ready for downstream use.
+
+In prior versions of DeepLabCut, pose estimation and tracking were separate procedures. This behavior can still be obtained by setting `auto_track=False`. With `auto_track=False`, no `.h5` file is produced â only a `*_full.pickle` file containing the raw detections. 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).
+```
+
+## Visualization before tracking
+
+### Visualize raw keypoint detections (without tracking)
+
+To validate raw pose estimation performance on a video before committing to the tracking
+results, run:
+
+```python
+videos_to_analyze = ['/fullpath/project/videos/testVideo.mp4']
+deeplabcut.analyze_videos(
+ config_path,
+ videos_to_analyze,
+ auto_track=False
+)
+deeplabcut.create_video_with_all_detections(
+ config_path,
+ videos_to_analyze
+)
+```
+
+### Visualizing part-affinity fields (PAFs)
+
+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`.
+
+````{tip}
+If these results do 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.
+
+You can either:
+
+1. extract more frames manually from existing or new videos and label as when initially building the training data set, or
+1. 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.
+````
+
+## Manually run tracking steps
+
+### Animal Assembly and Tracking across frames
+
+After pose estimation, now you perform assembly and tracking.
+
+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:
+#p/m pixels in width and height for increasing bounding boxes.
+boundingboxslack : 0
+# Intersection over Union (IoU) threshold for linking two bounding boxes
+iou_threshold: .2
+# maximum duration of a lost tracklet before it's considered a "new animal" (in frames)
+max_age: 100
+# minimum number of consecutive frames before a detection is tracked
+min_hits: 3
+```
+
+If the network was trained with identity supervision (i.e., `identity=True` in
+`config.yaml` before training), this information can be leveraged during: (i) animal
+assembly, where body parts are grouped by predicted identity rather than keypoint
+affinity; and (ii) tracking, where identity alone can be used in place of motion
+trackers to form tracklets.
+
+To use this ID information, simply pass:
+
+```python
+deeplabcut.convert_detections2tracklets(..., identity_only=True)
+```
+
+- **Note:** If only one individual is to be assembled and tracked, assembly and tracking are skipped, and detections are treated as in single-animal projects; i.e., it is the keypoints with highest confidence that are kept and accumulated over frames to form a single, long tracklet. No action is required from users, this is done automatically.
+
+**Animal assembly and tracking quality** can be assessed via `deeplabcut.utils.make_labeled_video.create_video_from_pickled_tracks`. This function provides an additional diagnostic tool before moving on to refining tracklets.
+
+If animal assemblies do not look pretty, an alternative to the outlier search described above is to pass the
+`_assemblies.pickle` to `find_outliers_in_raw_data` in place of the `_full.pickle`.
+This will focus the outlier search on unusual assemblies (i.e., animal skeletons that were oddly reconstructed). This may be a bit more sensitive with crowded scenes or frames where animals interact closely.
+Note though that at that stage it is likely preferable anyway to carry on with the remaining steps, and extract outliers
+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'],
+ video_extensions='mp4',
+ shuffle=1,
+ trainingsetindex=0,
+)
+```
+
+Note that the base signature of the function is identical to `analyze_videos` and `convert_detections2tracklets`.
+If the number of tracks to reconstruct is different from the number of individuals
+originally defined in the config.yaml, `n_tracks` (i.e., the number of animals you have in your video)
+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:
+
+```python
+deeplabcut.transformer_reID(config, videos_to_analyze, n_tracks=None, video_extensions="mp4")
+```
+
+Note you should pass the n_tracks (number of animals) you expect to see in the video.
+
+##### Refine Tracklets:
+
+You can also optionally **refine the tracklets**. You can fix both "major" ID swaps, i.e. perhaps when animals cross, and you can micro-refine the individual body points. You will load the `...trackertype.pickle` or `.h5'` file that was created above, and then you can launch a GUI to interactively refine the data. This also has several options, so please check out the docstring. Upon saving the refined tracks you get an `.h5` file (akin to what you might be used to from standard DLC. You can also load (1) filter this to take care of small jitters, and (2) load this `.h5` this to refine (again) in case you find another issue, etc!
+
+```python
+deeplabcut.refine_tracklets(config_path, pickle_or_h5_file, videofile_path, max_gap=0, min_swap_len=2, min_tracklet_len=2, trail_len=50)
+```
+
+If you use the GUI (or otherwise), here are some settings to consider:
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1619628014395-BQ09VLLTKCLQQGRB5T9A/ke17ZwdGBToddI8pDm48kLMj_XrWI9gi4tVeBdgcB8p7gQa3H78H3Y0txjaiv_0fDoOvxcdMmMKkDsyUqMSsMWxHk725yiiHCCLfrh8O1z4YTzHvnKhyp6Da-NYroOW3ZGjoBKy3azqku80C789l0lt53wR20brczws2A6XSGt3kSTbW7uM0ncVKHWPvgHR4kN5Ka1TcK96ljy4ji9jPkQ/TrackletGUI.png?format=1000w
+---
+name: fig-tracklet-gui
+alt: Tracklet refinement GUI showing key settings
+width: 950px
+align: center
+---
+Tracklet refinement GUI. Key settings to configure are described in the text.
+```
+
+\*note, setting `max_gap=0` can be used to fill in all frames across the video; otherwise, 1-n is the # of frames you want to fill in, i.e. maybe you want to fill in short gaps of 5 frames, but 15 frames indicates another issue, etc. You can test this in the GUI very easy by editing the value and then re-launch pop-up GUI.
+
+If you fill in gaps, they will be associated to an ultra low probability, 0.01, so you are aware this is not the networks best estimate, this is the human-override! Thus, if you create a video, you need to set your pcutoff to 0 if you want to see these filled in frames.
+
+[Read more here!](functionDetails.md#madeeplabcut-critical-point---assemble--refine-tracklets)
+
+Short demo:
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1588690928000-90ZMRIM8SN6QE20ZOMNX/ke17ZwdGBToddI8pDm48kJ1oJoOIxBAgRD2ClXVCmKFZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxBw7VlGKDQO2xTcc51Yv6DahHgScLwHgvMZoEtbzk_9vMJY_JknNFgVzVQ2g0FD_s/refineDEMO.gif?format=750w
+---
+name: fig-refine-tracklets-demo
+alt: Animated demonstration of the tracklet refinement workflow
+width: 70%
+align: center
+---
+Short demo of the tracklet refinement workflow.
+```
diff --git a/docs/main-workflows/user-guide.md b/docs/main-workflows/user-guide.md
new file mode 100644
index 0000000000..07ea80ae64
--- /dev/null
+++ b/docs/main-workflows/user-guide.md
@@ -0,0 +1,1679 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+---
+
+(file:dlc-userguide)=
+
+# DeepLabCut User Guide
+
+```{contents}
+---
+local:
+depth: 3
+---
+```
+
+This guide covers the standard single-animal and multi-animal 2D pose estimation projects.
+
+## Getting started
+
+DeepLabCut offers two equivalent interfaces: a **GUI** for those who prefer a visual
+workflow (no Python knowledge required), and a **Python API** for users who want
+scripting flexibility or to integrate DeepLabCut into a larger pipeline. All workflow
+steps are available in both.
+
+We assume you have DeepLabCut installed (if not, see {ref}`file:how-to-install`).
+Open a terminal and activate your conda environment:
+
+```bash
+conda activate DEEPLABCUT
+```
+
+```{important}
+On Windows, always open the terminal with administrator privileges: right-click and
+select "Run as administrator".
+```
+
+Choose your interface below to launch DeepLabCut:
+
+### GUI (recommended for beginners)
+
+```bash
+python -m deeplabcut
+```
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1572824438905-QY9XQKZ8LAJZG6BLPWOQ/ke17ZwdGBToddI8pDm48kIIa76w436aRzIF_cdFnEbEUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYxCRW4BPu10St3TBAUQYVKcLthF_aOEGVRewCT7qiippiAuU5PSJ9SSYal26FEts0MmqyMIhpMOn8vJAUvOV4MI/guilaunch.jpg?format=1000w
+---
+name: fig-gui-launch
+alt: The DeepLabCut Project Manager GUI after launch
+width: 60%
+align: center
+---
+The DeepLabCut Project Manager GUI.
+```
+
+### Python API
+
+In an interactive Python session (e.g. `ipython`), import DeepLabCut:
+
+```python
+import 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 DeepLabCut version 2.0.6). Additional functions and features are
+continually added to the package; we recommend reading the protocol alongside this
+documentation.
+
+## Workflow
+
+DeepLabCut's full workflow is described in steps (A)â(N) below. Code examples throughout this page use the Python API; if you are
+using the GUI, the same steps are available in the corresponding panels of the
+Project Manager.
+
+You should think of the workflow as 5 phases
+
+```{figure} ../images/dlc-workflow.png
+---
+name: dlc-workflow-figure
+alt: The 5 phases of the DeepLabCut workflow.
+align: center
+---
+The 5 phases of the DeepLabCut workflow. The expected outputs are indicated in the grey boxes.
+```
+
+1. Project setup: create and configure your new project.
+1. Data preparation: select frames and annotate your training data.
+1. Training and evaluation: configure, train and evaluate your neural network model.
+1. Analysis: run inference with your trained model to create predictions and labeled videos.
+1. Refinement (optional): improve your data quality for a next training iteration.
+
+```{admonition} Automated multi-animal tracking
+---
+class-container: multi-animal
+---
+ For multi-animal projects, the video-analysis step contains an automated tracking step. More information is available in the {ref}`multi-animal tracking guide `.
+```
+
+### Phase 1 â Project setup
+
+#### (A) Create a New Project
+
+##### Overview
+
+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
+- 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
+ - **Recommended**: Whether to copy the videos to the project directory
+ - Whether to create a single- or multi-animal project
+
+```{note}
+If the optional argument `working_directory` is unspecified, the
+project directory is created in the current working directory.
+
+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).
+This is why administrator privileges are required for Windows users, as creating symbolic links requires them.
+```
+
+##### Code example
+
+````{dropdown}
+---
+class-container: single-animal
+open:
+---
+```python
+deeplabcut.create_new_project(
+ "Name of the project",
+ "Name of the experimenter",
+ ["Full path of video 1", "Full path of video 2", "Full path of video 3"],
+ working_directory="Full path of the working directory",
+ copy_videos=True,
+ multianimal=False
+)
+```
+````
+
+````{dropdown}
+---
+class-container: multi-animal
+open:
+---
+```python
+deeplabcut.create_new_project(
+ "Name of the project",
+ "Name of the experimenter",
+ ["Full path of video 1", "Full path of video 2", "Full path of video 3"],
+ working_directory="Full path of the working directory",
+ copy_videos=True,
+ multianimal=True
+)
+```
+````
+
+###### Output & directory structure
+
+```{important}
+On Windows, 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 creates a project directory with the name:
+
+**`++`**
+
+in the **working directory** and creates the video copies in the videos directory.
+
+The project directory will have subdirectories:
+
+```
+++/
+âââ dlc-models/
+â âââ iteration-0/
+â âââ iteration-1/
+â âââ ...
+âââ dlc-models-pytorch/
+â âââ iteration-0/
+â â âââ /
+â â âââ train/
+â â âââ test/
+â âââ iteration-1/
+â âââ ...
+âââ labeled-data/
+â âââ /
+âââ training-datasets/
+âââ videos/
+âââ config.yaml
+```
+
+All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be managed independently of other projects.
+
+###### Subdirectory layout
+
+1. `dlc-models` and `dlc-models-pytorch`:
+ These directories have the same structure but store model files for different engines:
+
+ - `dlc-models` for TensorFlow
+ - `dlc-models-pytorch` for PyTorch
+ - At the top level, both contain **iteration folders** such as:
+ - `iteration-0`
+ - `iteration-1`, etc.
+ which correspond to successive rounds of label refinement.
+ - Each iteration folder in turn contains **shuffle directories**, each representing a specific experiment defined by a particular train/test split and model architecture.
+ - Within each shuffle directory:
+ - `train/` and `test/` store metadata and configuration files for the feature detectors.
+
+ - The `train/` folder also stores training checkpoints (snapshots), which let users reload a trained model or resume training from an intermediate checkpoint if training was interrupted.
+
+1. `labeled-data/`:
+ Contains the extracted frames used to build the training dataset. Frames from different videos are stored in separate subdirectories, and each frame filename encodes its temporal position in the source video, making it easy to trace each frame back to its origin.
+
+1. `training-datasets/`:
+ Stores the generated training datasets along with metadata describing how each dataset was created.
+
+1. `videos/`:
+ Stores either the project videos themselves or symbolic links to them:
+ If `copy_videos=False` (default), it contains symbolic links.
+ If `copy_videos=True`, the videos are copied into the directory.
+
+```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
+)
+```
+
+```{hint}
+The *Full path of the project configuration file* will be referenced as `config_path` throughout this guide.
+```
+
+###### Main configuration file (*config.yaml*)
+
+The project directory also contains the main configuration file called *config.yaml*. The *config.yaml* file contains many important parameters of the project. This includes:
+
+- *Task*
+- *Scorer*
+- *Date*
+- *Project_path*
+- A list of videos *video_sets*.
+
+```{important}
+After creating the project and initial configuration, the `task`, the `scorer` and `date` should **not be changed**. Many other parameters can still be changed or even **need to be changed**, such as the list of `bodyparts`. This is covered in the next section.
+```
+
+###### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `create_new_project` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/create_project/new/#deeplabcut.create_project.new.create_new_project)
+```
+
+#### (B) Configure the Project
+
+Open the **config.yaml** file, which was created with `create_new_project`. You can edit this file in any
+text editor.
+
+Familiarize yourself with the meaning of the parameters. For instance **it is important to configure the `bodyparts`**, but you can also configure default visualization settings, such as the
+`colormap` (see [matplotlib colormaps](https://matplotlib.org/tutorials/colors/colormaps.html)) in downstream steps, like labeling GUIs, videos, etc.
+
+The project configuration **differs between single-animal and multi-animal projects**, a complete overview is presented below.
+
+```{danger}
+Please **do not include spaces** in the names of `bodyparts`, `multianimalbodyparts` or `Uniquebodyparts`.
+```
+
+````{dropdown} : Configuration
+---
+class-container: single-animal
+open:
+---
+A complete list of parameters including their description can be found in
+{ref}`Box 1 `.
+
+You **must add** the list of *bodyparts* (or points of interest) that you want to track.
+
+```{figure} ../images/box1-single.png
+---
+name: config-box1-single
+alt: Box 1 - Single Animal Project Configuration File Glossary
+align: center
+---
+Single Animal project configuration file glossary
+```
+````
+
+````{dropdown} : Configuration
+---
+class-container: multi-animal
+open:
+---
+A complete list of parameters including their description can be found in
+{ref}`Box 1 `.
+
+
+You **must modify** `individuals`, `identity`, `multianimalbodyparts`,`Uniquebodyparts` (explanation below) 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.
+
+```yaml
+individuals:
+- m1
+- m2
+- m3
+uniquebodyparts:
+- topleftcornerofBox
+- toprightcornerofBox
+multianimalbodyparts:
+- snout
+- leftear
+- rightear
+- tailbase
+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`.
+- **Multianimalbodyparts:** are the bodyparts of each individual (in the above list).
+- **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, ...).
+
+
+```{figure} ../images/box1-multi-rec.png
+---
+name: config-box1-multi
+alt: Box 1 - Multi Animal Project Configuration File Glossary
+align: center
+---
+Multi Animal project configuration file glossary
+```
+````
+
+______________________________________________________________________
+
+### Phase 2 â Data preparation
+
+#### (C) Select Frames to Label
+
+```{dropdown} : Converting single-animal data to multi-animal data
+---
+class-container: multi-animal
+open:
+---
+You can use annotated data from single-animal projects, by converting those files.
+
+See the {ref}`conversion guide` for more information.
+```
+
+##### Overview
+
+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â subfolder.
+
+This function also has various parameters that might be useful based on the userâs
+need.
+
+```{important}
+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 dataset in an adaptive way** (see
+refinement below).
+```
+
+##### Code example
+
+```python
+deeplabcut.extract_frames(
+ config_path,
+ mode="automatic/manual",
+ algo="uniform/kmeans",
+ crop=True/False,
+ userfeedback=False
+)
+```
+
+```{important}
+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).
+```
+
+```{tip}
+`userfeedback` allows the user to specify which videos they wish to extract frames from. When set to `True`, a dialog
+will appear, 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.
+```
+
+##### Frame selection methods
+
+Select representative frames from videos for labeling.
+
+Frame selection can be performed in three ways:
+
+1. Uniform random sampling
+ Use when relevant postures and behaviors are distributed throughout the video.
+1. K-means-based selection
+ Use when important behaviors are sparse, brief, or when most frames look similar.
+ The video is downsampled, frames are clustered by visual appearance, and frames
+ are selected from different clusters to improve diversity. This can be slower
+ for large or long videos.
+1. Manual selection
+ Use when you already know which frames are informative and want precise control
+ over the training set.
+
+In brief:
+
+- Behavior varies throughout the video -> use uniform sampling.
+- Behavior is sparse or brief -> use k-means selection.
+- Specific useful frames are known -> use manual selection.
+
+For best results, restrict frame extraction to video intervals containing the
+behaviors of interest using the `start` and `stop` parameters in `config.yaml`.
+Aim to label a diverse, representative set of frames rather than simply increasing
+the total number of labeled frames.
+
+```{important}
+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
+parameters in the config.yaml file.
+Also, the user can change the number of frames to extract from each video using the numframes2pick in the config.yaml file.
+```
+
+##### Manual frame selection
+
+However, picking frames is highly dependent on the data and the behavior being studied.
+Therefore, it is hard to provide one-size-fits-all 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 to extract.
+
+
+
+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.
+
+````{comment}
+```{figure} https://static1.squarespace.com/static/57f6d51c9f74566f55ecf271/t/5c71bfbc71c10b4a23d20567/1550958540700/cropMANUAL.gif?format=750w
+---
+name: fig-manual-frame-selection
+alt: Manual frame selection using the extract_frames GUI
+width: 70%
+align: center
+---
+Manual frame selection using the `extract_frames` GUI.
+````
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `extract_frames` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/generate_training_dataset/?h=extract_frames#deeplabcut.generate_training_dataset.extract_frames)
+```
+
+______________________________________________________________________
+
+#### (D) Label Frames
+
+##### Overview
+
+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.
+
+```{hint}
+Check out the {ref}`napari-deeplabcut docs ` for
+more information about the labelling workflow.
+```
+
+##### Code example
+
+```python
+deeplabcut.label_frames(config_path)
+```
+
+```{important}
+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.
+```
+
+```{dropdown} : Annotation tips for multi-animal projects
+---
+class-container: multi-animal
+open:
+---
+- **Interacting Animals**:
+ For multi-animal projects with interacting animals, make sure that interaction-frames are well-represented in your training dataset: i.e. make sure that you have labeled frames with closely interacting animals!
+ If interactions do not not frequently occur in the video, it is advised to selecting some interaction-frames *manually*.
+
+- **Labeling and Identity**:
+ Unless you can visually distinguish the animals,
+ you do not need to maintain a consistent ID across frames. For example, with a white and
+ a black mouse, always label white as animal 1 and black as animal 2. With two
+ indistinguishable black mice, the ID assignment may switch between frames â
+ just be consistent *within* each frame. If one animal always has a distinguishing
+ feature (e.g., an optical fiber), then label them consistently across all frames
+```
+
+##### Demo
+
+[đ„ DEMO](https://youtu.be/hsA9IB5r73E)
+
+
+
+
+
+##### Optional: Adding new bodypart labels
+
+To add 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.
+
+For more information about the labelling workflow, check out the {ref}`napari-deeplabcut docs `.
+
+______________________________________________________________________
+
+#### (E) Check Annotated Frames
+
+##### Overview
+
+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:
+
+##### Code example
+
+```python
+deeplabcut.check_labels(config_path, visualizeindividuals=True/False)
+```
+
+##### What it creates
+
+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.
+
+````{dropdown} : Colors set per individual or body part
+---
+class-container: multi-animal
+open:
+---
+You may 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.
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1586203062876-D9ZL5Q7NZ464FUQN95NA/ke17ZwdGBToddI8pDm48kKmw982fUOZVIQXHUCR1F55Zw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpx7krGdD6VO1HGZR3BdeCbrijc_yIxzfnirMo-szZRSL5-VIQGAVcQr6HuuQP1evvE/img1068_individuals.png?format=750w
+---
+name: fig-check-labels-individuals
+alt: Example check_labels output showing annotated individuals per frame
+width: 50%
+align: center
+---
+Example `check_labels` output with annotations shown per individual.
+```
+
+````
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `check_labels` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/generate_training_dataset/?h=check_labels#deeplabcut.generate_training_dataset.check_labels)
+```
+
+______________________________________________________________________
+
+### Phase 3 â Training & evaluation
+
+(create-training-dataset)=
+
+#### (F) Create Training Dataset
+
+```{important}
+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 handled 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` (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.
+
+- 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 dataset will be used for evaluating the
+network.
+
+##### Code example
+
+```python
+deeplabcut.create_training_dataset(config_path)
+```
+
+- Optional: If the user wishes to benchmark the performance of different training settings, they can create multiple training
+ datasets by specifying an integer value for `num_shuffles`; see the docstring for more details.
+
+##### Output structure and configuration files
+
+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).
+
+A schematic view of the structure described above is:
+
+```text
+/
+âââ /
+ âââ train/
+ â âââ pytorch_config.yaml or pose_cfg.yaml
+ âââ test/
+ âââ pose_cfg.yaml
+```
+
+##### Network and augmentation selection
+
+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.
+
+- Networks: ImageNet pre-trained networks OR SuperAnimal pre-trained network 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).
+
+```{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.
+ - See the [**pytorch_config.yaml**](dlc3-pytorch-config) for more
+ information about image augmentation options.
+- TensorFlow Engine: The default augmentation works well for most tasks, 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.
+
+```{dropdown} : Augmentation details (TensorFlow)
+---
+class-container: multi-animal
+open:
+---
+Only `imgaug` augmentation is available for multi-animal projects with the TensorFlow
+engine.
+
+Image cropping is part of the augmentation pipeline â crops are **no longer** stored
+in `labeled-data/..._cropped` folders as in older versions. The crop size defaults to
+`(400, 400)`. If your images are very large (e.g. 2K or 4K pixels), consider
+increasing the crop size, but be aware that unless you have a GPU with â„24 GB memory
+you may hit out-of-memory errors. Lowering the batch size can help, but may affect
+performance.
+
+You can also specify a **crop sampling strategy** (editable in the `pose_cfg.yaml`
+before training):
+
+- `uniform` â crop centers are drawn at random over the image.
+- `keypoints` â crop centers are drawn at annotated keypoint locations.
+- `density` â crops focus on regions with high body-part density.
+- `hybrid` â combines `uniform` and `density` for a balanced strategy (**default**).
+
+See [Mathis et al., 2020 â A Primer on Motion Capture with Deep Learning](https://www.cell.com/neuron/pdf/S0896-6273(20)30717-0.pdf)
+(Fig. 8) for a worked example of the benefit of data augmentation with `imgaug` and
+exploiting the symmetries of your data.
+```
+
+##### 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
+
+```{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset
+---
+class: dropdown
+---
+[View the `create_training_dataset` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/generate_training_dataset/trainingsetmanipulation/#deeplabcut.generate_training_dataset.trainingsetmanipulation.create_training_dataset)
+```
+
+```{admonition} Click the button to see API Docs for deeplabcut.create_training_model_comparison
+---
+class: dropdown
+---
+[View the `create_training_model_comparison` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/generate_training_dataset/?h=create_training_model#deeplabcut.generate_training_dataset.create_training_model_comparison)
+```
+
+```{admonition} Click the button to see API Docs for deeplabcut.create_training_dataset_from_existing_split
+---
+class: dropdown
+---
+[View the `create_training_dataset_from_existing_split` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/generate_training_dataset/?h=create_training_dataset_from_existing_split#deeplabcut.generate_training_dataset.create_training_dataset_from_existing_split)
+```
+
+______________________________________________________________________
+
+#### (G) Train the Network
+
+##### Overview
+
+The function `train_network` helps the user in training the network. It is used as follows:
+
+##### Code example
+
+```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 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.
+
+````{admonition} Tips on training models with the PyTorch Engine
+---
+class: dropdown
+---
+Example parameters that one can call:
+
+```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.
+
+**Tip**: 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 allow 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.
+````
+
+````{admonition} Tips on training models with the TensorFlow Engine
+---
+class: dropdown
+---
+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=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 {ref}`Box 2 `).
+
+**Tip**: 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 recommendation:** 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. This will reduce your training time.
+
+```{figure} ../images/box2-single.png
+---
+name: pose-cfg-box2
+alt: Box 2 - Single Animal TensorFlow Configuration File Glossary
+align: center
+---
+Single-animal TensorFlow configuration file glossary
+```
+````
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs for train_network
+---
+class: dropdown
+---
+[View the `train_network` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/compat/?h=train_network#deeplabcut.compat.train_network)
+```
+
+______________________________________________________________________
+
+#### (H) Evaluate the Trained Network
+
+##### Overview
+
+It is important to evaluate the performance of the trained network. This performance is measured by computing
+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_{\text{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](https://doi.org/10.1038/s41593-018-0209-y)).
+
+For multi-animal projects, two additional metrics are reported alongside RMSE: **Mean Average Precision (mAP)**
+and **Mean Average Recall (mAR)**. These describe how precisely and completely the model detects individuals
+across frames, and are more informative than RMSE alone when multiple animals are present.
+
+```{dropdown} : mAP and mAR explained
+---
+class-container: multi-animal
+open:
+---
+For multi-animal pose estimation the model can produce multiple detections per image.
+**mAP** and **mAR** estimate precision and recall by sweeping over different thresholds
+of "correctness" and averaging the results. Correctness is evaluated
+through [object-keypoint similarity (OKS)](https://cocodataset.org/#keypoints-eval).
+
+A good resource is the [Stanford CS230 course notes on mAP](https://cs230.stanford.edu/section/8/#object-detection-iou-ap-and-map)
+(written for object detection with bounding boxes, but the same concept applies here
+with OKS instead of IoU).
+
+Unlike RMSE â which is computed per body part over all frames â mAP/mAR capture whether
+the correct number of individuals was detected and whether each individual's keypoints
+were assigned correctly, making them the primary metrics for judging multi-animal
+tracking readiness.
+```
+
+##### Code example
+
+```python
+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
+(colored by body part by default). For multi-animal projects you can also pass `plotting="individual"` to color
+predictions by individual instead. 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 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 | 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"`.
+
+- `show_errors: bool, optional` - Display train and test errors. The default is `True`
+
+- `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
+(governed by the colormap) and the plot labels indicate their source.
+
+```{important}
+Note that by default:
+- **Human labels** are plotted as plus ($+$)
+- **DeepLabCutâs predictions** either as:
+ - dot ($\cdot$) for confident predictions with likelihood $> p_{\text{cutoff}}$
+ - cross ($\times$) for (likelihood $\leq p_{\text{cutoff}}$).
+```
+
+##### Output and interpretation
+
+The evaluation results for each shuffle of the training dataset are stored in a unique subdirectory in a newly created
+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:
+
+- 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 dataset
+
+```{dropdown} : Skeleton selection and map inspection
+---
+class-container: multi-animal
+---
+In multi-animal projects, model evaluation is crucial because this is when the
+data-driven selection of the **optimal skeleton** is carried out. Skipping this step
+causes video analysis to use the redundant skeleton by default, which is slower and
+does not guarantee best performance.
+
+You should also plot the scoremaps, locref layers, and PAFs (for relevant models) to assess detection quality
+before proceeding to video analysis.
+```
+
+##### Optional maps
+
+**Optional:** You can also plot the scoremaps, locref layers, and PAFs:
+
+```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 slow!)
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `evaluate_network` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/compat/?h=evaluate_network#deeplabcut.compat.evaluate_network)
+```
+
+```{important}
+Before moving on, make a deliberate decision about whether the pose estimation quality is sufficient.
+**If you do not have good pose estimation evaluation metrics at this point, please revisit the original labels, add more training data and
+refine the model rather than proceeding with the current results.**
+```
+
+______________________________________________________________________
+
+### Phase 4 â Analysis
+
+#### (I) Analyze New Videos
+
+##### Overview
+
+The trained network can be used to analyze new videos. These videos **do not** need to be in the config file!
+You can analyze new videos with the following line of code:
+
+```python
+deeplabcut.analyze_videos(
+ config_path, ["fullpath/analysis/project/videos/reachingvideo1.avi"],
+ save_as_csv=True
+)
+```
+
+There are several other optional inputs to control the device, the output format, dynamic cropping parameters, etc:
+
+```python
+deeplabcut.analyze_videos(
+ config_path,
+ videos,
+ video_extensions="avi",
+ shuffle=1,
+ trainingsetindex=0,
+ device=None,
+ save_as_csv=False,
+ destfolder=None,
+ dynamic=(True, .5, 10)
+)
+```
+
+````{dropdown} : Automated tracking
+---
+class-container: multi-animal
+---
+ For multi-animal projects, the video analysis is slightly more complex compared to single-animal projects: besides
+ bare keypoint estimation, the keypoints need to be assigned to one of the different individuals and coherently tracked
+ across frames. This tracking procedure is **automated by default**, but it is worthwhile to understand the details,
+ which are discussed in the {ref}`multi-animal tracking guide `.
+
+ **Disabling automated tracking**
+ After bare pose estimation, multi-animal tracking is applied by default (`auto_track=True`). This produces an *.h5* file
+ that is ready for downstream use. To disable the automated tracking step and inspect raw detections before tracking,
+ pass `auto_track=False` explicitly to `deeplabcut.analyze_videos`. No *.h5* file will be produced - only a *.pickle*.
+ The results can be visualized by running:
+ ```
+ deeplabcut.create_video_with_all_detections(
+ config_path,
+ videos_to_analyze
+ )
+ ```
+
+ **Conditional top-down tracking**
+ For conditional top-down (CTD) models, tracking can be performed inside the model, using temporal context from
+ previous frames to condition predictions on the current frame. This is a distinct mechanism from `auto_track`.
+ Pass `ctd_tracking=True` to `deeplabcut.analyze_videos` when using any model whose name starts with `ctd_`.
+ When `ctd_tracking=True`, post-processing tracking (`auto_track`) is skipped automatically.
+````
+
+##### Output and storage
+
+The user can choose which checkpoint to use for video analysis by setting the corresponding checkpoint index in the
+`snapshotindex` variable of the `config.yaml` file. By default, the most recent checkpoint (i.e., last) is used for
+video analysis.
+
+The labels are stored in a MultiIndex [Pandas](http://pandas.pydata.org) DataFrame, containing the network name, body part name, (x, y) label positions in pixels, and the likelihood for each body part in each frame. These arrays are
+stored in an efficient Hierarchical Data Format (HDF) in the same directory, where the video is stored.
+
+If the `save_as_csv` flag is set to `True`, the data can also be exported in comma-separated values (.csv) format, which can be imported into many programs such as MATLAB, R, and Prism. By default, this flag is set to `False`.
+
+You can also specify a destination folder (`destfolder`) for the output files by providing the path to the folder where you would like the results to be written.
+
+##### Dynamic-cropping of videos
+
+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`.
+
+```text
+dynamic: tuple containing (state, detectionthreshold, margin)
+```
+
+If `state` is `True`, then dynamic cropping will be performed.
+That means that if an object is detected (i.e., any body part likelihood > `detectionthreshold`),
+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 `margin` and from then on only the posture within this crop is analyzed (until the object is lost;
+i.e., < `detectionthreshold`).
+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).
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `analyze_videos` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/compat/?h=analyze_videos#deeplabcut.compat.analyze_videos)
+```
+
+______________________________________________________________________
+
+#### (J) Filter Pose Data
+
+##### Overview
+
+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_video` and/or `plot_trajectories`.
+
+##### Code examples
+
+```python
+deeplabcut.filterpredictions(
+ config_path,
+ ["fullpath/analysis/project/videos/reachingvideo1.avi"]
+)
+```
+
+An example call:
+
+```python
+deeplabcut.filterpredictions(
+ config_path,
+ ["fullpath/analysis/project/videos"],
+ video_extensions=".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,
+ filtertype="arima",
+ p_bound=0.01,
+ ARdegree=3,
+ MAdegree=1,
+ alpha=0.01
+)
+```
+
+##### Example output
+
+Here is an example of how this can be applied to a video:
+
+```{figure} https://static1.squarespace.com/static/57f6d51c9f74566f55ecf271/t/5ccc8b8ae6e8df000100a995/1556908943893/filter_example-01.png?format=1000w
+---
+name: fig-filter-example
+alt: Example output of filterpredictions applied to a video
+width: 70%
+align: center
+---
+Example output of `filterpredictions` applied to a video.
+```
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `filterpredictions` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/post_processing/?h=filterpredictions#deeplabcut.post_processing.filterpredictions)
+```
+
+______________________________________________________________________
+
+#### (K) Plot Trajectories
+
+##### Overview
+
+The plotting components of this toolbox utilize 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
+(see {numref}`fig-trajectory-frame` and {numref}`fig-trajectory-plots`).
+
+```{tip}
+Before creating labeled videos, set the `pcutoff` threshold in `config.yaml`. For a
+well-trained network this should be high, e.g. `0.8` or higher. If you filled in gaps,
+set it to `0` to make those interpolated points visible.
+
+You can determine a good `pcutoff` value by inspecting the likelihood plot produced by
+`plot_trajectories`:
+```
+
+##### Code example
+
+```python
+deeplabcut.plot_trajectories(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi'])
+```
+
+##### Output
+
+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). Example outputs are shown below.
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559946148685-WHDO5IG9MMCHU0T7RC62/ke17ZwdGBToddI8pDm48kEOb1vFO6oRDmR8SXh4iL21Zw-zPPgdn4jUwVcJE1ZvWEtT5uBSRWt4vQZAgTJucoTqqXjS3CfNDSuuf31e0tVG1gXK66ltnjKh4U2immgm7AVAdfOWODmXNLQLqbLRZ2DqWIIaSPh2v08GbKqpiV54/file0289.png?format=500w
+---
+name: fig-trajectory-frame
+alt: Example video frame with tracked body parts overlaid
+height: 240px
+align: center
+---
+Example video frame with tracked body parts overlaid.
+```
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559939762886-CCB0R107I2HXAHZLHECP/ke17ZwdGBToddI8pDm48kNeA8e5AnyMqj80u4_mB0hV7gQa3H78H3Y0txjaiv_0fDoOvxcdMmMKkDsyUqMSsMWxHk725yiiHCCLfrh8O1z5QPOohDIaIeljMHgDF5CVlOqpeNLcJ80NK65_fV7S1UcpboONgOQYHLzaUWEI1Ir9fXt7Ehyn7DSgU3GCReAA-ZDqXZYzu2fuaodM4POSZ4w/plot_poses-01.png?format=1000w
+---
+name: fig-trajectory-plots
+alt: Example plot_trajectories output
+height: 250px
+align: center
+---
+Example `plot_trajectories` output: body part coordinates, likelihoods, and consecutive displacement histograms.
+```
+
+##### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `plot_trajectories` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/utils/?h=plot_trajectories#deeplabcut.utils.plot_trajectories)
+```
+
+______________________________________________________________________
+
+#### (L) Create Labeled Videos
+
+##### Overview
+
+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). One can use the command as follows to create multiple labeled videos:
+
+##### Code example
+
+```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.:
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ video_extensions=".mp4",
+ filtered=True
+)
+```
+
+You can also optionally add a skeleton to connect points and/or add a history of points for visualization
+(see {numref}`fig-skeleton-trail`). To set the "trailing points" you need to pass `trailpoints`:
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ video_extensions=".mp4",
+ trailpoints=10
+)
+```
+
+The `create_labeled_video` function contains a lot of other parameters that can be configured to tailor your output
+video. (Note that `displayedindividuals`, `color_by`, `track_method`, and `displaycropped` are *multi-animal-specific*).
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ [videos],
+ video_extensions='avi',
+ 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',
+ track_method='',
+)
+```
+
+##### Skeleton configuration
+
+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, used by calling `deeplabcut.SkeletonBuilder(config_path)`, where `config_path` is the path to your project's `config.yaml` file on disk.
+
+Here is how the `config.yaml` additions/edits should look (for example, on the Openfield demo data we provide):
+
+```yaml
+# Plotting configuration
+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:
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ video_extensions=".mp4",
+ draw_skeleton=True
+)
+```
+
+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"],
+ video_extensions=".mp4",
+ keypoints_only=True
+)
+```
+
+```{tip}
+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`!
+```
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559935526258-KFYZC8BDHK01ZIDPNVIX/ke17ZwdGBToddI8pDm48kJbosy0LGK_KqcAZRQ_Qph1Zw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpzkC6kmM1CbNgeHQVxASNv0wiXikHv274BIFe4LR7nd1rKmAka4uxYMJ9FupazBoaU/mouse_skel_trail.gif?format=750w
+---
+name: fig-skeleton-trail
+alt: Labeled video with skeleton overlay and trailing points
+width: 40%
+align: center
+---
+Labeled video with skeleton overlay and trailing points (`draw_skeleton=True`, `trailpoints=10`).
+```
+
+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
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `create_labeled_video` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/utils/?h=create_labeled_video#deeplabcut.utils.create_labeled_video)
+```
+
+##### Extract "Skeleton" Features
+
+###### Overview
+
+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:
+
+###### Code example
+
+```python
+deeplabcut.analyzeskeleton(
+ config_path,
+ video,
+ video_extensions="avi",
+ shuffle=1,
+ trainingsetindex=0,
+ save_as_csv=False,
+ destfolder=None
+)
+```
+
+###### API Docs
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `analyzeskeleton` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/post_processing/analyze_skeleton/?h=analyzeskeleton#deeplabcut.post_processing.analyze_skeleton.analyzeskeleton)
+```
+
+______________________________________________________________________
+
+### Phase 5 â Refinement (optional)
+
+(active-learning)=
+
+#### (M) Optional Active Learning - Network Refinement: Extract Outlier Frames
+
+##### Overview
+
+While DeepLabCut typically generalizes well across datasets, one might want to optimize its performance in various,
+perhaps unexpected, situations. For generalization to large datasets, images with insufficient labeling performance
+can be extracted, manually corrected by adjusting the labels to increase the training set and iteratively improve the
+feature detectors. Such an active learning framework can be used to achieve a predefined level of confidence for all
+images with minimal labeling cost (discussed in Mathis et al 2018). Then, due to the large capacity of the neural network that underlies the feature detectors, one can continue training the network with these additional examples. One does not
+necessarily need to correct all errors as common errors could be eliminated by relabeling a few examples and then
+re-training. A priori, given that there is no ground truth data for analyzed videos, it is challenging to find putative
+âoutlier framesâ. However, one can use heuristics such as the continuity of body part trajectories, to identify images
+where the decoder might make large errors.
+
+All this can be done for a specific video by typing (see other optional inputs below):
+
+##### Code example
+
+```python
+deeplabcut.extract_outlier_frames(config_path, ["videofile_path"])
+```
+
+##### Frame-selection methods
+
+We provide various frame-selection methods for this purpose. In particular
+the user can set:
+
+```text
+outlieralgorithm: "fitting", "jump", or "uncertain"
+```
+
+- `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).
+
+- `outlieralgorithm="jump"`: select frames where a particular body part or all body parts jumped more than `epsilon`
+ pixels from the last frame.
+
+- `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`).
+
+- `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")
+```
+
+##### Selection after detection
+
+In general, depending on the parameters, these methods might return many 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="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
+---
+[View the `extract_outlier_frames` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/refine_training_dataset/?h=extract_outlier_frames#deeplabcut.refine_training_dataset.extract_outlier_frames)
+```
+
+______________________________________________________________________
+
+#### (N) Refine Labels: Augmentation of the Training Dataset
+
+##### Overview
+
+Based on the performance of DeepLabCut, four scenarios are possible:
+
+1. Visible body part with accurate DeepLabCut prediction. These labels do not need any modifications.
+
+1. Visible body part but wrong DeepLabCut prediction. Move the labelâs location to the actual position of the
+ body part.
+
+1. Invisible, occluded body part. Remove the predicted label by DeepLabCut with a middle click. Every predicted
+ label is shown, even when DeepLabCut is uncertain. This is necessary, so that the user can potentially move
+ the predicted label. However, to help the user to remove all invisible body parts the low-likelihood predictions
+ are shown as open circles (rather than disks).
+
+1. Invalid images: In the unlikely event that there are any invalid images, the user should remove such an image
+ and their corresponding predictions, if any. Here, the GUI will prompt the user to remove an image identified
+ as invalid.
+
+The labels for extracted putative outlier frames can be refined by opening the GUI:
+
+##### Code example
+
+```python
+deeplabcut.refine_labels(config_path)
+```
+
+This will launch a GUI where the user can refine the labels.
+
+Please refer to the {ref}`napari-deeplabcut docs ` for more information about the labelling workflow.
+
+##### Merge datasets
+
+After correcting the labels for all the frames in each of the subdirectories, the users should merge the dataset to
+create a new dataset. In this step the iteration parameter in the config.yaml file is automatically updated.
+
+```python
+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 new 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).
+
+Now you can run `create_training_dataset`, then `train_network`, etc. If your original labels were adjusted at all,
+start from fresh weights (which is generally recommended), otherwise consider using your already trained network
+weights (see {ref}`Box 2 `).
+
+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
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `refine_labels` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/cli/?h=refine_labels#deeplabcut.cli.refine_labels)
+```
+
+##### API Docs for deeplabcut.merge_datasets
+
+```{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+[View the `merge_datasets` API documentation](https://deeplabcut.github.io/DeepLabCut/dev/latest-release/reference/deeplabcut/refine_training_dataset/?h=merge_datasets#deeplabcut.refine_training_dataset.merge_datasets)
+```
+
+______________________________________________________________________
+
+## Resources and further reading
+
+### Jupyter notebooks demo
+
+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
+ [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 tracking
+
+For stereo or multi-camera setups, see the {ref}`3D overview <3D-overview>`.
+
+### Additional helper functions
+
+A collection of optional utility functions is available in the
+{ref}`helper functions reference `.
diff --git a/docs/notebooks/extra.md b/docs/notebooks/extra.md
new file mode 100644
index 0000000000..127406c582
--- /dev/null
+++ b/docs/notebooks/extra.md
@@ -0,0 +1 @@
+# Additional features notebooks
diff --git a/docs/notebooks/main_demos.md b/docs/notebooks/main_demos.md
new file mode 100644
index 0000000000..789d47b336
--- /dev/null
+++ b/docs/notebooks/main_demos.md
@@ -0,0 +1 @@
+# Demo notebooks
diff --git a/docs/notebooks/your_data.md b/docs/notebooks/your_data.md
new file mode 100644
index 0000000000..3ec1e2fc59
--- /dev/null
+++ b/docs/notebooks/your_data.md
@@ -0,0 +1 @@
+# Notebooks for your data
diff --git a/docs/pytorch/Benchmarking_shuffle_guide.md b/docs/pytorch/Benchmarking_shuffle_guide.md
new file mode 100644
index 0000000000..1d587c9550
--- /dev/null
+++ b/docs/pytorch/Benchmarking_shuffle_guide.md
@@ -0,0 +1,161 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: move
+ notes: Useful and well-written, but it could be better grouped with other tutorials/guides rather than being a PyTorch docs only page, as its contents are somewhat in between the two backends.
+---
+
+# 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*
+
+1. Select *'Create training dataset'* tab
+
+1. Tick *Use an existing data split* option
+
+ 
+
+1. 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
+
+ 
+
+1. 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
+
+ 
+
+1. To create this new dataset, set the shuffle option to an un-used shuffle
+ (here 4)
+
+ 
+
+1. 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)
+
+ 
+
+1. 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..7b800ab966
--- /dev/null
+++ b/docs/pytorch/architectures.md
@@ -0,0 +1,169 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: keep
+---
+
+(dlc3-architectures)=
+
+# 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`
+
+### CSPNeXt
+
+- From [Jiang, Tao et al. "RTMPose: Real-Time Multi-Person Pose Estimation based on MMPose"](https://arxiv.org/abs/2303.07399) and ["CSPNeXt: A new efficient token hybrid backbone"](https://www.sciencedirect.com/science/article/abs/pii/S0952197624000447)
+- CSPNeXt is a convolutional backbone designed for efficient computer vision models.
+ - It can be selected directly as a backbone
+ - It is also used as the backbone of other model architectures, including
+ RTMPose.
+ - When CSPNeXt is selected directly, it will be combined with the standard head
+ appropriate for the project type.
+- Available CSPNeXt variants include `cspnext_s`, `cspnext_m`, and `cspnext_x`.
+
+### 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/index.md b/docs/pytorch/index.md
new file mode 100644
index 0000000000..7d609e2c82
--- /dev/null
+++ b/docs/pytorch/index.md
@@ -0,0 +1 @@
+# PyTorch backend guides
diff --git a/docs/pytorch/pytorch_config.md b/docs/pytorch/pytorch_config.md
new file mode 100644
index 0000000000..e9aee9fbab
--- /dev/null
+++ b/docs/pytorch/pytorch_config.md
@@ -0,0 +1,643 @@
+---
+deeplabcut:
+ last_content_updated: '2025-10-02'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: verify
+ notes: Check for accuracy and completeness of content, and update as needed. Formatting is fairly consistent and does not need an urgent update.
+---
+
+(dlc3-pytorch-config)=
+
+# Configuration file reference
+
+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.
+1. **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`.
+1. **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)`.
+1. 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.
+1. 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..7762705ed5
--- /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)=
+
+# What is new in DeepLabCut 3.0
+
+## 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())
+```
+
+
+
+
diff --git a/docs/pytorch_dlc.md b/docs/pytorch_dlc.md
new file mode 100644
index 0000000000..b46b30a507
--- /dev/null
+++ b/docs/pytorch_dlc.md
@@ -0,0 +1,190 @@
+---
+deeplabcut:
+ last_content_updated: '2024-01-17'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: orphaned
+ status: viable
+ recommendation: move
+ notes: Unclear why this is unlisted in TOC; recommend updating and moving to PyTorch section.
+---
+
+# 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/index.md b/docs/quick-start/index.md
new file mode 100644
index 0000000000..f05057b8a7
--- /dev/null
+++ b/docs/quick-start/index.md
@@ -0,0 +1,15 @@
+---
+deeplabcut:
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: keep
+ last_metadata_updated: '2026-05-12'
+ last_verified: '2026-05-12'
+ verified_for: 3.0.0rc14
+---
+
+# Quick start guides
+
+- Single animal: {ref}`file:single-animal-quick-start`
+- Multi-animal: {ref}`file:multi-animal-quick-start`
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..3f8960e27d
--- /dev/null
+++ b/docs/quick-start/single_animal_quick_guide.md
@@ -0,0 +1,159 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: archive
+ notes: This is a bit stuck between minimal guide and quick start, as the lack of explanations makes it more into a catalogue of commands (which is an API docs responsibility), and a proper quick start guide that gives users a proper sense of the workflow. This should either be expanded greatly or simply archived. For simplicity, I recommend archiving.
+---
+
+(file:single-animal-quick-start)=
+
+# Single-animal at a glance
+
+This page summarizes the main DeepLabCut functions used in a standard single-animal
+2D pose-estimation workflow, from project creation to analyzed videos.
+
+## Start Python
+
+Open a terminal and start an interactive Python session, for example with IPython:
+
+```bash
+ipython
+```
+
+Then import DeepLabCut:
+
+```python
+import deeplabcut
+```
+
+## Workflow
+
+### 1. Create a project
+
+```python
+project_name = "project_name"
+experimenter = "experimenter"
+video_paths = [
+ "/absolute/path/to/video_1.mp4",
+ "/absolute/path/to/video_2.mp4",
+]
+
+config_path = deeplabcut.create_new_project(
+ project_name,
+ experimenter,
+ video_paths,
+ copy_videos=True,
+)
+```
+
+```{note}
+Use absolute paths to your video files. The returned `config_path` is the full path to
+the project `config.yaml` file and is used throughout the rest of the workflow.
+```
+
+### 2. Configure the project
+
+Open the generated `config.yaml` file and edit it for your experiment.
+
+At this stage, define the body parts or points of interest that you want to track. You
+can also adjust project settings such as the number of frames to extract, visualization
+settings, and training options.
+
+```{important}
+Do not include spaces in body-part names.
+```
+
+### 3. Extract frames
+
+```python
+deeplabcut.extract_frames(config_path)
+```
+
+### 4. Label frames
+
+```python
+deeplabcut.label_frames(config_path)
+```
+
+### 5. Check labels
+
+```python
+deeplabcut.check_labels(config_path)
+```
+
+```{tip}
+This step is optional, but strongly recommended. Use the generated labeled images to
+visually confirm that the annotations were saved correctly before training.
+```
+
+### 6. Create the training dataset
+
+```python
+deeplabcut.create_training_dataset(config_path)
+```
+
+### 7. Train the network
+
+```python
+deeplabcut.train_network(config_path)
+```
+
+### 8. Evaluate the trained network
+
+```python
+deeplabcut.evaluate_network(config_path)
+```
+
+Inspect the evaluation results before moving on to video analysis. If pose-estimation
+quality is not sufficient, improve the labels, add more training data, or train for
+longer before continuing.
+
+### 9. Analyze videos
+
+```python
+deeplabcut.analyze_videos(
+ config_path,
+ video_paths,
+)
+```
+
+### 10. Filter predictions
+
+```python
+deeplabcut.filterpredictions(
+ config_path,
+ video_paths,
+)
+```
+
+```{note}
+Filtering is optional. It can help smooth pose predictions before plotting
+trajectories or creating labeled videos.
+```
+
+### 11. Plot trajectories
+
+```python
+deeplabcut.plot_trajectories(
+ config_path,
+ video_paths,
+ filtered=True,
+)
+```
+
+### 12. Create labeled videos
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ video_paths,
+ filtered=True,
+)
+```
+
+This creates videos with the predicted keypoints overlaid, which is useful for a quick
+visual inspection of tracking quality.
diff --git a/docs/quick-start/tutorial_maDLC.md b/docs/quick-start/tutorial_maDLC.md
new file mode 100644
index 0000000000..56f0a18432
--- /dev/null
+++ b/docs/quick-start/tutorial_maDLC.md
@@ -0,0 +1,227 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: viable
+ recommendation: keep
+---
+
+(file:multi-animal-quick-start)=
+
+# Multi-animal at a glance
+
+This page summarizes the main DeepLabCut functions used in a standard multi-animal
+2D pose-estimation workflow.
+
+## GUI workflow
+
+DeepLabCut provides a full graphical user interface. To launch it, run:
+
+```bash
+python -m deeplabcut
+```
+
+Then follow the tabs in the Project Manager GUI.
+
+## Python workflow
+
+The same workflow can also be run from Python. Start by importing DeepLabCut:
+
+```python
+import deeplabcut
+```
+
+### 1. Create a project
+
+```python
+project_name = "cutemice"
+experimenter = "teamdlc"
+video_paths = ["/absolute/path/to/video_file.mp4"]
+
+config_path = deeplabcut.create_new_project(
+ project_name,
+ experimenter,
+ video_paths,
+ multianimal=True,
+ copy_videos=True,
+)
+```
+
+```{note}
+Use absolute paths to your video file(s).
+
+On Windows, you can quickly copy a file path with Shift +
+Right click and **Copy as path**. On macOS, use
+Option + Right click and **Copy as Pathname**.
+On Ubuntu, copying the file usually also copies its path to the clipboard.
+```
+
+The returned `config_path` is the full path to the project `config.yaml` file. This
+variable is used throughout the rest of the workflow.
+
+### 2. Configure the project
+
+Open the generated `config.yaml` file and edit it for your experiment.
+
+At this stage, define the animal identities and keypoints you want to track. For
+multi-animal projects, the most important fields are typically:
+
+- `individuals`
+- `multianimalbodyparts`
+- `uniquebodyparts`
+- `identity`
+- `numframes2pick`
+
+```{important}
+Do not include spaces in the names of individuals, body parts, multi-animal body
+parts, or unique body parts.
+```
+
+```{tip}
+You can also adjust the number of frames to extract in the next step by editing
+`numframes2pick` in `config.yaml`.
+```
+
+### 3. Extract video frames to annotate
+
+```python
+deeplabcut.extract_frames(
+ config_path,
+ mode="automatic",
+ algo="kmeans",
+ userfeedback=False,
+)
+```
+
+```{tip}
+For robust training data, it is usually better to extract a few informative frames
+from many videos than many similar frames from a single video.
+```
+
+### 4. Annotate frames
+
+```python
+deeplabcut.label_frames(config_path)
+```
+
+### 5. Check annotated frames
+
+```python
+deeplabcut.check_labels(
+ config_path,
+ draw_skeleton=False,
+)
+```
+
+Use the generated labeled images to visually confirm that the annotations were saved
+correctly.
+
+### 6. Create the training dataset
+
+```python
+deeplabcut.create_multianimaltraining_dataset(
+ config_path,
+ num_shuffles=1,
+ net_type="dlcrnet_ms5",
+)
+```
+
+### 7. Train the network
+
+Choose the example corresponding to the engine you are using.
+
+#### PyTorch engine
+
+```python
+deeplabcut.train_network(
+ config_path,
+ device="cuda",
+ save_epochs=5,
+ epochs=200,
+)
+```
+
+#### TensorFlow engine
+
+```python
+deeplabcut.train_network(
+ config_path,
+ saveiters=10000,
+ maxiters=50000,
+ allow_growth=True,
+)
+```
+
+### 8. Evaluate the network
+
+```python
+deeplabcut.evaluate_network(
+ config_path,
+ plotting=True,
+)
+```
+
+Inspect the evaluation results before moving on to video analysis. For multi-animal
+projects, pay particular attention to detection quality and tracking readiness.
+
+### 9. Analyze videos
+
+```python
+deeplabcut.analyze_videos(
+ config_path,
+ video_paths,
+ auto_track=True,
+)
+```
+
+```{note}
+With `auto_track=True`, DeepLabCut automatically performs the tracking steps needed to
+produce the final `.h5` output file. Use the manual tracking steps below only if you
+need to inspect detections or tune tracking parameters for your dataset.
+```
+
+### 10. Convert detections to tracklets
+
+This step performs spatial and local temporal grouping, assembling body parts into
+tracklets frame by frame.
+
+```python
+deeplabcut.convert_detections2tracklets(
+ config_path,
+ video_paths,
+ track_method="ellipse",
+)
+```
+
+### 11. Stitch tracklets into trajectories
+
+This step reconstructs full animal trajectories from the tracklets.
+
+```python
+deeplabcut.stitch_tracklets(
+ config_path,
+ video_paths,
+ track_method="ellipse",
+ min_length=5,
+)
+```
+
+### 12. Create labeled videos
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ video_paths,
+ color_by="individual",
+ keypoints_only=False,
+ trailpoints=10,
+ draw_skeleton=False,
+ track_method="ellipse",
+)
+```
+
+This creates a video with the predicted keypoints overlaid. For multi-animal projects,
+`color_by="individual"` is useful for visually checking identity assignment and
+trajectory consistency.
diff --git a/docs/recipes/BatchProcessing.md b/docs/recipes/BatchProcessing.md
index abac0bf27d..574cebd431 100644
--- a/docs/recipes/BatchProcessing.md
+++ b/docs/recipes/BatchProcessing.md
@@ -1,12 +1,20 @@
+---
+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 +41,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 +65,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)
```
@@ -71,7 +79,8 @@ for subfolder in subfolders: #this would be January, February etc. in the upper
## Now, what about training over multiple Projects
Make your labmates happy by helping run everyone's projects! We use this for workshops, but can easily be adapted for your needs. Here is an example script. You can copy/paste into a file and end with ".py" to make it a python script.
-```
+
+````
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
@@ -90,25 +99,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 +125,19 @@ 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
new file mode 100644
index 0000000000..aec07fcf39
--- /dev/null
+++ b/docs/recipes/ClusteringNapari.md
@@ -0,0 +1,100 @@
+---
+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.
+
+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.
+```
+
+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'.
+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://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:
+
+
+
+### Clustering
+
+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:
+
+
+
+```{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](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)
+```
+
+::::\{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).
+::::
+
+```
+
+### What's coming
+
+- Right now we demo the feature with user-labels, which are always worth checking and correcting for the best models!
+- Next, we will support the machine-labeled.h5 files for full active learning support.
+
+Happy Hacking!
+```
diff --git a/docs/recipes/DLCMethods.md b/docs/recipes/DLCMethods.md
index b2f7a5a7af..0e13f1b65e 100644
--- a/docs/recipes/DLCMethods.md
+++ b/docs/recipes/DLCMethods.md
@@ -1,17 +1,31 @@
+---
+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.
+\*If any defaults were changed in *`pose_config.yaml`*, mention them.
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.
+- 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 2021, and Sandler et al, 2018. If you use DLCRNet, please cite Lauer et al, 2021.
> Mathis, A. et al. Deeplabcut: markerless pose estimation
@@ -43,4 +57,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
new file mode 100644
index 0000000000..662f752334
--- /dev/null
+++ b/docs/recipes/MegaDetectorDLCLive.md
@@ -0,0 +1,139 @@
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: orphaned
+ status: outdated
+ recommendation: archive
+---
+
+# đ 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.
+
+In this #cookbook recipe, we show you how to use MegaDetector to detect animals and run DeepLabCut-Live (using ModelZoo models) to get the pose estimation. This doc is contributed by 2022 DLC AI Resident [Nirel Kadzo](https://github.com/Kadzon) đ!
+
+## What is MegaDetector?
+
+
+
+MegaDetector detects an animal and generates a bounding box around the animal. Thanks to [Sara Beery](https://beerys.github.io/) for visiting the #DLCAIResidents in the summer of 2022 to tell us more about this amazing project. An example result is shown:
+
+## 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](deeplabcut-live).
+
+### MegaDetector meets DeepLabCut
+
+The combination of MegaDetector and DeepLabCut now enables animal pose estimation on animal-bounded images. Here is an example of the `full_macaque` model, which is from MacaquePose. Model contributed by Jumpei Matsumoto, at the Univ of Toyama. See their paper for many details [here](https://www.biorxiv.org/content/10.1101/2020.07.30.229989v2). If you use this model, please [cite their paper](https://doi.org/10.3389/fnbeh.2020.581154).
+
+
+
+# đ€ HuggingFace App
+
+We use the [Hugging Face](huggingface.co) spaces based on gradio to create an App of MegaDetector+DeepLabCut for you to interact with it and to try it out for yourself. Thanks to [Merve Noyan](https://github.com/merveenoyan) who visited the #DLCAIResidents in the summer of 2022 to teach us about their ecosystem, and thanks to the other App co-authors from the DLC Residency Program (see the [App page](https://huggingface.co/spaces/DeepLabCut/MegaDetector_DeepLabCut)).
+
+Let's get into the details of how to use the App:
+
+1. Click on this link to be redirected to the [MegaDetector+DeepLabCut application](https://huggingface.co/spaces/DeepLabCut/MegaDetector_DeepLabCut) on **Hugging Face**.
+
+1. Upload your image on the *Input Image* section or drag and drop.
+
+1. Choose the features for your image
+
+
+
+- `Select MegaDetector model` lets you choose between md_v5a and md_v5b, you can find out more about them [here](https://github.com/microsoft/CameraTraps/releases). They run on YOLOv5 which makes it 3x-4x faster than prior versions.
+
+- `Select DeepLabCut Model` choose the relevant ModelZoo model closest to the image you uploaded. The selected model will run on the image to predict the keypoints on the animal.
+
+```{hint}
+To get close to accurate keypoints in your model, the animal you upload into the interface should have the animal model listed in "Select DeepLabCut Model" panel.
+```
+
+- `Run DLClive` checkbox allows you to run DeepLabCut-Live directly on the image without MegaDetector. However, MegaDetector often simplifies the pose estimation by blocking out the pixels outside the bounding box. But no harm to run it (just might be slower), test it out for yourself ;)
+
+- `Set confidence threshold for animal detections` in the example above, the confidence threshold is set for 0.8, this means MegaDetector will put a bounding box if it is >0.8 sure it is an animal. The image displayed has a 0.94 confidence level.
+
+- `Set confidence threshold for keypoints` suggests how confident the model is about predicting the accurate key points on the animal. This is displayed by the opacity of the coloured keypoints on the animal.
+
+- `Set marker size, Set font size, Select keypoiny label font` are design specs you can choose for yourself - we all love pretty plots!
+
+4. Once set and you are satisfied with the image and features, submit the image. The expected output will display your input image: with the animal(s) surrounded by a bounding box (if used), the tracked keypoints, and the labels. A downloadable `JSON` file with the markings as shown below:
+
+
+
+Image from [Scientific American](https://www.scientificamerican.com/article/dogs-personalities-arent-determined-by-their-breed/).
+
+All information seen on the output image is recorded on the **Download JSON file**. The snippet below is commented on to give you an overall understanding of what the code means đ
+
+```
+{
+ "date": "2022-08-26",
+ "MD_model": "md_v5a", //Megadetector model used
+ "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": {
+ "corner_1": [ //top left corner
+ 76.08082580566406, //x
+ 91.02932739257812 //y
+ ],
+ "corner_2": [ //bottom right corner
+ 393.8626708984375, //x
+ 399.9506530761719 //y
+ ],
+ "predict MD": "animal", // MegaDetector predicts the image is in class animal
+ "confidence MD": 0.9437874555587769, // 0.94% confident it's an animal
+ "dlc_pred": { //keypoints prediction coordinates
+ "Nose": [ //label
+ 264.89501953125, //x
+ 89.19121551513672, //y
+ 0.9611953496932983 //z
+ ],
+ ...
+}
+```
+
+```{hint}
+To experiment with more camera trap images, check out [Lila Science!](https://lila.science/)
+```
+
+Examples have also been added to the Hugging Face interface where you can try out a variety of animals to get a feel of things and also add your own.
+
+## Examples
+
+We encourage you to try out and experiment on your camera trap or other animal images. Indeed, we found it is not only limited to camera trap images you can test it out with photos taken from your camera. Have a look at a đŠpicture Mackenzie took in Geneva and used the MegaDetector+DeepLabCut [Hugging Face](huggingface.co).
+
+
+
+Or these lil' cuties đ¶đ¶đđ¶ outside a restaurant.
+
+
+
+```{note}
+DLC-Live allows you to process videos and frames in bulk, however the current release of MegaDetetctor+DeepLabCut-Live allows you to process one image at a time. But stay tuned for further releases, we are just getting started ;)
+```
+
+### Developer Mode:
+
+To run it locally you can `git clone` the repository on your terminal and explore MegaDetector+DeepLabCut for yourself :)
+
+In your terminal run each line:
+
+```bash
+git clone https://huggingface.co/spaces/DeepLabCut/MegaDetector_DeepLabCut
+
+conda create -n megaDLC python==3.8
+conda activate megaDLC
+
+cd MegaDetector_DeepLabCut
+
+pip install -r requirements.txt
+python3 app.py
+```
+
+It should then print out links for you to locally load. Have fun and happy hacking!
diff --git a/docs/recipes/OpenVINO.md b/docs/recipes/OpenVINO.md
index fce2ba9f6d..97388ce53e 100644
--- a/docs/recipes/OpenVINO.md
+++ b/docs/recipes/OpenVINO.md
@@ -1,10 +1,23 @@
+---
+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(
@@ -13,7 +26,7 @@ def analyze_videos(
)
```
-OpenVINO is an optional dependency. You can install it with DeepLabCut bu the following command:
+OpenVINO is an optional dependency. You can install it with DeepLabCut by the following command:
```bash
pip install deeplabcut[openvino]
diff --git a/docs/recipes/OtherData.md b/docs/recipes/OtherData.md
new file mode 100644
index 0000000000..fcdb097535
--- /dev/null
+++ b/docs/recipes/OtherData.md
@@ -0,0 +1,40 @@
+---
+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 f7a3f5631b..429927316a 100644
--- a/docs/recipes/TechHardware.md
+++ b/docs/recipes/TechHardware.md
@@ -1,32 +1,61 @@
-# Technical (Hardware) Considerations
+---
+deeplabcut:
+ last_content_updated: '2026-02-10'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: update
+ notes: Useful but needs to be updated and clarified.
+---
-## Quick summary:
-[On our install page](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/installation.md#technical-considerations)
-we highlight that for GPU computing through standard installation you need a NVIDIA GPU, with at least 8 GB of memory. If you have an Intel or AMD GPU, and are on windows, there is an alternative method of installation available which is shown on the [installation tips page](https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/recipes/installTips.md) under "How to install Deeplabcut for Intel and AMD GPUs".
+(file:hardware-requirements)=
+
+# Technical & hardware considerations
+
+## Quick summary
+
+On our {ref}`install page `
+we highlight that for GPU computing through standard installation you need a NVIDIA GPU, with at least 8 GB of memory. If you have an Intel or AMD GPU, and are on windows, there is an alternative method of installation available which is shown on the [installation tips page](installation-tips) under "How to install Deeplabcut for Intel and AMD GPUs".
Note, some info is repeated here, and will be updated as systems and hardware changes.
-### Computer:
+### Computer
For reference, we use e.g. Dell workstations (79xx series) with **Ubuntu 16.04 LTS, 18.04 LTS, or 20.04 LTS** and run a Docker container that has TensorFlow, etc. installed (https://github.com/DeepLabCut/Docker4DeepLabCut2.0).
-### Computer Hardware:
+### 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://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.
-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.
+```{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:
+### Camera Hardware
The software is very robust to track data from any camera (cell phone cameras, grayscale, color; captured under infrared light, different manufacturers, etc.). See demos on our [website](https://www.mousemotorlab.org/deeplabcut/).
-### Software:
+### Software
-**Operating System:** Linux (Ubuntu), MacOS* (Mojave), or Windows 10. However, the authors strongly recommend Ubuntu! *MacOS does not support NVIDIA GPUs (easily), so we only suggest this option for CPU use or a case where the user wants to label data, refine data, etc and then push the project to a cloud resource for GPU computing steps, or use MobileNets.
+**Operating System:** Linux (Ubuntu), MacOS\* (Mojave), or Windows 10. However, the authors strongly recommend Ubuntu! \*MacOS does not support NVIDIA GPUs (easily), so we only suggest this option for CPU use or a case where the user wants to label data, refine data, etc and then push the project to a cloud resource for GPU computing steps, or use MobileNets.
**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.18**); we
+recommend TF2.12 for Python 3.10 with GPU support. Note that native GPU support for Windows was dropped after TF version 2.10. We recommend Windows users to install [the Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) if they want to keep GPU support with TensorFlow.
-**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
new file mode 100644
index 0000000000..175fcf8671
--- /dev/null
+++ b/docs/recipes/UsingModelZooPupil.md
@@ -0,0 +1,200 @@
+---
+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.
+
+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 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) đ.
+
+## `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.
+
+
+
+
+
+| Landmark_Number | Landmark_Name | Description |
+| --------------- | ------------- | ----------------------------- |
+| 1 | Lpupil | Left aspect of pupil |
+| 2 | LDpupil | Left/dorsal aspect of pupil |
+| 3 | Dpupil | Dorsal aspect of pupil |
+| 4 | DRpupil | Dorsal/Right aspect of pupil |
+| 5 | Rpupil | Right aspect of pupil |
+| 6 | RVpupil | Right/ventral aspect of pupil |
+| 7 | Vpupil | Ventral aspect of pupil |
+| 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](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 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.
+
+### 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!
+
+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 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.
+
+
+
+
+
+````{hint}
+You are happy with the model and want to go on analyzing further videos on your local machine or you want to refine the model for your specific usecase?
+```html
+!zip -r /content/file.zip /content/pigeon_modelZoo-nessi-2022-08-22
+from google.colab import files
+files.download("/content/file.zip")
+
+````
+
+### Analyze Videos at Your Local Machine
+
+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](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
+
+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,
+ engine=deeplabcut.Engine.TF,
+)
+```
+
+::::\{important}
+Your videos should be cropped around the eye for better model accuracy! đđ
+::::
+
+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 đ).
+
+
+
+But you also might encounter larger problems than small tracking glitches:
+
+
+
+To deal with this, you can extract poorly tracked outlier frames, refine them and feed the training data set with them for re-training.
+Be sure that you set the number of frames to label in the `config.yaml` file of your project folder.
+The more problems you encounter, the higher the number of frames you might want to label.
+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
+)
+```
+
+The `deeplabcut.extract_outlier_frames` function will check for outliers and ask your feedback on whether to extract these outliers frames.
+
+```python
+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.
+It will ask you to define the `likelihood` threshold: labels under the threshold should be refined at this stage.
+
+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")
+```
+
+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.
+Edit the value of the `init_weights` key in the `pose_cfg.yaml` file and start to re-train your model!
+
+`init_weights: pathofyourproject\dlc-models\iteration-0\DLCFeb31-trainset95shuffle1\train\snapshot-610000`
+
+```python
+deeplabcut.train_network("/pathofproject/config.yaml", shuffle=1, saveiters=25000)
+```
+
+```{hint}
+Check this video for model refining!
+
+
+
+```
diff --git a/docs/recipes/external_data_import.md b/docs/recipes/external_data_import.md
new file mode 100644
index 0000000000..21ca77ef81
--- /dev/null
+++ b/docs/recipes/external_data_import.md
@@ -0,0 +1,37 @@
+---
+deeplabcut:
+ last_metadata_updated: '2026-05-22'
+ last_verified: '2026-05-22'
+ verified_for: 3.0.0rc14
+ ignore: false
+---
+
+(file:recipe-importing-data)=
+
+# Importing annotated data from elsewhere
+
+## 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).
+
+1. Create a project
+
+1. 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".
+
+1. 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). For example, see [this file](https://github.com/DeepLabCut/DeepLabCut/blob/main/examples/Reaching-Mackenzie-2018-08-30/labeled-data/reachingvideo1/CollectedData_Mackenzie.csv).
+
+1. 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.
+
+1. 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.
+
+## Merging multiple datasets
+
+1. Rename the CSV files to be the target name.
+1. 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/flip_and_rotate.ipynb b/docs/recipes/flip_and_rotate.ipynb
new file mode 100644
index 0000000000..bea9391cb8
--- /dev/null
+++ b/docs/recipes/flip_and_rotate.ipynb
@@ -0,0 +1,2030 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Improving network performance on unbalanced data via augmentation đŠ\n",
+ "By Jonas Bengt Carina HÄkansson\n",
+ "\n",
+ " Part of the 2022 AI Residency Program"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Background\n",
+ "Read the [DeepLabCut Blog post](https://deeplabcut.medium.com/balancing-bats-d3c5bcfb71e1) about how one can solve unbalanced data with flipping-keypoint augmentation, then see below for the process and code to do so!\n",
+ "\n",
+ "## The dataset\n",
+ "This dataset consists of videos of bats flying freely at the [Austin Bat Refuge](https://austinbatrefuge.org/). The videos were shot by Dr. Aaron Corcoran, Head of the [Sensory & Aerial Ecology lab](https://sonarjamming.com/) at the University of Colorado, Colorado Sprungs (UCCS) in 2020. The dataset is comprised of 26 video triplets. Each triplet consists of three videos depicting the same bat flight from different angles.\n",
+ "\n",
+ "The videos have been partially digitized in [DLTdv](https://github.com/tlhedrick/dltdv) after which custom MATLAB code has been used to extract the digitized frames and coordinates in a format that DeepLabCut can read.\n",
+ "\n",
+ "## Background\n",
+ "This text documents my experience in using data augmentation to improve the accuracy of DeepLabCut in tracking bodyparts of the bats in the videos during my time as a [DeepLabCut AI Resident](https://www.deeplabcutairesidency.org/) during the summer of 2022."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Creating the DeepLabCut project\n",
+ "We start by defining the path we will be working in. Then we create the project."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "scrolled": false,
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "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(\n",
+ " project=\"bat_augmentation_austin_2020_bat_data\",\n",
+ " experimenter=\"DLC\",\n",
+ " videos=[\"/home/user/dummyVideos/\"],\n",
+ " working_directory=project_folder,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Edit the config file\n",
+ "\n",
+ "Now that the project is created we need to modify our project config file. Open config.yaml in the project folder.\n",
+ "\n",
+ "The config file can be modified with any text editor, but I will do it via Python commands to highlight the possibility of automating this process.\n",
+ "\n",
+ "\n",
+ "\n",
+ "*The list of videos and the list of bodyparts need to change to reflect the actual videos and bodyparts*"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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 edit_config, read_config"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n",
+ "*The locations of the labels/bodyparts on the bats.*"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# replace the default list of bodyparts with a list of the parts that we have actually digitized\n",
+ "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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Convert training data to DLC format\n",
+ "Since this data was labeled outside of DeepLabCut, we first used our own code to convert it into csv formatted csv files, we then use the function ```convertcsv2h5``` to convert these csv:s into h5 files that DeepLabCut can read.\n",
+ "\n",
+ "After doing this, I like to use the function ```check_labels``` to make sure that the conversion was successful. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "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",
+ "deeplabcut.check_labels(config_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "*The labels appear to be in the right place.*"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Structuring the project for testing\n",
+ "\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",
+ "### Test data\n",
+ "I randomly chose seven digitized triplets of videos for testing. Every 50th frame of these will be used for testing.\n",
+ "\n",
+ "### Treatments\n",
+ "I will use two \"treatments\" for testing. Firstly, I simply test how increasing the amount of training data affects accuracy. Secondly, I test how adding refining data affects accuracy. The result is a four permutations of treatments in total, sorted into four shuffles as shown in *table 1*.\n",
+ "\n",
+ "#### Refining\n",
+ "The seven testing video triplets have had every 25th frame digitized. Frames 50, 100, 150, ... is used for testing. In this treatment, frames 25, 75, 175, ... will be used for used as training data, i.e. refining. I call the networks not trained on refining data OOD for out of domain and the ones traioned on refining data Ref for refining.\n",
+ "\n",
+ "#### Amount of base training data\n",
+ "Subtracting the seven testing video triplets, I'm left with 19 (57 videos in total). I then randomly split these into two groups of 9 and 10. I will either train on only 10 of these video triplets, or on all 19. I call these treatments \"half\" and \"full\".\n",
+ "\n",
+ "This treatment might not seem relevant for figuring out how augmentation affects performance, and in truth, I chose to perform this test to answer another question, but you will see that the results of this test are very relevant to the challenge written in bold above.\n",
+ "\n",
+ "I will refer to training data from the 19 videos not tested on as \"base training data\".\n",
+ "\n",
+ "*Table 1: The two treatments result in four permutations, organized in four shuffles.*\n",
+ "```\n",
+ "| |**Half**|**Full**|\n",
+ "|-------|--------|--------|\n",
+ "|**OOD**|shuffle1|shuffle2|\n",
+ "|**Ref**|shuffle3|shuffle4|\n",
+ "```\n",
+ "\n",
+ "### Training data indices\n",
+ "By default, DeepLabCut assumes that we want to randomly divide the data into training and testing data. But for testing, you might want more control of this. Therefore, we will manually define which frames are for training and which are for testing. More on this later, but for now, we need to create a dummy training dataset as this will make DeepLabCut create a nice list of all the frames in the dataset."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\n",
+ "# Dummy training dataset to get indexes and so on from later\n",
+ "deeplabcut.create_training_dataset(config_path, Shuffles=[99])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Define shuffles\n",
+ "Now that we have created a dummy training dataset, we can parse its list of files and add the indices of the ones we want to train on to the list of training data indices, and the ones we want to test on to the test data indices.\n",
+ "\n",
+ "This is easy to do since when I converted our training data from DLTdv, I made sure to name the folders with endings indicating where they belong with regards to our treatments.\n",
+ "\n",
+ "The naming convention looks like this:\n",
+ "- The folders that end with \"_A\" belong in the first half of the training data, that means that when we train on \"half\" the data, we only train on these.\n",
+ "- The folders that end with \"_B\" belong in the second half of the training data, that means that when we train on the \"full\" data, we train on these as well as the ones from the first half.\n",
+ "- The folders that end with \"_25_ref\" contain the refining frames from the 7 test videos, so when we train with the \"Ref\" data, we include these as training data.\n",
+ "- The folders that end with \"_50_test\" are from the 7 test videos and they are always used as testing data.\n",
+ "\n",
+ "So by parsing the list of frames from the dataset, we can create our four shuffles as specified in *table 1* and in the list above."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\n",
+ "# 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:\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",
+ "\n",
+ "# create empty lists for putting testing and training indices in\n",
+ "test_inds = []\n",
+ "train_inds = []\n",
+ "\n",
+ "# train on half, test data is OOD, shuffle 1\n",
+ "test_inds = []\n",
+ "train_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_A\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "deeplabcut.create_training_dataset(\n",
+ " config_path,\n",
+ " Shuffles=[1],\n",
+ " trainIndices=[train_inds],\n",
+ " testIndices=[test_inds],\n",
+ " net_type=\"resnet_50\",\n",
+ " augmenter_type=\"../imagesaug\",\n",
+ ")\n",
+ "\n",
+ "# train on half+ref, shuffle 2\n",
+ "test_inds = []\n",
+ "train_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_A\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_25_ref\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "deeplabcut.create_training_dataset(\n",
+ " config_path,\n",
+ " Shuffles=[2],\n",
+ " trainIndices=[train_inds],\n",
+ " testIndices=[test_inds],\n",
+ " net_type=\"resnet_50\",\n",
+ " augmenter_type=\"../imagesaug\",\n",
+ ")\n",
+ "\n",
+ "# train on full, test data is OOD, shuffle 3\n",
+ "test_inds = []\n",
+ "train_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_A\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_B\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "deeplabcut.create_training_dataset(\n",
+ " config_path,\n",
+ " Shuffles=[3],\n",
+ " trainIndices=[train_inds],\n",
+ " testIndices=[test_inds],\n",
+ " net_type=\"resnet_50\",\n",
+ " augmenter_type=\"../imagesaug\",\n",
+ ")\n",
+ "\n",
+ "# train on full+ref, shuffle 4\n",
+ "test_inds = []\n",
+ "train_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_A\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_B\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_25_ref\"):\n",
+ " train_inds.append(i)\n",
+ " elif str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "deeplabcut.create_training_dataset(\n",
+ " config_path,\n",
+ " Shuffles=[4],\n",
+ " trainIndices=[train_inds],\n",
+ " testIndices=[test_inds],\n",
+ " net_type=\"resnet_50\",\n",
+ " augmenter_type=\"../imagesaug\",\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Using modelprefixes to keep track of augmentations\n",
+ "\n",
+ "Now we are using the shuffles to keep track of the training data used for our different networks, but we will also be trying different augmentatns. So how do we keep track of that? We could just keep making new shuffles and keep a list of what shuffle is what, but that can quickly become unwieldy.\n",
+ "\n",
+ "Instead, we can use modelprefixes. They provide a way to structure a DLC project into something like subprojects. Basically, if you copy a dlc-model folder (in my case, found in ```/home/user/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/dlc-models/iteration-0```) into a subfolder in the project, you can then interact with the copied dlc model by using the argument ```modelprefix```.\n",
+ "\n",
+ "Say, for example, that I want to try a more aggressive motion blur augmentation, then I could create a folder called ```extra_blurry``` in my project, copy the dlc-models into it, and then change the pose_cfg.yaml files that contain the augmentation parameters to increase the motion blur augmentation.\n",
+ "\n",
+ "I would then train the model with this command:\n",
+ "```\n",
+ "deeplabcut.train_network(\n",
+ " config_path,\n",
+ " shuffle=shuffle_number,\n",
+ " modelprefix='extra_blurry',\n",
+ " )\n",
+ "```\n",
+ "\n",
+ "Our first networks will be trained with the default augmentation, but it's still useful to create a folder for them as we will want to copy the dlc-models in the root project folder for later augmentations, so it makes sense to keep those clean and do all training with model prefixes. \n",
+ "\n",
+ "### Regarding trainingsetindex\n",
+ "Before we can create our subfolders, we need to spend a minute on the trainingset indices.\n",
+ "\n",
+ "We often use different splits between training and testing data. This is a number < 1 that sets the proportion of the training dataset that should be used for training and testing. The default is 0.95, meaning 95% is used for training, and 5% for testing. This can be either be specified in the config.yaml file or deduced automatically by DeepLabCut if we manually specify the training and testing indices (like we did). In the case of the latter, the split is indicated by the filenames of the the files in the training-dataset folder in the project folder. In my case this would be:\n",
+ "```\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",
+ "\n",
+ "Let's look at the contents of that folder to deduce the training splits."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\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\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "I've worked a bit on other stuff in this project, so there is some stuff here we need not consider (51 and 52)- But we see files related to shuffles 1 to 4. Let's focus on the .mat files:\n",
+ "- bat_augmentation_austin_2020_bat_data_DLC80shuffle1.mat\n",
+ "- bat_augmentation_austin_2020_bat_data_DLC84shuffle2.mat\n",
+ "- bat_augmentation_austin_2020_bat_data_DLC88shuffle3.mat\n",
+ "- bat_augmentation_austin_2020_bat_data_DLC89shuffle4.mat\n",
+ "\n",
+ "Not that \"DLC\" here refers to the scorer for the training data, so if you used the scorer name \"StephinMerritt\", your shuffle1 file name could be \"crocodiles_galloping_2023_StephinMerritt95shuffle1.mat\".\n",
+ "\n",
+ "Anyway, we see that there are numbers between the scorer name and the shuffle name, these represent the fraction of the training data compared to the training data plus the testing data.\n",
+ "\n",
+ "So for shuffle 1, of the data used, 80% is used as training data, for shuffle 2, that number is instead 84%, and so on. These numbers are called training fractions.\n",
+ "\n",
+ "In config.yaml, there is a property called ```TrainingFraction```. This is a list of the training fractions used in the project. In this project, that list needs to be changed to look like this:\n",
+ "\n",
+ "\n",
+ "\n",
+ "When we then reference a shuffle, we also need to reference the corresponding training fraction, this is used by specifying the parameter ```trainingsetindex```, and note that we start counting from zero. So for each of our shuffles, the index of the training fraction is one smaller than the shuffle number.\n",
+ "\n",
+ "With all that clarified, let's now create our first project subfolder/modelprefix."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\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",
+ "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",
+ "\n",
+ "# Get config as dict and associated paths\n",
+ "cfg = read_config(config_path)\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",
+ "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, 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, 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(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
+ "\n",
+ "# make the folder for this modelprefix\n",
+ "try:\n",
+ " os.mkdir(aug_project_path)\n",
+ "except OSError as error:\n",
+ " print(error)\n",
+ " 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, 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",
+ " # 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], one_train_pose_config_file_path)\n",
+ " # copy base test config file\n",
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Edit pose_cfg.yaml\n",
+ "We will also edit the settings for this network to use the adam optimizer."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "model_prefix = \"data_augm_00_base\"\n",
+ "\n",
+ "## Initialise dict with additional edits to train config: optimizer\n",
+ "train_edits_dict = {}\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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Train networks\n",
+ "Now we need to train our four shuffles. This will take several hours."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "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",
+ "\n",
+ "# the computer I'm working on has several gpus, here I used the third one.\n",
+ "gputouse = 3\n",
+ "\n",
+ "# define shuffles and trainingsetindices\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, 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",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "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 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"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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/juser/projects/bat_augmentation_austin_2020_bat_data-DLC-2022-08-18/config.yaml\"\n",
+ "\n",
+ "from deeplabcut.utils.auxiliaryfunctions import edit_config, read_config\n",
+ "\n",
+ "edit_config(config_path, {\"snapshotindex\": \"all\"})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Now we can evaluate the all three snapshots of all three shuffles. Remember that each shuffle has a different ```trainingsetindex```, so we need to perform the evaluations in a loop."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "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",
+ "\n",
+ "for shuffle, trainingsetindex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Get data for plotting and statistical comparison\n",
+ "The ```evaluate_network``` function calculates the euclidean distances between predicted points and groundtruth (i.e. human labels), but it only shows the average for that snapshot and shuffle over all the test data. What we want are long lists of the distances between ground truth points and predicted points. That way, on top of calculating the average, we can also perform stastistical testing.\n",
+ "\n",
+ "We will use a function I wrote called ```getErrorDistribution``` to get the list mentioned above. We will do this for each shuffle."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "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",
+ "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:\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",
+ "\n",
+ "# get test indices\n",
+ "test_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "error_distributions = []\n",
+ "error_distributions_pcut = []\n",
+ "\n",
+ "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]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "\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(\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_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",
+ "\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.xlabel(\"Iterations (thousands)\")\n",
+ "plt.ylabel(\"Error (px)\")"
+ ]
+ },
+ {
+ "attachments": {},
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Interpret plot\n",
+ "Just looking at the plot, a few things seem clear, let's start with the obvious and least surprising. By training a network on a subset of frames of the videos to be analyzed, we really improve accuracy. When doing this, i.e. when refining, increasing the amount of training data, i.e. frames not belonging to the videos to be analyzed, has little effect on accuracy.\n",
+ "\n",
+ "But surprisingly, when not refining, it appears that increasing the amount of training data can worsen performance. What's going on? We will get back to this later, but for now, let's just confirm my points here with some statistical testing.\n",
+ "\n",
+ "For the statistical comparison, we'll use a paired Wilcoxon ranked sum test, since we can't assume the data to be normally distributed. We'll do this for the set of errors with P-cut filter applied since doing it on both would be tedious and I assume most users will use some form of P-cut on their data. Furthermore, I will do the statistical comparison only on the last snapshot since, again, doing it on all three would be tedious."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "from scipy.stats import wilcoxon\n",
+ "\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:\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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Plot per video\n",
+ "The statistics are in line with expectations, except for regarding the difference between the two refined shuffles (shuffle2: \"half, ref\" and shuffle4: \"full, ref\"). The two of them being significantly different surprises me and makes me want to investigate the error distributions more in detail.\n",
+ "\n",
+ "To that end, let's plot the error distributions of the four shuffles per camera and per video number. Remember, out seven test video triplets each consist of three videos depicting the same bat flight."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 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",
+ "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",
+ "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:\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",
+ "\n",
+ "# get test indices\n",
+ "test_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "# 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 = [\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",
+ "\n",
+ "for i, path in enumerate(image_paths):\n",
+ " for j in range(nvideos):\n",
+ " if str(path[1]).__eq__(test_paths_cam1[j]):\n",
+ " test_inds_cam1[j].append(i)\n",
+ " elif str(path[1]).__eq__(test_paths_cam2[j]):\n",
+ " test_inds_cam2[j].append(i)\n",
+ " elif str(path[1]).__eq__(test_paths_cam3[j]):\n",
+ " test_inds_cam3[j].append(i)\n",
+ "\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",
+ "\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",
+ "\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",
+ " trainFractionIndex = i\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",
+ " )\n",
+ " for movie_number in range(7):\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",
+ " # 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(\n",
+ " movie_number,\n",
+ " meanPcut_cam1[i, :],\n",
+ " stePcut_cam1[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
+ "\n",
+ " ax2.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam2[i, :],\n",
+ " stePcut_cam2[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
+ "\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",
+ "ax3.set_ylim([0, 50])\n",
+ "ax1.set_title(\"Cam 1\")\n",
+ "ax2.set_title(\"Cam 2\")\n",
+ "ax3.set_title(\"Cam 3\")\n",
+ "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\"])"
+ ]
+ },
+ {
+ "attachments": {},
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Interpret per video plot\n",
+ "The first thing that becomes clear to me from inspecting this plot is that there are some videos on which all shuffles perform well no matter refining. These would be video 3, 6 and 7. The second thing is that there are instances where \"half, ref\" does better than \"full, ref\" and vice versa, which could explain why they are significantly different according to the statistical test performed above, despite their overall means being relatively similar according to the plot of the mean errors on the full error distributions.\n",
+ "\n",
+ "## Compare \"easy\" and \"difficult\" videos\n",
+ "For shuffle3, \"full, OOD\", video 1, 2, 4, and 5 does poorly whereas video 3, 6, and 7 do rather well. Why is this? To get an idea of if the videos on which the network does well differs from the one on which it does poorly, we can create labeled frames with the predicted positions of the bodyparts and the correct positions (human labels). This is done with the ```evaluate_network``` function using the ```plotting``` input parameter."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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/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 edit_config, read_config\n",
+ "\n",
+ "edit_config(config_path, {\"snapshotindex\": -1})\n",
+ "\n",
+ "shuffle = 3\n",
+ "trainingsetindex = 2\n",
+ "\n",
+ "deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex, plotting=True\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Inspect DeepLabCut predictions from evaluations\n",
+ "\n",
+ "Inspecting and comparing frames reveal an interesting relationship. It appears the network does well when the bat is flying left-right, and bad when the bat is flying right to left.\n",
+ "\n",
+ "The two following example images reveal what I mean.\n",
+ "\n",
+ "\n",
+ "\n",
+ "*The bat is flying from the right to the left, and the network has mistaken the left side of the bat for the right side.*\n",
+ "\n",
+ "\n",
+ "\n",
+ "*The bat is flying from the left to the right, and the network is accurately predicting all points.\n",
+ "\n",
+ "The inspection of the predicted bodypart locations reveal that the low accuracy for some videos is caused by the network mixing up left and right. This could also explain why the performance goes down when we add more data. It appears that the added data results in the network becoming biased towards one flight direction."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Augment to reduce left-right bias\n",
+ "Our evaluation showed that our network has a bias towards bats flying in one direction. In an attempt to reduce that bias and make the network better at telling left from right independent of flight direction, we will use a left-right flipping augmentaton.\n",
+ "\n",
+ "First up, let's make another modelprefix folder in our project. It basically the same code as before but with some minor modifications, I'll highlight the changes."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\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",
+ "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",
+ "\n",
+ "# Get config as dict and associated paths\n",
+ "cfg = read_config(config_path)\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",
+ "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, 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, 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(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
+ "\n",
+ "# make the folder for this modelprefix\n",
+ "try:\n",
+ " os.mkdir(aug_project_path)\n",
+ "except OSError as error:\n",
+ " print(error)\n",
+ " 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, 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",
+ " # 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], one_train_pose_config_file_path)\n",
+ " # copy base test config file\n",
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Edit pose_cfg.yaml for fliplr augmentation\n",
+ "As before, we will edit the settings for this network to use the adam optimizer. More importantly, though, we will apply the ```fliplr``` augmentation.\n",
+ "\n",
+ "This randomly flips some training frames left and right during training. Now, this alone would lead to worse performance, since we would be training the network to think that the left wing is the right wing, and vice versa. But this augmentation method takes that into account, and in addition to mirroring the image, it also swaps the labels from left to right and vice versa. To accomlish this, we need to supply the pose_cfg.yaml file with a list of which points should be swapped.\n",
+ "\n",
+ "The image of a bat outline above shows he locations of the labels/bodyparts on the bats, and from it, it's clear which points should be swapped, all points that aren't on the bilateral have a name and end in eithr R or L. So t3R should be swapped with t3L when flipping, and so on.\n",
+ "\n",
+ "The labels are these, and in this order:\n",
+ "0. 't3L'\n",
+ "1. 'wstL'\n",
+ "2. 't5L'\n",
+ "3. 'elbL'\n",
+ "4. 'shdL'\n",
+ "5. 'ankL'\n",
+ "6. 'nl'\n",
+ "7. 'str'\n",
+ "8. 'lmb'\n",
+ "9. 'shdR'\n",
+ "10. 'ankR'\n",
+ "11. 'elbR'\n",
+ "12. 'wstR'\n",
+ "13. 't5R'\n",
+ "14. 't3R'\n",
+ "15. 'tail'\n",
+ "\n",
+ "So the symmetric pairs are the following:\n",
+ "- t3: (0, 14)\n",
+ "- wst: (1, 12)\n",
+ "- t5: (2, 13)\n",
+ "- elb: (3, 11)\n",
+ "- shd: (4, 9)\n",
+ "- ank: (5, 10)\n",
+ "\n",
+ "Now, let's edit the ```pose_cfg.yaml``` accordingly.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# import tools for changing our config file\n",
+ "from deeplabcut.utils.auxiliaryfunctions import edit_config\n",
+ "\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 = {\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, 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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Train network with ```fliplr``` augmentation\n",
+ "Now let's train the network with the new augmentation. Again, this will take several hours."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "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",
+ "\n",
+ "# the computer I'm working on has several gpus, here I used the third one.\n",
+ "gputouse = 3\n",
+ "\n",
+ "# define shuffles and trainingsetindices\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, 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",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Compare performance of fliplr augmented network to refined baseline\n",
+ "\n",
+ "Since the challenge I set is **to use augmentation to match or beat the accuracy of refining**, let's stick to comparing baseline \"Full, ref\" to fliplr augmented \"Full, OOD\".\n",
+ "\n",
+ "The process is similar to what we've already done, but now we are only comparing two networks.\n",
+ "\n",
+ "We will use ```evaluate_network``` to get create accuracy data, then we will use ```getErrorDistribution``` to access that data and plot the average performance with error bars. Same as before."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "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_01_fliplr\"\n",
+ "Shuffles = [1, 2, 3, 4]\n",
+ "trainingsetindices = [0, 1, 2, 3]\n",
+ "\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",
+ "\n",
+ "for shuffle, trainingsetindex in zip(Shuffles, trainingsetindices, strict=False):\n",
+ " deeplabcut.evaluate_network(\n",
+ " config_path, modelprefix=model_prefix, Shuffles=[shuffle], trainingsetindex=trainingsetindex\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 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",
+ "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",
+ "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:\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",
+ "\n",
+ "# get test indices\n",
+ "test_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "error_distributions_pcut = []\n",
+ "\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",
+ "\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(\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.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",
+ "\n",
+ "plt.xlabel(\"Iterations (thousands)\")\n",
+ "plt.ylabel(\"Error (px)\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Interpret performance of network after \"fliplr\" augmentation\n",
+ "\n",
+ "That is a significant improvement in accuracy. The ```fliplr``` augmentation seems to prevent the network from becoming biased to one flight direction. It is however, not quite beating the performance of the baseline refined case (shuffle4: full, Ref).\n",
+ "\n",
+ "There is another augmentation that, in our case, is related to the flight direction, namely the rotation augmentation. The idea here is that in order to prevent the network from associating absolute in-image spatial orientation with positions for certain labels, we want to randomly rotate some of the training data. The degree to which this makes sense is context dependent. If you are analyzing a horse walking in a horizontal direction from the camera's point of view, large degrees of rotation make little sense as the network will never encounter a horse walking upside down. But if you are filming an animal from above, or below if the animal is flying or swimming, such that the animal is free to move in any direction, then this type of augmentation makes sense.\n",
+ "\n",
+ "By default, DeepLabCut applies a 25° rotation augmentation, I tried increasing that, first to 90°, then to 180°. I will only cover the 180° case here as the 90° case is exactly the same, only with slightly worse performance.\n",
+ "\n",
+ "All the steps taken are the same as before.\n",
+ "- Create the model prefix folder\n",
+ "- Change the ```pose_cfg.yaml``` file to apply the augmentation\n",
+ "- Train the network\n",
+ "- Evaluate the network performance\n",
+ "- Plot results\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\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",
+ "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",
+ "\n",
+ "# Get config as dict and associated paths\n",
+ "cfg = read_config(config_path)\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",
+ "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, 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, 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(\n",
+ " aug_project_path,\n",
+ " \"dlc-models\",\n",
+ ")\n",
+ "\n",
+ "# make the folder for this modelprefix\n",
+ "try:\n",
+ " os.mkdir(aug_project_path)\n",
+ "except OSError as error:\n",
+ " print(error)\n",
+ " 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, 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",
+ " # 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], one_train_pose_config_file_path)\n",
+ " # copy base test config file\n",
+ " shutil.copyfile(list_base_test_pose_config_file_paths[j], one_test_pose_config_file_path)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "\n",
+ "## Initialise dict with additional edits to train config: optimizer\n",
+ "train_edits_dict = {}\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",
+ "edits_dict[\"rotation\"] = 180\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), edits_dict)\n",
+ " edit_config(str(one_train_pose_config_file_path), train_edits_dict)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Train network with ```fliplr``` and stronger ```rotation``` augmentation\n",
+ "Now let's train the network with the new augmentation. Again, this will take several hours."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "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",
+ "\n",
+ "# the computer I'm working on has several gpus, here I used the third one.\n",
+ "gputouse = 3\n",
+ "\n",
+ "# define shuffles and trainingsetindices\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, 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",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# Evaluate the network\n",
+ "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",
+ "\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",
+ "\n",
+ "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",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "tags": [
+ "hide-output"
+ ]
+ },
+ "outputs": [],
+ "source": [
+ "# Get detailed error data for plotting\n",
+ "\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",
+ "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",
+ "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:\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",
+ "\n",
+ "# get test indices\n",
+ "test_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "error_distributions_pcut = []\n",
+ "\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",
+ "\n",
+ "error_distributions_pcut = np.array(error_distributions_pcut) # array with dimensions [shuffle, snapshot, frames]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "\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(\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.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",
+ "\n",
+ "plt.xlabel(\"Iterations (thousands)\")\n",
+ "plt.ylabel(\"Error (px)\")"
+ ]
+ },
+ {
+ "attachments": {},
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "With this, it appears that we have reached our goal, which was **to use augmentation to match or beat the accuracy of refining**. But let's also take a look at the accuracy per video. I will also plot the baseline, un-refined full network accuracy here to highlight how much the augmentation has improved the performance of the network."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# 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",
+ "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",
+ "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:\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",
+ "\n",
+ "# get test indices\n",
+ "test_inds = []\n",
+ "for i, path in enumerate(image_paths):\n",
+ " if str(path[1]).endswith(\"_50_test\"):\n",
+ " test_inds.append(i)\n",
+ "\n",
+ "# 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 = [\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",
+ "\n",
+ "for i, path in enumerate(image_paths):\n",
+ " for j in range(nvideos):\n",
+ " if str(path[1]).__eq__(test_paths_cam1[j]):\n",
+ " test_inds_cam1[j].append(i)\n",
+ " elif str(path[1]).__eq__(test_paths_cam2[j]):\n",
+ " test_inds_cam2[j].append(i)\n",
+ " elif str(path[1]).__eq__(test_paths_cam3[j]):\n",
+ " test_inds_cam3[j].append(i)\n",
+ "\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",
+ "\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",
+ "\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:\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",
+ " )\n",
+ " for movie_number in range(7):\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",
+ " # 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(\n",
+ " movie_number,\n",
+ " meanPcut_cam1[i, :],\n",
+ " stePcut_cam1[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
+ "\n",
+ " ax2.errorbar(\n",
+ " movie_number,\n",
+ " meanPcut_cam2[i, :],\n",
+ " stePcut_cam2[\n",
+ " i,\n",
+ " :,\n",
+ " ],\n",
+ " )\n",
+ "\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",
+ "ax3.set_ylim([0, 50])\n",
+ "ax1.set_title(\"Cam 1\")\n",
+ "ax2.set_title(\"Cam 2\")\n",
+ "ax3.set_title(\"Cam 3\")\n",
+ "ax1.set_ylabel(\"Error (px\")\n",
+ "ax2.set_ylabel(\"Error (px\")\n",
+ "ax3.set_ylabel(\"Error (px\")\n",
+ "ax1.legend([\"Full, ref, baseline\", \"Full, OOD, fliplr_180_rotate\", \"Full, OOD, baseline\"])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n",
+ "## Interpret performance of network after ```fliplr``` and 180° rotation augmentation\n",
+ "The results look very promising. Video 1 keeps being difficult. This is likely due to this video depicting a bat flying in a manner that is atypical for the dataset used here. Most videos in the dataset depict rather straight-forward flybys, but video 1 shows a bat slowly ascending while turning. Even so, the augmentation has lowered the pixel error for video 1 compared to the baseline, and for all other videos, the augmentation has led to performance close to, or better than, the refined case.\n",
+ "\n",
+ "# Conclusion\n",
+ "What follows are some of my conclusions from using augmentation to improve performance when tracking wing movement of bats using DeepLabCut.\n",
+ "\n",
+ "## Where scalability is not important, consider refining\n",
+ "We saw from our baseline accuracy tests that when refining, there was no gain from increasing the amount of base training data. In practical terms this means that if you know which videos you want to analyze, and when you do not plan on using the network for analyzing other videos in the future, then it makes sense to prioritize digitizing frames from the videos to be analyzed.\n",
+ "\n",
+ "## Inspect bodypart predictions and augment with purpose\n",
+ "At first, I tried a couple of permutations of different image augmentations and saw no or little performance improvement. I tried augmentations related to scale, brightness, blurriness, and probably some other that slip my mind at the moment. I more or less concluded that augmentations have little effect and that for a given set of training and test data, DeepLabCut's default training parameters are close to optimal and little can be gained by augmenting the training data. But then I looked closer at the labeled frames from ```evaluate_network``` and realized that the high pixel errors were mainly caused by the network getting left and right mixed up and augmented accordingly. Doing that, I saw an average accuracy improvement of more than 10 pixels. For this dataset, 10 pixels is quite big, for comparison, on the largest image in the test dataset, the forearm of the bat is less than 70 pixels long.\n",
+ "\n"
+ ]
+ }
+ ],
+ "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",
+ "name": "conda-env-DEEPLABCUT_newGUI-py"
+ },
+ "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"
+ },
+ "vscode": {
+ "interpreter": {
+ "hash": "d59ffae89e6bb4db73216b2b6c5dd5d3dc066a6f8df0277b318288143e8a2adf"
+ }
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/docs/recipes/index.md b/docs/recipes/index.md
new file mode 100644
index 0000000000..195e8987fc
--- /dev/null
+++ b/docs/recipes/index.md
@@ -0,0 +1 @@
+# The DeepLabCut Cookbook
diff --git a/docs/recipes/installTips.md b/docs/recipes/installTips.md
index 7e0a06ee7b..044e8efd67 100644
--- a/docs/recipes/installTips.md
+++ b/docs/recipes/installTips.md
@@ -1,14 +1,43 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-04-27'
+ ignore: false
+ visibility: online
+ status: outdated
+ recommendation: archive
+ notes: Should be removed in favor of the main installation guide.
+---
+
+(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.
+
+### 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.:
-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.
+```
+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:
@@ -22,7 +51,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__`
@@ -37,25 +66,15 @@ Then, you can see what version you have with `deeplabcut.__version__`
If you make changes, you can also then utilize our test scripts. Run the desired test script found here (you will need to git clone first): https://github.com/DeepLabCut/DeepLabCut/blob/master/examples/.
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
```
-
## Installation on Ubuntu 18.04 LTS
### Here are our tips for an easy installation. This is done on a fresh computer installation (Ubuntu 18.04 LTS)
@@ -72,7 +91,8 @@ then, download CUDA 10 from here: https://developer.nvidia.com/cuda-downloads an
wget http://developer.download.nvidia.com/compute/cuda/10.1/Prod/local_installers/cuda_10.1.243_418.87.00_linux.run
sudo sh cuda_10.1.243_418.87.00_linux.run
```
- with the exception that I also (afterwards):
+
+with the exception that I also (afterwards):
```
sudo add-apt-repository ppa:graphics-drivers/ppa
@@ -89,13 +109,16 @@ gcc --version
```
output:
+
```
gcc (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE
```
+
Then:
+
```
sudo apt install nvidia-cuda-toolkit gcc-7
```
@@ -135,6 +158,7 @@ sudo apt-get -y install cuda
```
Then:
+
```
sudo add-apt-repository ppa:graphics-drivers/ppa
sudo apt update
@@ -150,6 +174,7 @@ re-open terminal and check gcc version:
`gcc --version`
output:
+
```python
gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
@@ -157,6 +182,7 @@ Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
```
+
Then finish installation:
`sudo apt install nvidia-cuda-toolkit gcc-9`
@@ -200,6 +226,7 @@ sudo apt-get install \
gnupg \
lsb-release
```
+
add key: `curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg`
```
@@ -207,7 +234,9 @@ echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```
+
Then:
+
```
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
@@ -219,6 +248,7 @@ some clean up:
now you can run `sudo docker run hello-world`
and get:
+
```
Hello from Docker!
This message shows that your installation appears to be working correctly.
@@ -241,6 +271,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
@@ -254,6 +285,7 @@ and run:
`bash Anaconda3-2021.05-Linux-x86_64.sh`
and you get:
+
```python
Welcome to Anaconda3 2021.05
@@ -281,6 +313,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.
@@ -288,9 +325,11 @@ failed
CondaEnvException: Pip failed
```
+
You can either: remove conda env: `conda remove --name DEEPLABCUT --all`, open the DLC-GPU.yaml file (any text editor!) and change `deeplabcut[gui]` to `deeplabcut`. Then run: `conda env create -f DEEPLABCUT.yaml` again...
then you will get:
+
```python
Successfully uninstalled decorator-5.0.9
@@ -311,46 +350,13 @@ Activate! `conda activate DEEPLABCUT` and then run: `conda install -c conda-forg
Then run `python -m deeplabcut` which launches the DLC GUI.
-
-## DeepLabCut M1 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 chip, and follow these steps:
-
-(1) git clone the deeplabcut cut repo:
-
-`git clone https://github.com/DeepLabCut/DeepLabCut.git`
-
-(2) in the program terminal, `cd DeepLabCut/conda-environments`
-
-(3) Click here to download the Rosetta wheel for TensorFlow.
-
-For instance, for 2.4.1:
-https://drive.google.com/file/d/17pSwfoNuyf3YR8vCaVggHeI-pMQ3xL7l/view?usp=sharing
-(for different versions see here: https://github.com/tensorflow/tensorflow/issues/46044)
-
-(4) Then, run:
-
-`conda env create -f DEEPLABCUT_M1.yaml`
-
-(5) Next, **activate the environment,** and in the terminal `cd` to Downloads, and then run:
-
-`pip install tensorflow-2.4.1-py3-none-any.whl --no-dependencies --force-reinstall`
-
-(6) Next, launch DLC with `pythonw -m deeplabcut`
-
-GUI will open!
-
-Note: Based on issue #1380 thanks!
-
## How to confirm that your GPU is being used by DeepLabCut
During training and analysis steps, DeepLabCut does not use the GPU processor heavily. To confirm that DeepLabCut is properly using your GPU:
**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"

@@ -358,15 +364,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..e78d2436d3 100644
--- a/docs/recipes/io.md
+++ b/docs/recipes/io.md
@@ -1,6 +1,14 @@
+---
+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
+
Analyzing hour-long videos may take a while, but the task can be
conveniently broken down into the analysis of smaller video clips:
@@ -15,39 +23,50 @@ 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"
```
+
For instance, to re-encode to `.mp4` format with compression use:
+
```
ffmpeg -i "path_to_video" -c:v h264 -crf 18 -preset fast "output_path"
```
+
`-crf` is a quality-size tradeoff from 0 to 63 with 0 being highest quality but lowest compression. Ideally youâd want to use values between 18-23 for similar visual quality to the original.
`-preset` is a quality-speed tradeoff. Higher values give you faster encoding but will result in bigger filesizes and/or worse quality.
For `.avi` files you want to change the codec and the quality metric, since `crf` is used by H264/265 and not MJPEG. For instance, the encoding with some compression would be:
+
```
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
-`"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:
+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"
```
+
If audio is not a necessary in the videos you can also save some space by requesting specifically for the encoder to not encode any audio stream by adding `-an` just before specifying output filename, like so:
+
```
for %i in (*.avi) do ffmpeg -i "%i" -c:v libx265 -preset fast -crf 18 -filter:v "scale= iw/2:ih/2" -an "%~ni.mp4"
```
diff --git a/docs/recipes/nn.md b/docs/recipes/nn.md
index 3d6b4e00e4..07351311d9 100644
--- a/docs/recipes/nn.md
+++ b/docs/recipes/nn.md
@@ -1,15 +1,24 @@
+---
+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 +27,8 @@ 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,86 +36,103 @@ 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.
-
-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')
-```
+by DeepLabCut, default values can be readily overwritten prior to training. See the
+augmentation variables defined in the:
-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).
+- 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)
-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).
## 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.
-***
-## ResNets:
+**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.**
-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):
+______________________________________________________________________
+
+### 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):
-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)
@@ -114,14 +142,25 @@ 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
new file mode 100644
index 0000000000..4452f1935f
--- /dev/null
+++ b/docs/recipes/pose_cfg_file_breakdown.md
@@ -0,0 +1,284 @@
+---
+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.
+- 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.
+
+# 2. Quick start: full parameter list TOC
+
+
+
+- [2. Full parameter list](#2-full-parameter-list)
+ - [2.1 Training Hyperparameters](#21-training-hyperparameters)
+ - [2.1.A `max_input_size` and `min_input_size`](#21a-max_input_size-and-min_input_size)
+ - [2.1.B `global_scale`](#21b-global_scale)
+ - [2.1.C `batch_size`](#21c-batch_size)
+ - [2.1.D `pos_dist_thresh`](#21d-pos_dist_thresh)
+ - [2.1.E `pafwidth`](#21e-pafwidth)
+ - [2.2 Data augmentation parameters](#22-data-augmentation-parameters)
+ - [Geometric transformations](#geometric-transformations)
+ - [2.2.1 `scale_jitter_lo` and `scale_jitter_up`](#221-scale_jitter_lo-and-scale_jitter_up)
+ - [2.1.2 `rotation`](#212-rotation)
+ - [2.2.3 `rotratio` (rotation ratio)](#223-rotratio-rotation-ratio)
+ - [2.2.4 `fliplr` (or a horizontal flip)](#224-fliplr-or-a-horizontal-flip)
+ - [2.2.5 `crop_size`](#225-crop_size)
+ - [2.2.6 `crop_ratio`](#226-crop_ratio)
+ - [2.2.7 `max_shift`](#227-max_shift)
+ - [2.2.8 `crop_sampling`](#228-crop_sampling)
+ - [Kernel transformations](#kernel-transformations)
+ - [2.2.9 `sharpening` and `sharpenratio`](#229-sharpening-and-sharpenratio)
+ - [2.2.10 `edge`](#2210-edge)
+- [References](#references)
+
+
+
+## 2.1 Training Hyperparameters
+
+
+
+### 2.1.A `max_input_size` and `min_input_size`
+
+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
+- change `min_input_size` when the resolution of the video is smaller than 64x64 or when `scale_jitter_lo` will possibly go below that value
+
+
+
+### 2.1.B `global_scale`
+
+The default value is `0.8`. It's the most basic, first scaling that happens to all images in the training queue.
+
+đĄPro-tip:đĄ
+
+- With images that are low resolution or lack detail, it may be beneficial to increase the `global_scale` to 1, to keep the original size and retain as much information as possible.
+
+### 2.1.C `batch_size`
+
+
+
+The default for single animal projects is 1, and for maDLC projects it's `8`. It's the number of frames used per training iteration.
+
+In both cases, you can increase the batchsize up to the limit of your GPU memory and train for a lower number of iterations. The relationship between the number of iterations and `batch_size` is not linear, so `batch_size: 8` doesn't mean you can train for 8x less iterations, but like with every training, plateauing loss can be treated as an indicator of reaching optimal performance.
+
+đĄPro-tip:đĄ
+
+- Having a higher `batch_size` can be beneficial in terms of models' generalization
+
+______________________________________________________________________
+
+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.
+
+
+
+### 2.1.E `pafwidth`
+
+The default value is `20`. PAF stands for part affinity fields. It is a method of learning associations between pairs of bodyparts by preserving the location and orientation of the limb (the connection between two keypoints). This learned part affinity helps in proper animal assembly, making the model less prone to associating bodyparts of one individual with those of another. [1](#ref1)
+
+
+## 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.
+
+Classes of data augmentations, characterized by their nature, are given by:
+
+- [**Geometric transformations**](#geometric)
+ 1. [`scale_jitter_lo` and `scale_jitter_up`](#scale_jitter)
+ 1. [`rotation`](#rot)
+ 1. [`rotratio`](#rotratio)
+ 1. [`mirror`](#mirror)
+ 1. [`crop size`](#crop_size)
+ 1. [`crop ratio`](#crop_ratio)
+ 1. [`max shift`](#max_shift)
+ 1. [`crop sampling`](#crop_sampling)
+- [**Kernel transformations**](#kernel)
+ 9\. [`sharpening` and `sharpen_ratio`](#sharp)
+ 10\. [`edge_enhancement`](#edge)
+
+
+
+### Geometric transformations
+
+**Geometric transformations** such as *flipping*, *rotating*, *translating*, *cropping*, *scaling*, and *injecting noise*, which are very good for positional biases present in the training data.
+
+
+
+### 2.2.1 `scale_jitter_lo` and `scale_jitter_up`
+
+*Scale jittering* resizes an image within a given resize range. This allows the model to learn from different sizes of objects in the scene, therefore increasing its robustness to generalize, especially on newer scenes or object sizes.
+
+The image below, retrieved from [3](#ref3), illustrates the difference between two scale jittering methods.
+
+
+
+During training, each image is randomly scaled within the range `[scale_jitter_lo, scale_jitter_up]` to augment training data. The default values for these two parameters are:
+
+- `scale_jitter_lo = 0.5`
+- `scale_jitter_up = 1.25`
+
+đĄPro-tips:đĄ
+
+- âââ If the target animal/s do not have an incredibly high variance in size throughout the video (e.g., jumping or moving towards the static camera), keeping the **default** values **unchanged** will give just enough variability in the data for the model to generalize better â
+
+- ââHowever, you may want to adjust these parameters if you want your model to:
+
+ - handle new data with possibly **larger (25% bigger than original)** animal subjects âĄïž in this scenario, increase the value of *scale_jitter_up*
+ - handle new data with possibly **smaller (50% smaller than the original)** animal subjects âĄïž in this scenario, decrease the value of *scale_jitter_lo*
+ - **generalize well in new set-ups/environments** with minimal to no pre-training
+ â ïž But as a consequence, **training time will take longer**.đđ
+
+- âIf you have a fully static camera set-up and the sizes of the animals do not vary much, you may also try to **shorten** this range to **reduce training time**.đđ(â ïž but, as a consequence, your model might only fit your data and not generalize well)
+
+
+
+### 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
+
+The image below, retrieved from [2](#ref2), illustrates the difference between the different rotation degrees.
+
+
+
+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** â
+
+- 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
+ - But as a consequence, the more you increase the rotation degree, the more the original keypoint labels may not be preserved
+
+
+
+### 2.2.3 `rotratio` (rotation ratio)
+
+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** â
+
+
+
+### 2.2.4 `fliplr` (or a horizontal flip)
+
+**Mirroring**, otherwise called **horizontal axis fipping**, is much more common than flipping the vertical axis. This augmentation is one of the easiest to implement and has proven useful on datasets such as CIFAR-10 and ImageNet. However, on datasets involving text recognition, such as MNIST or SVHN, this is not a label-preserving transformation.
+
+The image below is an illustration of this property (shown on the right-most column).
+
+
+
+This parameter randomly flips an image horizontally to augment training data.
+By default, this parameter is set to `False` especially on poses with mirror symmetric joints (for example, so the left hand and right hand are not swapped).
+
+đĄPro-tip:đĄ
+
+- â If you work with labels with symmetric joints, keep the **default** value **unchanged** - unless the dataset is biased (animal moves mostly in one direction, but sometimes in the opposite)â
+- Keeping the default value to `False` will work well in most cases.
+
+
+
+### 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.
+
+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.
+
+
+
+### 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.
+
+
+
+### 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).
+
+
+
+
+
+### 2.2.8 `crop_sampling`
+
+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.
+- `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 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.
+
+The image below is modified from
+[2](#references).
+
+
+
+
+
+### 2.2.10 `edge`
+
+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
+
+
+ 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
+ Ghiasi, G., Cui, Y., Srinivas, A., Qian, R., Lin, T.-Y., Cubuk, E. D., Le, Q. V., & Zoph, B. (2020). Simple Copy-Paste is a Strong Data Augmentation Method for Instance Segmentation (Version 2). arXiv. https://doi.org/10.48550/ARXIV.2012.07177
+ Shorten, C., & Khoshgoftaar, T. M. (2019). A survey on Image Data Augmentation for Deep Learning. In Journal of Big Data (Vol. 6, Issue 1). https://doi.org/10.1186/s40537-019-0197-0
+
diff --git a/docs/recipes/post.md b/docs/recipes/post.md
index 338e5725a5..bdf486fdf9 100644
--- a/docs/recipes/post.md
+++ b/docs/recipes/post.md
@@ -1,3 +1,10 @@
+---
+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
@@ -17,7 +24,10 @@ bpt1 = df.xs('head', level='bodyparts', axis=1).to_numpy()
bpt2 = df.xs('tail', level='bodyparts', axis=1).to_numpy()
# We calculate the vectors from a point to the other
# and group them per frame and per animal.
-diff = (bpt1 - bpt2).reshape((len(df), -1, 2))
+try:
+ diff = (bpt1 - bpt2).reshape((len(df), -1, 2))
+except ValueError:
+ diff = (bpt1 - bpt2).reshape((len(df), -1, 3))
dist = np.linalg.norm(diff, axis=2)
mask = np.any(dist >= max_dist, axis=1)
flagged_frames = df.iloc[mask].index
diff --git a/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md b/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md
new file mode 100644
index 0000000000..428e938c59
--- /dev/null
+++ b/docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md
@@ -0,0 +1,167 @@
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: verify
+ notes: Slightly redundant with CONTRIBUTING.md, style may need adjusted based on the rest of the repo.
+---
+
+# Publishing Notebooks into the Main DLC Cookbook
+
+### Your Recipe Guide to Contributing to the DLC Cookbook
+
+## Introduction
+
+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
+
+- **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.
+ - **Consider Minor vs. Major Changes**: If you're adding a new section or significantly altering the current content, it might be worth noting the changes at the beginning or end of the recipe for clarity.
+ - **Maintain Consistency**: Ensure your updates adhere to the current style, tone, and structure of the existing content to maintain a seamless reading experience.
+
+## 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.
+
+Now, let's dive into the process of contributing your content to the DLC Jupyter book.
+
+## Steps
+
+1. **Set-up your local environment.** You need `deeplabcut[docs]` installed:
+ You can do this by running the following command:
+ ```
+ pip install deeplabcut[docs]
+ ```
+
+This command installs DeepLabCut along with the dependencies required to build the documentation.
+
+2. **Fork the DLC Repository**:
+
+ - Go to the DeepLabCut GitHub repository: [https://github.com/DeepLabCut/DeepLabCut](https://github.com/DeepLabCut/DeepLabCut)
+
+ - Click on the `Fork` button on the top-right corner of the page. This will create a copy of the repository in your own GitHub account.
+
+1. **Clone your forked repository**:
+
+ - Navigate to your forked repo on GitHub.
+ - Click the `Code` button and copy the URL.
+ - Clone the repository to your local machine:
+
+ ```
+ git clone [REPO_URL]
+ ```
+
+1. **Create a new branch**:
+ It's a good practice to create a new branch for each new feature or change:
+
+ ```
+ cd [YOUR_REPO_DIRECTORY]
+ git checkout -b my-new-notebook
+ ```
+
+1. **Create a new notebook** or **update an existing one**.
+
+ - **Creating a new notebook**
+ - **Choose Your Topic Wisely:** Before you start, make sure your topic fits the DLC Jupyter book's theme and brings value to its readers. A novel topic or a unique twist on an existing topic can be particularly impactful.
+ - **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:
+ ```
+ [YOUR_REPO_DIRECTORY]/docs/recipes/
+ ```
+ - Open the corresponding Jupyter notebook (.ipynb file) you wish to update.
+ - Make the necessary changes or additions to the content.
+ - Save the notebook once your updates are finalized.
+ - Proceed to **Step 6** *(Proofreading)* and **9** *(Testing the documentation)* (skip Steps 7 and 8).
+
+1. **Proofread:** Double check for spelling and grammatical errors by using Jupyter notebook's spellcheck extension called `spellchecker` (or your preferred spell-checker).
+
+ ```
+ jupyter nbextension enable spellchecker/main
+ ```
+
+ Once installed, restart your notebook, and when you load your notebook again, you will see the incorrectly spelled words highlighted in red.
+
+1. **Add your notebook** to the recipe directory at `[YOUR_REPO_DIRECTORY]/docs/recipes/`
+
+ - 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
+
+ ```
+
+1. **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]
+ ```
+
+1. **Test the documentation:**
+
+ - Build your notebook into the DLC recipe book
+ ```
+ jupyter book build [YOUR_REPO_DIRECTORY]
+ ```
+ - Once build is successful, the newly built book can be accessed at `[YOUR_REPO_DIRECTORY]/_build/html/`.
+ - Open `index.html` and check whether your notebook was rendered properly and if the links are working.
+
+1. **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]"
+ ```
+
+1. **Push your branch to your fork:**
+
+ ```
+ git push origin my-new-notebook
+ ```
+
+1. **Submit a Pull Request (PR):**
+
+ - Go to your forked repository on GitHub.
+ - You'll likely see a message prompting you to create a pull request from your recently pushed branch. Click `Compare & pull request`.
+ - Fill out the PR form with a descriptive title and comments describing your notebook. This will help the maintainers understand the context and purpose of your notebook.
+ - Click `Create pull request`.
+
+1. **Make Necessary Changes**: The DeepLabCut maintainers will then review your PR and provide feedback. If changes are required, make the necessary changes on your local branch, commit them, and push the branch again. The PR will automatically update.
+
+1. **đ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).
+
+## Wrap-Up đ
+
+Alright! đ By now, you've got the playbook to jazz up the DeepLabCut Jupyter book. Remember, it's not just about cooking up new recipes but also spicing up the old ones. Dive in, have fun, and let's make this book a flavor-packed feast for all DLC enthusiasts out there. High-five for joining the party! đđ
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 61042d0b97..eb56139f14 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,50 +1,67 @@
+---
+deeplabcut:
+ last_content_updated: '2025-02-28'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+---
+
+(dev-roadmap)=
+
## A development roadmap for DeepLabCut
+đą âł đ§
+
+**General Enhancements:**
+
+- [ ] DeepLabCut PyTorch & Model Zoo --> DLC 3.0 đ„
+- [x] DLC-CookBook v0.1
+- [x] DLC BLog for releases and user-highlights
+- [x] New Docker containers into main repo / linked to Docker hub and repo(s)
+- [ ] 3D >2 camera support --> better 3D in PyTorch version đ„
+
+**General NN Improvements:**
-:loudspeaker: :hourglass_flowing_sand: :construction:
+- [x] EfficientNet backbones added (currently SOTA on ImageNet). https://openaccess.thecvf.com/content/WACV2021/html/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.html https://github.com/DeepLabCut/DeepLabCut/commit/96da2cacf837a9b84ecdeafb50dfb4a93b402f33
+- [x] New multi-fusion multi-scale networks; DLCRNet_ms5
+- [ ] BUCTD Integration, see ICCV 2023 paper at https://arxiv.org/abs/2306.07879
**deeplabcut 2.2: multi-animal pose estimation and tracking with DeepLabCut**
-- [X] alpha testing complete (early May 2020)
-- [X] beta release: 2.2.b5 on 5 / 22 / 20 :smile:
-- [X] beta release: 2.2b8 released 9/2020 :smile:
-- [X] beta release 2.2b9 (rolled into 2.1.9 --> candidate release, slotted for Oct 2020)
-- [X] 2.2rc1
-- [X] 2.2rc2
-- [X] 2.2rc3
-- [X] Manuscript Lauer et al 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1
-- [X] full 2.2 stable release
+
+- [x] alpha testing complete (early May 2020)
+- [x] beta release: 2.2.b5 on 5 / 22 / 20 :smile:
+- [x] beta release: 2.2b8 released 9/2020 :smile:
+- [x] beta release 2.2b9 (rolled into 2.1.9 --> candidate release, slotted for Oct 2020)
+- [x] 2.2rc1
+- [x] 2.2rc2
+- [x] 2.2rc3
+- [x] Manuscript Lauer et al 2021 https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1
+- [x] full 2.2 stable release
**real-time module with DEMO for how to set up on your camera system, integration with our [Camera Control Software]**(https://github.com/AdaptiveMotorControlLab/Camera_Control)
-- [X] Integration with Bonsai completed! See: https://github.com/bonsai-rx/deeplabcut
-- [X] Integreation with Auto-pi-lot. See: https://auto-pi-lot.com/
-- [X] DeepLabCut-live! released Aug 5th, 2020: preprint & code: https://www.biorxiv.org/content/10.1101/2020.08.04.236422v1
-- [X] DeepLabCut-live! published in eLife
+
+- [x] Integration with Bonsai completed! See: https://github.com/bonsai-rx/deeplabcut
+- [x] Integration with Auto-pi-lot. See: https://auto-pi-lot.com/
+- [x] DeepLabCut-live! released Aug 5th, 2020: preprint & code: https://www.biorxiv.org/content/10.1101/2020.08.04.236422v1
+- [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
-- [ ] Manuscript forthcoming!
-- [X] new models added; horse, cheetah
-- [ ] TopView_Mouse model
+
+- [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
+- [x] Quadruped model
- [ ] contribution module
+- [ ] PyTorch Model zoo code
**DeepLabCut GUI and DeepLabCut-core:**
-- [X] to make DLC more modular, we will move core functions to https://github.com/DeepLabCut/DeepLabCut-core
-- [X] DLC-core depreciated, and core is now simply `pip install deeplabcut` GUI is with `pip install deeplabcut[gui]`
-- [ ] new GUI for DeepLabCut; due to extended issues with wxPython, we will be moving to release a napari plugin https://github.com/napari/napari
-- [ ] New project management GUI
-- [X] tensorflow 2.2 support in DeepLabCut-core: https://github.com/DeepLabCut/DeepLabCut/issues/601
-- [X] DeepLabCut-Core to be depreciated; TF2 will go into main repo.
-- [X] TF2 support while also maintaining TF1 support until 2022.
-- [ ] Web-based GUI for labeling --> Colab training pipeline for users (full no-install DLC)
-**General NN Improvements:**
-- [X] EfficientNet backbones added (currently SOTA on ImageNet). https://openaccess.thecvf.com/content/WACV2021/html/Mathis_Pretraining_Boosts_Out-of-Domain_Robustness_for_Pose_Estimation_WACV_2021_paper.html https://github.com/DeepLabCut/DeepLabCut/commit/96da2cacf837a9b84ecdeafb50dfb4a93b402f33
-- [X] New multi-fusion multi-scale networks; DLCRNet_ms5
-
-**General Enhancements:**
-- [ ] DeepLabCut PyTorch model zoo
-- [X] DLC-CookBook v0.1
-- [X] DLC BLog for releases and user-highlights
-- [X] New Docker containers into main repo / linked to Docker hub and repo(s)
-- [ ] 3D >2 camera support
+- [x] to make DLC more modular, we will move core functions to https://github.com/DeepLabCut/DeepLabCut-core
+- [x] DLC-core depreciated, and core is now simply `pip install deeplabcut` GUI is with `pip install deeplabcut[gui]`
+- [x] new GUI for DeepLabCut; due to extended issues with wxPython, we will be moving to release a napari plugin https://github.com/napari/napari
+- [x] New project management GUI
+- [x] tensorflow 2.2 support in DeepLabCut-core: https://github.com/DeepLabCut/DeepLabCut/issues/601
+- [x] DeepLabCut-Core to be depreciated; TF2 will go into main repo.
+- [x] TF2 support while also maintaining TF1 support until 2022.
+- [ ] Web-based GUI for labeling --> Colab training pipeline for users (full no-install DLC)
diff --git a/docs/standardDeepLabCut_UserGuide.md b/docs/standardDeepLabCut_UserGuide.md
index b509243503..105681eaa7 100644
--- a/docs/standardDeepLabCut_UserGuide.md
+++ b/docs/standardDeepLabCut_UserGuide.md
@@ -1,503 +1,1222 @@
-# DeepLabCut User Guide (for single animal projects)
+---
+deeplabcut:
+ last_content_updated: '2025-06-30'
+ last_metadata_updated: '2026-03-06'
+ ignore: false
+ visibility: online
+ status: review_needed
+ recommendation: update
+ notes: This is a crucial piece of the doc, but it is rather long and verbose. Recommend breaking it up into smaller sections, and adding more visuals (e.g. screenshots of the GUI, etc.) to make it more engaging and easier to read. Also, consider adding a table of contents at the beginning for easier navigation.
+---
+
+(file:single-animal-userguide)=
+
+# Single animal projects
+
+```{contents}
+---
+local:
+depth: 3
+---
+```
-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](https://deeplabcut.github.io/DeepLabCut/docs/maDLC_UserGuide.html).
+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.
+## Getting started
-## DeepLabCut Project Manager GUI (recommended for beginners)
+DeepLabCut offers two equivalent interfaces: a **GUI** for those who prefer a visual
+workflow (no Python knowledge required), and a **Python API** for users who want
+scripting flexibility or to integrate DeepLabCut into a larger pipeline. All workflow
+steps are available in both.
-**GUI:**
-Simply ``python -m deeplabcut`` or MacOS: ``pythonw -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 [here](https://deeplabcut.github.io/DeepLabCut/docs/PROJECT_GUI.html).
+We assume you have DeepLabCut installed (if not, see {ref}`file:how-to-install`).
+Open a terminal and activate your conda environment:
-
-
-
+```bash
+conda activate DEEPLABCUT
+```
-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!
+```{important}
+On Windows, always open the terminal with administrator privileges: right-click and
+select "Run as administrator".
+```
-## DeepLabCut in the Terminal:
+Choose your interface below to launch DeepLabCut:
-To begin, navigate to anaconda prompt 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, go here). Next, launch your conda env (i.e., for example `conda activate DLC-CPU`) and then type (windows/unix) `ipython` or (macOS) `pythonw`. Then type `import deeplabcut`.
+### GUI (recommended for beginners)
-### (A) Create a New Project
+```bash
+python -m deeplabcut
+```
-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.
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1572824438905-QY9XQKZ8LAJZG6BLPWOQ/ke17ZwdGBToddI8pDm48kIIa76w436aRzIF_cdFnEbEUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8N_N4V1vUb5AoIIIbLZhVYxCRW4BPu10St3TBAUQYVKcLthF_aOEGVRewCT7qiippiAuU5PSJ9SSYal26FEts0MmqyMIhpMOn8vJAUvOV4MI/guilaunch.jpg?format=1000w
+---
+name: fig-gui-launch
+alt: The DeepLabCut Project Manager GUI after launch
+width: 60%
+align: center
+---
+The DeepLabCut Project Manager GUI.
+```
-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.
+### Python API
-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).
+In an interactive Python session (e.g. `ipython`), import DeepLabCut:
```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)
+import deeplabcut
```
-**Important path formatting note**
+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 DeepLabCut version 2.0.6). Additional functions and features are
+continually added to the package; we recommend reading the protocol alongside this
+documentation.
+
+## Workflow
-Windows users, you must input paths as: ``r'C:\Users\computername\Videos\reachingvideo1.avi' `` or
+DeepLabCut's full workflow is described in steps (A)â(N) below. Code examples throughout this page use the Python API; if you are
+using the GUI, the same steps are available in the corresponding panels of the
+Project Manager.
-`` 'C:\\Users\\computername\\Videos\\reachingvideo1.avi'``
+### Phase 1 â Project setup
- (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(...)``)
+#### (A) Create a New Project
+##### Overview
-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:
+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.
-**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.
+Thus, this function requires the user to input:
-**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.
+- The name of the project
+- The name of the experimenter
+- 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
+ - Whether to copy the videos to the project directory
-**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.
+```{note}
+If the optional argument `working_directory` is unspecified, the
+project directory is created in the current working directory.
-**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.
+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).
+This is why administrator privileges are required for Windows users, as creating symbolic links requires them.
+```
+
+##### Code example
```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.create_new_project(
+ "Name of the project",
+ "Name of the experimenter",
+ ["Full path of video 1", "Full path of video 2", "Full path of video 3"],
+ working_directory="Full path of the working directory",
+ copy_videos=True/False,
+ multianimal=False
+)
+```
+
+###### Output & directory structure
+
+```{important}
+On Windows, 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(...)`
```
-*Please note, *Full path of the project configuration file* will be referenced as ``config_path`` throughout this protocol.
+This set of arguments creates a project directory with the name
+**++** in the **working directory** and creates the symbolic links to videos in the videos directory.
-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 will have subdirectories:
-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.
+```
+++/
+âââ dlc-models/
+â âââ iteration-0/
+â âââ iteration-1/
+â âââ ...
+âââ dlc-models-pytorch/
+â âââ iteration-0/
+â â âââ /
+â â âââ train/
+â â âââ test/
+â âââ iteration-1/
+â âââ ...
+âââ labeled-data/
+â âââ /
+âââ training-datasets/
+âââ videos/
+âââ config.yaml
+```
+
+All the outputs generated during the course of a project will be stored in one of these subdirectories, thus allowing each project to be managed independently of other projects.
+
+###### Subdirectory layout
+
+1. `dlc-models` and `dlc-models-pytorch`:
+ These directories have the same structure but store model files for different engines:
+
+ - `dlc-models` for TensorFlow
+ - `dlc-models-pytorch` for PyTorch
+ - At the top level, both contain **iteration folders** such as:
+ - `iteration-0`
+ - `iteration-1`, etc.
+ which correspond to successive rounds of label refinement.
+ Each iteration folder in turn contains **shuffle directories**, each representing a specific experiment defined by a particular train/test split and model architecture.
+ Within each shuffle directory:
+ - `train/` and `test/` store metadata and configuration files for the feature detectors.
+ - These configuration files are written in YAML, a human-readable format that can be edited with any standard text editor.
+ - The `train/` folder also stores training checkpoints (snapshots), which let users reload a trained model or resume training from an intermediate checkpoint if training was interrupted.
+
+1. `labeled-data/`:
+ Contains the extracted frames used to build the training dataset. Frames from different videos are stored in separate subdirectories, and each frame filename encodes its temporal position in the source video, making it easy to trace each frame back to its origin.
-
-
-
+1. `training-datasets/`:
+ Stores the generated training datasets along with metadata describing how each dataset was created.
+
+1. `videos/`:
+ Stores either the project videos themselves or symbolic links to them:
+ If copy_videos=False (default), it contains symbolic links.
+ If copy_videos=True, the videos are copied into the directory.
+
+```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
+)
+```
+
+```{note}
+The *Full path of the project configuration file* will be referenced as `config_path` throughout this guide.
+```
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+###### Main configuration file (*config.yaml*)
+
+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
+{ref}`Box 1 `.
+
+The `create_new_project` step writes the following parameters to the configuration file:
+
+- *Task*
+- *Scorer*
+- *Date*
+- *Project_path*
+- A list of videos *video_sets*.
+
+```{caution}
+The first three parameters should **not** be changed.
+The list of videos can be changed by adding new videos or manually removing videos.
+```
+
+```{figure} images/box1-single.png
+---
+name: config-box1
+alt: Box 1 - Single Animal Project Configuration File Glossary
+align: center
+---
+Single Animal project configuration file glossary
+```
+
+###### API Docs
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.create_new_project.rst
```
+````
+
+#### (B) Configure the Project
+
+```{image} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1588892210304-EW7WD46PYAU43WWZS4QZ/ke17ZwdGBToddI8pDm48kAXtGtTuS2U1SVcl-tYMBOAUqsxRUqqbr1mOJYKfIPR7LoDQ9mXPOjoJoqy81S2I8PaoYXhp6HxIwZIk7-Mi3Tsic-L2IOPH3Dwrhl-Ne3Z2YjE9w60pqfeJxDohDRZk1jXSVCSSfcEA7WmgMAGpjTehHAH51QaxKq4KdVMVBxpG/1nktc1kdgq2.jpg?format=1000w
+---
+width: 175px
+title: colormaps
+alt: DLC Utils
+align: right
+---
+```
-### (B) Configure the Project
+##### Overview
-
+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 ({ref}`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.
+```{caution}
+Please do not include spaces in the names of bodyparts.
+```
-**bodyparts:** are the bodyparts of each individual (in the above list).
+##### Key item
+- **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).
+### Phase 2 â Data preparation
+
+#### (C) Select Frames to Label
+
+```{important}
+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 dataset in an adaptive way (see
+refinement below).
+```
+
+##### Overview
+
+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.
+
+##### Code example
-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
+
+```{important}
+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).
+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).
+```
+
+##### Parameter note
-`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`.
+`userfeedback` allows the user to specify which videos they wish to extract frames from. When set to `True`, a dialog
+will appear, 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
-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.
+##### Frame selection methods
-**CRITICAL POINT:** It is advisable to extract frames from a period of the video that contains interesting
+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.
+
+```{important}
+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
-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.
+parameters in the config.yaml file.
+Also, the user can change the number of frames to extract from each video using the numframes2pick in the config.yaml file.
+```
+
+##### Manual frame selection
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 one-size-fits-all 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 to extract the frame(s)
+(see {numref}`fig-manual-frame-selection`).
+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.
+
+```{figure} https://static1.squarespace.com/static/57f6d51c9f74566f55ecf271/t/5c71bfbc71c10b4a23d20567/1550958540700/cropMANUAL.gif?format=750w
+---
+name: fig-manual-frame-selection
+alt: Manual frame selection using the extract_frames GUI
+width: 70%
+align: center
+---
+Manual frame selection using the `extract_frames` GUI.
+```
-
-
-
+##### API Docs
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.extract_frames.rst
```
+````
+
+______________________________________________________________________
+
+#### (D) Label Frames
+
+##### Overview
-### (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 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.
+
+```{hint}
+Check out the {ref}`napari-deeplabcut docs ` for
+more information about the labelling workflow.
+```
+
+##### Code example
-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.
```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
+
+[đ„ DEMO](https://youtu.be/hsA9IB5r73E)
+
+
+
+
-**CRITICAL POINT:** It is advisable to **consistently label similar spots** (e.g., on a wrist that is very large, try
+```{important}
+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.
+##### Optional: Adding new bodypart labels
-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
-```
+To add 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.
+
+For more information about the labelling workflow, check out the {ref}`napari-deeplabcut docs `.
+
+______________________________________________________________________
+
+#### (E) Check Annotated Frames
-### (E) Check Annotated Frames
+##### Overview
-OPTIONAL: Checking if the labels were created and stored correctly is beneficial for training, since labeling
+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:
+
+##### Code example
+
```python
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.
+##### What it creates
+
+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
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.check_labels.rst
```
+````
-### (F) Create Training Dataset(s)
+______________________________________________________________________
-**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).
+### Phase 3 â Training & evaluation
-- 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.
+(create-training-dataset)=
-- 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).
+#### (F) Create Training Dataset
-**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.
-
-```python
-deeplabcut.create_training_dataset(config_path, augmenter_type='imgaug')
+```{important}
+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 handled automatically (it
+saves file sets as both Linux and Windows for you).
```
-- 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.
+- 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.
-- 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.
+- 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).
-- 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)**).
+##### Overview
-**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.
+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 dataset will be used for evaluating the
+network.
-**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') ```
+##### Code example
-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.
+```python
+deeplabcut.create_training_dataset(config_path)
+```
-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).
+- Optional: If the user wishes to benchmark the performance of different training settings, they can create multiple training
+ datasets by specifying an integer value for `num_shuffles`; see the docstring for more details.
+
+##### Output structure and configuration files
+
+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)).
+
+A schematic view of the structure described above is:
+
+```text
+/
+âââ /
+ âââ train/
+ â âââ pytorch_config.yaml or pose_cfg.yaml
+ âââ test/
+ âââ pose_cfg.yaml
+```
+
+##### Network and augmentation selection
-**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``.
+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.
-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
+- Networks: ImageNet pre-trained networks OR SuperAnimal pre-trained network 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).
- 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:
-#### API Docs for deeplabcut.create_training_dataset
-```{admonition} Click the button to see API Docs
-:class: dropdown
+```python
+deeplabcut.create_training_dataset_from_existing_split(
+ config_path,
+ from_shuffle=0,
+ shuffles=[10, 11, 12],
+ net_type="resnet_50",
+)
+```
+````
+
+##### 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
-:class: dropdown
+````{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
```
+````
-### (G) Train The Network
+````{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
+
+##### Overview
+
+The function `train_network` helps the user in training the network. It is used as follows:
+
+##### Code example
-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).
+
+At user-specified iterations during training checkpoints are stored in the subdirectory
+*train* under the respective iteration & shuffle directory.
+
+````{admonition} Tips on training models with the PyTorch Engine
+---
+class: dropdown
+---
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.train_network(
+ config_path,
+ shuffle=1,
+ trainingsetindex=0,
+ device="cuda:0",
+ max_snapshots_to_keep=5,
+ displayiters=100,
+ save_epochs=5,
+ epochs=200,
+)
```
-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).
+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.
+
+**Tip**: 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 allow 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.
+````
+
+````{admonition} Tips on training models with the TensorFlow Engine
+---
+class: dropdown
+---
+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,
+ gputouse=None,
+ max_snapshots_to_keep=5,
+ autotune=False,
+ displayiters=100,
+ saveiters=25000,
+ maxiters=300000,
+ allow_growth=True,
+)
+```
-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.
+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 {ref}`Box 2 `).
+
+**Tip**: 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 recommendation:** 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. This will reduce your training time.
+
+```{figure} images/box2-single.png
+---
+name: pose-cfg-box2
+alt: Box 2 - Single Animal TensorFlow Configuration File Glossary
+align: center
+---
+Single-animal TensorFlow configuration file glossary
+```
+````
-**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!!!
+##### API Docs
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs for train_network
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.train_network.rst
```
+````
+
+______________________________________________________________________
-### (H) Evaluate the Trained Network
+#### (H) Evaluate the Trained Network
+
+##### Overview
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](https://doi.org/10.1038/s41593-018-0209-y)). The evaluation results are computed by typing:
+
+##### Code example
+
```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]
+##### Optional parameters
- 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
(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
-âxâ for (likelihood <= `pcutoff`).
+âxâ for (likelihood \<= `pcutoff`).
+
+##### Output and interpretation
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:
-âą 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
+- 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 dataset
-âą consider labeling additional images and make another iteration of the training data set
+##### Optional maps
-**OPTIONAL:** You can also plot the scoremaps, locref layers, and PAFs:
+**Optional:** You can also plot the scoremaps, locref layers, and PAFs:
```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 slow!)
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+you can drop `Indices` to run this on all training/testing images (this is slow!)
+
+##### API Docs
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.evaluate_network.rst
```
+````
+
+______________________________________________________________________
+
+### Phase 4 â Analysis
-### (I) Novel Video Analysis:
+#### (I) Analyze new Videos
+
+##### Overview
+
+The trained network can be used to analyze new videos. Novel/new videos **do not need to be in the config file!**.
+You can analyze new videos anytime by simply using the following line of code:
+
+##### Code example
-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:
```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))
+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
-```{admonition} Click the button to see API Docs
-:class: dropdown
+##### Output and storage
+
+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}
.. include:: ./api/deeplabcut.analyze_videos.rst
```
+````
-### Novel Video Analysis: extra features
+##### 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)
+"""
+dynamic: tuple containing (state, detectionthreshold, margin)
+"""
+```
+
+If `state` is `True`, then dynamic cropping will be performed.
+That means that if an object is detected (i.e., any body part likelihood > `detectionthreshold`),
+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 `margin` and from then on only the posture within this crop is analyzed (until the object is lost;
+i.e., < `detectionthreshold`).
+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
- 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.,
-
-
+##### API Docs
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.filterpredictions.rst
```
+````
+
+______________________________________________________________________
+
+#### (K) Plot Trajectories
-### (K) Plot Trajectories:
+##### Overview
-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
-can be called by typing:
+The plotting components of this toolbox utilize 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
+(see {numref}`fig-trajectory-frame` and {numref}`fig-trajectory-plots`).
+##### Code example
+
+```python
+deeplabcut.plot_trajectories(config_path, ['fullpath/analysis/project/videos/reachingvideo1.avi'])
```
-deeplabcut.plot_trajectories(config_path, [âfullpath/analysis/project/videos/reachingvideo1.aviâ])
+
+##### Output
+
+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). Example outputs are shown below.
+
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559946148685-WHDO5IG9MMCHU0T7RC62/ke17ZwdGBToddI8pDm48kEOb1vFO6oRDmR8SXh4iL21Zw-zPPgdn4jUwVcJE1ZvWEtT5uBSRWt4vQZAgTJucoTqqXjS3CfNDSuuf31e0tVG1gXK66ltnjKh4U2immgm7AVAdfOWODmXNLQLqbLRZ2DqWIIaSPh2v08GbKqpiV54/file0289.png?format=500w
+---
+name: fig-trajectory-frame
+alt: Example video frame with tracked body parts overlaid
+height: 240px
+align: center
+---
+Example video frame with tracked body parts overlaid.
```
-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):
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559939762886-CCB0R107I2HXAHZLHECP/ke17ZwdGBToddI8pDm48kNeA8e5AnyMqj80u4_mB0hV7gQa3H78H3Y0txjaiv_0fDoOvxcdMmMKkDsyUqMSsMWxHk725yiiHCCLfrh8O1z5QPOohDIaIeljMHgDF5CVlOqpeNLcJ80NK65_fV7S1UcpboONgOQYHLzaUWEI1Ir9fXt7Ehyn7DSgU3GCReAA-ZDqXZYzu2fuaodM4POSZ4w/plot_poses-01.png?format=1000w
+---
+name: fig-trajectory-plots
+alt: Example plot_trajectories output
+height: 250px
+align: center
+---
+Example `plot_trajectories` output: body part coordinates, likelihoods, and consecutive displacement histograms.
+```
-
-
-
-
+##### API Docs
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.plot_trajectories.rst
```
+````
+
+______________________________________________________________________
+
+#### (L) Create Labeled Videos
-### (L) Create Labeled Videos:
+##### Overview
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:
+
+##### Code example
+
```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.:
-```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/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', trailpoints=10)
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ filtered=True
+)
```
-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):
+You can also optionally add a skeleton to connect points and/or add a history of points for visualization
+(see {numref}`fig-skeleton-trail`). To set the "trailing points" you need to pass `trailpoints`:
+
+```python
+deeplabcut.create_labeled_video(
+ config_path,
+ ["fullpath/afolderofvideos"],
+ videotype=".mp4",
+ trailpoints=10
+)
+```
+
+##### Skeleton configuration
+
+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, used by calling `deeplabcut.SkeletonBuilder(config_path)`, where `config_path` is the path to your project's `config.yaml` file on disk.
+
+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`:
+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``!
+```{tip}
+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`!
+```
-
-
-
+```{figure} https://images.squarespace-cdn.com/content/v1/57f6d51c9f74566f55ecf271/1559935526258-KFYZC8BDHK01ZIDPNVIX/ke17ZwdGBToddI8pDm48kJbosy0LGK_KqcAZRQ_Qph1Zw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpzkC6kmM1CbNgeHQVxASNv0wiXikHv274BIFe4LR7nd1rKmAka4uxYMJ9FupazBoaU/mouse_skel_trail.gif?format=750w
+---
+name: fig-skeleton-trail
+alt: Labeled video with skeleton overlay and trailing points
+width: 40%
+align: center
+---
+Labeled video with skeleton overlay and trailing points (`draw_skeleton=True`, `trailpoints=10`).
+```
-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
-```{admonition} Click the button to see API Docs
-:class: dropdown
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.create_labeled_video.rst
```
+````
+
+##### Extract "Skeleton" Features
-#### Extract "Skeleton" Features:
+###### Overview
-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:
+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:
+
+###### Code example
```python
-deeplabcut.analyzeskeleton(config, video, videotype='avi', shuffle=1, trainingsetindex=0, save_as_csv=False, destfolder=None)
+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
+###### API Docs
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.analyzeskeleton.rst
```
+````
+
+______________________________________________________________________
-### (M) Optional Active Learning -> Network Refinement: Extract Outlier Frames
+### Phase 5 â Refinement (optional)
+
+(active-learning)=
+
+#### (M) Optional Active Learning - Network Refinement: Extract Outlier Frames
+
+##### Overview
While DeepLabCut typically generalizes well across datasets, one might want to optimize its performance in various,
-perhaps unexpected, situations. For generalization to large data sets, images with insufficient labeling performance
+perhaps unexpected, situations. For generalization to large datasets, images with insufficient labeling performance
can be extracted, manually corrected by adjusting the labels to increase the training set and iteratively improve the
feature detectors. Such an active learning framework can be used to achieve a predefined level of confidence for all
images with minimal labeling cost (discussed in Mathis et al 2018). Then, due to the large capacity of the neural network that underlies the feature detectors, one can continue training the network with these additional examples. One does not
@@ -508,110 +1227,169 @@ where the decoder might make large errors.
All this can be done for a specific video by typing (see other optional inputs below):
+##### Code example
+
```python
-deeplabcut.extract_outlier_frames(config_path, ['videofile_path'])
+deeplabcut.extract_outlier_frames(config_path, ["videofile_path"])
```
+##### Frame-selection methods
+
We provide various frame-selection methods for this purpose. In particular
the user can set:
+```text
+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``.
-âą select frames where a particular body part or all body parts jumped more than *\uf* pixels from the last frame (``outlieralgorithm='jump'``).
+- `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).
+
+- `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:
- 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.
+##### Selection after detection
+
+In general, depending on the parameters, these methods might return many 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="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).
-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
-#### API Docs
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.extract_outlier_frames.rst
```
+````
+
+______________________________________________________________________
+
+#### (N) Refine Labels: Augmentation of the Training Dataset
- ### (N) Refine Labels: Augmentation of the Training Dataset
+##### Overview
- Based on the performance of DeepLabCut, four scenarios are possible:
+Based on the performance of DeepLabCut, four scenarios are possible:
-(A) Visible body part with accurate DeepLabCut prediction. These labels do not need any modifications.
+- (A) Visible body part with accurate DeepLabCut prediction. These labels do not need any modifications.
-(B) Visible body part but wrong DeepLabCut prediction. Move the labelâs location to the actual position of the
-body part.
+- (B) Visible body part but wrong DeepLabCut prediction. Move the labelâs location to the actual position of the
+ body part.
-(C) Invisible, occluded body part. Remove the predicted label by DeepLabCut with a middle click. Every predicted
-label is shown, even when DeepLabCut is uncertain. This is necessary, so that the user can potentially move
-the predicted label. However, to help the user to remove all invisible body parts the low-likelihood predictions
-are shown as open circles (rather than disks).
+- (C) Invisible, occluded body part. Remove the predicted label by DeepLabCut with a middle click. Every predicted
+ label is shown, even when DeepLabCut is uncertain. This is necessary, so that the user can potentially move
+ the predicted label. However, to help the user to remove all invisible body parts the low-likelihood predictions
+ are shown as open circles (rather than disks).
-(D) Invalid images: In the unlikely event that there are any invalid images, the user should remove such an image
-and their corresponding predictions, if any. Here, the GUI will prompt the user to remove an image identified
-as invalid.
+- (D) Invalid images: In the unlikely event that there are any invalid images, the user should remove such an image
+ and their corresponding predictions, if any. Here, the GUI will prompt the user to remove an image identified
+ as invalid.
The labels for extracted putative outlier frames can be refined by opening the GUI:
+
+##### Code example
+
```python
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 {ref}`napari-deeplabcut docs ` 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
+##### Merge datasets
+
+After correcting the labels for all the frames in each of the subdirectories, the users should merge the dataset to
create a new dataset. In this step the iteration parameter in the config.yaml file is automatically updated.
+
```python
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).
+Next, with this expanded training set the user can now create a new 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).
-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 (which is generally recommended), otherwise consider using your already trained network
+weights (see {ref}`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.merge_datasets
-```{admonition} Click the button to see API Docs
-:class: dropdown
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
+```{eval-rst}
+.. include:: ./api/deeplabcut.refine_labels.rst
+```
+````
+
+##### API Docs for deeplabcut.merge_datasets
+
+````{admonition} Click the button to see API Docs
+---
+class: dropdown
+---
```{eval-rst}
.. include:: ./api/deeplabcut.merge_datasets.rst
```
+````
+
+______________________________________________________________________
+
+## Resources and further reading
-### Jupyter Notebooks for Demonstration of the DeepLabCut Workflow
+### Jupyter notebooks demo
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.
-## 3D Toolbox
+- 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).
-Please find all the information on using the 3D toolbox of DeepLabCut (as of 2.0.7+) here: https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/Overviewof3D.md
+### 3D tracking
+For stereo or multi-camera setups, see the {ref}`3D overview <3D-overview>`.
-## Other functions, some are yet-to-be-documented:
+### Additional helper functions
-We suggest you [check out these additional helper functions]((https://github.com/DeepLabCut/DeepLabCut/blob/master/docs/HelperFunctions.md), that could be useful (they are all optional).
+A collection of optional utility functions is available in the
+{ref}`helper functions reference `.
diff --git a/docs/tutorial.md b/docs/tutorial.md
deleted file mode 100644
index 947e036513..0000000000
--- a/docs/tutorial.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Multi-animal pose estimation with DeepLabCut: A 5-minute tutorial
-
-## GUI:
-
-Full graphical user interface: just follow the tabs in the GUI! `python -m deeplabcut` launches the GUI.
-
-## Terminal:
-
-**Import deeplabcut**
-```python
-import deeplabcut
-```
-
-**(1) Create a project**
-```python
-project_name = "cutemice"
-experimenter = "teamdlc"
-video_path = "path_to_a_video_file"
-config_path = deeplabcut.create_new_project(
- project_name,
- experimenter,
- [video_paths],
- multianimal=True,
- copy_videos=True,
-)
-```
-> **_NOTE:_** Make sure to specify the absolute path to the video file(s).
-> It is quickly obtained on Windows with â§ Shift +Right click and `Copy as path`,
-> and on Mac with â„ Option +Right click and `Copy as Pathname`.
-> Ubuntu users only need to copy the file and its path gets added to the clipboard.
-
-> Next, you can set a variable for the config_path: 'Full path of the project configuration file*'
-
-**(2) Edit the config.ymal file to set up your project**
-> **_NOTE:_** Here is were you will define your key point names and animal IDs. Also you can change the default # of frames to extract for the next step.
-
-**(3) Extract video frames to annotate**
-```python
-deeplabcut.extract_frames(
- config_path,
- mode="automatic",
- algo="kmeans",
- userfeedback=False,
-)
-```
-> **_NOTE:_** try to extract a few frames from many videos vs. a lot of frames from one video!
-
-**(4) Annotate Frames**
-```python
-deeplabcut.label_frames(config_path)
-```
-
-
-**(5) Visually check annotated frames**
-```python
-deeplabcut.check_labels(
- config_path,
- draw_skeleton=False,
-)
-```
-
-**(6) Create the training dataset**
-```python
-deeplabcut.create_multianimaltraining_dataset(
- config_path,
- num_shuffles=1,
- net_type="dlcrnet_ms5",
-)
-```
-
-**(7) Train the network**
-```python
-deeplabcut.train_network(
- config_path,
- saveiters=10000,
- maxiters=50000,
- allow_growth=True,
-)
-```
-
-**(8) Evaluate the network**
-```python
-deeplabcut.evaluate_network(
- config_path,
- plotting=True,
-)
-```
-
-**(9) Analyze a video (extracts detections and association costs)**
-```python
-deeplabcut.analyze_videos(
- config_path,
- [video],
- auto_track=True,
-)
-```
-> **_NOTE:_** `auto_track=True` will complete steps 10-11 for you automatically so you get the "final" H5 file. Use the below steps if you need to change the parameters of tracking based on your dataset.
-
-
-**(10) Spatial and (locally) temporal grouping: Track body part assemblies frame-by-frame**
-```python
-deeplabcut.convert_detections2tracklets(
- config_path,
- [video],
- track_method="ellipse",
-)
-```
-
-
-**(11) Reconstruct full animal trajectories (tracks from tracklets)**
-```python
-deeplabcut.stitch_tracklets(
- config_path,
- [video],
- track_method="ellipse",
- min_length=5,
-)
-```
-
-
-**(12) Create a pretty video output**
-```python
-deeplabcut.create_labeled_video(
- config_path,
- [video],
- color_by="individual",
- keypoints_only=False,
- trailpoints=10,
- draw_skeleton=False,
- track_method="ellipse",
-)
-```
diff --git a/examples/COLAB/COLAB_3miceDemo.ipynb b/examples/COLAB/COLAB_3miceDemo.ipynb
index cd67e98119..5a79c32f26 100644
--- a/examples/COLAB/COLAB_3miceDemo.ipynb
+++ b/examples/COLAB/COLAB_3miceDemo.ipynb
@@ -1,256 +1,302 @@
{
- "nbformat": 4,
- "nbformat_minor": 0,
- "metadata": {
- "colab": {
- "name": "Copy of 3micedemo.ipynb",
- "provenance": [],
- "collapsed_sections": [],
- "include_colab_link": true
- },
- "kernelspec": {
- "name": "python3",
- "display_name": "Python 3"
- },
- "language_info": {
- "name": "python"
- },
- "accelerator": "GPU"
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
},
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "view-in-github",
- "colab_type": "text"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "TGChzLdc-lUJ"
- },
- "source": [
- "# DeepLabCut 2.2 Toolbox Demo on 3 mice data\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",
- "- 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/docs/intro.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/master/examples/COLAB_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": "code",
- "metadata": {
- "id": "HoNN2_0Z9rr_"
- },
- "source": [
- "# Install the latest DeepLabCut version:\n",
- "!pip install https://github.com/DeepLabCut/DeepLabCut/archive/master.zip"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "Wid0GTGMAEnZ"
- },
- "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:"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "PusLdqbqJi60"
- },
- "source": [
- "# Download our demo project:\n",
- "import urllib.request\n",
- "from io import BytesIO\n",
- "from zipfile import ZipFile\n",
- "\n",
- "def unzip_from_url(url, dest_folder=''):\n",
- " # Directly extract files without writing the archive to disk\n",
- " resp = urllib.request.urlopen(url)\n",
- " with ZipFile(BytesIO(resp.read())) as zf:\n",
- " zf.extractall(path=dest_folder)\n",
- "\n",
- "\n",
- "project_url = \"http://deeplabcut.rowland.harvard.edu/datasets/demo-me-2021-07-14.zip\"\n",
- "unzip_from_url(project_url, \"/content\")"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "8iXtySnQB0BE"
- },
- "source": [
- "## Analyze a novel 3 mouse video with our maDLC DLCRNet, pretrained on 3 mice data (i.e., here you extract detections and association costs):"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "odYrU3o8BSAr"
- },
- "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=False )"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "zmdSLRTOER00"
- },
- "source": [
- "### Next, you compute the local, spatio-temporal grouping and track body part assemblies frame-by-frame:"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "QxYwpFB8EVGw"
- },
- "source": [
- "TRACK_METHOD = \"ellipse\" # Could also be \"box\", but \"ellipse\" was found to be more robust on this dataset.\n",
- "\n",
- "dlc.convert_detections2tracklets(\n",
- " config_path,\n",
- " [video],\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",
- ")"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "nlpGe9obEvFa"
- },
- "source": [
- "### Reconstruct full animal trajectories (tracks from tracklets):"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "mKWi7JQIEvbn"
- },
- "source": [
- "dlc.stitch_tracklets(\n",
- " config_path,\n",
- " [video],\n",
- " videotype='mp4',\n",
- " shuffle=0,\n",
- " track_method=TRACK_METHOD,\n",
- " n_tracks=3,\n",
- ")"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "F-d6kXqnGeUP"
- },
- "source": [
- "## Create a pretty video output:"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "aTRbuUQ1FBO0"
- },
- "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",
- "\n",
- "dlc.create_labeled_video(\n",
- " config_path,\n",
- " [video],\n",
- " videotype='mp4',\n",
- " shuffle=0,\n",
- " color_by=\"individual\",\n",
- " keypoints_only=False,\n",
- " draw_skeleton=True,\n",
- " filtered=True,\n",
- " track_method=TRACK_METHOD,\n",
- ")"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "AYNlrgeNUG4U"
- },
- "source": [
- "Now, on the left panel if you click the folder icon, you will see the project folder \"demo-me..\"; click on this and go into \"videos\" and you can find the \"..._id_labeled.mp4\" video, which you can double-click on to download and inspect!"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "n7GWMBJUA9x5"
- },
- "source": [
- "### 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"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "7w9BDIA7BB_i"
- },
- "source": [
- "dlc.plot_trajectories(config_path, [video], shuffle=0,videotype='mp4', track_method=TRACK_METHOD)"
- ],
- "execution_count": null,
- "outputs": []
- }
- ]
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "TGChzLdc-lUJ"
+ },
+ "source": [
+ "# DeepLabCut MultiMouse Data Demo\n",
+ "\n",
+ "\n",
+ "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "This Colab notebook was written to accompany the Nature Methods publication \n",
+ "> 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., Dulac, C., Mathis, M. W., & Mathis, A. (2022). \n",
+ "> **Multi-animal pose estimation, identification and tracking with DeepLabCut.** \n",
+ "> *Nature Methods, 19*(4), 496â504. \n",
+ "> https://doi.org/10.1038/s41592-022-01443-0\n",
+ "\n",
+ "\n",
+ " **Note:** The paper used DeepLabCut version 2.2.1 for Python 3.8 with the TensorFlow engine. Since this Python version is no longer supported on Google Colab, this notebook uses a more recent version of DeepLabCut (namely, 3.0.x), which still supports TensorFlow. 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",
+ "\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/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",
+ "Note that DeepLabCut version 2.2.1 requires Python < 3.10 and no longer runs on Google Colab. You can still run it locally in a Python 3.8 environment. In Google Colab, use a more recent TensorFlow-enabled version of DeepLabCut than can be installed with Python 3.12 (e.g. **DeepLabCut version 3.0**, which supports both PyTorch and TensorFlow)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Install DeepLabCut 3.0 (for Google Colab with Python 3.12)\n",
+ "!pip install --pre \"deeplabcut[tf]<3.1\""
+ ]
+ },
+ {
+ "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": "Wid0GTGMAEnZ"
+ },
+ "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:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "PusLdqbqJi60"
+ },
+ "outputs": [],
+ "source": [
+ "# Download our demo project:\n",
+ "from io import BytesIO\n",
+ "from pathlib import Path\n",
+ "from zipfile import ZipFile\n",
+ "\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",
+ " 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=\"/content\")\n",
+ " # Fix missing metadata.yaml\n",
+ " data_dir = Path(\"/content/demo-me-2021-07-14/training-datasets/iteration-0/UnaugmentedDataSet_demoJul14\")\n",
+ " data_dir.mkdir(parents=True, exist_ok=True)\n",
+ " (data_dir / \"metadata.yaml\").write_text(\n",
+ " \"\\nshuffles:\\n demoJul14-trainset95shuffle0:\\n train_fraction: 0.95\\n index: 0\\n\"\n",
+ " \" split: 1\\n engine: TensorFlow\\n\"\n",
+ " )\n",
+ "else:\n",
+ " raise ValueError(f\"The URL {url_record} could not be reached.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "8iXtySnQB0BE"
+ },
+ "source": [
+ "## Analyze a novel 3 mouse video with our maDLC DLCRNet, pretrained on 3 mice data (i.e., here you extract detections and association costs):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "odYrU3o8BSAr"
+ },
+ "outputs": [],
+ "source": [
+ "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(\n",
+ " config_path,\n",
+ " [video],\n",
+ " shuffle=0,\n",
+ " videotype=\"mp4\",\n",
+ " auto_track=False,\n",
+ " engine=dlc.Engine.TF,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "zmdSLRTOER00"
+ },
+ "source": [
+ "## Next, you compute the local, spatio-temporal grouping and track body part assemblies frame-by-frame:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "QxYwpFB8EVGw"
+ },
+ "outputs": [],
+ "source": [
+ "TRACK_METHOD = \"ellipse\" # Could also be \"box\", but \"ellipse\" was found to be more robust on this dataset.\n",
+ "\n",
+ "dlc.convert_detections2tracklets(\n",
+ " config_path,\n",
+ " [video],\n",
+ " videotype=\"mp4\",\n",
+ " shuffle=0,\n",
+ " track_method=TRACK_METHOD,\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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "nlpGe9obEvFa"
+ },
+ "source": [
+ "## Reconstruct full animal trajectories (tracks from tracklets):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "mKWi7JQIEvbn"
+ },
+ "outputs": [],
+ "source": [
+ "dlc.stitch_tracklets(\n",
+ " config_path,\n",
+ " [video],\n",
+ " videotype=\"mp4\",\n",
+ " shuffle=0,\n",
+ " track_method=TRACK_METHOD,\n",
+ " n_tracks=3,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "F-d6kXqnGeUP"
+ },
+ "source": [
+ "## Create a pretty video output:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "aTRbuUQ1FBO0"
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ " shuffle=0,\n",
+ " color_by=\"individual\",\n",
+ " keypoints_only=False,\n",
+ " draw_skeleton=True,\n",
+ " filtered=True,\n",
+ " track_method=TRACK_METHOD,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "AYNlrgeNUG4U"
+ },
+ "source": [
+ "Now, on the left panel if you click the folder icon, you will see the project folder \"demo-me..\"; click on this and go into \"videos\" and you can find the \"..._id_labeled.mp4\" video, which you can double-click on to download and inspect!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "n7GWMBJUA9x5"
+ },
+ "source": [
+ "## 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"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "7w9BDIA7BB_i"
+ },
+ "outputs": [],
+ "source": [
+ "dlc.plot_trajectories(config_path, [video], shuffle=0, videotype=\"mp4\", track_method=TRACK_METHOD)"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [],
+ "include_colab_link": true,
+ "name": "Copy of 3micedemo.ipynb",
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-05-07",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14",
+ "notes": "This notebook is a demo for the TensorFlow-based pipeline described in the Nature Methods publication (https://doi.org/10.1038/s41592-022-01443-0). However, the corresponding DeepLabCut version does not run anymore on Colab (which requires Python >= 3.11). The notebook has been adapted to use the latest TensorFlow-enabled version of DeepLabCut (namely, 3.0), which supports both PyTorch and TensorFlow."
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
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/7o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",
+ "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
new file mode 100644
index 0000000000..1acd927b05
--- /dev/null
+++ b/examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb
@@ -0,0 +1,254 @@
+{
+ "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}*{model_name}*{detector_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",
+ "if labeled_video_path is not None:\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": "2026-05-07",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14"
+ },
+ "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 704730271b..f05eec33c1 100644
--- a/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb
+++ b/examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb
@@ -1,290 +1,345 @@
{
- "nbformat": 4,
- "nbformat_minor": 0,
- "metadata": {
- "accelerator": "GPU",
- "colab": {
- "name": "Colab_DEMO_mouse_openfield.ipynb",
- "provenance": [],
- "collapsed_sections": []
- },
- "kernelspec": {
- "display_name": "Python [default]",
- "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.6.9"
- }
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
},
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "view-in-github",
- "colab_type": "text"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "TGChzLdc-lUJ"
- },
- "source": [
- "# DeepLabCut Toolbox - Colab Demo on Topview Mouse Data\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
- "\n",
- "\n",
- "\n",
- "Demo supporting: Nath\\*, Mathis\\* et al. *Using DeepLabCut for markerless3D pose estimation during behavior across species. Nature Protocols, 2019 \n",
- "\n",
- "This notebook demonstrates the necessary steps to use DeepLabCut on our demo data. We provide a sub-set of the mouse data from Mathis et al, 2018 Nature Neuroscience.\n",
- "\n",
- "This demo notebook mostly shows the most simple code to train and evaluate your model, but many of the functions have additional features, so please check out the overview & the protocol paper!\n",
- "\n",
- "This notebook illustrates how to use the cloud to:\n",
- "\n",
- "- load demo data\n",
- "- create a training set\n",
- "- train a network\n",
- "- evaluate a network\n",
- "- analyze a novel video"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "txoddlM8hLKm"
- },
- "source": [
- "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\""
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Ew6r4hotoQjt"
- },
- "source": [
- "# Clone the entire deeplabcut repo so we can use the demo data:\n",
- "!git clone -l -s https://github.com/DeepLabCut/DeepLabCut.git cloned-DLC-repo\n",
- "%cd cloned-DLC-repo\n",
- "!ls"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "yDaY78dFoxyD"
- },
- "source": [
- "%cd /content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30\n",
- "!ls"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "q23BzhA6CXxu"
- },
- "source": [
- "#(this will take a few minutes to install all the dependences!)\n",
- "\n",
- "!pip install deeplabcut"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "XymV_Hnlp1OJ"
- },
- "source": [
- "## PLEASE, click \"restart runtime\" from the output above before proceeding! "
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "sXufoX6INe6w"
- },
- "source": [
- "import deeplabcut"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Z7ZlDr3wV4D1"
- },
- "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",
- "\n",
- "# Loading example data set:\n",
- "deeplabcut.load_demo_data(path_config_file)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "c4FczXGDoEJU"
- },
- "source": [
- "## Start training:\n",
- "This function trains the network for a specific shuffle of the training dataset. "
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "_pOvDq_2oEJW"
- },
- "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",
- "\n",
- "deeplabcut.train_network(path_config_file, shuffle=1, displayiters=100,saveiters=500, maxiters=10000)\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...."
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "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! :)**"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "xZygsb2DoEJc"
- },
- "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**"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "nv4zlbrnoEJg"
- },
- "source": [
- "%matplotlib notebook\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!"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "oxy5JG-kYKF4"
- },
- "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)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "OVFLSKKfoEJk"
- },
- "source": [
- "## Start Analyzing videos: \n",
- "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
- "\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)**"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Y_LZiS_0oEJl"
- },
- "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')"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "pCrUvQIvoEKD"
- },
- "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!"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "6aDF7Q7KoEKE"
- },
- "source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "8GTiuJESoEKH"
- },
- "source": [
- "## Plot the trajectories of the analyzed videos:\n",
- "This function plots the trajectories of all the body parts across the entire video. Each body part is identified by a unique color."
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "gX21zZbXoEKJ"
- },
- "source": [
- "deeplabcut.plot_trajectories(path_config_file,videofile_path)"
- ],
- "execution_count": null,
- "outputs": []
- }
- ]
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "TGChzLdc-lUJ"
+ },
+ "source": [
+ "# 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",
+ "Demo supporting: \n",
+ "> Nath, T., Mathis, A., Chen, A. C., Patel, A., Bethge, M., & Mathis, M. W. (2019). \n",
+ "> **Using DeepLabCut for 3D markerless pose estimation across species and behaviors.** \n",
+ "> *Nature Protocols, 14*(7), 2152â2176. \n",
+ "> https://doi.org/10.1038/s41596-019-0176-0\n",
+ "\n",
+ "\n",
+ "This notebook demonstrates the necessary steps to use DeepLabCut on our demo data. We provide a sub-set of the mouse data from Mathis et al, 2018 Nature Neuroscience.\n",
+ "\n",
+ "This demo notebook mostly shows the most simple code to train and evaluate your model, but many of the functions have additional features, so please check out the overview & the protocol paper!\n",
+ "\n",
+ "This notebook illustrates how to use the cloud to:\n",
+ "\n",
+ "- load demo data\n",
+ "- create a training set\n",
+ "- train a network\n",
+ "- evaluate a network\n",
+ "- analyze a novel video"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "txoddlM8hLKm"
+ },
+ "source": [
+ "## Installation\n",
+ "\n",
+ "### First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Ew6r4hotoQjt"
+ },
+ "outputs": [],
+ "source": [
+ "# Clone the entire deeplabcut repo so we can use the demo data:\n",
+ "!git clone -l -s https://github.com/DeepLabCut/DeepLabCut.git cloned-DLC-repo\n",
+ "%cd cloned-DLC-repo\n",
+ "!ls"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "yDaY78dFoxyD"
+ },
+ "outputs": [],
+ "source": [
+ "%cd /content/cloned-DLC-repo/examples/openfield-Pranav-2018-10-30\n",
+ "!ls"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "q23BzhA6CXxu"
+ },
+ "outputs": [],
+ "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 \".\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "XymV_Hnlp1OJ"
+ },
+ "source": [
+ "### NOTE click \"restart runtime\" from the output above before proceeding!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "sXufoX6INe6w"
+ },
+ "outputs": [],
+ "source": [
+ "import deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Z7ZlDr3wV4D1"
+ },
+ "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",
+ "\n",
+ "# Loading example data set:\n",
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "c4FczXGDoEJU"
+ },
+ "source": [
+ "## Start training:\n",
+ "This function trains the network for a specific shuffle of the training dataset. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "_pOvDq_2oEJW"
+ },
+ "outputs": [],
+ "source": [
+ "# 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(\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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "RiDwIVf5-3H_"
+ },
+ "source": [
+ "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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "xZygsb2DoEJc"
+ },
+ "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**"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "nv4zlbrnoEJg"
+ },
+ "outputs": [],
+ "source": [
+ "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\n",
+ "# good as the labeler, so be sure your labels are good!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "oxy5JG-kYKF4"
+ },
+ "source": [
+ "**Check the images**:\n",
+ "\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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "OVFLSKKfoEJk"
+ },
+ "source": [
+ "## Start Analyzing videos: \n",
+ "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
+ "\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 ~ 90 seconds! (The demo frames are 640x480, which should run around 25 FPS on the google-provided T4 GPU)**"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Y_LZiS_0oEJl"
+ },
+ "outputs": [],
+ "source": [
+ "# 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\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "pCrUvQIvoEKD"
+ },
+ "source": [
+ "## Create labeled video:\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. This should run around 215 FPS on the demo video!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "6aDF7Q7KoEKE"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "8GTiuJESoEKH"
+ },
+ "source": [
+ "## Plot the trajectories of the analyzed videos:\n",
+ "This function plots the trajectories of all the body parts across the entire video. Each body part is identified by a unique color."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "gX21zZbXoEKJ"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path)"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [],
+ "name": "Colab_DEMO_mouse_openfield.ipynb",
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-05-07",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14"
+ },
+ "kernelspec": {
+ "display_name": "Python [default]",
+ "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.6.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
diff --git a/examples/COLAB/COLAB_DLC_ModelZoo.ipynb b/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
index 76ac45e935..ead5dab059 100644
--- a/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
+++ b/examples/COLAB/COLAB_DLC_ModelZoo.ipynb
@@ -1,312 +1,320 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "colab_type": "text",
- "id": "view-in-github"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RK255E7YoEIt"
- },
- "source": [
- "# **DeepLabCut Model Zoo!**\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 dependences!)\n",
- "!pip install deeplabcut\n",
- "%reload_ext numpy\n",
- "%reload_ext matplotlib\n",
- "%reload_ext mpl_toolkits"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "ZT4PwGSbYQEO"
- },
- "source": [
- "## Now let's set the backend & import the DeepLabCut package:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "bvoiWefrYQEP"
- },
- "outputs": [],
- "source": [
- "import os\n",
- "\n",
- "# stifle tensorflow warnings, like we get it already.\n",
- "os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'\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,\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": {
- "accelerator": "GPU",
- "colab": {
- "collapsed_sections": [],
- "include_colab_link": true,
- "name": "Copy of COLAB_DLC_ModelZoo.ipynb",
- "provenance": [],
- "toc_visible": true
- },
- "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": "2026-03-30",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14"
+ },
+ "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..78df4f6bc8
--- /dev/null
+++ b/examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb
@@ -0,0 +1,2225 @@
+{
+ "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 = \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"] # fmt: skip # noqa: E501\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 = \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"] # fmt: skip # noqa: E501\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 = \"fasterrcnn_resnet50_fpn_v2\" # @param [\"fasterrcnn_resnet50_fpn_v2\", \"fasterrcnn_mobilenet_v3_large_fpn\"] # fmt: skip # noqa: E501"
+ ]
+ },
+ {
+ "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": "2026-05-11",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14"
+ },
+ "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 507d318c44..bf905df7b4 100644
--- a/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb
+++ b/examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb
@@ -1,428 +1,435 @@
{
- "nbformat": 4,
- "nbformat_minor": 0,
- "metadata": {
- "colab": {
- "name": "Copy of latest_Colab_TrainNetwork_VideoAnalysis.ipynb",
- "provenance": [],
- "collapsed_sections": [],
- "toc_visible": true,
- "include_colab_link": true
- },
- "kernelspec": {
- "name": "python3",
- "display_name": "Python 3"
- },
- "accelerator": "GPU"
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
},
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "view-in-github",
- "colab_type": "text"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RK255E7YoEIt",
- "colab_type": "text"
- },
- "source": [
- "# DeepLabCut Toolbox - Colab for standard (single animal) projects!\n",
- "https://github.com/DeepLabCut/DeepLabCut\n",
- "\n",
- "This notebook illustrates how to use the cloud to:\n",
- "- create a training set\n",
- "- train a network\n",
- "- evaluate a network\n",
- "- create simple quality check plots\n",
- "- analyze novel videos!\n",
- "\n",
- "###This notebook assumes you already have a project folder with labeled data! \n",
- "\n",
- "This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
- "\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",
- "\n",
- "Nath\\*, Mathis\\* et al.: Using DeepLabCut for markerless pose estimation during behavior across species. Nature Protocols, 2019.\n",
- "\n",
- "\n",
- "Paper: https://www.nature.com/articles/s41596-019-0176-0\n",
- "\n",
- "Pre-print: https://www.biorxiv.org/content/biorxiv/early/2018/11/24/476531.full.pdf\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "txoddlM8hLKm",
- "colab_type": "text"
- },
- "source": [
- "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "q23BzhA6CXxu",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "#(this will take a few minutes to install all the dependences!)\n",
- "!pip install deeplabcut"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "25wSj6TlVclR",
- "colab_type": "text"
- },
- "source": [
- "**(Be sure to click \"RESTART RUNTIME\" is it is displayed above above before moving on !)**"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Y36K4Eux3h-X",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "# Use TensorFlow 1.x:\n",
- "%tensorflow_version 1.x"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "cQ-nlTkri4HZ",
- "colab_type": "text"
- },
- "source": [
- "## Link your Google Drive (with your labeled data, or the demo data):\n",
- "\n",
- "### First, place your project folder into you google drive! \"i.e. move the folder named \"Project-YourName-TheDate\" into google drive."
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "KS4Q4UkR9rgG",
- "colab_type": "code",
- "colab": {}
- },
- "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",
- "\n",
- "from google.colab import drive\n",
- "drive.mount('/content/drive')"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "Frnj1RVDyEqs",
- "colab_type": "text"
- },
- "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"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "vhENAlQnFENJ",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "#Setup your project variables:\n",
- "# PLEASE EDIT THESE:\n",
- " \n",
- "ProjectFolderName = 'myproject-teamDLC-2020-03-29'\n",
- "VideoType = 'mp4' \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"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "sXufoX6INe6w",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "#GUIs don't work on the cloud, so label your data locally on your computer! This will suppress the GUI support\n",
- "import os\n",
- "os.environ[\"DLClight\"]=\"True\""
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "3K9Ndy1beyfG",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "import deeplabcut"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "o4orkg9QTHKK",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "deeplabcut.__version__"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Z7ZlDr3wV4D1",
- "colab_type": "code",
- "colab": {}
- },
- "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"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "xNi9s1dboEJN",
- "colab_type": "text"
- },
- "source": [
- "## Create a training dataset:\n",
- "### You must do this step inside of Colab:\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",
- "\n",
- "Now it is the time to start training the network!"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "scrolled": true,
- "id": "eMeUwgxPoEJP",
- "colab_type": "code",
- "colab": {}
- },
- "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')"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "c4FczXGDoEJU",
- "colab_type": "text"
- },
- "source": [
- "## Start training:\n",
- "This function trains the network for a specific shuffle of the training dataset. "
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "_pOvDq_2oEJW",
- "colab_type": "code",
- "colab": {}
- },
- "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",
- "\n",
- "deeplabcut.train_network(path_config_file, shuffle=1, displayiters=10,saveiters=500)\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...."
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RiDwIVf5-3H_",
- "colab_type": "text"
- },
- "source": [
- "**When you hit \"STOP\" you will get a KeyInterrupt \"error\"! No worries! :)**"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "xZygsb2DoEJc",
- "colab_type": "text"
- },
- "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**"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "nv4zlbrnoEJg",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "%matplotlib notebook\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 ;)"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "BaLBl3TQtrfB",
- "colab_type": "text"
- },
- "source": [
- "## There is an optional refinement step you can do outside of Colab:\n",
- "- if your pixel errors are not low enough, please check out the protocol guide on how to refine your network!\n",
- "- You will need to adjust the labels **outside of Colab!** We recommend coming back to train and analyze videos... \n",
- "- Please see the repo and protocol instructions on how to refine your data!"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "OVFLSKKfoEJk",
- "colab_type": "text"
- },
- "source": [
- "## Start Analyzing videos: \n",
- "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
- "\n",
- "The results are stored in hd5 file in the same directory where the video resides. "
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "Y_LZiS_0oEJl",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "deeplabcut.analyze_videos(path_config_file,videofile_path, videotype=VideoType)"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "8GTiuJESoEKH",
- "colab_type": "text"
- },
- "source": [
- "## Plot the trajectories of the analyzed videos:\n",
- "This function plots the trajectories of all the body parts across the entire video. Each body part is identified by a unique color."
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "gX21zZbXoEKJ",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "deeplabcut.plot_trajectories(path_config_file,videofile_path, videotype=VideoType)"
- ],
- "execution_count": 0,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "pqaCw15v8EmB",
- "colab_type": "text"
- },
- "source": [
- "Now you can look at the plot-poses file and check the \"plot-likelihood.png\" might want to change the \"p-cutoff\" in the config.yaml file so that you have only high confidnece points plotted in the video. i.e. ~0.8 or 0.9. The current default is 0.4. "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "pCrUvQIvoEKD",
- "colab_type": "text"
- },
- "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. "
- ]
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "RK255E7YoEIt"
+ },
+ "source": [
+ "# 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",
+ "- train a network\n",
+ "- evaluate a network\n",
+ "- create simple quality check plots\n",
+ "- analyze novel videos!\n",
+ "\n",
+ "### This notebook assumes you already have a project folder with labeled data! \n",
+ "\n",
+ "This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
+ "\n",
+ "This shows the most simple code to do so, but many of the functions have additional features, so please check out the [documentation](https://deeplabcut.github.io/DeepLabCut/docs/standardDeepLabCut_UserGuide.html) & the protocol paper!\n",
+ "\n",
+ "> Nath, T., Mathis, A., Chen, A. C., Patel, A., Bethge, M., & Mathis, M. W. (2019). \n",
+ "> **Using DeepLabCut for 3D markerless pose estimation across species and behaviors.** \n",
+ "> *Nature Protocols, 14*(7), 2152â2176. \n",
+ "> https://doi.org/10.1038/s41596-019-0176-0\n",
+ "\n",
+ "Pre-print: https://www.biorxiv.org/content/biorxiv/early/2018/11/24/476531.full.pdf\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "txoddlM8hLKm"
+ },
+ "source": [
+ "## 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": {},
+ "outputs": [],
+ "source": [
+ "# this will take a couple of minutes to install all the dependencies!\n",
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "25wSj6TlVclR"
+ },
+ "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": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
},
+ "id": "oTwAcbq2-FZz",
+ "outputId": "9cfd8dcf-a0a8-4801-ed1d-fbcd5ec056af"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "metadata": {
- "id": "6aDF7Q7KoEKE",
- "colab_type": "code",
- "colab": {}
- },
- "source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path, videotype=VideoType)"
- ],
- "execution_count": 0,
- "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"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "cQ-nlTkri4HZ"
+ },
+ "source": [
+ "## Link your Google Drive (with your labeled data, or the demo data):\n",
+ "\n",
+ "### First, place your 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": {},
+ "colab_type": "code",
+ "id": "KS4Q4UkR9rgG"
+ },
+ "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",
+ "\n",
+ "from google.colab import drive\n",
+ "\n",
+ "drive.mount(\"/content/drive\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "Frnj1RVDyEqs"
+ },
+ "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"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "vhENAlQnFENJ"
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "# 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"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "xNi9s1dboEJN"
+ },
+ "source": [
+ "## Create a training dataset:\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-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!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "eMeUwgxPoEJP",
+ "scrolled": true
+ },
+ "outputs": [],
+ "source": [
+ "# 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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "c4FczXGDoEJU"
+ },
+ "source": [
+ "## Start training:\n",
+ "This function trains the network for a specific shuffle of the training dataset. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "_pOvDq_2oEJW"
+ },
+ "outputs": [],
+ "source": [
+ "# 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(\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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "RiDwIVf5-3H_"
+ },
+ "source": [
+ "Note, that **when you hit \"STOP\" you will get a `KeyboardInterrupt` \"error\"! No worries! :)**"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "xZygsb2DoEJc"
+ },
+ "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-pytorch**"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "nv4zlbrnoEJg"
+ },
+ "outputs": [],
+ "source": [
+ "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\n",
+ "# good as the labeler, so be sure your labels are good!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "BaLBl3TQtrfB"
+ },
+ "source": [
+ "## There is an optional refinement step you can do outside of Colab:\n",
+ "- if your pixel errors are not low enough, please check out the protocol guide on how to refine your network!\n",
+ "- You will need to adjust the labels **outside of Colab!** We recommend coming back to train and analyze videos... \n",
+ "- Please see the repo and protocol instructions on how to refine your data!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "OVFLSKKfoEJk"
+ },
+ "source": [
+ "## Start Analyzing videos: \n",
+ "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
+ "\n",
+ "The results are stored in hd5 file in the same directory where the video resides. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "Y_LZiS_0oEJl"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.analyze_videos(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "8GTiuJESoEKH"
+ },
+ "source": [
+ "## Plot the trajectories of the analyzed videos:\n",
+ "This function plots the trajectories of all the body parts across the entire video. Each body part is identified by a unique color."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "gX21zZbXoEKJ"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.plot_trajectories(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "pqaCw15v8EmB"
+ },
+ "source": [
+ "Now you can look at the plot-poses file and check the \"plot-likelihood.png\" might want to change the \"p-cutoff\" in the config.yaml file so that you have only high confidnece points plotted in the video. i.e. ~0.8 or 0.9. The current default is 0.4. "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "pCrUvQIvoEKD"
+ },
+ "source": [
+ "## Create labeled video:\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. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "6aDF7Q7KoEKE"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_labeled_video(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " videotype=video_type,\n",
+ " destfolder=destfolder,\n",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [],
+ "include_colab_link": true,
+ "name": "Copy of latest_Colab_TrainNetwork_VideoAnalysis.ipynb",
+ "provenance": [],
+ "toc_visible": true
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-05-11",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14",
+ "notes": "This notebook demos the primary generic/PyTorch API for single-animal DeepLabCut projects. Note that it is a bit outdated and may need revisions after dropping TensorFlow support. Also it does not reflect any of the planned refactors currently in the works (e.g. structured configs, keypoints)."
+ },
+ "kernelspec": {
+ "display_name": "benchmarking",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.10.19"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
diff --git a/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb b/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb
new file mode 100644
index 0000000000..702612b8e4
--- /dev/null
+++ b/examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb
@@ -0,0 +1,547 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "view-in-github"
+ },
+ "source": [
+ " "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "RK255E7YoEIt"
+ },
+ "source": [
+ "# 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",
+ "\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",
+ "- train a network\n",
+ "- evaluate a network\n",
+ "- analyze novel videos\n",
+ "- assemble animals and tracklets\n",
+ "- create quality check plots!\n",
+ "\n",
+ "### This notebook assumes you already have a DLC project folder with labeled data and you uploaded it to your own Google Drive.\n",
+ "\n",
+ "This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
+ "\n",
+ "This shows the most simple code to do so, but many of the functions have additional features, so please check out the docs on GitHub. We also recommend checking out our preprint, which covers the science of maDLC\n",
+ "\n",
+ "**Lauer et al 2021:** https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1\n",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "txoddlM8hLKm"
+ },
+ "source": [
+ "## First, go to \"Runtime\" ->\"change runtime type\"->select \"Python3\", and then select \"GPU\"\n",
+ "\n",
+ "Note: Colab uses Python 3.12, which is not supported by older versions of DeepLabCut. We need to install a recent DeepLabCut version."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# this will take a couple of minutes to install all the dependencies!\n",
+ "!pip install --pre deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "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": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "oTwAcbq2-FZz",
+ "outputId": "9cfd8dcf-a0a8-4801-ed1d-fbcd5ec056af"
+ },
+ "outputs": [],
+ "source": [
+ "import deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "cQ-nlTkri4HZ"
+ },
+ "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: [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",
+ "- Then, click run on the cell below to link this notebook to your Google Drive:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "KS4Q4UkR9rgG"
+ },
+ "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",
+ "\n",
+ "from google.colab import drive\n",
+ "\n",
+ "drive.mount(\"/content/drive\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Frnj1RVDyEqs"
+ },
+ "source": [
+ "## 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"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "vhENAlQnFENJ"
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
+ "# 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"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "xNi9s1dboEJN"
+ },
+ "source": [
+ "## Create a multi-animal 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):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "-AAYSoW313me"
+ },
+ "outputs": [],
+ "source": [
+ "# OPTIONAL LEARNING: did you know you can check what each function does by running with a ?\n",
+ "deeplabcut.create_multianimaltraining_dataset?"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "eMeUwgxPoEJP",
+ "scrolled": true
+ },
+ "outputs": [],
+ "source": [
+ "# ATTENTION:\n",
+ "# Which shuffle do you want to create and train?\n",
+ "shuffle = 1 # Edit if needed; 1 is the default.\n",
+ "\n",
+ "deeplabcut.create_multianimaltraining_dataset(\n",
+ " path_config_file,\n",
+ " Shuffles=[shuffle],\n",
+ " net_type=\"dlcrnet_ms5\",\n",
+ " engine=deeplabcut.Engine.PYTORCH,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "c4FczXGDoEJU"
+ },
+ "source": [
+ "## Start training:\n",
+ "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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "_pOvDq_2oEJW"
+ },
+ "outputs": [],
+ "source": [
+ "# 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(\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."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "RiDwIVf5-3H_"
+ },
+ "source": [
+ "Note, that **when you hit \"STOP\" you will get a `KeyboardInterrupt` \"error\"! No worries! :)**"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "xZygsb2DoEJc"
+ },
+ "source": [
+ "## Start evaluating: \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",
+ "- 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",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "nv4zlbrnoEJg"
+ },
+ "outputs": [],
+ "source": [
+ "# 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])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "fYlGbloolDU2"
+ },
+ "source": [
+ "IF these images, numbers, and maps do not look good, do not proceed. You should increase the diversity and number of frames you label, and re-create a training dataset and re-train! "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "OVFLSKKfoEJk"
+ },
+ "source": [
+ "## Start Analyzing videos: \n",
+ "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
+ "\n",
+ "The results are stored in a pickle file in the same directory where the video resides. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Y_LZiS_0oEJl"
+ },
+ "outputs": [],
+ "source": [
+ "print(\"Start Analyzing my video(s)!\")\n",
+ "#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(\n",
+ " path_config_file,\n",
+ " videofile_path,\n",
+ " shuffle=shuffle,\n",
+ " videotype=video_type,\n",
+ " auto_track=False,\n",
+ " destfolder=destfolder,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "91xBLOcBzGxo"
+ },
+ "source": [
+ "Optional: Now you have the option to check the raw detections before animals are tracked. To do so, pass a video path:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "65mWwX5bTc5C"
+ },
+ "outputs": [],
+ "source": [
+ "##### PROTIP: #####\n",
+ "## 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",
+ "\n",
+ "# Don't edit:\n",
+ "deeplabcut.create_video_with_all_detections(\n",
+ " path_config_file, [specific_videofile], shuffle=shuffle, destfolder=destfolder,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "3-OgTJ0Lz20e"
+ },
+ "source": [
+ "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)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "PxRLS2_-r55K"
+ },
+ "source": [
+ "## Next, we will assemble animals using our data-driven optimal graph method:\n",
+ "\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:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "zIvXM7TXIs-U"
+ },
+ "outputs": [],
+ "source": [
+ "#Check and edit:\n",
+ "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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "TqbAnyfL0Q7h"
+ },
+ "source": [
+ "Now let's filter the data to remove any small jitter:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "a6izVWX8sdzL"
+ },
+ "outputs": [],
+ "source": [
+ "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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Zk4xGb8Ftf3B"
+ },
+ "source": [
+ "## Create plots of your trajectories:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "gX21zZbXoEKJ"
+ },
+ "outputs": [],
+ "source": [
+ "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",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "pqaCw15v8EmB"
+ },
+ "source": [
+ "Now you can look at the plot-poses file and check the \"plot-likelihood.png\" might want to change the \"p-cutoff\" in the config.yaml file so that you have only high confidnece points plotted in the video. i.e. ~0.8 or 0.9. The current default is 0.4. "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "pCrUvQIvoEKD"
+ },
+ "source": [
+ "## Create labeled video:\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. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "6aDF7Q7KoEKE"
+ },
+ "outputs": [],
+ "source": [
+ "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",
+ ")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "collapsed_sections": [],
+ "include_colab_link": true,
+ "name": "COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb",
+ "provenance": []
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2026-02-10",
+ "last_metadata_updated": "2026-05-11",
+ "last_verified": "2026-05-11",
+ "verified_for": "3.0.0rc14",
+ "notes": "This notebook demos the primary generic/PyTorch API for multi-animal DeepLabCut (maDLC) projects. Note that it may need revisions after dropping TensorFlow support. And does not reflect any of the planned refactors currently in the works (e.g. structured configs, keypoints)."
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb b/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb
deleted file mode 100644
index 53125ab13e..0000000000
--- a/examples/COLAB/COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb
+++ /dev/null
@@ -1,533 +0,0 @@
-{
- "nbformat": 4,
- "nbformat_minor": 0,
- "metadata": {
- "colab": {
- "name": "COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb",
- "provenance": [],
- "collapsed_sections": [],
- "machine_shape": "hm",
- "include_colab_link": true
- },
- "kernelspec": {
- "name": "python3",
- "display_name": "Python 3"
- },
- "accelerator": "GPU"
- },
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {
- "colab_type": "text",
- "id": "view-in-github"
- },
- "source": [
- " "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RK255E7YoEIt"
- },
- "source": [
- "# DeepLabCut 2.2+ Toolbox - COLAB\n",
- "\n",
- "\n",
- "https://github.com/DeepLabCut/DeepLabCut\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",
- "- train a network\n",
- "- evaluate a network\n",
- "- analyze novel videos\n",
- "- assemble animals and tracklets\n",
- "- create quality check plots!\n",
- "\n",
- "###This notebook assumes you already have a DLC project folder with labeled data and you uploaded it to your own Google Drive.\n",
- "\n",
- "This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
- "\n",
- "This shows the most simple code to do so, but many of the functions have additional features, so please check out the docs on GitHub. We also recommend checking out our preprint, which covers the science of maDLC\n",
- "\n",
- "**Lauer et al 2021:** https://www.biorxiv.org/content/10.1101/2021.04.30.442096v1\n",
- "\n",
- "\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "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",
- "!pip install deeplabcut\n",
- "%reload_ext numpy\n",
- "%reload_ext scipy\n",
- "%reload_ext matplotlib\n",
- "%reload_ext mpl_toolkits"
- ]
- },
- {
- "cell_type": "code",
- "metadata": {
- "id": "-MVvZ13_FMvP"
- },
- "source": [
- "#a few colab specific things needed:\n",
- "!pip install --upgrade scikit-image\n",
- "!pip3 install pickle5"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "bqUEb8TBdpWb"
- },
- "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 ^."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "oTwAcbq2-FZz",
- "colab": {
- "base_uri": "https://localhost:8080/"
- },
- "outputId": "9cfd8dcf-a0a8-4801-ed1d-fbcd5ec056af"
- },
- "outputs": [],
- "source": [
- "import deeplabcut\n",
- "import pickle5 as pickle"
- ],
- "execution_count": 2,
- "outputs": [
- {
- "output_type": "stream",
- "name": "stdout",
- "text": [
- "DLC loaded in light mode; you cannot use any GUI (labeling, relabeling and standalone GUI)\n"
- ]
- }
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "cQ-nlTkri4HZ"
- },
- "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",
- "- 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:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "KS4Q4UkR9rgG"
- },
- "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",
- "\n",
- "from google.colab import drive\n",
- "drive.mount('/content/drive')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "Frnj1RVDyEqs"
- },
- "source": [
- "## Next, edit the few items below, and click run:\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "vhENAlQnFENJ"
- },
- "outputs": [],
- "source": [
- "# PLEASE EDIT THIS:\n",
- "ProjectFolderName = 'MontBlanc-Daniel-2019-12-16'\n",
- "VideoType = 'mp4' #, mp4, MOV, or avi, whatever you uploaded!\n",
- "\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",
- "\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"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "xNi9s1dboEJN"
- },
- "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",
- "- please check the text below, edit if needed, and then click run (this can take some time):"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "-AAYSoW313me"
- },
- "outputs": [],
- "source": [
- "#OPTIONAL LEARNING: did you know you can check what each function does by running with a ?\n",
- "deeplabcut.create_multianimaltraining_dataset?"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "eMeUwgxPoEJP",
- "scrolled": true
- },
- "outputs": [],
- "source": [
- "# ATTENTION:\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)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "c4FczXGDoEJU"
- },
- "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"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "_pOvDq_2oEJW"
- },
- "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",
- "\n",
- "deeplabcut.train_network(path_config_file, shuffle=shuffle, displayiters=100,saveiters=1000, maxiters=75000, allow_growth=True)\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...."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "RiDwIVf5-3H_"
- },
- "source": [
- "**When you hit \"STOP\" you will get a KeyInterrupt \"error\"! No worries! :)**"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "xZygsb2DoEJc"
- },
- "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",
- "- 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",
- "\n",
- "Here is an example of what you'd aim to see before proceeding:\n",
- "\n",
- "\n",
- "\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "nv4zlbrnoEJg"
- },
- "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])"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "fYlGbloolDU2"
- },
- "source": [
- "IF these images, numbers, and maps do not look good, do not proceed. You should increase the diversity and number of frames you label, and re-create a training dataset and re-train! "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "OVFLSKKfoEJk"
- },
- "source": [
- "## Start Analyzing videos: \n",
- "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
- "\n",
- "The results are stored in a pickle file in the same directory where the video resides. "
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "Y_LZiS_0oEJl"
- },
- "outputs": [],
- "source": [
- "print(\"Start Analyzing my video(s)!\")\n",
- "#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)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "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:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "65mWwX5bTc5C"
- },
- "outputs": [],
- "source": [
- "##### PROTIP: #####\n",
- "## 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",
- "\n",
- "#don't edit:\n",
- "deeplabcut.create_video_with_all_detections(path_config_file, [Specific_videofile], shuffle=shuffle)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "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"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "PxRLS2_-r55K"
- },
- "source": [
- "# 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",
- "\n",
- "\n",
- "\n",
- "\n",
- "- note, you can set the number of animals you expect to see, so check, edit, then click run:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "zIvXM7TXIs-U"
- },
- "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)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "TqbAnyfL0Q7h"
- },
- "source": [
- "Now let's filter the data to remove any small jitter:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "a6izVWX8sdzL"
- },
- "outputs": [],
- "source": [
- "deeplabcut.filterpredictions(path_config_file, \n",
- " videofile_path, \n",
- " shuffle=shuffle,\n",
- " videotype=VideoType, \n",
- " track_method = tracktype)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "Zk4xGb8Ftf3B"
- },
- "source": [
- "## Create plots of your trajectories:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "gX21zZbXoEKJ"
- },
- "outputs": [],
- "source": [
- "deeplabcut.plot_trajectories(path_config_file, videofile_path, videotype=VideoType, shuffle=shuffle, track_method=tracktype)"
- ],
- "execution_count": null,
- "outputs": []
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "pqaCw15v8EmB"
- },
- "source": [
- "Now you can look at the plot-poses file and check the \"plot-likelihood.png\" might want to change the \"p-cutoff\" in the config.yaml file so that you have only high confidnece points plotted in the video. i.e. ~0.8 or 0.9. The current default is 0.4. "
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "pCrUvQIvoEKD"
- },
- "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. "
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {
- "id": "6aDF7Q7KoEKE"
- },
- "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)"
- ]
- }
- ],
- "metadata": {
- "accelerator": "GPU",
- "colab": {
- "collapsed_sections": [],
- "include_colab_link": true,
- "name": "COLAB_maDLC_TrainNetwork_VideoAnalysis.ipynb",
- "provenance": []
- },
- "kernelspec": {
- "display_name": "Python 3",
- "name": "python3"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 0
-}
diff --git a/examples/COLAB/COLAB_transformer_reID.ipynb b/examples/COLAB/COLAB_transformer_reID.ipynb
index bc0a04a72c..12eb4f89ea 100644
--- a/examples/COLAB/COLAB_transformer_reID.ipynb
+++ b/examples/COLAB/COLAB_transformer_reID.ipynb
@@ -1,31 +1,13 @@
{
- "nbformat": 4,
- "nbformat_minor": 0,
- "metadata": {
- "colab": {
- "name": "COLAB_transformer_reID.ipynb",
- "provenance": [],
- "collapsed_sections": [],
- "include_colab_link": true
- },
- "kernelspec": {
- "name": "python3",
- "display_name": "Python 3"
- },
- "language_info": {
- "name": "python"
- },
- "accelerator": "GPU"
- },
"cells": [
{
"cell_type": "markdown",
"metadata": {
- "id": "view-in-github",
- "colab_type": "text"
+ "colab_type": "text",
+ "id": "view-in-github"
},
"source": [
- " "
+ " "
]
},
{
@@ -34,37 +16,58 @@
"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",
"- create quality check plots and video.\n",
"\n",
- "### To create a full maDLC pipeline please see our full docs: https://deeplabcut.github.io/DeepLabCut/docs/intro.html \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/master/examples/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": "code",
+ "cell_type": "markdown",
"metadata": {
- "id": "HoNN2_0Z9rr_"
+ "id": "xOe2hvy85EVP"
},
"source": [
- "# Install the latest DeepLabCut version:\n",
- "!pip install https://github.com/DeepLabCut/DeepLabCut/archive/master.zip\n",
- "#!pip install git+https://github.com/DeepLabCut/DeepLabCut.git@mwm/doploymentTransformer"
- ],
+ "âŒïž **Attention: this demo is for maDLC, which is version 2.2**\n"
+ ]
+ },
+ {
+ "cell_type": "code",
"execution_count": null,
- "outputs": []
+ "metadata": {
+ "id": "NXmLeZBX45Oe"
+ },
+ "outputs": [],
+ "source": [
+ "# 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",
@@ -72,6 +75,10 @@
"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:"
@@ -79,27 +86,42 @@
},
{
"cell_type": "code",
+ "execution_count": 5,
"metadata": {
- "id": "PusLdqbqJi60"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "PusLdqbqJi60",
+ "outputId": "dbe30821-d3a7-443f-de74-6cb0bee49aac"
},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Downloading demo-me-2021-07-14.zip...\n"
+ ]
+ }
+ ],
"source": [
"# Download our demo project:\n",
- "import urllib.request\n",
"from io import BytesIO\n",
"from zipfile import ZipFile\n",
"\n",
- "def unzip_from_url(url, dest_folder=''):\n",
- " # Directly extract files without writing the archive to disk\n",
- " resp = urllib.request.urlopen(url)\n",
- " with ZipFile(BytesIO(resp.read())) as zf:\n",
- " zf.extractall(path=dest_folder)\n",
- "\n",
+ "import requests\n",
"\n",
- "project_url = \"http://deeplabcut.rowland.harvard.edu/datasets/demo-me-2021-07-14.zip\"\n",
- "unzip_from_url(project_url, \"/content\")"
- ],
- "execution_count": null,
- "outputs": []
+ "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",
+ " 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=\"/content\")\n",
+ "else:\n",
+ " raise ValueError(f\"The URL {url_record} could not be reached.\")"
+ ]
},
{
"cell_type": "markdown",
@@ -107,28 +129,145 @@
"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": 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"
+ }
],
- "execution_count": null,
- "outputs": []
+ "source": [
+ "deeplabcut.analyze_videos(config_path, [video], shuffle=0, videotype=\"mp4\", auto_track=True)"
+ ]
},
{
"cell_type": "markdown",
@@ -150,30 +289,76 @@
},
{
"cell_type": "code",
+ "execution_count": 8,
"metadata": {
- "id": "aTRbuUQ1FBO0"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "aTRbuUQ1FBO0",
+ "outputId": "0d182f64-512d-463d-a997-226c7199b724"
},
+ "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",
" draw_skeleton=True,\n",
" filtered=True,\n",
")"
- ],
- "execution_count": null,
- "outputs": []
+ ]
},
{
"cell_type": "markdown",
@@ -198,82 +383,252 @@
},
{
"cell_type": "code",
+ "execution_count": 9,
"metadata": {
- "id": "7w9BDIA7BB_i"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "7w9BDIA7BB_i",
+ "outputId": "a163087d-cbcb-4e4d-f461-2e24ed19a80b"
},
- "source": [
- "dlc.plot_trajectories(config_path, [video], shuffle=0,videotype='mp4')"
+ "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"
+ ]
+ }
],
- "execution_count": null,
- "outputs": []
+ "source": [
+ "deeplabcut.plot_trajectories(config_path, [video], shuffle=0, videotype=\"mp4\")"
+ ]
},
{
"cell_type": "markdown",
+ "metadata": {
+ "id": "l7BJQq7nxHVz"
+ },
"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! "
- ],
- "metadata": {
- "id": "l7BJQq7nxHVz"
- }
+ "while the tracking here is very good without using the transformer, we want to demo the workflow for you!"
+ ]
},
{
"cell_type": "code",
- "source": [
- "dlc.transformer_reID(config_path, [video],\n",
- " shuffle=0, videotype='mp4',\n",
- " track_method='ellipse',n_triplets=100\n",
- " )"
- ],
+ "execution_count": 10,
"metadata": {
- "id": "5xlO6TVYxQWc"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "5xlO6TVYxQWc",
+ "outputId": "a433221f-0390-4028-fe68-be0b90adad48"
},
- "execution_count": null,
- "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": [
+ "deeplabcut.transformer_reID(\n",
+ " config_path,\n",
+ " [video],\n",
+ " shuffle=0,\n",
+ " videotype=\"mp4\",\n",
+ " track_method=\"ellipse\",\n",
+ " n_triplets=100,\n",
+ ")"
+ ]
},
{
"cell_type": "markdown",
- "source": [
- "now we can make another video with the transformer-guided tracking:\n"
- ],
"metadata": {
"id": "uO_yoqN7xiBT"
- }
+ },
+ "source": [
+ "now we can make another video with the transformer-guided tracking:\n"
+ ]
},
{
"cell_type": "code",
- "source": [
- "dlc.plot_trajectories(config_path, [video], \n",
- " shuffle=0,videotype='mp4', \n",
- " track_method=\"transformer\"\n",
- " )"
- ],
+ "execution_count": 11,
"metadata": {
- "id": "MBMbRFEMxmi4"
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "MBMbRFEMxmi4",
+ "outputId": "5ca4357a-c8e1-46c6-ecad-141bfce48cc5"
},
- "execution_count": null,
- "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": [
+ "deeplabcut.plot_trajectories(\n",
+ " config_path,\n",
+ " [video],\n",
+ " shuffle=0,\n",
+ " videotype=\"mp4\",\n",
+ " track_method=\"transformer\",\n",
+ ")"
+ ]
},
{
"cell_type": "code",
+ "execution_count": 12,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "vx3e-r1CoXaX",
+ "outputId": "46cdbd39-d1f6-4b78-abba-7e979740f2a2"
+ },
+ "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",
")"
- ],
- "metadata": {
- "id": "vx3e-r1CoXaX"
- },
- "execution_count": null,
- "outputs": []
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "gpuType": "A100",
+ "include_colab_link": true,
+ "machine_shape": "hm",
+ "name": "COLAB_transformer_reID.ipynb",
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
}
- ]
-}
\ No newline at end of file
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb b/examples/JUPYTER/Demo_3D_DeepLabCut.ipynb
index d98b3d21ee..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)"
]
},
{
@@ -120,7 +120,7 @@
" \n",
"#### DEMO images:\n",
" \n",
- "Here, we used a standard set along with this notebook. These images are a part of the Camera Calibration ToolBox for Matlab; specifically example 5: http://www.vision.caltech.edu/bouguetj/calib_doc/htmls/example5.html \n",
+ "Here, we used a standard set along with this notebook. These images are a part of the Camera Calibration ToolBox for Matlab; specifically example 5. The images can be downloaded at: https://data.caltech.edu/records/20164. After downloading, the calibration images can be found at ../calib_doc/htmls/calib_example.zip\n",
"\n",
"\n",
"If you wish to run this DEMO notebook, download the files and place inside the **calibration_images** directory. (To note, pairs 1 and 6 are not detected correctly, so please delete these images!). \n",
@@ -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:DLC2]",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
- "name": "conda-env-DLC2-py"
+ "name": "python3"
},
"language_info": {
"codemirror_mode": {
@@ -289,7 +293,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.6.9"
+ "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 9af5d97b6c..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 wx\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
new file mode 100644
index 0000000000..764390965d
--- /dev/null
+++ b/examples/JUPYTER/Demo_napari.ipynb
@@ -0,0 +1,588 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "RK255E7YoEIt"
+ },
+ "source": [
+ "# DeepLabCut Toolbox, labeling with napari\n",
+ "https://github.com/DeepLabCut/DeepLabCut\n",
+ "\n",
+ "\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",
+ "\n",
+ "This notebook illustrates how to:\n",
+ "- create a project\n",
+ "- extract training frames\n",
+ "- label the frames [NEW! with napari]\n",
+ "- plot the labeled images\n",
+ "- create a training set\n",
+ "- train a network\n",
+ "- evaluate a network\n",
+ "- analyze a novel video\n",
+ "- create an automatically labeled video \n",
+ "- plot the trajectories\n",
+ "\n",
+ "This notebook demonstrates the necessary steps to use DeepLabCut for your own project.\n",
+ "\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",
+ "\n",
+ "Nath\\*, Mathis\\* et al.: Using DeepLabCut for markerless pose estimation during behavior across species. Nature Protocols, 2019.\n",
+ "\n",
+ "Paper: https://www.nature.com/articles/s41596-019-0176-0\n",
+ "\n",
+ "Pre-print: https://www.biorxiv.org/content/biorxiv/early/2018/11/24/476531.full.pdf"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "9Uoz9mdPoEIy"
+ },
+ "source": [
+ "## Create a new project\n",
+ "\n",
+ "It is always good idea to keep the projects separate if you want to use different networks to analze your data. You should use one project if you are tracking similar subjects/items even if in different environments. This function creates a new project with sub-directories and a basic configuration file in the user defined directory otherwise the project is created in the current working directory.\n",
+ "\n",
+ "You can always add new videos (for lableing more data) to the project at any stage of the project. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "jqLZhp7EoEI0"
+ },
+ "outputs": [],
+ "source": [
+ "import deeplabcut"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "c9DjG55FoEI7"
+ },
+ "outputs": [],
+ "source": [
+ "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",
+ "\n",
+ "# 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\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Now, go edit the config.yaml file that was created! \n",
+ "Add your body part labels, edit the number of frames to extract per video, etc. "
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### Note that you can see more information about ANY function by adding a ? at the end, i.e. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "deeplabcut.extract_frames?"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "0yXW0bx1oEJA"
+ },
+ "source": [
+ "## Extract frames from videos \n",
+ "A key point for a successful feature detector is to select diverse frames, which are typical for the behavior you study that should be labeled.\n",
+ "\n",
+ "This function selects N frames either uniformly sampled from a particular video (or folder) ('uniform'). Note: this might not yield diverse frames, if the behavior is sparsely distributed (consider using kmeans), and/or select frames manually etc.\n",
+ "\n",
+ "Also make sure to get select data from different (behavioral) sessions and different animals if those vary substantially (to train an invariant feature detector).\n",
+ "\n",
+ "Individual images should not be too big (i.e. < 850 x 850 pixel). Although this can be taken care of later as well, it is advisable to crop the frames, to remove unnecessary parts of the frame as much as possible.\n",
+ "\n",
+ "Always check the output of cropping. If you are happy with the results proceed to labeling."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "t1ulumCuoEJC"
+ },
+ "outputs": [],
+ "source": [
+ "# there are other ways to grab frames, such as uniformly; please see the paper:\n",
+ "\n",
+ "# AUTOMATIC:\n",
+ "deeplabcut.extract_frames(path_config_file)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "Gjn6ZDonoEJH"
+ },
+ "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. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "scrolled": false
+ },
+ "outputs": [],
+ "source": [
+ "# 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)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "iyROSOiEoEJI"
+ },
+ "outputs": [],
+ "source": [
+ "# napari will pop up! Please go to plugin > deeplabcut to start:\n",
+ "%gui qt6\n",
+ "import napari\n",
+ "\n",
+ "napari.Viewer()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "vim95ZvkPSeN"
+ },
+ "source": [
+ "## Check the labels\n",
+ "\n",
+ "[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 `check\\_labels' to do so. It is used as follows:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "NwvgPJouPP2O"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.check_labels(path_config_file) # this creates a subdirectory with the frames + your labels"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "of87fOjgPqzH"
+ },
+ "source": [
+ "If the labels need adjusted, you can use relauch the labeling GUI to move them around, save, and re-plot!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "xNi9s1dboEJN"
+ },
+ "source": [
+ "## Create a training dataset\n",
+ "\n",
+ "This function generates the training data information for network training 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 if they want to benchmark the performance (typcailly, 1 is what you will set, so you pass nothing!). \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-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!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "eMeUwgxPoEJP",
+ "scrolled": true
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_training_dataset(path_config_file)\n",
+ "# remember, there are several networks you can pick, the default is resnet-50!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "c4FczXGDoEJU"
+ },
+ "source": [
+ "## Start training:\n",
+ "\n",
+ "This function trains the network for a specific shuffle of the training dataset. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "_pOvDq_2oEJW"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.train_network(path_config_file)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "xZygsb2DoEJc"
+ },
+ "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-pytorch**"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "nv4zlbrnoEJg"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.evaluate_network(path_config_file, plotting=True)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "OVFLSKKfoEJk"
+ },
+ "source": [
+ "## Start Analyzing videos\n",
+ "This function analyzes the new video. The user can choose the best model from the evaluation results and specify the correct snapshot index for the variable **snapshotindex** in the **config.yaml** file. Otherwise, by default the most recent snapshot is used to analyse the video.\n",
+ "\n",
+ "The results are stored in hd5 file in the same directory where the video resides. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "Y_LZiS_0oEJl"
+ },
+ "outputs": [],
+ "source": [
+ "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\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "iGu_PdTWoEJr"
+ },
+ "source": [
+ "## Extract outlier frames [optional step]\n",
+ "\n",
+ "This is an optional step and is used only when the evaluation results are poor i.e. the labels are incorrectly predicted. In such a case, the user can use the following function to extract frames where the labels are incorrectly predicted. This step has many options, so please look at:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "deeplabcut.extract_outlier_frames?"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "gkbaBOJVoEJs"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.extract_outlier_frames(path_config_file, [\"/videos/video3.avi\"]) # pass a specific video"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "8ib0uvhaoEJx"
+ },
+ "source": [
+ "## Refine Labels [optional step]\n",
+ "Following the extraction of outlier frames, the user can use the following function to move the predicted labels to the correct location. Thus augmenting the training dataset. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "n_FpEXtyoEJy"
+ },
+ "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",
+ "%gui qt6\n",
+ "import napari\n",
+ "\n",
+ "napari.Viewer()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**NOTE:** Afterwards, if you want to look at the adjusted frames, you can load them in the main GUI by running: ``deeplabcut.label_frames(path_config_file)``\n",
+ "\n",
+ "(you can add a new \"cell\" below to add this code!)\n",
+ "\n",
+ "#### Once all folders are relabeled, check the labels again! If you are not happy, adjust them in the main GUI:\n",
+ "\n",
+ "``deeplabcut.label_frames(path_config_file)``\n",
+ "\n",
+ "Check Labels:\n",
+ "\n",
+ "``deeplabcut.check_labels(path_config_file)``"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "CHzstWr8oEJ2"
+ },
+ "outputs": [],
+ "source": [
+ "# NOW, merge this with your original data:\n",
+ "\n",
+ "deeplabcut.merge_datasets(path_config_file)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "QCHj7qyboEJ6"
+ },
+ "source": [
+ "## Create a new iteration of training dataset [optional step]\n",
+ "Following the refinement of labels and appending them to the original dataset, this creates a new iteration of training dataset. This is automatically set in the config.yaml file, so let's get training!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "ytQoxIldoEJ7"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_training_dataset(path_config_file)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "pCrUvQIvoEKD"
+ },
+ "source": [
+ "## Create labeled video\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",
+ "\n",
+ "So please check:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_labeled_video?"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "6aDF7Q7KoEKE"
+ },
+ "outputs": [],
+ "source": [
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "colab_type": "text",
+ "id": "8GTiuJESoEKH"
+ },
+ "source": [
+ "## Plot the trajectories of the analyzed videos\n",
+ "This function plots the trajectories of all the body parts across the entire video. Each body part is identified by a unique color."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {},
+ "colab_type": "code",
+ "id": "gX21zZbXoEKJ"
+ },
+ "outputs": [],
+ "source": [
+ "%matplotlib notebook #for making interactive plots.\n",
+ "deeplabcut.plot_trajectories(path_config_file, videofile_path)"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "name": "Demo-yourowndata.ipynb",
+ "provenance": [],
+ "version": "0.3.2"
+ },
+ "deeplabcut": {
+ "ignore": false,
+ "last_content_updated": "2025-09-16",
+ "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.11.11"
+ },
+ "varInspector": {
+ "cols": {
+ "lenName": 16,
+ "lenType": 16,
+ "lenVar": 40
+ },
+ "kernels_config": {
+ "python": {
+ "delete_cmd_postfix": "",
+ "delete_cmd_prefix": "del ",
+ "library": "var_list.py",
+ "varRefreshCmd": "print(var_dic_list())"
+ },
+ "r": {
+ "delete_cmd_postfix": ") ",
+ "delete_cmd_prefix": "rm(",
+ "library": "var_list.r",
+ "varRefreshCmd": "cat(var_dic_list()) "
+ }
+ },
+ "types_to_exclude": [
+ "module",
+ "function",
+ "builtin_function_or_method",
+ "instance",
+ "_Feature"
+ ],
+ "window_display": false
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 1
+}
diff --git a/examples/JUPYTER/Demo_yourowndata.ipynb b/examples/JUPYTER/Demo_yourowndata.ipynb
index 066f08e165..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",
@@ -65,7 +70,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "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": [
@@ -129,7 +144,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -138,22 +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) "
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "#AND/OR:\n",
- "#SELECT RARE EVENTS MANUALLY:\n",
- "%gui wx\n",
- "deeplabcut.extract_frames(path_config_file,'manual')"
+ "# AUTOMATIC:\n",
+ "deeplabcut.extract_frames(path_config_file)"
]
},
{
@@ -165,12 +168,14 @@
"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!"
]
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -178,8 +183,15 @@
},
"outputs": [],
"source": [
- "%gui wx\n",
- "deeplabcut.label_frames(path_config_file)"
+ "# napari will pop up!\n",
+ "# Please go to plugin > deeplabcut to start\n",
+ "# then, drag-and-drop the project configuration file into the viewer (the value of path_config_file)\n",
+ "# finally, drop the folder containing the images (in 'labeled-data') in the viewer\n",
+ "\n",
+ "%gui qt6\n",
+ "import napari\n",
+ "\n",
+ "napari.Viewer()"
]
},
{
@@ -196,7 +208,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -204,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"
]
},
{
@@ -230,14 +242,14 @@
"\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!"
]
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -247,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!"
]
},
{
@@ -259,12 +272,14 @@
"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. "
]
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -284,12 +299,12 @@
"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**"
]
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -315,7 +330,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -323,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\")"
]
},
{
@@ -342,7 +357,7 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
@@ -351,7 +366,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -359,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"
]
},
{
@@ -375,7 +390,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -383,7 +398,7 @@
},
"outputs": [],
"source": [
- "%gui wx\n",
+ "%gui qt6\n",
"deeplabcut.refine_labels(path_config_file)"
]
},
@@ -406,7 +421,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -414,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)"
]
@@ -432,7 +447,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -451,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": [
@@ -471,7 +506,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -479,7 +514,7 @@
},
"outputs": [],
"source": [
- "deeplabcut.create_labeled_video(path_config_file,videofile_path)"
+ "deeplabcut.create_labeled_video(path_config_file, videofile_path)"
]
},
{
@@ -495,7 +530,7 @@
},
{
"cell_type": "code",
- "execution_count": 0,
+ "execution_count": null,
"metadata": {
"colab": {},
"colab_type": "code",
@@ -504,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)"
]
}
],
@@ -515,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": {
@@ -530,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 f707f18fa2..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.2.1-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/testM1.sh b/examples/testM1.sh
deleted file mode 100755
index c6ab3fefb5..0000000000
--- a/examples/testM1.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-rm -r TEST*
-rm -r multi_mouse*
-rm -r 3D*
-rm -r OUT
-
-
-#git log --all --graph --decorate --oneline
-#codespell -q 3 -L ro,sur -w
-
-cd ..
-pip uninstall deeplabcut
-pythonw setup.py sdist bdist_wheel
-pip install dist/deeplabcut-2.2.1-py3-none-any.whl
-
-# download: https://drive.google.com/file/d/17pSwfoNuyf3YR8vCaVggHeI-pMQ3xL7l/view?usp=sharing
-# assuming it's in Downloads...
-pip install ~/Downloads/tensorflow-2.4.1-py3-none-any.whl --no-dependencies --force-reinstall
-
-cd examples
-
-#pythonw testscript.py
-#pythonw testscript_3d.py #does not work in container
-#pythonw testscript_multianimal.py
-pythonw testscript_transreid.py
diff --git a/examples/testscript_3d.py b/examples/testscript_3d.py
index c9b31a0cbf..a31c034a9f 100644
--- a/examples/testscript_3d.py
+++ b/examples/testscript_3d.py
@@ -1,3 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
"""
DeepLabCut2.0 Toolbox (deeplabcut.org)
© A. & M. Mathis Labs
@@ -10,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!")
@@ -24,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",
@@ -37,7 +48,7 @@
]
folder = os.path.join(basepath, "3Dtestviews_videos")
- deeplabcut.auxiliaryfunctions.attempttomakefolder(folder)
+ deeplabcut.auxiliaryfunctions.attempt_to_make_folder(folder)
# copying demo video from reaching data set and create two "views":
dst_videoname1 = "vid1_camera-1"
@@ -78,7 +89,7 @@
output2,
]
)
- except:
+ except Exception:
pass
"""
@@ -93,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
@@ -111,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
@@ -128,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()
@@ -140,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
@@ -149,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
@@ -170,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 cd1f21a25e..01e033ee65 100644
--- a/examples/testscript_deterministicwithResNet152.py
+++ b/examples/testscript_deterministicwithResNet152.py
@@ -1,5 +1,14 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
+#
+# 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
+#
"""
Created on Tue Oct 2 13:56:11 2018
@author: alex
@@ -28,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
@@ -96,7 +101,7 @@
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -108,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 382b98a308..18342cb347 100644
--- a/examples/testscript_mobilenets.py
+++ b/examples/testscript_mobilenets.py
@@ -1,29 +1,40 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
+#
+# 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
+#
"""
Created on Tue Oct 2 13:56:11 2018
@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"],
@@ -62,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
@@ -72,23 +83,17 @@ 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")
- deeplabcut.auxiliaryfunctions.attempttomakefolder(dfolder)
+ deeplabcut.auxiliaryfunctions.attempt_to_make_folder(dfolder)
# dfolder=None
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
@@ -133,7 +138,7 @@ def Cuttrainingschedule(
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -143,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
@@ -154,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,
@@ -190,7 +191,7 @@ def Cuttrainingschedule(
shuffle=shuffle,
save_as_csv=True,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("CREATE VIDEO")
@@ -199,7 +200,7 @@ def Cuttrainingschedule(
[newvideo],
shuffle=shuffle,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("Making plots")
@@ -208,7 +209,7 @@ def Cuttrainingschedule(
[newvideo],
shuffle=shuffle,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
print("EXTRACT OUTLIERS")
@@ -220,7 +221,7 @@ def Cuttrainingschedule(
epsilon=0,
automatic=True,
destfolder=dfolder,
- videotype="avi",
+ video_extensions="avi",
)
file = os.path.join(
cfg["project_path"],
@@ -232,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(
@@ -251,7 +250,7 @@ def Cuttrainingschedule(
vname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -260,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 7ee843b070..8faf1c3a5d 100644
--- a/examples/testscript_openfielddata.py
+++ b/examples/testscript_openfielddata.py
@@ -1,7 +1,15 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-Created on Mon Nov 5 18:06:13 2018
+#
+# 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
+#
+"""Created on Mon Nov 5 18:06:13 2018.
@author: alex
@@ -17,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
@@ -35,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)
@@ -48,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 50d632b808..6e88425d40 100644
--- a/examples/testscript_openfielddata_augmentationcomparison.py
+++ b/examples/testscript_openfielddata_augmentationcomparison.py
@@ -1,8 +1,15 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-
-This is a test script to compare the loaders and models.
+#
+# 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
+#
+"""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)
@@ -42,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")
@@ -72,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
@@ -105,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 03f5b9e092..3a668c5caa 100644
--- a/examples/testscript_pretrained_models.py
+++ b/examples/testscript_pretrained_models.py
@@ -1,42 +1,49 @@
-"""
-Testscript human network
-
-"""
-import os, subprocess, deeplabcut
-from pathlib import Path
-import pandas as pd
-import numpy as np
+#
+# 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 human network."""
+
+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(
Task,
YourName,
video,
- model="full_human",
- videotype="avi",
+ model=MODEL_NAME,
+ video_extensions="avi",
analyzevideo=True,
createlabeledvideo=True,
copy_videos=False,
+ engine=deeplabcut.Engine.TF,
) # must leave copy_videos=True
+"""
lastvalue = 5
DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(path_train_config)
pretrainedDeeperCutweights = DLC_config["init_weights"]
@@ -78,7 +85,7 @@
videoname,
"CollectedData_" + cfg["scorer"] + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -154,7 +161,7 @@
videoname,
"CollectedData_" + cfg["scorer"] + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -169,3 +176,4 @@
# deeplabcut.train_network(configfile,shuffle=1) #>> fails one body part too much!
deeplabcut.train_network(configfile, shuffle=1, keepdeconvweights=False)
+"""
diff --git a/examples/testscript_pytorch_multi_animal.py b/examples/testscript_pytorch_multi_animal.py
new file mode 100644
index 0000000000..e4a98d8d47
--- /dev/null
+++ b/examples/testscript_pytorch_multi_animal.py
@@ -0,0 +1,147 @@
+#
+# 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,
+)
+
+# Enable pandas future mode warnings if DLC_PANDAS_FUTURE env var is set
+from deeplabcut.utils.pandas_future_mode import configure_pandas_future_if_enabled
+
+configure_pandas_future_if_enabled()
+
+import deeplabcut.utils.auxiliaryfunctions as af # noqa: E402
+from deeplabcut.compat import Engine # noqa: E402
+from deeplabcut.pose_estimation_pytorch.config.utils import is_model_cond_top_down, is_model_top_down # noqa: E402
+
+
+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,
+ "logger": logger,
+ }
+
+ # Only add detector config updates for top-down models
+ if is_model_top_down(net_type):
+ pytorch_cfg_updates.update(
+ {
+ "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,
+ }
+ )
+
+ 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,
+ ctd_conditions=(conditions_shuffle, -1) if is_model_cond_top_down(net_type) else None,
+ )
+ 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..19f3634fe3
--- /dev/null
+++ b/examples/testscript_pytorch_single_animal.py
@@ -0,0 +1,115 @@
+"""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,
+)
+
+# Enable pandas future mode warnings if DLC_PANDAS_FUTURE env var is set
+from deeplabcut.utils.pandas_future_mode import configure_pandas_future_if_enabled
+
+configure_pandas_future_if_enabled()
+
+import deeplabcut.utils.auxiliaryfunctions as af # noqa: E402
+from deeplabcut.compat import Engine # noqa: E402
+
+
+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
new file mode 100644
index 0000000000..42d6e2fba9
--- /dev/null
+++ b/examples/testscript_superanimal_adaptation.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""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 = deeplabcut.ShortenVideo(
+ video,
+ start="00:00:00",
+ stop="00:00:01",
+ outsuffix="short",
+ )
+
+ print("adaptation training for superanimal_topviewmouse")
+
+ superanimal_name = "superanimal_topviewmouse"
+ video_extensions = ".mp4"
+ scale_list = [200, 300, 400]
+ deeplabcut.video_inference_superanimal(
+ [video],
+ superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_extensions=".mp4",
+ video_adapt=True,
+ scale_list=scale_list,
+ pcutoff=0.1,
+ adapt_iterations=50,
+ )
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
new file mode 100644
index 0000000000..4db84b2b91
--- /dev/null
+++ b/examples/testscript_superanimal_inference.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Testscript for super animal inference."""
+
+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")]
+
+ print("testing superanimal_topviewmouse")
+ superanimal_name = "superanimal_topviewmouse"
+ scale_list = [200, 300, 400]
+ deeplabcut.video_inference_superanimal(
+ video,
+ superanimal_name,
+ model_name="hrnet_w32",
+ detector_name="fasterrcnn_resnet50_fpn_v2",
+ video_extensions=".avi",
+ scale_list=scale_list,
+ )
+
+ print("testing superanimal_quadruped")
+ superanimal_name = "superanimal_quadruped"
+ deeplabcut.video_inference_superanimal(
+ video,
+ superanimal_name,
+ 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
new file mode 100644
index 0000000000..cced5b26e6
--- /dev/null
+++ b/examples/testscript_superanimal_transfer_learning.py
@@ -0,0 +1,44 @@
+#
+# 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
+#
+"""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"
+
+ 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,
+ net_type=model_name,
+ )
+
+ deeplabcut.train_network(
+ config_path,
+ 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 60%
rename from examples/testscript_multianimal.py
rename to examples/testscript_tensorflow_multi_animal.py
index e864e33b0c..c90ec120e7 100644
--- a/examples/testscript_multianimal.py
+++ b/examples/testscript_tensorflow_multi_animal.py
@@ -1,13 +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/master/AUTHORS
+#
+# 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
-MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0", "mobilenet_v2_0.35"]
+import matplotlib
+import numpy as np
+import pandas as pd
+
+matplotlib.use("Agg") # Non-interactive backend, for CI/CD on Windows
+
+# Enable pandas future mode warnings if DLC_PANDAS_FUTURE env var is set
+from deeplabcut.utils.pandas_future_mode import configure_pandas_future_if_enabled
+
+configure_pandas_future_if_enabled()
+
+import deeplabcut # noqa: E402
+from deeplabcut.core.engine import Engine # noqa: E402
+from deeplabcut.utils import auxfun_multianimal, auxiliaryfunctions # noqa: E402
+from deeplabcut.utils.auxfun_videos import VideoReader # noqa: E402
+
+MODELS = ["dlcrnet_ms5", "dlcr101_ms5", "efficientnet-b0"]
N_ITER = 5
@@ -16,11 +38,11 @@
USE_SHELVE = False # random.choice([True, False])
if __name__ == "__main__":
-
TASK = "multi_mouse"
SCORER = "dlc_team"
NUM_FRAMES = 5
TRAIN_SIZE = 0.8
+ ENGINE = Engine.TF
# NET = "dlcr101_ms5"
NET = "dlcrnet_ms5"
@@ -32,14 +54,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.")
@@ -68,33 +86,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.")
@@ -104,16 +116,22 @@
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.")
# Check the training image paths are correctly stored as arrays of strings
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- datafile, _ = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, 0.8, 1, cfg,
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ datafile, _ = auxiliaryfunctions.get_data_and_metadata_filenames(
+ trainingsetfolder,
+ 0.8,
+ 1,
+ cfg,
)
- datafile = datafile.split(".mat")[0] + ".pickle"
+ datafile = datafile.with_suffix(".pickle")
with open(os.path.join(cfg["project_path"], datafile), "rb") as f:
pickledata = pickle.load(f)
num_images = len(pickledata)
@@ -121,7 +139,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 = {
@@ -140,7 +158,7 @@
print("Network trained.")
print("Evaluating network...")
- deeplabcut.evaluate_network(config_path, plotting=True)
+ deeplabcut.evaluate_network(config_path, plotting=True, per_keypoint_evaluation=True)
print("Network evaluated....")
@@ -156,19 +174,21 @@
)
print("Analyzing video...")
- deeplabcut.analyze_videos(
- config_path,
- [new_video_path],
- "mp4",
- robust_nframes=True,
- allow_growth=True,
- use_shelve=USE_SHELVE,
- )
-
- print("Video analyzed.")
+ try:
+ deeplabcut.analyze_videos(
+ config_path,
+ [new_video_path],
+ "mp4",
+ robust_nframes=True,
+ allow_growth=True,
+ use_shelve=USE_SHELVE,
+ )
+ print("Video analyzed.")
+ except ValueError:
+ pass
print("Create video with all detections...")
- scorer, _ = auxiliaryfunctions.GetScorerName(cfg, 1, TRAIN_SIZE)
+ scorer, _ = auxiliaryfunctions.get_scorer_name(cfg, 1, TRAIN_SIZE)
deeplabcut.create_video_with_all_detections(
config_path, [new_video_path], shuffle=1, displayedbodyparts=["bodypart1"]
@@ -177,23 +197,36 @@
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...")
-
- deeplabcut.stitch_tracklets(
- config_path,
- [new_video_path],
- "mp4",
- output_name=os.path.splitext(new_video_path)[0] + scorer + "_el.h5",
- track_method=TESTTRACKER,
- )
+ h5path = os.path.splitext(new_video_path)[0] + scorer + "_el.h5"
+ try:
+ deeplabcut.stitch_tracklets(
+ config_path,
+ [new_video_path],
+ "mp4",
+ output_name=h5path,
+ track_method=TESTTRACKER,
+ )
+ except ValueError:
+ # Sometimes tracks cannot be reconstructed as test data are randomly
+ # created; when this happens, we generate a fake h5 data file.
+ 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 bp in bodyparts_single for _ in range(3)]
+ columns = pd.MultiIndex.from_arrays(
+ [scorer, individuals, bodyparts, coords],
+ names=["scorer", "individuals", "bodyparts", "coords"],
+ )
+ vid = VideoReader(new_video_path)
+ fake_data = np.ones((len(vid), columns.shape[0]))
+ df = pd.DataFrame(fake_data, columns=columns)
+ 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...")
@@ -208,15 +241,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
@@ -231,7 +260,7 @@
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 = DF.columns.set_levels([scorer.replace(DLCscorer, SCORER)], level=0)
DF = DF.drop("likelihood", axis=1, level=3)
DF.to_csv(
os.path.join(
@@ -248,21 +277,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)
+ deeplabcut.evaluate_network(config_path, plotting=True, per_keypoint_evaluation=True)
print("Network evaluated....")
@@ -282,16 +311,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 74%
rename from examples/testscript.py
rename to examples/testscript_tensorflow_single_animal.py
index 3ccada04de..c169ca8387 100644
--- a/examples/testscript.py
+++ b/examples/testscript_tensorflow_single_animal.py
@@ -1,5 +1,14 @@
#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
+#
+# 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
+#
"""
Created on Tue Oct 2 13:56:11 2018
@author: alex
@@ -12,36 +21,41 @@
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
-from deeplabcut.utils import auxiliaryfunctions
+# Enable pandas future mode warnings if DLC_PANDAS_FUTURE env var is set
+from deeplabcut.utils.pandas_future_mode import configure_pandas_future_if_enabled
-import random
+configure_pandas_future_if_enabled()
+
+import deeplabcut # noqa: E402
+from deeplabcut.core.engine import Engine # noqa: E402
+from deeplabcut.utils import auxiliaryfunctions # noqa: E402
+
+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'
@@ -61,12 +75,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
@@ -81,6 +94,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(
@@ -112,7 +126,7 @@
videoname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -123,13 +137,19 @@
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
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- datafile, _ = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, 0.8, 1, cfg,
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ datafile, _ = auxiliaryfunctions.get_data_and_metadata_filenames(
+ trainingsetfolder,
+ 0.8,
+ 1,
+ cfg,
)
mlab = sio.loadmat(os.path.join(cfg["project_path"], datafile))["dataset"]
num_images = mlab.shape[1]
@@ -153,18 +173,26 @@
)
DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile)
- DLC_config["save_iters"] = N_ITER
+ DLC_config["save_iters"] = SAVE_ITER
DLC_config["display_iters"] = 2
- DLC_config["multi_step"] = [[0.001, N_ITER]]
print("CHANGING training parameters to end quickly!")
deeplabcut.auxiliaryfunctions.write_plainconfig(posefile, DLC_config)
print("TRAIN")
- deeplabcut.train_network(path_config_file)
+ deeplabcut.train_network(path_config_file, maxiters=N_ITER)
print("EVALUATE")
- deeplabcut.evaluate_network(path_config_file, plotting=True)
+ deeplabcut.evaluate_network(
+ 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!)")
@@ -179,10 +207,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()
@@ -204,14 +232,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)
@@ -244,7 +269,7 @@ def make_frame(t):
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 = DF.columns.set_levels([scorer.replace(DLCscorer, scorer)], level=0)
DF = DF.drop("likelihood", axis=1, level=2)
DF.to_csv(
os.path.join(
@@ -261,16 +286,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(
@@ -287,15 +310,14 @@ 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
- DLC_config["multi_step"] = [[0.001, N_ITER]]
print("CHANGING training parameters to end quickly!")
deeplabcut.auxiliaryfunctions.write_config(posefile, DLC_config)
print("TRAIN")
- deeplabcut.train_network(path_config_file)
+ deeplabcut.train_network(path_config_file, maxiters=N_ITER)
try: # you need ffmpeg command line interface
# subprocess.call(['ffmpeg','-i',video[0],'-ss','00:00:00','-to','00:00:00.4','-c','copy',newvideo])
@@ -307,11 +329,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()
@@ -336,27 +356,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.")
@@ -364,7 +382,11 @@ def make_frame(t):
print("will be used for 3D testscript...")
# TENSORPACK could fail in WINDOWS...
deeplabcut.create_training_dataset(
- path_config_file, Shuffles=[2], net_type=NET, augmenter_type=augmenter_type3,
+ path_config_file,
+ Shuffles=[2],
+ net_type=NET,
+ augmenter_type=augmenter_type3,
+ engine=engine,
)
posefile = os.path.join(
@@ -382,15 +404,15 @@ def make_frame(t):
)
DLC_config = deeplabcut.auxiliaryfunctions.read_plainconfig(posefile)
- DLC_config["save_iters"] = 10
+ updated_max_iters = 10
+ DLC_config["save_iters"] = updated_max_iters
DLC_config["display_iters"] = 2
- DLC_config["multi_step"] = [[0.001, 10]]
print("CHANGING training parameters to end quickly!")
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)
+ 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(
@@ -402,9 +424,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(
@@ -412,6 +432,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 5a67a68f38..2a0b270102 100644
--- a/examples/testscript_transreid.py
+++ b/examples/testscript_transreid.py
@@ -1,12 +1,24 @@
+#
+# 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 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",
@@ -18,7 +30,6 @@
USE_SHELVE = False # random.choice([True, False])
if __name__ == "__main__":
-
TASK = "multi_mouse"
SCORER = "dlc_team"
NUM_FRAMES = 5
@@ -34,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.")
@@ -70,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...")
@@ -105,26 +101,25 @@
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
- trainingsetfolder = auxiliaryfunctions.GetTrainingSetFolder(cfg)
- datafile, _ = auxiliaryfunctions.GetDataandMetaDataFilenames(
- trainingsetfolder, 0.8, 1, cfg,
+ trainingsetfolder = auxiliaryfunctions.get_training_set_folder(cfg)
+ datafile, _ = auxiliaryfunctions.get_data_and_metadata_filenames(
+ trainingsetfolder,
+ 0.8,
+ 1,
+ cfg,
)
- datafile = datafile.split(".mat")[0] + ".pickle"
+ datafile = datafile.with_suffix(".pickle")
with open(os.path.join(cfg["project_path"], datafile), "rb") as f:
pickledata = pickle.load(f)
num_images = len(pickledata)
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,
@@ -170,7 +165,7 @@
print("Video analyzed.")
print("Create video with all detections...")
- scorer, _ = auxiliaryfunctions.GetScorerName(cfg, 1, TRAIN_SIZE)
+ scorer, _ = auxiliaryfunctions.get_scorer_name(cfg, 1, TRAIN_SIZE)
deeplabcut.create_video_with_all_detections(
config_path, [new_video_path], shuffle=1, displayedbodyparts=["bodypart1"]
@@ -179,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
@@ -190,14 +183,15 @@
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")
deeplabcut.pose_estimation_tensorflow.create_tracking_dataset(
- config_path, [new_video_path], TESTTRACKER, videotype="mp4",
+ config_path,
+ [new_video_path],
+ TESTTRACKER,
+ video_extensions="mp4",
)
train_epochs = 10
@@ -215,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")
@@ -230,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...")
@@ -247,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
@@ -288,7 +274,7 @@
vname,
"CollectedData_" + scorer + ".h5",
),
- "df_with_missing",
+ key="df_with_missing",
format="table",
mode="w",
)
@@ -297,24 +283,24 @@
print("MERGING")
deeplabcut.merge_datasets(config_path) # iteration + 1
- print('CREATING TRAININGSET UPDATED TRAINING SET')
+ print("CREATING TRAININGSET UPDATED TRAINING SET")
deeplabcut.create_training_dataset(config_path, Shuffles=[3], net_type=NET)
- print('TRAINING NETWORK...')
+ print("TRAINING NETWORK...")
deeplabcut.train_network(config_path, shuffle=3, maxiters=N_ITER)
print("NETWORK TRAINED!")
- print('EVALUATING NETWORK...')
+ print("EVALUATING NETWORK...")
deeplabcut.evaluate_network(config_path, Shuffles=[3], plotting=True)
- print('NETWORK EVALUATED....')
+ print("NETWORK EVALUATED....")
- print('ANALYZING VIDEO WITH AUTO_TRACK....')
+ print("ANALYZING VIDEO WITH AUTO_TRACK....")
deeplabcut.analyze_videos(
config_path,
[new_video_path],
shuffle=3,
- videotype='mp4',
+ video_extensions="mp4",
save_as_csv=True,
destfolder=DESTFOLDER,
cropping=[0, 50, 0, 50],
@@ -325,12 +311,12 @@
n_tracks = 3
- print('TESTING THE UNIFIED API FOR TRANSFORMER')
+ print("TESTING THE UNIFIED API FOR TRANSFORMER")
deeplabcut.transformer_reID(
config_path,
[new_video_path],
- videotype='mp4',
+ video_extensions="mp4",
shuffle=3,
n_tracks=n_tracks,
track_method=TESTTRACKER,
@@ -339,12 +325,12 @@
destfolder=DESTFOLDER,
)
- print('CREATING LABELED VIDEOS (FOR ELLIPSE AND TRANSFORMER)...')
+ print("CREATING LABELED VIDEOS (FOR ELLIPSE AND TRANSFORMER)...")
deeplabcut.create_labeled_video(
config_path,
[new_video_path],
- videotype = 'mp4',
+ video_extensions="mp4",
shuffle=3,
track_method="ellipse",
destfolder=DESTFOLDER,
@@ -353,11 +339,10 @@
deeplabcut.create_labeled_video(
config_path,
[new_video_path],
- videotype = 'mp4',
+ video_extensions="mp4",
shuffle=3,
track_method="transformer",
destfolder=DESTFOLDER,
)
-
print("ALL DONE!!! - default multianimal cases are functional.")
diff --git a/examples/utils.py b/examples/utils.py
new file mode 100644
index 0000000000..8ffb0fe637
--- /dev/null
+++ b/examples/utils.py
@@ -0,0 +1,439 @@
+#
+# 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,
+ ctd_conditions: tuple[int, int] | None = None,
+) -> None:
+ times = [time.time()]
+ log_step(f"Testing with net type {net_type}")
+ log_step("Creating the training dataset")
+ deeplabcut.create_training_dataset(config_path, net_type=net_type, engine=engine, ctd_conditions=ctd_conditions)
+ 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..cec3b6077a
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,247 @@
+[build-system]
+build-backend = "setuptools.build_meta"
+requires = [ "setuptools>=61" ]
+
+[project]
+name = "deeplabcut"
+version = "3.0.1"
+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",
+ # Migration to pandas 3.0 is tracked in https://github.com/DeepLabCut/DeepLabCut/issues/3362.
+ "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; platform_system=='Linux' and platform_machine=='x86_64'",
+ "qdarkstyle>=3.1; platform_system!='Linux' or platform_machine!='x86_64'",
+]
+openvino = [ "openvino-dev==2022.1" ]
+docs = [
+ "jupyter-book==1.0.4.post1",
+ "sphinxcontrib-mermaid",
+]
+dev-docs = [
+ "black>=24",
+ "mike>=2.1",
+ "mkdocs>=1.6",
+ "mkdocs-api-autonav>=0.1",
+ "mkdocs-autorefs>=1.2",
+ "mkdocs-jupyter>=0.25",
+ "mkdocs-material[imaging]>=9.5",
+ "mkdocstrings[python]>=0.27",
+]
+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",
+]
+gui-dev = [
+ "pytest-qt",
+]
+
+[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.format]
+docstring-code-format = true
+[tool.ruff.lint]
+select = [ "E", "F", "B", "I", "UP", "PIE" ]
+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"
+
+# Config for the docformatter pre-commit hook (currently disabled in favour of ruff).
+# Re-enable the hook in .pre-commit-config.yaml when large-scale docstring
+# reformatting is needed.
+[tool.docformatter]
+wrap-descriptions = 88
+wrap-summaries = 88
+black = true
+
+[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",
+]
+
+[tool.mdformat]
+# Preserve prose line breaks; avoid spurious diffs from reflowing paragraphs.
+wrap = "no"
+end_of_line = "lf"
diff --git a/reinstall.sh b/reinstall.sh
index a8d875554a..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.2.1-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 b1aba7a000..0000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,22 +0,0 @@
-ipython
-filterpy
-ruamel.yaml>=0.15.0
-intel-openmp
-imgaug==0.4.0
-numba>=0.54.0
-matplotlib
-networkx>=2.6
-numpy>=1.18.5
-pandas>=1.0.1
-pyyaml
-scikit-image>=0.17,<1.0.0
-scikit-learn>=1.0
-scipy>=1.4
-statsmodels>=0.11,<=0.13.1
-tensorflow>=2.0
-tables==3.7.0
-tensorpack==0.11
-tf_slim==1.1.0
-torch
-tqdm
-Pillow>=7.1
diff --git a/setup.py b/setup.py
index 295087d526..c3e301e85e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,78 +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", "r") as fh:
- long_description = fh.read()
-
-
-setuptools.setup(
- name="deeplabcut",
- version="2.2.1",
- 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=[
- "filterpy>=1.4.4",
- "ruamel.yaml>=0.15.0",
- "imgaug>=0.4.0",
- "numba>=0.54",
- "matplotlib>=3.3",
- "networkx>=2.6",
- "numpy>=1.18.5",
- "pandas>=1.0.1",
- "scikit-image>=0.17,<=1.0.0",
- "scikit-learn>=1.0",
- "scipy>=1.4",
- "statsmodels>=0.11,!=0.13.2",
- "tables>=3.7.0",
- "tensorflow>=2.0",
- "tensorpack>=0.11",
- "tf_slim>=1.1.0",
- "tqdm",
- "pyyaml",
- "Pillow>=7.1",
- ],
- extras_require={
- "gui": ["wxpython<4.1"],
- "openvino": ["openvino-dev==2022.1.0"],
- "docs": ["numpydoc"],
- },
- 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/gui/media/logo.png",
- "deeplabcut/gui/media/dlc_1-01.png",
- ],
- )
- ],
- 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 72fc12a85a..9c4b5c3597 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,20 +1,46 @@
-import numpy as np
+#
+# 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 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
+# Enable pandas future mode warnings if DLC_PANDAS_FUTURE env var is set
+from deeplabcut.utils.pandas_future_mode import configure_pandas_future_if_enabled
-TEST_DATA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data")
+configure_pandas_future_if_enabled()
+from deeplabcut.core import inferenceutils # noqa: E402
-def unzip_from_url(url, dest_folder):
+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: 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)
@@ -26,25 +52,38 @@ 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)
-def pytest_sessionfinish(session, exitstatus):
- shutil.rmtree(session.__DATA_FOLDER)
+@pytest.fixture(scope="session", autouse=True)
+def ensure_test_data():
+ """Ensure shared test data exists once per pytest session.
+
+ 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="session")
+def test_data_dir():
+ """Path to shared test data under tests/data/."""
+ return TEST_DATA_DIR
+
+
+@pytest.fixture(scope="function")
def ground_truth_detections():
with open(os.path.join(TEST_DATA_DIR, "dets.pickle"), "rb") as file:
return pickle.load(file)
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def model_outputs():
with open(os.path.join(TEST_DATA_DIR, "outputs.pickle"), "rb") as file:
scmaps, locrefs, pafs = pickle.load(file)
@@ -54,19 +93,19 @@ def model_outputs():
return scmaps, locrefs, pafs
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def sample_image():
return np.asarray(Image.open(os.path.join(TEST_DATA_DIR, "image.png")))
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def sample_keypoints():
with open(os.path.join(TEST_DATA_DIR, "trimouse_assemblies.pickle"), "rb") as file:
temp = pickle.load(file)
return np.concatenate(temp[0])[:, :2]
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def real_assemblies():
with open(os.path.join(TEST_DATA_DIR, "trimouse_assemblies.pickle"), "rb") as file:
temp = pickle.load(file)
@@ -74,7 +113,7 @@ def real_assemblies():
return inferenceutils._parse_ground_truth_data(data)
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def real_assemblies_montblanc():
with open(os.path.join(TEST_DATA_DIR, "montblanc_assemblies.pickle"), "rb") as file:
temp = pickle.load(file)
@@ -86,19 +125,19 @@ def real_assemblies_montblanc():
return inferenceutils._parse_ground_truth_data(data), single
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def real_tracklets():
with open(os.path.join(TEST_DATA_DIR, "trimouse_tracklets.pickle"), "rb") as file:
return pickle.load(file)
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def real_tracklets_montblanc():
with open(os.path.join(TEST_DATA_DIR, "montblanc_tracklets.pickle"), "rb") as file:
return pickle.load(file)
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def evaluation_data_and_metadata():
full_data_file = os.path.join(TEST_DATA_DIR, "trimouse_eval.pickle")
metadata_file = full_data_file.replace("eval", "meta")
@@ -109,7 +148,7 @@ def evaluation_data_and_metadata():
return data, metadata
-@pytest.fixture(scope="session")
+@pytest.fixture(scope="function")
def evaluation_data_and_metadata_montblanc():
full_data_file = os.path.join(TEST_DATA_DIR, "montblanc_eval.pickle")
metadata_file = full_data_file.replace("eval", "meta")
diff --git a/tests/core/config/conftest.py b/tests/core/config/conftest.py
new file mode 100644
index 0000000000..6f62520f50
--- /dev/null
+++ b/tests/core/config/conftest.py
@@ -0,0 +1,45 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Shared fixtures for config tests (no import-time migration registration)."""
+
+import pytest
+
+from deeplabcut.core.config import versioning
+from deeplabcut.core.config.versioning import register_migration
+
+_TOY_VERSION_OLD = 98
+_TOY_VERSION_NEW = 99
+_LEGACY_FIELD = "toy_legacy_field"
+_NEW_FIELD = "toy_new_field"
+
+
+@pytest.fixture(autouse=True)
+def isolated_migration_registry(monkeypatch):
+ """Snapshot the migration registry per test; production migrations only at baseline."""
+ monkeypatch.setattr(versioning, "_MIGRATIONS", versioning._MIGRATIONS.copy())
+
+
+@pytest.fixture
+def register_toy_migrations(monkeypatch):
+ """Register v98 <-> v99 toy migrations for the given config_type(s)."""
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", _TOY_VERSION_NEW)
+
+ def register_pair(config_type: str) -> None:
+ @register_migration(_TOY_VERSION_OLD, _TOY_VERSION_NEW, config_type=config_type)
+ def up(config: dict) -> dict:
+ if _LEGACY_FIELD in config:
+ config[_NEW_FIELD] = config.pop(_LEGACY_FIELD)
+ return config
+
+ @register_migration(_TOY_VERSION_NEW, _TOY_VERSION_OLD, config_type=config_type)
+ def down(config: dict) -> dict:
+ if _NEW_FIELD in config:
+ config[_LEGACY_FIELD] = config.pop(_NEW_FIELD)
+ return config
+
+ return register_pair
diff --git a/tests/core/config/test_base_config.py b/tests/core/config/test_base_config.py
new file mode 100644
index 0000000000..4173bfd8fd
--- /dev/null
+++ b/tests/core/config/test_base_config.py
@@ -0,0 +1,493 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for DLCBaseConfig."""
+
+import pytest
+from pydantic import Field, ValidationError
+
+from deeplabcut.core.config import DLCBaseConfig, DLCVersionedConfig
+from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+_TOY_VERSION_OLD = 98
+_TOY_VERSION_NEW = 99
+_LEGACY_FIELD = "toy_legacy_field"
+
+
+class ToyConfig(DLCBaseConfig):
+ """Minimal config used to exercise DLCBaseConfig."""
+
+ Task: str = "DefaultTask"
+ project_path: str = Field(
+ default="DefaultProjectPath",
+ json_schema_extra={"aliases": ["projectPath"]},
+ )
+
+
+class NestedInner(DLCBaseConfig):
+ lr: float = 0.001
+ momentum: float = 0.9
+
+
+class NestedOuter(DLCBaseConfig):
+ name: str = "outer"
+ inner: NestedInner | None = Field(default_factory=NestedInner)
+
+
+class DictBranchConfig(DLCBaseConfig):
+ payload: dict = Field(default_factory=lambda: {"section": {"key": "old", "count": 1}})
+
+
+# ------------------------------------------------------------------
+# Dict-like access protocol
+# ------------------------------------------------------------------
+
+
+class TestGetitem:
+ def test_getitem_returns_field_value(self):
+ cfg = ToyConfig(Task="t", project_path="/p")
+ assert cfg["Task"] == "t"
+ assert cfg["project_path"] == "/p"
+
+ def test_getitem_missing_key_raises_key_error(self):
+ cfg = ToyConfig()
+ with pytest.raises(KeyError):
+ cfg["nonexistent"]
+
+
+class TestSetitem:
+ def test_setitem_updates_field(self):
+ cfg = ToyConfig()
+ cfg["Task"] = "new_task"
+ assert cfg.Task == "new_task"
+ assert cfg["Task"] == "new_task"
+
+ def test_setitem_unknown_field_raises_key_error(self):
+ cfg = ToyConfig()
+ with pytest.raises(KeyError, match="no field"):
+ cfg["nonexistent"] = 42
+
+
+class TestContains:
+ def test_contains_existing_field(self):
+ cfg = ToyConfig()
+ assert "Task" in cfg
+ assert "project_path" in cfg
+
+ def test_contains_missing_field(self):
+ cfg = ToyConfig()
+ assert "nonexistent" not in cfg
+
+ def test_contains_non_string_returns_false(self):
+ cfg = ToyConfig()
+ assert 42 not in cfg
+
+
+class TestGet:
+ def test_get_existing_key(self):
+ cfg = ToyConfig(Task="hello")
+ assert cfg.get("Task") == "hello"
+
+ def test_get_missing_key_returns_default(self):
+ cfg = ToyConfig()
+ assert cfg.get("nonexistent") is None
+ assert cfg.get("nonexistent", 42) == 42
+
+
+class TestUpdate:
+ def test_update_applies_fields_dict(self):
+ cfg = ToyConfig()
+ cfg.update({"Task": "t", "project_path": "/p"})
+ assert cfg.Task == "t"
+ assert cfg.project_path == "/p"
+
+ def test_update_applies_fields_kwargs(self):
+ cfg = ToyConfig()
+ cfg.update(Task="t", project_path="/p")
+ assert cfg.Task == "t"
+ assert cfg.project_path == "/p"
+
+ def test_update_empty_is_noop(self):
+ cfg = ToyConfig(Task="unchanged")
+ assert cfg.update() is cfg
+ assert cfg.update({}) is cfg
+ assert cfg.Task == "unchanged"
+
+ def test_update_rejects_dict_and_kwargs_together(self):
+ cfg = ToyConfig()
+ with pytest.raises(TypeError, match="either a dict or keyword"):
+ cfg.update({"Task": "x"}, Task="y")
+
+ def test_update_unknown_field_raises_validation_error(self):
+ cfg = ToyConfig()
+ with pytest.raises(ValidationError):
+ cfg.update({"nonexistent": 42})
+
+ def test_update_invalid_value_raises(self):
+ cfg = TypedBase()
+ with pytest.raises(ValidationError):
+ cfg.update({"count": "not-a-number"})
+ assert cfg.count == 0
+
+ def test_update_resolves_alias_with_warning(self):
+ cfg = ToyConfig()
+ with pytest.warns(DLCDeprecationWarning, match="projectPath") as record:
+ cfg.update({"projectPath": "/alias"})
+ assert len(record) == 1
+ assert cfg.project_path == "/alias"
+
+ def test_update_rejects_alias_and_canonical_together(self):
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ with pytest.raises(TypeError, match=r"projectPath.*project_path"):
+ ToyConfig().update({"projectPath": "/a", "project_path": "/b"})
+
+
+class TestKeysValuesItems:
+ def test_keys(self):
+ cfg = ToyConfig(Task="t", project_path="/p")
+ assert cfg.keys() == ["Task", "project_path"]
+
+ def test_values(self):
+ cfg = ToyConfig(Task="t", project_path="/p")
+ assert cfg.values() == ["t", "/p"]
+
+ def test_items(self):
+ cfg = ToyConfig(Task="t", project_path="/p")
+ assert cfg.items() == [("Task", "t"), ("project_path", "/p")]
+
+
+class TestIterAndLen:
+ def test_iter_yields_keys(self):
+ cfg = ToyConfig()
+ assert list(cfg) == ["Task", "project_path"]
+
+ def test_len(self):
+ cfg = ToyConfig()
+ assert len(cfg) == 2
+
+ def test_dict_constructor(self):
+ cfg = ToyConfig(Task="t", project_path="/p")
+ d = dict(cfg.items())
+ assert d == {"Task": "t", "project_path": "/p"}
+
+
+# ------------------------------------------------------------------
+# select() utility
+# ------------------------------------------------------------------
+
+
+class TestSelect:
+ def test_select_single_level(self):
+ cfg = ToyConfig(Task="t")
+ assert cfg.select("Task") == "t"
+
+ def test_select_nested_config(self):
+ cfg = NestedOuter(name="outer", inner=NestedInner(lr=0.01))
+ assert cfg.select("inner.lr") == 0.01
+ assert cfg.select("inner.momentum") == 0.9
+
+ def test_select_missing_returns_default(self):
+ cfg = ToyConfig()
+ assert cfg.select("nonexistent") is None
+ assert cfg.select("nonexistent", "fallback") == "fallback"
+
+ def test_select_deep_missing_returns_default(self):
+ cfg = NestedOuter()
+ assert cfg.select("inner.nonexistent") is None
+ assert cfg.select("nope.deep.path", 0) == 0
+
+ def test_select_none_intermediate(self):
+ cfg = NestedOuter(inner=None)
+ assert cfg.select("inner.lr") is None
+
+
+# ------------------------------------------------------------------
+# set_nested() utility
+# ------------------------------------------------------------------
+
+
+class TestSetNested:
+ def test_set_nested_top_level_field(self):
+ cfg = ToyConfig()
+ cfg.set_nested("Task", "updated")
+ assert cfg.Task == "updated"
+ assert cfg.select("Task") == "updated"
+
+ def test_set_nested_nested_typed_field(self):
+ cfg = NestedOuter(inner=NestedInner(lr=0.001))
+ cfg.set_nested("inner.lr", 0.05)
+ assert cfg.inner.lr == 0.05
+ assert cfg.select("inner.lr") == 0.05
+
+ def test_set_nested_dict_field(self):
+ cfg = DictBranchConfig()
+ cfg.set_nested("payload.section.key", "new")
+ cfg.set_nested("payload.section.count", 42)
+ assert cfg.select("payload.section.key") == "new"
+ assert cfg.select("payload.section.count") == 42
+
+ def test_set_nested_returns_self_for_nested_path(self):
+ cfg = NestedOuter()
+ assert cfg.set_nested("inner.lr", 0.02) is cfg
+
+ def test_set_nested_missing_path_raises(self):
+ cfg = NestedOuter()
+ with pytest.raises(AttributeError, match="has no 'inner.missing'"):
+ cfg.set_nested("inner.missing", 1.0)
+
+ def test_set_nested_none_intermediate_raises(self):
+ cfg = NestedOuter(inner=None)
+ with pytest.raises(AttributeError, match="has no 'inner.lr'"):
+ cfg.set_nested("inner.lr", 0.1)
+
+ def test_set_nested_invalid_value_raises(self):
+ cfg = NestedOuter()
+ with pytest.raises(ValidationError):
+ cfg.set_nested("inner.lr", "not-a-float")
+ assert cfg.inner.lr == 0.001
+
+ def test_set_nested_unknown_top_level_field_raises(self):
+ cfg = ToyConfig()
+ with pytest.raises(ValidationError):
+ cfg.set_nested("nonexistent", 42)
+
+
+# ------------------------------------------------------------------
+# model_validate / extra=forbid
+# ------------------------------------------------------------------
+
+
+class TestModelValidate:
+ def test_model_validate_returns_instance(self):
+ cfg = ToyConfig.model_validate({"Task": "task", "project_path": ""})
+ assert isinstance(cfg, ToyConfig)
+ assert cfg.Task == "task"
+
+ def test_model_validate_rejects_extra_keys(self):
+ with pytest.raises(ValidationError):
+ ToyConfig.model_validate({"Task": "ok", "project_path": "", "extra": 1})
+
+
+# ------------------------------------------------------------------
+# aliases
+# ------------------------------------------------------------------
+
+
+class TestAliases:
+ def test_model_validate_resolves_alias_without_from_dict(self):
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ cfg = ToyConfig.model_validate({"Task": "t", "projectPath": "/alias"})
+ assert cfg.project_path == "/alias"
+
+ def test_model_validate_rejects_alias_and_canonical_together(self):
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ with pytest.raises(TypeError, match=r"projectPath.*project_path"):
+ ToyConfig.model_validate({"Task": "t", "projectPath": "/alias", "project_path": "/canonical"})
+
+ def test_getitem_alias_warns_and_reads_canonical(self):
+ cfg = ToyConfig(project_path="/p")
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ assert cfg["projectPath"] == "/p"
+
+ def test_setitem_alias_warns_once_and_writes_canonical(self):
+ cfg = ToyConfig()
+ with pytest.warns(DLCDeprecationWarning, match="projectPath") as record:
+ cfg["projectPath"] = "/new"
+ assert len(record) == 1
+ assert cfg.project_path == "/new"
+
+ def test_setattr_alias_warns_and_validates(self):
+ cfg = ToyConfig()
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ cfg.projectPath = "/attr"
+ assert cfg.project_path == "/attr"
+
+ def test_getattr_alias_warns(self):
+ cfg = ToyConfig(project_path="/p")
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ assert cfg.projectPath == "/p"
+
+ def test_contains_accepts_alias(self):
+ cfg = ToyConfig()
+ assert "projectPath" in cfg
+
+ def test_keys_does_not_include_alias(self):
+ """keys() / iter() only yield canonical field names, never alias names."""
+ cfg = ToyConfig()
+ assert "projectPath" not in cfg.keys()
+ assert "projectPath" not in list(cfg)
+
+ def test_to_dict_uses_canonical_names_only(self):
+ """to_dict() always emits canonical field names, even when loaded via alias."""
+ with pytest.warns(DLCDeprecationWarning):
+ cfg = ToyConfig.model_validate({"Task": "t", "projectPath": "/alias"})
+ d = cfg.to_dict()
+ assert "project_path" in d
+ assert "projectPath" not in d
+
+ def test_from_yaml_with_alias_key_warns_and_loads(self, tmp_path):
+ """A YAML file that contains an alias key emits a warning on load."""
+ path = tmp_path / "config.yaml"
+ path.write_text("Task: t\nprojectPath: /from_yaml\n")
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ cfg = ToyConfig.from_yaml(path)
+ assert cfg.project_path == "/from_yaml"
+
+ def test_to_yaml_then_from_yaml_no_alias_warning(self, tmp_path):
+ """After saving via to_yaml, reloading must not emit any alias warning
+ (the YAML file should contain canonical field names)."""
+ import warnings
+
+ path = tmp_path / "config.yaml"
+ cfg = ToyConfig(Task="t", project_path="/p")
+ cfg.to_yaml(path)
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DLCDeprecationWarning)
+ loaded = ToyConfig.from_yaml(path)
+ assert loaded.project_path == "/p"
+
+
+# ------------------------------------------------------------------
+# validate_assignment on DLCBaseConfig (not just versioned)
+# ------------------------------------------------------------------
+
+
+class TypedBase(DLCBaseConfig):
+ count: int = 0
+
+
+class TestBaseConfigValidateAssignment:
+ def test_invalid_assignment_raises(self):
+ cfg = TypedBase()
+ with pytest.raises(ValidationError):
+ cfg.count = "not-a-number"
+ assert cfg.count == 0
+
+ def test_valid_assignment_takes_effect(self):
+ cfg = TypedBase()
+ cfg.count = 7
+ assert cfg.count == 7
+
+ def test_coercion_applied(self):
+ cfg = TypedBase()
+ cfg.count = True # coerced to 1
+ assert cfg.count == 1
+
+
+# ------------------------------------------------------------------
+# from_dict / from_any / from_yaml / to_dict / to_yaml
+# ------------------------------------------------------------------
+
+
+def test_from_dict_returns_instance():
+ cfg = ToyConfig.from_dict({"Task": "t", "project_path": "/p"})
+ assert isinstance(cfg, ToyConfig)
+ assert cfg.Task == "t"
+ assert cfg.project_path == "/p"
+
+
+def test_from_dict_incomplete_uses_defaults():
+ cfg = ToyConfig.from_dict({"Task": "custom_task"})
+ assert cfg.Task == "custom_task"
+ assert cfg.project_path == "DefaultProjectPath"
+
+ cfg_empty = ToyConfig.from_dict({})
+ assert cfg_empty.Task == "DefaultTask"
+ assert cfg_empty.project_path == "DefaultProjectPath"
+
+
+def test_from_any_with_instance_returns_same():
+ cfg = ToyConfig.from_dict({"Task": "x", "project_path": ""})
+ out = ToyConfig.from_any(cfg)
+ assert out is cfg
+
+
+def test_from_any_with_dict_returns_instance():
+ cfg = ToyConfig.from_any({"Task": "y", "project_path": ""})
+ assert isinstance(cfg, ToyConfig)
+ assert cfg.Task == "y"
+
+
+def test_from_any_with_invalid_type_raises():
+ with pytest.raises(TypeError, match="Expected.*Got "):
+ ToyConfig.from_any(42)
+
+
+def test_from_yaml_loads_file(tmp_path):
+ path = tmp_path / "config.yaml"
+ path.write_text("Task: mytask\nproject_path: /foo\n")
+ cfg = ToyConfig.from_yaml(path)
+ assert cfg.Task == "mytask"
+ assert cfg.project_path == "/foo"
+
+
+def test_to_dict_returns_dict():
+ cfg = ToyConfig.from_dict({"Task": "t", "project_path": ""})
+ d = cfg.to_dict()
+ assert isinstance(d, dict)
+ assert d["Task"] == "t"
+
+
+def test_from_dict_to_dict_roundtrip():
+ d = {"Task": "rt", "project_path": "/x"}
+ cfg = ToyConfig.from_dict(d)
+ out = cfg.to_dict()
+ restored = ToyConfig.from_dict(out)
+ assert restored.Task == cfg.Task
+ assert restored.project_path == cfg.project_path
+
+
+def test_to_yaml_roundtrip(tmp_path):
+ path = tmp_path / "out.yaml"
+ orig = ToyConfig.from_dict({"Task": "rt", "project_path": "/x"})
+ orig.to_yaml(path)
+ loaded = ToyConfig.from_yaml(path)
+ assert loaded.Task == orig.Task
+ assert loaded.project_path == orig.project_path
+
+
+def test_print_no_error(capsys):
+ cfg = ToyConfig.from_dict({"Task": "p", "project_path": ""})
+ cfg.print()
+ out, _ = capsys.readouterr()
+ assert "Task" in out
+
+
+# ------------------------------------------------------------------
+# No schema migration on DLCBaseConfig
+# ------------------------------------------------------------------
+
+
+class _ToyBaseOnly(DLCBaseConfig):
+ toy_new_field: str = ""
+
+
+class _ToyVersioned(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ toy_new_field: str = ""
+
+
+class TestNoMigrationOnBaseConfig:
+ """DLCBaseConfig resolves aliases but does not run version migrations."""
+
+ def test_base_config_rejects_legacy_key_without_migration(self):
+ legacy_cfg = {
+ "config_version": _TOY_VERSION_OLD,
+ _LEGACY_FIELD: "value",
+ }
+ with pytest.raises(ValidationError):
+ _ToyBaseOnly.from_dict(legacy_cfg)
+
+ def test_versioned_config_applies_migration_for_same_input(self, register_toy_migrations):
+ register_toy_migrations("_ToyVersioned")
+ legacy_cfg = {
+ "config_version": _TOY_VERSION_OLD,
+ _LEGACY_FIELD: "value",
+ }
+ cfg = _ToyVersioned.from_dict(legacy_cfg)
+ assert cfg.toy_new_field == "value"
diff --git a/tests/core/config/test_change_tracking.py b/tests/core/config/test_change_tracking.py
new file mode 100644
index 0000000000..d2f2a90dcc
--- /dev/null
+++ b/tests/core/config/test_change_tracking.py
@@ -0,0 +1,333 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for change tracking on DLCVersionedConfig."""
+
+from __future__ import annotations
+
+import logging
+
+import pytest
+from pydantic import Field, ValidationError
+
+from deeplabcut.core.config import DLCVersionedConfig
+from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+
+class TrackedConfig(DLCVersionedConfig):
+ name: str = "default"
+ count: int = 0
+ flag: bool = False
+
+
+# ------------------------------------------------------------------
+# Dirty-state tracking
+# ------------------------------------------------------------------
+
+
+class TestDirtyState:
+ def test_clean_after_construction(self):
+ cfg = TrackedConfig()
+ assert not cfg.is_dirty
+ assert cfg.dirty_fields == frozenset()
+
+ def test_dirty_after_field_change(self):
+ cfg = TrackedConfig()
+ cfg.name = "changed"
+ assert cfg.is_dirty
+ assert "name" in cfg.dirty_fields
+
+ def test_not_dirty_when_set_to_same_value(self):
+ cfg = TrackedConfig(name="same")
+ cfg.name = "same"
+ assert not cfg.is_dirty
+
+ def test_multiple_fields_tracked(self):
+ cfg = TrackedConfig()
+ cfg.name = "new"
+ cfg.count = 5
+ assert cfg.dirty_fields == frozenset({"name", "count"})
+
+ def test_update_marks_fields_dirty(self):
+ cfg = TrackedConfig()
+ cfg.update({"name": "new", "count": 5})
+ assert cfg.is_dirty
+ assert cfg.dirty_fields == frozenset({"name", "count"})
+
+ def test_dirty_fields_is_frozen(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ fs = cfg.dirty_fields
+ assert isinstance(fs, frozenset)
+
+
+# ------------------------------------------------------------------
+# mark_clean
+# ------------------------------------------------------------------
+
+
+class TestMarkClean:
+ def test_mark_clean_resets_dirty_fields(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.mark_clean()
+ assert not cfg.is_dirty
+ assert cfg.dirty_fields == frozenset()
+
+ def test_mark_clean_resets_change_notes(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.record_change_note("name", "renamed")
+ cfg.mark_clean()
+ assert cfg.change_notes == []
+
+ def test_dirty_again_after_mark_clean(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.mark_clean()
+ cfg.count = 10
+ assert cfg.is_dirty
+ assert cfg.dirty_fields == frozenset({"count"})
+
+
+# ------------------------------------------------------------------
+# Change notes
+# ------------------------------------------------------------------
+
+
+class TestChangeNotes:
+ def test_no_notes_by_default(self):
+ cfg = TrackedConfig()
+ assert cfg.change_notes == []
+
+ def test_record_and_retrieve_note(self):
+ cfg = TrackedConfig()
+ cfg.name = "new"
+ cfg.record_change_note("name", "name was updated to 'new'")
+ assert cfg.change_notes == ["name was updated to 'new'"]
+
+ def test_note_overwrites_previous_for_same_field(self):
+ cfg = TrackedConfig()
+ cfg.name = "a"
+ cfg.record_change_note("name", "first")
+ cfg.record_change_note("name", "second")
+ assert cfg.change_notes == ["second"]
+
+ def test_multiple_notes_for_different_fields(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.count = 1
+ cfg.record_change_note("name", "note-name")
+ cfg.record_change_note("count", "note-count")
+ assert set(cfg.change_notes) == {"note-name", "note-count"}
+
+ def test_include_caller_appends_tag(self):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.record_change_note("name", "updated", include_caller=True)
+ notes = cfg.change_notes
+ assert len(notes) == 1
+ assert notes[0].startswith("updated [")
+ assert "test_change_tracking.py:" in notes[0]
+
+
+# ------------------------------------------------------------------
+# log_changes
+# ------------------------------------------------------------------
+
+
+class TestLogChanges:
+ def test_log_changes_no_output_when_clean(self, caplog):
+ cfg = TrackedConfig()
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+ assert caplog.text == ""
+
+ def test_log_changes_includes_note(self, caplog):
+ cfg = TrackedConfig()
+ cfg.name = "x"
+ cfg.record_change_note("name", "name was updated")
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+ assert "name was updated" in caplog.text
+
+ def test_log_changes_shows_field_without_note(self, caplog):
+ cfg = TrackedConfig()
+ cfg.count = 99
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+ assert "count was modified" in caplog.text
+
+ def test_log_changes_mixes_notes_and_bare_fields(self, caplog):
+ cfg = TrackedConfig()
+ cfg.name = "y"
+ cfg.count = 5
+ cfg.record_change_note("name", "renamed to y")
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+ assert "renamed to y" in caplog.text
+ assert "count was modified" in caplog.text
+
+ def test_log_changes_header_contains_class_name(self, caplog):
+ cfg = TrackedConfig()
+ cfg.flag = True
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+ assert "TrackedConfig" in caplog.text
+
+
+# ------------------------------------------------------------------
+# Integration with DLCVersionedConfig.from_yaml
+# ------------------------------------------------------------------
+
+
+class TestFromYamlIntegration:
+ def test_clean_after_from_yaml(self, tmp_path):
+ path = tmp_path / "config.yaml"
+ path.write_text("name: loaded\ncount: 42\nflag: true\n")
+ cfg = TrackedConfig.from_yaml(path)
+ assert cfg.name == "loaded"
+ assert not cfg.is_dirty
+ assert cfg.change_notes == []
+
+ def test_roundtrip_stays_clean(self, tmp_path):
+ path = tmp_path / "config.yaml"
+ orig = TrackedConfig(name="rt", count=7)
+ orig.to_yaml(path)
+ loaded = TrackedConfig.from_yaml(path)
+ assert not loaded.is_dirty
+
+
+# ------------------------------------------------------------------
+# Interaction with pydantic validate_assignment
+# ------------------------------------------------------------------
+
+
+class TestValidateAssignment:
+ """Verify that change tracking wraps *around* pydantic's validator."""
+
+ def test_valid_assignment_is_tracked(self):
+ cfg = TrackedConfig()
+ cfg.count = 7
+ assert cfg.count == 7
+ assert "count" in cfg.dirty_fields
+
+ def test_invalid_assignment_rejected_and_not_tracked(self):
+ cfg = TrackedConfig()
+ with pytest.raises(ValidationError):
+ cfg.count = "not-an-int"
+ assert cfg.count == 0
+ assert not cfg.is_dirty
+
+ def test_coerced_value_stored(self):
+ """Pydantic coerces compatible types (e.g. bool -> int); the coerced
+ value should be stored and tracked."""
+ cfg = TrackedConfig()
+ cfg.count = True # coerced to 1
+ assert cfg.count == 1
+ assert "count" in cfg.dirty_fields
+
+
+# ------------------------------------------------------------------
+# Instance isolation â two instances of the same class are independent
+# ------------------------------------------------------------------
+
+
+class TestInstanceIsolation:
+ """The class-level __setattr__ patch must not cause instances to share state."""
+
+ def test_two_instances_independent_dirty_sets(self):
+ cfg1 = TrackedConfig()
+ cfg2 = TrackedConfig()
+ cfg1.name = "one"
+ assert "name" in cfg1.dirty_fields
+ assert not cfg2.is_dirty
+
+ def test_mark_clean_on_one_does_not_affect_other(self):
+ cfg1 = TrackedConfig()
+ cfg2 = TrackedConfig()
+ cfg1.name = "x"
+ cfg2.count = 5
+ cfg1.mark_clean()
+ assert not cfg1.is_dirty
+ assert cfg2.is_dirty
+ assert "count" in cfg2.dirty_fields
+
+ def test_different_subclasses_independent(self):
+ class ConfigA(DLCVersionedConfig):
+ x: int = 0
+
+ class ConfigB(DLCVersionedConfig):
+ y: int = 0
+
+ a = ConfigA()
+ b = ConfigB()
+ a.x = 1
+ assert "x" in a.dirty_fields
+ assert not b.is_dirty
+
+
+# ------------------------------------------------------------------
+# from_dict and to_yaml lifecycle
+# ------------------------------------------------------------------
+
+
+class TestLifecycle:
+ def test_from_dict_is_clean(self):
+ cfg = TrackedConfig.from_dict({"name": "loaded", "count": 5})
+ assert not cfg.is_dirty
+ assert cfg.change_notes == []
+
+ def test_to_yaml_marks_writer_clean(self, tmp_path):
+ path = tmp_path / "out.yaml"
+ cfg = TrackedConfig(name="dirty")
+ cfg.name = "changed"
+ assert cfg.is_dirty
+ cfg.to_yaml(path)
+ assert not cfg.is_dirty
+
+ def test_to_yaml_with_mark_clean_false_does_not_reset(self, tmp_path):
+ path = tmp_path / "out.yaml"
+ cfg = TrackedConfig()
+ cfg.name = "changed"
+ cfg.to_yaml(path, mark_clean=False)
+ assert cfg.is_dirty
+
+ def test_record_change_note_for_unmodified_field_is_stored(self):
+ """record_change_note does not require the field to be dirty; it just stores the note."""
+ cfg = TrackedConfig()
+ cfg.record_change_note("name", "set during init")
+ assert cfg.change_notes == ["set during init"]
+ assert not cfg.is_dirty # still clean â no assignment was made
+
+
+# ------------------------------------------------------------------
+# Alias access with change tracking
+# ------------------------------------------------------------------
+
+
+class TrackedAliasConfig(DLCVersionedConfig):
+ project_path: str = Field(
+ default="",
+ json_schema_extra={"aliases": ["projectPath"]},
+ )
+
+
+class TestAliasChangeTracking:
+ def test_setitem_alias_marks_canonical_field_dirty(self):
+ cfg = TrackedAliasConfig()
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ cfg["projectPath"] = "/new"
+ assert cfg.project_path == "/new"
+ assert "project_path" in cfg.dirty_fields
+
+ def test_setattr_alias_marks_canonical_field_dirty(self):
+ cfg = TrackedAliasConfig()
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ cfg.projectPath = "/attr"
+ assert cfg.project_path == "/attr"
+ assert "project_path" in cfg.dirty_fields
diff --git a/tests/core/config/test_config_breakage.py b/tests/core/config/test_config_breakage.py
new file mode 100644
index 0000000000..af85cc2724
--- /dev/null
+++ b/tests/core/config/test_config_breakage.py
@@ -0,0 +1,282 @@
+"""Pathological casess tests for the centralized config model."""
+
+from __future__ import annotations
+
+import enum
+import logging
+from pathlib import Path
+
+import pytest
+from pydantic import Field, ValidationError
+
+from deeplabcut.core.config import DLCBaseConfig, DLCVersionedConfig, ProjectConfig, versioning
+from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+# -----------------------------------------------------------------------------
+# In-place nested mutation
+# -----------------------------------------------------------------------------
+
+
+@pytest.mark.xfail(
+ reason="This may make validation difficult and cause subtle issues "
+ "if we start changing how functions such as append() operate."
+ " Avoiding lists in configs could be a better long-term solution."
+)
+def test_in_place_list_mutation_should_be_validated_like_assignment():
+ """Appending to a config list changes config state without calling __setattr__.
+
+ A strict config system should reject invalid values even when users mutate an
+ existing list instead of assigning a replacement list.
+
+ Note that this pattern would incur validation:
+ cfg.TrainingFraction = [*cfg.TrainingFraction, 0.8]
+ cfg.video_sets = {**cfg.video_sets, "video.mp4": {"crop": "0, 100, 0, 100"}}
+ """
+ cfg = ProjectConfig(TrainingFraction=[0.95])
+ cfg.mark_clean()
+
+ with pytest.raises(ValidationError):
+ cfg.TrainingFraction.append(1.5)
+
+
+@pytest.mark.xfail(reason="Not implemented yet")
+@pytest.mark.parametrize(
+ ("field_name", "mutate"),
+ [
+ ("TrainingFraction", lambda cfg: cfg.TrainingFraction.append(0.8)),
+ ("video_sets", lambda cfg: cfg.video_sets.__setitem__("video.mp4", {"crop": "0, 100, 0, 100"})),
+ ],
+)
+def test_in_place_nested_mutation_should_mark_config_dirty(field_name, mutate):
+ """Mutating nested containers changes the saved config but may bypass dirty tracking.
+
+ Dirty tracking is useful only if semantic config changes are recorded, whether
+ the user assigns a whole field or mutates a nested list/dict in place.
+ This ties with the problem that some nested fields are still dict instead of config models,
+ maybe once we have full config models we can come up with a parent/child system where changes in a child
+ are detected when querying the parent for its dirty state (or a simpler equivalent)
+ """
+ cfg = ProjectConfig(TrainingFraction=[0.95], video_sets={})
+ cfg.mark_clean()
+
+ mutate(cfg)
+
+ assert cfg.is_dirty
+ assert field_name in cfg.dirty_fields
+
+
+# -----------------------------------------------------------------------------
+# Private state isolation
+# -----------------------------------------------------------------------------
+
+
+class _TrackedForIsolation(DLCVersionedConfig):
+ name: str = "default"
+ count: int = 0
+
+
+def test_dirty_state_is_not_shared_between_instances():
+ """Private dirty-field state must be per-instance, not shared via a mutable default."""
+ first = _TrackedForIsolation()
+ second = _TrackedForIsolation()
+
+ first.name = "changed"
+
+ assert "name" in first.dirty_fields
+ assert not second.is_dirty
+ assert "name" not in second.dirty_fields
+
+
+def test_change_notes_are_not_shared_between_instances():
+ """Private change-note state must be per-instance, not shared via a mutable default."""
+ first = _TrackedForIsolation()
+ second = _TrackedForIsolation()
+
+ first.record_change_note("name", "name changed on first instance")
+
+ assert first.change_notes == ["name changed on first instance"]
+ assert second.change_notes == []
+
+
+# -----------------------------------------------------------------------------
+# Changes note consistency
+# -----------------------------------------------------------------------------
+
+
+def test_change_note_should_reject_unknown_field_names():
+ """Notes for misspelled fields are silently lost during logging.
+
+ Rejecting unknown field names catches typos at the call site instead of
+ storing a note that can never match a dirty field.
+ """
+ cfg = ProjectConfig()
+
+ with pytest.raises(KeyError):
+ cfg.record_change_note("not_a_real_field", "this note should not be accepted")
+
+
+def test_change_note_recorded_with_alias_should_follow_canonical_dirty_field(caplog):
+ """Aliases should not split notes from the canonical dirty field name.
+
+ If a note is recorded with a deprecated alias, log_changes should still use
+ that note when the canonical field is modified.
+ """
+ cfg = ProjectConfig()
+ cfg.mark_clean()
+
+ with pytest.warns(DLCDeprecationWarning, match="with_identity"):
+ cfg.record_change_note("with_identity", "identity changed through compatibility alias")
+ cfg.identity = True
+
+ with caplog.at_level(logging.INFO):
+ cfg.log_changes()
+
+ assert "identity changed through compatibility alias" in caplog.text
+
+
+# -----------------------------------------------------------------------------
+# Nested YAML comments and nested serialization
+# -----------------------------------------------------------------------------
+
+
+class _CommentedInner(DLCBaseConfig):
+ threshold: float = Field(
+ default=0.5,
+ json_schema_extra={"comment": "Nested threshold comment"},
+ )
+
+
+class _CommentedOuter(DLCBaseConfig):
+ inner: _CommentedInner = Field(
+ default_factory=_CommentedInner,
+ json_schema_extra={"comment": "Inner config section"},
+ )
+
+
+def test_to_yaml_should_emit_comments_for_nested_config_fields(tmp_path):
+ """Nested config models can have field comments too.
+
+ This protects against only applying YAML comments to the top-level model and
+ silently dropping useful nested schema documentation.
+ """
+ path = tmp_path / "commented.yaml"
+
+ _CommentedOuter().to_yaml(path)
+
+ text = path.read_text()
+ assert "Inner config section" in text
+ assert "Nested threshold comment" in text
+
+
+class _SerializationMode(enum.Enum):
+ FAST = "fast"
+ ACCURATE = "accurate"
+
+
+class _SerializableInner(DLCBaseConfig):
+ output_path: Path = Path("outputs/predictions")
+ mode: _SerializationMode = _SerializationMode.FAST
+
+
+class _SerializableOuter(DLCBaseConfig):
+ inner: _SerializableInner = Field(default_factory=_SerializableInner)
+
+
+def test_nested_config_normalization_should_handle_paths_and_enums():
+ """Nested model dumps should normalize non-primitive values before YAML output.
+
+ Path and Enum values are common in configs and should become plain YAML-safe
+ values even when they appear inside nested config models.
+ """
+ cfg = _SerializableOuter()
+
+ assert cfg.to_dict(normalize=True) == {
+ "inner": {
+ "output_path": str(Path("outputs/predictions")),
+ "mode": "fast",
+ }
+ }
+
+
+def test_nested_config_to_yaml_should_write_plain_path_and_enum_values(tmp_path):
+ """YAML output should not rely on Python-specific object representation.
+
+ This catches failures where nested BaseModel values, pathlib paths, or enums
+ are passed to the YAML dumper without normalization.
+ """
+ path = tmp_path / "serializable.yaml"
+
+ _SerializableOuter().to_yaml(path)
+
+ text = path.read_text()
+ assert str(Path("outputs/predictions")) in text
+ assert "fast" in text
+
+
+# -----------------------------------------------------------------------------
+# Alias warnings and canonical writes
+# -----------------------------------------------------------------------------
+
+
+def test_item_assignment_with_alias_should_warn_once_and_track_canonical_field():
+ """Dict-style alias assignment should not warn twice or track the alias name.
+
+ __setitem__ resolves aliases before delegating to assignment validation, so
+ the visible side effects should be one warning and one canonical dirty field.
+ """
+ cfg = ProjectConfig()
+ cfg.mark_clean()
+
+ with pytest.warns(DLCDeprecationWarning, match="with_identity") as caught:
+ cfg["with_identity"] = True
+
+ assert len(caught) == 1
+ assert cfg.identity is True
+ assert "identity" in cfg.dirty_fields
+ assert "with_identity" not in cfg.dirty_fields
+
+
+# -----------------------------------------------------------------------------
+# Cross-field validation vs bulk update
+# -----------------------------------------------------------------------------
+
+
+@pytest.mark.xfail(
+ reason=(
+ "update() applies overrides one setattr at a time; cross-field "
+ "model validators run on each assignment, so intermediate states "
+ "like multianimalproject=True with bodyparts=[] are rejected."
+ ),
+)
+def test_update_applies_cross_field_overrides_atomically():
+ """Bulk update should validate the merged state, not each setattr in isolation."""
+ cfg = ProjectConfig(bodyparts=[], multianimalproject=False)
+ # Invalid state, midway updating: when bodyparts is set to "MULTI!" before multianimalproject is set to True.
+ cfg.update({"bodyparts": "MULTI!", "multianimalproject": True})
+ assert cfg.multianimalproject is True
+ assert cfg.bodyparts == "MULTI!"
+
+
+# -----------------------------------------------------------------------------
+# Migration skipped on typed construction
+# -----------------------------------------------------------------------------
+
+
+class _TypedConstructionCfg(DLCVersionedConfig):
+ config_version: int = 1
+ epochs: int = 50
+
+
+@pytest.mark.parametrize("kwargs", [{}, {"epochs": 100}])
+def test_migration_skipped_on_typed_construction(monkeypatch, kwargs):
+ """Typed construction must not run the legacy YAML migration chain.
+
+ ``MyCfg(epochs=100)`` is current-schema construction, not loading an old file.
+ Migration should only run via ``from_dict`` / ``from_yaml``.
+ """
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", 1)
+
+ cfg = _TypedConstructionCfg(**kwargs)
+
+ assert cfg.epochs == kwargs.get("epochs", 50)
+ assert cfg.config_version == 1
diff --git a/tests/core/config/test_core_config.py b/tests/core/config/test_core_config.py
new file mode 100644
index 0000000000..a5422afa6d
--- /dev/null
+++ b/tests/core/config/test_core_config.py
@@ -0,0 +1,482 @@
+#
+# 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.core.config."""
+
+import logging
+from collections.abc import Mapping
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+from ruamel.yaml.error import YAMLError
+
+from deeplabcut.core.config import (
+ create_config_template,
+ create_config_template_3d,
+ edit_config,
+ pretty_print,
+ read_config,
+ read_config_as_dict,
+ resolve_alias,
+ resolve_aliases_in_dict,
+ write_config,
+ write_config_3d,
+ write_config_3d_template,
+ write_project_config,
+)
+from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+# -----------------------------------------------------------------------------
+# read_config_as_dict
+# -----------------------------------------------------------------------------
+
+
+def test_read_config_as_dict_loads_yaml(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("a: 1\nb: [2, 3]\nc:\n d: 4\n")
+ cfg = read_config_as_dict(str(config_path))
+ assert cfg == {"a": 1, "b": [2, 3], "c": {"d": 4}}
+
+
+def test_read_config_as_dict_raises_when_file_missing():
+ with pytest.raises(FileNotFoundError):
+ read_config_as_dict(Path("/nonexistent/config.yaml"))
+
+
+def test_read_config_as_dict_raises_on_invalid_yaml(tmp_path):
+ """Broken YAML syntax (e.g. unclosed bracket) raises an exception."""
+ config_path = tmp_path / "broken.yaml"
+ config_path.write_text("key: [unclosed\n")
+ with pytest.raises(YAMLError):
+ read_config_as_dict(config_path)
+
+
+@pytest.mark.parametrize("content", ["", "null", "~"])
+def test_read_config_as_dict_raises_when_empty_or_null(tmp_path, content):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(content)
+ with pytest.raises(ValueError, match="empty or null"):
+ read_config_as_dict(config_path)
+
+
+def test_read_config_as_dict_raises_when_root_is_not_mapping(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("- item\n")
+ with pytest.raises(ValueError, match="must be a YAML mapping"):
+ read_config_as_dict(config_path)
+
+
+def test_read_config_as_dict_breaks_for_yaml_tags(tmp_path):
+ """read_config breaks for YAML tags like !!python/tuple"""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("project_path: /old/path\nengine: pytorch\nbodyparts: !!python/tuple [a, b, c]\n")
+ with pytest.raises(YAMLError):
+ read_config_as_dict(config_path)
+
+
+def test_read_config_as_dict_accepts_config_with_misnamed_fields(tmp_path):
+ """Config with typos/misnamed keys still loads; keys are not validated."""
+ # NOTE @deruyter92 2026-02-03: This test captures the current behavior where
+ # read_config_as_dict does not validate the keys. This behavior is different
+ # from read_config, which should validate the keys (in the future).
+ # The tests can be updated according, depending on the preferred behavior.
+ config_path = tmp_path / "typos.yaml"
+ config_path.write_text(
+ "project_pathh: /wrong\n" # typo
+ "bodypartz: [a, b]\n" # typo
+ "Task: mytask\n"
+ )
+ cfg = read_config_as_dict(config_path)
+ assert cfg["project_pathh"] == "/wrong"
+ assert cfg["bodypartz"] == ["a", "b"]
+ assert cfg["Task"] == "mytask"
+ assert "project_path" not in cfg
+ assert "bodyparts" not in cfg
+
+
+# -----------------------------------------------------------------------------
+# write_config
+# -----------------------------------------------------------------------------
+
+
+def test_write_config_creates_file(tmp_path):
+ config_path = tmp_path / "out.yaml"
+ write_config(config_path, {"a": 1, "b": 2})
+ assert config_path.exists()
+ cfg = read_config_as_dict(config_path)
+ assert cfg["a"] == 1 and cfg["b"] == 2
+
+
+def test_write_config_overwrites_existing(tmp_path):
+ config_path = tmp_path / "out.yaml"
+ config_path.write_text("old: true\n")
+ write_config(config_path, {"new": True}, overwrite=True)
+ cfg = read_config_as_dict(config_path)
+ assert "new" in cfg and "old" not in cfg
+
+
+def test_write_config_raises_when_overwrite_false_and_file_exists(tmp_path):
+ config_path = tmp_path / "out.yaml"
+ config_path.write_text("x: 1\n")
+ with pytest.raises(FileExistsError):
+ write_config(config_path, {"x": 2}, overwrite=False)
+
+
+def test_write_config_allows_overwrite_false_when_file_missing(tmp_path):
+ config_path = tmp_path / "new.yaml"
+ write_config(config_path, {"x": 1}, overwrite=False)
+ assert read_config_as_dict(config_path) == {"x": 1}
+
+
+# -----------------------------------------------------------------------------
+# pretty_print
+# -----------------------------------------------------------------------------
+
+
+def test_pretty_print_flat_config(capsys):
+ pretty_print({"a": 1, "b": 2})
+ out, _ = capsys.readouterr()
+ assert "a: 1" in out and "b: 2" in out
+
+
+def test_pretty_print_nested_config(capsys):
+ pretty_print({"top": {"nested": 42}})
+ out, _ = capsys.readouterr()
+ assert "top:" in out and "nested: 42" in out
+
+
+def test_pretty_print_with_indent(capsys):
+ pretty_print({"k": "v"}, indent=4)
+ out, _ = capsys.readouterr()
+ assert out.startswith(" k:")
+
+
+def test_pretty_print_with_custom_print_fn():
+ lines = []
+
+ def capture(s):
+ lines.append(s)
+
+ pretty_print({"a": 1}, print_fn=capture)
+ assert any("a: 1" in line for line in lines)
+
+
+# -----------------------------------------------------------------------------
+# create_config_template
+# -----------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("multianimal", [False, True])
+def test_create_config_template_returns_tuple(multianimal):
+ cfg_file, ruamel_file = create_config_template(multianimal=multianimal)
+ assert isinstance(cfg_file, Mapping)
+ assert ruamel_file is not None
+
+
+def test_create_config_template_single_animal_has_expected_keys():
+ cfg_file, _ = create_config_template(multianimal=False)
+ assert "Task" in cfg_file
+ assert "project_path" in cfg_file
+ assert "bodyparts" in cfg_file
+ assert "engine" in cfg_file
+ assert "video_sets" in cfg_file
+ assert "multianimalproject" in cfg_file
+ assert "detector_batch_size" in cfg_file
+
+
+def test_create_config_template_multianimal_has_extra_keys():
+ cfg_file, _ = create_config_template(multianimal=True)
+ assert "individuals" in cfg_file
+ assert "uniquebodyparts" in cfg_file
+ assert "multianimalbodyparts" in cfg_file
+ assert "bodyparts" in cfg_file
+
+
+# -----------------------------------------------------------------------------
+# create_config_template_3d
+# -----------------------------------------------------------------------------
+
+
+def test_create_config_template_3d_returns_tuple():
+ cfg_file_3d, ruamel_file_3d = create_config_template_3d()
+ assert isinstance(cfg_file_3d, Mapping)
+ assert ruamel_file_3d is not None
+
+
+def test_create_config_template_3d_has_expected_keys():
+ cfg_file, _ = create_config_template_3d()
+ assert "Task" in cfg_file
+ assert "project_path" in cfg_file
+ assert "skeleton" in cfg_file
+ assert "num_cameras" in cfg_file
+ assert "camera_names" in cfg_file
+ assert "scorername_3d" in cfg_file
+
+
+# -----------------------------------------------------------------------------
+# read_config
+# -----------------------------------------------------------------------------
+
+
+def test_read_config_raises_when_file_missing(tmp_path):
+ with pytest.raises(FileNotFoundError):
+ read_config(tmp_path / "missing.yaml")
+
+
+def test_read_config_sets_missing_engine_and_writes_back(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("project_path: /other/path\n")
+ cfg = read_config(config_path)
+ assert cfg["engine"] == "pytorch"
+ assert cfg["project_path"] == tmp_path
+ # File should have been updated
+ cfg_again = read_config_as_dict(config_path)
+ assert cfg_again["engine"] == "pytorch"
+
+
+def test_read_config_sets_detector_snapshotindex_when_missing(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, {"project_path": str(tmp_path), "engine": "pytorch"})
+ cfg = read_config(config_path)
+ assert cfg["detector_snapshotindex"] == -1
+
+
+def test_read_config_sets_detector_batch_size_when_missing(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, {"project_path": str(tmp_path), "engine": "pytorch"})
+ cfg = read_config(config_path)
+ assert cfg["detector_batch_size"] == 1
+
+
+def test_read_config_updates_project_path_when_different(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(
+ config_path,
+ {"project_path": "/old/path", "engine": "pytorch"},
+ )
+ cfg = read_config(config_path)
+ assert cfg["project_path"] == tmp_path
+
+
+@pytest.mark.parametrize("engine", ["pytorch", "tensorflow"])
+def test_read_config_preserves_existing_engine_and_project_path(tmp_path, engine):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(
+ config_path,
+ {"project_path": str(tmp_path), "engine": engine},
+ )
+ cfg = read_config(config_path)
+ assert cfg["engine"] == engine
+ assert cfg["project_path"] == tmp_path
+
+
+def test_read_config_breaks_for_yaml_tags(tmp_path):
+ """read_config raises for YAML files containing unsafe tags like !!python/tuple."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("project_path: /old/path\nengine: pytorch\nbodyparts: !!python/tuple [a, b, c]\n")
+ with pytest.raises(YAMLError):
+ read_config(config_path)
+
+
+def test_read_config_breaks_for_invalid_fields(tmp_path):
+ """read_config raises when the YAML contains field names not declared by ProjectConfig."""
+ config_path = tmp_path / "typos.yaml"
+ config_path.write_text(
+ "project_pathh: /wrong\n" # typo
+ "bodypartz: [a, b]\n" # typo
+ "Task: mytask\n"
+ )
+ with pytest.raises(ValidationError):
+ read_config(config_path)
+
+
+# -----------------------------------------------------------------------------
+# write_project_config
+# -----------------------------------------------------------------------------
+
+
+def test_write_project_config_writes_valid_yaml(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(
+ config_path,
+ {"project_path": str(tmp_path), "Task": "mytask", "bodyparts": ["a", "b"]},
+ )
+ cfg = read_config_as_dict(config_path)
+ assert cfg["project_path"] == str(tmp_path)
+ assert cfg["Task"] == "mytask"
+ assert cfg["bodyparts"] == ["a", "b"]
+
+
+def test_write_project_config_adds_skeleton_defaults_when_missing(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(
+ config_path,
+ {"project_path": str(tmp_path), "multianimalproject": False},
+ )
+ cfg = read_config_as_dict(config_path)
+ assert cfg["skeleton"] == []
+ assert cfg["skeleton_color"] == "black"
+
+
+def test_write_project_config_uses_multianimal_template_when_flag_true(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(
+ config_path,
+ {"project_path": str(tmp_path), "multianimalproject": True},
+ )
+ cfg = read_config_as_dict(config_path)
+ assert "individuals" in cfg
+ assert "uniquebodyparts" in cfg
+
+
+def test_write_project_config_falls_back_and_preserves_unknown_keys(tmp_path, caplog):
+ """Unvalidated legacy write succeeds via fallback with logging, but may not round-trip via read_config."""
+ config_path = tmp_path / "config.yaml"
+ payload = {
+ "project_path": str(tmp_path),
+ "Task": "mytask",
+ "bodyparts": ["a", "b"],
+ "legacy_custom_field": "keep-me", # extra="forbid" on ProjectConfig, will be preserved
+ }
+
+ with caplog.at_level(logging.ERROR, logger="deeplabcut.core.config.utils"):
+ with pytest.warns(UserWarning, match="legacy config file writing"):
+ write_project_config(config_path, payload)
+
+ assert "Invalid configuration" in caplog.text
+ assert config_path.is_file()
+ written = read_config_as_dict(config_path)
+ assert written["legacy_custom_field"] == "keep-me"
+ assert written["Task"] == "mytask"
+
+ with pytest.raises(ValidationError):
+ read_config(config_path)
+
+
+# -----------------------------------------------------------------------------
+# edit_config
+# -----------------------------------------------------------------------------
+
+
+def test_edit_config_applies_edits_and_overwrites_original(tmp_path):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("a: 1\nb: 2\n")
+ cfg = edit_config(config_path, {"b": 99, "c": 3})
+ assert cfg["a"] == 1 and cfg["b"] == 99 and cfg["c"] == 3
+ loaded = read_config_as_dict(config_path)
+ assert loaded["b"] == 99 and loaded["c"] == 3
+
+
+def test_edit_config_writes_to_output_name_when_given(tmp_path):
+ src = tmp_path / "src.yaml"
+ src.write_text("x: 1\n")
+ out = tmp_path / "out.yaml"
+ cfg = edit_config(src, {"x": 2}, output_name=out)
+ assert cfg["x"] == 2
+ assert read_config_as_dict(out)["x"] == 2
+ assert read_config_as_dict(src)["x"] == 1
+
+
+# -----------------------------------------------------------------------------
+# write_config_3d
+# -----------------------------------------------------------------------------
+
+
+def test_write_config_3d_writes_valid_yaml(tmp_path):
+ config_path = tmp_path / "config_3d.yaml"
+ write_config_3d(
+ config_path,
+ {"project_path": str(tmp_path), "Task": "3dtask", "num_cameras": 2},
+ )
+ cfg = read_config_as_dict(config_path)
+ assert cfg["project_path"] == str(tmp_path)
+ assert cfg["Task"] == "3dtask"
+ assert cfg["num_cameras"] == 2
+
+
+# -----------------------------------------------------------------------------
+# write_config_3d_template
+# -----------------------------------------------------------------------------
+
+
+def test_write_config_3d_template_writes_given_template(tmp_path):
+ config_path = tmp_path / "config_3d.yaml"
+ cfg_file, ruamel_file = create_config_template_3d()
+ cfg_file["Task"] = "custom_3d"
+ cfg_file["num_cameras"] = 3
+ write_config_3d_template(config_path, cfg_file, ruamel_file)
+ cfg = read_config_as_dict(config_path)
+ assert cfg["Task"] == "custom_3d"
+ assert cfg["num_cameras"] == 3
+
+
+# -----------------------------------------------------------------------------
+# resolve_alias / resolve_aliases_in_dict
+# -----------------------------------------------------------------------------
+
+
+ALIAS_MAP = {"projectPath": "project_path"}
+
+
+def test_resolve_alias_returns_canonical_for_alias():
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ assert resolve_alias("projectPath", ALIAS_MAP) == "project_path"
+
+
+def test_resolve_alias_returns_name_unchanged_for_unknown_key():
+ assert resolve_alias("Task", ALIAS_MAP, warn=False) == "Task"
+
+
+def test_resolve_alias_no_warning_when_warn_false():
+ import warnings
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DLCDeprecationWarning)
+ assert resolve_alias("projectPath", ALIAS_MAP, warn=False) == "project_path"
+
+
+def test_resolve_aliases_in_dict_resolves_single_alias():
+ with pytest.warns(DLCDeprecationWarning, match="projectPath"):
+ resolved = resolve_aliases_in_dict(
+ {"Task": "t", "projectPath": "/p"},
+ ALIAS_MAP,
+ target="ToyConfig",
+ )
+ assert resolved == {"Task": "t", "project_path": "/p"}
+
+
+def test_resolve_aliases_in_dict_empty_alias_map_returns_input_unchanged():
+ cfg_dict = {"Task": "t", "project_path": "/p"}
+ assert resolve_aliases_in_dict(cfg_dict, {}) is cfg_dict
+
+
+def test_resolve_aliases_in_dict_rejects_alias_and_canonical_together():
+ with pytest.raises(TypeError, match=r"projectPath.*project_path"):
+ resolve_aliases_in_dict(
+ {"projectPath": "/alias", "project_path": "/canonical"},
+ ALIAS_MAP,
+ target="ToyConfig",
+ warn=False,
+ )
+
+
+def test_resolve_aliases_in_dict_rejects_two_aliases_for_same_field():
+ alias_map = {
+ "projectPath": "project_path",
+ "legacyProjectPath": "project_path",
+ }
+ with pytest.raises(TypeError, match=r"projectPath.*legacyProjectPath"):
+ resolve_aliases_in_dict(
+ {"projectPath": "/a", "legacyProjectPath": "/b"},
+ alias_map,
+ target="ToyConfig",
+ warn=False,
+ )
diff --git a/tests/core/config/test_project_config.py b/tests/core/config/test_project_config.py
new file mode 100644
index 0000000000..249266d522
--- /dev/null
+++ b/tests/core/config/test_project_config.py
@@ -0,0 +1,238 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for ProjectConfig â the real DLCVersionedConfig consumer."""
+
+import logging
+from pathlib import Path
+
+import pytest
+
+from deeplabcut.core.config import ProjectConfig, read_config_as_dict
+from deeplabcut.core.config.versioning import CURRENT_CONFIG_VERSION
+
+# -----------------------------------------------------------------------------
+# Basic construction / defaults
+# -----------------------------------------------------------------------------
+
+
+class TestProjectConfigDefaults:
+ def test_defaults(self):
+ cfg = ProjectConfig()
+ assert cfg.config_version == CURRENT_CONFIG_VERSION
+ assert cfg.engine == "pytorch"
+ assert cfg.Task == ""
+ assert isinstance(cfg.project_path, Path)
+ assert cfg.pose_config_path is None
+ assert cfg.config_yaml_path == cfg.project_path / "config.yaml"
+ assert cfg.config_yaml_path == cfg.project_path / "config.yaml"
+ assert cfg.config_yaml_path == cfg.project_path / "config.yaml"
+
+ def test_from_dict_minimal(self):
+ cfg = ProjectConfig.from_dict(
+ {
+ "Task": "mytask",
+ "scorer": "scorer",
+ "date": "Jan01",
+ "project_path": "/tmp/project",
+ }
+ )
+ assert cfg.Task == "mytask"
+ assert cfg.project_path == Path("/tmp/project")
+
+ def test_from_dict_rejects_unknown_keys(self):
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError):
+ ProjectConfig.from_dict({"Task": "t", "not_a_real_field": 1})
+
+ def test_is_versioned_config(self):
+ cfg = ProjectConfig()
+ assert hasattr(cfg, "is_dirty")
+ assert not cfg.is_dirty
+
+
+# -----------------------------------------------------------------------------
+# bodyparts_list
+# -----------------------------------------------------------------------------
+
+
+class TestProjectConfigBodypartsList:
+ def test_single_animal_bodyparts_list_returns_bodyparts(self):
+ cfg = ProjectConfig(multianimalproject=False, bodyparts=["nose", "tail"])
+ assert cfg.bodyparts_list == ["nose", "tail"]
+
+ def test_multi_animal_bodyparts_list_returns_multianimalbodyparts(self):
+ cfg = ProjectConfig(
+ multianimalproject=True,
+ bodyparts="MULTI!",
+ multianimalbodyparts=["nose", "tail"],
+ )
+ assert cfg.bodyparts_list == ["nose", "tail"]
+
+ def test_single_animal_multi_sentinel_raises(self):
+ from pydantic import ValidationError
+
+ with pytest.raises(ValidationError, match="MULTI!"):
+ ProjectConfig(multianimalproject=False, bodyparts="MULTI!")
+
+
+# -----------------------------------------------------------------------------
+# validate_project_path / from_any(repair_path=True)
+# -----------------------------------------------------------------------------
+
+
+def _write_wrong_project_path_yaml(config_path: Path) -> None:
+ config_path.write_text("project_path: /completely/wrong/path\nengine: pytorch\n")
+
+
+class TestValidateProjectPath:
+ def test_raises_when_yaml_not_found(self, tmp_path):
+ cfg = ProjectConfig(project_path=tmp_path)
+ with pytest.raises(FileNotFoundError, match="config.yaml not found"):
+ cfg.validate_project_path()
+
+ @pytest.mark.parametrize("write", [False, True])
+ def test_validate_project_path_write_flag(self, tmp_path, write):
+ config_path = tmp_path / "config.yaml"
+ _write_wrong_project_path_yaml(config_path)
+ cfg = ProjectConfig(project_path=Path("/completely/wrong/path"))
+ cfg.validate_project_path(yaml_path=config_path, write=write)
+ assert cfg.project_path == tmp_path
+ saved = read_config_as_dict(config_path)
+ if write:
+ assert Path(saved["project_path"]) == tmp_path
+ assert not cfg.is_dirty
+ else:
+ assert saved["project_path"] == "/completely/wrong/path"
+ assert "project_path" in cfg.dirty_fields
+
+ @pytest.mark.parametrize(
+ ("config_input", "repair_path"),
+ [
+ ("yaml_path", True),
+ ("dict", True),
+ ("dict", False),
+ ],
+ ids=["yaml_path", "dict_repair", "dict_no_repair"],
+ )
+ def test_from_any_repair_path(self, tmp_path, config_input, repair_path):
+ config_path = tmp_path / "config.yaml"
+ _write_wrong_project_path_yaml(config_path)
+ if config_input == "yaml_path":
+ cfg = ProjectConfig.from_any(config_path, repair_path=repair_path)
+ else:
+ cfg = ProjectConfig.from_any(
+ {"project_path": tmp_path, "engine": "pytorch"},
+ repair_path=repair_path,
+ )
+ assert cfg.project_path == tmp_path
+ assert not cfg.is_dirty
+ saved = read_config_as_dict(config_path)
+ if config_input == "yaml_path" and repair_path:
+ assert Path(saved["project_path"]) == tmp_path
+ else:
+ assert saved["project_path"] == "/completely/wrong/path"
+
+
+# -----------------------------------------------------------------------------
+# _post_yaml_load_updates: project_path repair
+# -----------------------------------------------------------------------------
+
+
+class TestPostYamlLoadUpdates:
+ def test_project_path_updated_when_differs_from_yaml_location(self, tmp_path):
+ """When the stored project_path differs from the YAML's directory,
+ _post_yaml_load_updates corrects it and marks the field dirty."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("project_path: /completely/wrong/path\nengine: pytorch\n")
+ cfg = ProjectConfig.from_yaml(config_path)
+ assert cfg.project_path == tmp_path
+ assert "project_path" in cfg.dirty_fields
+ assert any("project_path updated" in n for n in cfg.change_notes)
+
+ def test_project_path_not_updated_when_already_correct(self, tmp_path):
+ """When the stored project_path already matches the YAML directory,
+ nothing is changed and the config stays clean."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(f"project_path: {tmp_path}\nengine: pytorch\n")
+ cfg = ProjectConfig.from_yaml(config_path)
+ assert cfg.project_path == tmp_path
+ assert not cfg.is_dirty
+
+
+# -----------------------------------------------------------------------------
+# YAML round-trip
+# -----------------------------------------------------------------------------
+
+
+class TestProjectConfigYamlRoundtrip:
+ def test_roundtrip_preserves_scalar_fields(self, tmp_path):
+ config_path = tmp_path / "config.yaml"
+ orig = ProjectConfig(
+ Task="pose",
+ scorer="scorer1",
+ date="2026-01-01",
+ project_path=tmp_path,
+ engine="pytorch",
+ pcutoff=0.6,
+ )
+ orig.to_yaml(config_path)
+ loaded = ProjectConfig.from_yaml(config_path)
+ assert loaded.Task == "pose"
+ assert loaded.scorer == "scorer1"
+ assert loaded.pcutoff == 0.6
+ assert loaded.engine == "pytorch"
+
+ def test_roundtrip_config_is_clean(self, tmp_path):
+ """After saving and reloading, the loaded config must be clean
+ (project_path already matches YAML location)."""
+ config_path = tmp_path / "config.yaml"
+ orig = ProjectConfig(project_path=tmp_path)
+ orig.to_yaml(config_path)
+ loaded = ProjectConfig.from_yaml(config_path)
+ assert not loaded.is_dirty
+
+ def test_roundtrip_no_unknown_keys_in_yaml(self, tmp_path):
+ """The saved YAML must not contain keys that ProjectConfig doesn't declare."""
+ config_path = tmp_path / "config.yaml"
+ ProjectConfig(project_path=tmp_path).to_yaml(config_path)
+ saved = read_config_as_dict(config_path)
+ declared = set(ProjectConfig.model_fields.keys())
+ unknown = set(saved.keys()) - declared
+ assert unknown == set(), f"Unknown keys written to YAML: {unknown}"
+
+ def test_to_yaml_writes_config_version(self, tmp_path):
+ config_path = tmp_path / "config.yaml"
+ ProjectConfig(project_path=tmp_path).to_yaml(config_path)
+ saved = read_config_as_dict(config_path)
+ assert "config_version" in saved
+ assert saved["config_version"] == CURRENT_CONFIG_VERSION
+
+
+# -----------------------------------------------------------------------------
+# Migration / versioning integration
+# -----------------------------------------------------------------------------
+
+
+class TestProjectConfigVersioning:
+ def test_no_migration_log_for_current_version_yaml(self, tmp_path, caplog):
+ """Loading a config already at CURRENT_CONFIG_VERSION emits no migration INFO log."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(f"config_version: {CURRENT_CONFIG_VERSION}\nproject_path: {tmp_path}\nengine: pytorch\n")
+ with caplog.at_level(logging.INFO, logger="deeplabcut.core.config.versioning"):
+ ProjectConfig.from_yaml(config_path)
+
+ migration_records = [r for r in caplog.records if "igrating" in r.message]
+ assert len(migration_records) == 0
+
+ def test_unversioned_yaml_treated_as_v0(self, tmp_path):
+ """A YAML file without config_version is treated as version 0."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(f"project_path: {tmp_path}\nengine: pytorch\n")
+ cfg = ProjectConfig.from_yaml(config_path)
+ assert cfg.config_version == CURRENT_CONFIG_VERSION
diff --git a/tests/core/config/test_versioning.py b/tests/core/config/test_versioning.py
new file mode 100644
index 0000000000..12dbe95db0
--- /dev/null
+++ b/tests/core/config/test_versioning.py
@@ -0,0 +1,921 @@
+#
+# 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 configuration versioning and migration system."""
+
+import logging
+
+import pytest
+from pydantic import Field, ValidationError
+
+from deeplabcut.core.config import DLCVersionedConfig, versioning
+from deeplabcut.core.config.versioning import (
+ get_config_version,
+ migrate_config,
+ register_migration,
+)
+from deeplabcut.core.deprecation import DLCDeprecationWarning
+
+_LOGGER_NAME = "deeplabcut.core.config.versioning"
+
+
+# -----------------------------------------------------------------------------
+# Toy migrations (registered only for tests; v98 <-> v99)
+# -----------------------------------------------------------------------------
+
+_TOY_VERSION_OLD = 98
+_TOY_VERSION_NEW = 99
+
+_LEGACY_FIELD = "toy_legacy_field"
+_NEW_FIELD = "toy_new_field"
+
+
+_TOY_CONFIG_TYPE = "ToyVersionedConfig"
+
+
+@pytest.fixture(autouse=True)
+def _toy_versioned_config_migrations(register_toy_migrations):
+ """Default v98 <-> v99 migrations for ToyVersionedConfig tests in this module."""
+ register_toy_migrations(_TOY_CONFIG_TYPE)
+
+
+# -----------------------------------------------------------------------------
+# get_config_version
+# -----------------------------------------------------------------------------
+
+
+def test_get_config_version_():
+ assert get_config_version({}) == 0
+ assert get_config_version({"Task": "mytask"}) == 0
+ assert get_config_version({"config_version": 1}) == 1
+ assert get_config_version({"config_version": 0}) == 0
+
+
+# -----------------------------------------------------------------------------
+# migrate_config (no-op, errors)
+# -----------------------------------------------------------------------------
+
+
+def test_migrate_config_same_version_returns_unchanged():
+ cfg = {"a": 1, "b": 2}
+ assert migrate_config(cfg, config_type=_TOY_CONFIG_TYPE, target_version=0) is cfg
+
+
+def test_migrate_config_does_not_mutate_input():
+ cfg = {"config_version": _TOY_VERSION_OLD, _LEGACY_FIELD: "x"}
+ result = migrate_config(cfg, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_NEW)
+ assert cfg[_LEGACY_FIELD] == "x"
+ assert result is not cfg
+
+
+def test_migrate_config_target_exceeds_current_raises(monkeypatch):
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", 99)
+ with pytest.raises(ValueError, match="Target version .* exceeds current"):
+ migrate_config({"config_version": 0}, config_type=_TOY_CONFIG_TYPE, target_version=100)
+
+
+# -----------------------------------------------------------------------------
+# Toy migration round-trip (v98 <-> v99)
+# -----------------------------------------------------------------------------
+
+
+def test_migration_v98_to_v99_renames_toy_field():
+ """Upgrade v98 -> v99: toy_legacy_field becomes toy_new_field."""
+ v98 = {"config_version": _TOY_VERSION_OLD, _LEGACY_FIELD: "value", "other": 42}
+ v99 = migrate_config(v98, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_NEW)
+ assert get_config_version(v99) == _TOY_VERSION_NEW
+ assert _NEW_FIELD in v99
+ assert v99[_NEW_FIELD] == "value"
+ assert _LEGACY_FIELD not in v99
+ assert v99["other"] == 42
+
+
+def test_migration_v99_to_v98_renames_toy_field_back():
+ """Downgrade v99 -> v98: toy_new_field becomes toy_legacy_field."""
+ v99 = {"config_version": _TOY_VERSION_NEW, _NEW_FIELD: "value", "other": 42}
+ v98 = migrate_config(v99, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_OLD)
+ assert get_config_version(v98) == _TOY_VERSION_OLD
+ assert _LEGACY_FIELD in v98
+ assert v98[_LEGACY_FIELD] == "value"
+ assert _NEW_FIELD not in v98
+ assert v98["other"] == 42
+
+
+def test_roundtrip_v98_to_v99_to_v98_preserves_content():
+ """Round-trip v98 -> v99 -> v98: content matches original v98."""
+ original = {
+ "config_version": _TOY_VERSION_OLD,
+ _LEGACY_FIELD: "test_value",
+ "Task": "mytask",
+ "extra": [1, 2],
+ }
+ v99 = migrate_config(original, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_NEW)
+ back = migrate_config(v99, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_OLD)
+ assert get_config_version(back) == _TOY_VERSION_OLD
+ assert back[_LEGACY_FIELD] == original[_LEGACY_FIELD]
+ assert back["Task"] == original["Task"]
+ assert back["extra"] == original["extra"]
+
+
+def test_roundtrip_v99_to_v98_to_v99_preserves_content():
+ """Round-trip v99 -> v98 -> v99: content matches original v99."""
+ original = {
+ "config_version": _TOY_VERSION_NEW,
+ _NEW_FIELD: "test_value",
+ "Task": "mytask",
+ "extra": [1, 2],
+ }
+ v98 = migrate_config(original, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_OLD)
+ back = migrate_config(v98, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_NEW)
+ assert get_config_version(back) == _TOY_VERSION_NEW
+ assert back[_NEW_FIELD] == original[_NEW_FIELD]
+ assert back["Task"] == original["Task"]
+ assert back["extra"] == original["extra"]
+
+
+def test_roundtrip_v98_without_toy_field_unchanged():
+ """Config without toy field is unchanged by v98 -> v99 -> v98."""
+ original = {
+ "config_version": _TOY_VERSION_OLD,
+ "Task": "mytask",
+ "bodyparts": ["a", "b"],
+ }
+ v99 = migrate_config(original, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_NEW)
+ back = migrate_config(v99, config_type=_TOY_CONFIG_TYPE, target_version=_TOY_VERSION_OLD)
+ assert get_config_version(back) == _TOY_VERSION_OLD
+ assert back["Task"] == original["Task"]
+ assert back["bodyparts"] == original["bodyparts"]
+
+
+# -----------------------------------------------------------------------------
+# DLCVersionedConfig: migration prevents validation error for renamed field
+# -----------------------------------------------------------------------------
+
+
+@pytest.fixture
+def ToyConfigWithValidField():
+ class ToyConfig(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ valid_project_config_field: str = ""
+
+ return ToyConfig
+
+
+def test_config_with_legacy_field_raises_without_migration(monkeypatch, ToyConfigWithValidField):
+ """Legacy unknown field raises validation error when no migration is registered."""
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", _TOY_VERSION_OLD)
+ config_with_legacy_field = {
+ "config_version": _TOY_VERSION_OLD,
+ "this_fieldname_is_not_in_project_config": "some_value",
+ }
+ with pytest.raises((ValidationError, TypeError)):
+ ToyConfigWithValidField(**config_with_legacy_field)
+
+
+def test_config_after_migration_accepts_renamed_field(monkeypatch, ToyConfigWithValidField):
+ """Registering a migration renames the legacy field so the model accepts the config."""
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", _TOY_VERSION_NEW)
+ config_with_legacy_field = {
+ "config_version": _TOY_VERSION_OLD,
+ "this_fieldname_is_not_in_project_config": "some_value",
+ }
+
+ @register_migration(_TOY_VERSION_OLD, _TOY_VERSION_NEW, config_type="ToyConfig")
+ def _toy_migrate_legacy_to_valid_field(config: dict) -> dict:
+ """Test-only: rename legacy field -> valid_project_config_field."""
+ if "this_fieldname_is_not_in_project_config" in config:
+ config["valid_project_config_field"] = config.pop("this_fieldname_is_not_in_project_config")
+ return config
+
+ cfg = ToyConfigWithValidField.from_dict(config_with_legacy_field)
+ assert cfg.valid_project_config_field == "some_value"
+
+
+# -----------------------------------------------------------------------------
+# Migration before alias resolution on DLCVersionedConfig
+# -----------------------------------------------------------------------------
+
+
+class _MigrateThenAliasConfig(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ toy_new_field: str = Field(
+ default="",
+ json_schema_extra={"aliases": ["deprecated_alias"]},
+ )
+
+
+@pytest.fixture
+def _migrate_then_alias_current(register_toy_migrations):
+ register_toy_migrations("_MigrateThenAliasConfig")
+
+
+def test_migration_then_alias_legacy_version_key(_migrate_then_alias_current):
+ """v98 legacy key is renamed by migration, then validated."""
+ cfg = _MigrateThenAliasConfig.from_dict(
+ {
+ "config_version": _TOY_VERSION_OLD,
+ _LEGACY_FIELD: "from_migration",
+ }
+ )
+ assert cfg.toy_new_field == "from_migration"
+
+
+def test_migration_then_alias_same_version_deprecated_name(_migrate_then_alias_current):
+ """Same-version deprecated alias is resolved after migration (with a warning)."""
+ with pytest.warns(DLCDeprecationWarning, match="deprecated_alias"):
+ cfg = _MigrateThenAliasConfig.model_validate(
+ {
+ "config_version": _TOY_VERSION_NEW,
+ "deprecated_alias": "from_alias",
+ }
+ )
+ assert cfg.toy_new_field == "from_alias"
+
+
+def test_migration_then_alias_on_old_version_with_deprecated_alias(
+ _migrate_then_alias_current,
+):
+ """Migrate to current version, then resolve alias when v98 used the alias key."""
+ with pytest.warns(DLCDeprecationWarning, match="deprecated_alias"):
+ cfg = _MigrateThenAliasConfig.model_validate(
+ {
+ "config_version": _TOY_VERSION_OLD,
+ "deprecated_alias": "both_steps",
+ }
+ )
+ assert cfg.toy_new_field == "both_steps"
+
+
+def test_from_dict_runs_migration(_migrate_then_alias_current):
+ """Legacy dicts loaded via from_dict are migrated before validation."""
+
+ class _KwOnlyVersioned(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ toy_new_field: str = ""
+
+ @register_migration(_TOY_VERSION_OLD, _TOY_VERSION_NEW, config_type="_KwOnlyVersioned")
+ def _kw_migrate_v98_to_v99(config: dict) -> dict:
+ if _LEGACY_FIELD in config:
+ config[_NEW_FIELD] = config.pop(_LEGACY_FIELD)
+ return config
+
+ cfg = _KwOnlyVersioned.from_dict(
+ {
+ "config_version": _TOY_VERSION_OLD,
+ _LEGACY_FIELD: "kwargs_value",
+ }
+ )
+ assert cfg.toy_new_field == "kwargs_value"
+ assert cfg.config_version == _TOY_VERSION_NEW
+
+
+def test_constructor_does_not_run_migration(_migrate_then_alias_current):
+ """Typed construction must not run the legacy migration chain."""
+
+ class _KwOnlyVersioned(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ toy_new_field: str = ""
+
+ @register_migration(_TOY_VERSION_OLD, _TOY_VERSION_NEW, config_type="_KwOnlyVersioned")
+ def _kw_migrate_v98_to_v99(config: dict) -> dict:
+ if _LEGACY_FIELD in config:
+ config[_NEW_FIELD] = config.pop(_LEGACY_FIELD)
+ return config
+
+ with pytest.raises(ValidationError):
+ _KwOnlyVersioned(
+ config_version=_TOY_VERSION_OLD,
+ **{_LEGACY_FIELD: "kwargs_value"},
+ )
+
+
+# =============================================================================
+# Multi-step migration chain (v50 â v51 â v52 â v53)
+#
+# Each step renames one field, so we can verify individual steps, full chains,
+# downgrades, missing intermediates, missing fields, and mid-chain errors.
+# =============================================================================
+
+_V50, _V51, _V52, _V53 = 50, 51, 52, 53
+_CHAIN_CONFIG_TYPE = "ChainConfig"
+
+
+def _register_chain_migrations():
+ """Register a v50âv53 migration chain (upgrades and downgrades)."""
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v50_to_v51(config: dict) -> dict:
+ if "field_a_old" in config:
+ config["field_a_new"] = config.pop("field_a_old")
+ return config
+
+ @register_migration(_V51, _V52, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v51_to_v52(config: dict) -> dict:
+ if "field_b_old" in config:
+ config["field_b_new"] = config.pop("field_b_old")
+ return config
+
+ @register_migration(_V52, _V53, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v52_to_v53(config: dict) -> dict:
+ if "field_c_old" in config:
+ config["field_c_new"] = config.pop("field_c_old")
+ return config
+
+ @register_migration(_V51, _V50, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v51_to_v50(config: dict) -> dict:
+ if "field_a_new" in config:
+ config["field_a_old"] = config.pop("field_a_new")
+ return config
+
+ @register_migration(_V52, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v52_to_v51(config: dict) -> dict:
+ if "field_b_new" in config:
+ config["field_b_old"] = config.pop("field_b_new")
+ return config
+
+ @register_migration(_V53, _V52, config_type=_CHAIN_CONFIG_TYPE)
+ def _chain_v53_to_v52(config: dict) -> dict:
+ if "field_c_new" in config:
+ config["field_c_old"] = config.pop("field_c_new")
+ return config
+
+
+@pytest.fixture
+def chain_migrations(monkeypatch):
+ """Register v50âv53 chain and set CURRENT_CONFIG_VERSION=53."""
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", _V53)
+ _register_chain_migrations()
+
+
+# -----------------------------------------------------------------------------
+# Multi-step upgrade chains
+# -----------------------------------------------------------------------------
+
+
+class TestMultiStepUpgrade:
+ """Tests for chained upgrade migrations across multiple versions."""
+
+ def test_upgrade_v50_to_v53_applies_all_steps(self, chain_migrations):
+ """Full chain v50âv53 renames all three fields."""
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ "untouched": 42,
+ }
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert result["config_version"] == _V53
+ assert result["field_a_new"] == "a"
+ assert result["field_b_new"] == "b"
+ assert result["field_c_new"] == "c"
+ assert result["untouched"] == 42
+ assert "field_a_old" not in result
+ assert "field_b_old" not in result
+ assert "field_c_old" not in result
+
+ def test_upgrade_v50_to_v52_partial_chain(self, chain_migrations):
+ """Partial chain v50âv52 renames only fields a and b."""
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ }
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V52)
+ assert result["config_version"] == _V52
+ assert result["field_a_new"] == "a"
+ assert result["field_b_new"] == "b"
+ assert result["field_c_old"] == "c" # untouched â v52âv53 not applied
+
+ def test_upgrade_single_step_v51_to_v52(self, chain_migrations):
+ """Single step in the middle of the chain works."""
+ cfg = {"config_version": _V51, "field_b_old": "b"}
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V52)
+ assert result["config_version"] == _V52
+ assert result["field_b_new"] == "b"
+
+ def test_upgrade_does_not_mutate_original(self, chain_migrations):
+ """Original config dict is untouched after multi-step upgrade."""
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ }
+ original_copy = cfg.copy()
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert cfg == original_copy
+
+
+# -----------------------------------------------------------------------------
+# Multi-step downgrade chains
+# -----------------------------------------------------------------------------
+
+
+class TestMultiStepDowngrade:
+ """Tests for chained downgrade migrations across multiple versions."""
+
+ def test_downgrade_v53_to_v50_applies_all_steps(self, chain_migrations):
+ """Full downgrade v53âv50 reverses all three field renames."""
+ cfg = {
+ "config_version": _V53,
+ "field_a_new": "a",
+ "field_b_new": "b",
+ "field_c_new": "c",
+ "untouched": 42,
+ }
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert result["config_version"] == _V50
+ assert result["field_a_old"] == "a"
+ assert result["field_b_old"] == "b"
+ assert result["field_c_old"] == "c"
+ assert result["untouched"] == 42
+
+ def test_downgrade_v53_to_v51_partial(self, chain_migrations):
+ """Partial downgrade v53âv51 reverses only fields c and b."""
+ cfg = {
+ "config_version": _V53,
+ "field_a_new": "a",
+ "field_b_new": "b",
+ "field_c_new": "c",
+ }
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert result["config_version"] == _V51
+ assert result["field_a_new"] == "a" # untouched â v51âv50 not applied
+ assert result["field_b_old"] == "b"
+ assert result["field_c_old"] == "c"
+
+ def test_downgrade_does_not_mutate_original(self, chain_migrations):
+ """Original config dict is untouched after multi-step downgrade."""
+ cfg = {
+ "config_version": _V53,
+ "field_a_new": "a",
+ "field_b_new": "b",
+ "field_c_new": "c",
+ }
+ original_copy = cfg.copy()
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert cfg == original_copy
+
+
+# -----------------------------------------------------------------------------
+# Multi-step round-trips
+# -----------------------------------------------------------------------------
+
+
+class TestMultiStepRoundTrip:
+ """Verify that upgradeâdowngrade and downgradeâupgrade round-trips preserve data."""
+
+ def test_roundtrip_v50_to_v53_and_back(self, chain_migrations):
+ original = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ "extra": [1, 2, 3],
+ }
+ upgraded = migrate_config(original, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ back = migrate_config(upgraded, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert back["config_version"] == _V50
+ assert back["field_a_old"] == "a"
+ assert back["field_b_old"] == "b"
+ assert back["field_c_old"] == "c"
+ assert back["extra"] == [1, 2, 3]
+
+ def test_roundtrip_v53_to_v50_and_back(self, chain_migrations):
+ original = {
+ "config_version": _V53,
+ "field_a_new": "a",
+ "field_b_new": "b",
+ "field_c_new": "c",
+ }
+ downgraded = migrate_config(original, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ back = migrate_config(downgraded, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert back["config_version"] == _V53
+ assert back["field_a_new"] == "a"
+ assert back["field_b_new"] == "b"
+ assert back["field_c_new"] == "c"
+
+
+# -----------------------------------------------------------------------------
+# Missing intermediate migrations
+# -----------------------------------------------------------------------------
+
+
+class TestMissingIntermediateMigration:
+ """Verify that gaps in the migration chain produce clear errors."""
+
+ def test_missing_middle_step_raises_with_context(self, chain_migrations):
+ """v50âv53 with v51âv52 removed raises naming the missing step."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V51, _V52)]
+ cfg = {"config_version": _V50}
+ with pytest.raises(ValueError, match=r"No migration registered for 'ChainConfig' v51 -> v52"):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+
+ def test_missing_first_step_raises(self, chain_migrations):
+ """v50âv53 with v50âv51 removed raises naming the first step."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+ cfg = {"config_version": _V50}
+ with pytest.raises(ValueError, match=r"No migration registered for 'ChainConfig' v50 -> v51"):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+
+ def test_missing_downgrade_step_raises(self, chain_migrations):
+ """v53âv50 with v52âv51 removed raises naming the missing step."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V52, _V51)]
+ cfg = {"config_version": _V53}
+ with pytest.raises(ValueError, match=r"No migration registered for 'ChainConfig' v52 -> v51"):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+
+
+# -----------------------------------------------------------------------------
+# Migration with missing / extra fields
+# -----------------------------------------------------------------------------
+
+
+class TestMissingFields:
+ """Migration functions should tolerate configs that lack optional fields."""
+
+ def test_upgrade_with_no_renameable_fields(self, chain_migrations):
+ """Config without any of the fields the migrations rename still upgrades."""
+ cfg = {"config_version": _V50, "unrelated": "data"}
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert result["config_version"] == _V53
+ assert result["unrelated"] == "data"
+ assert "field_a_new" not in result
+ assert "field_b_new" not in result
+ assert "field_c_new" not in result
+
+ def test_upgrade_with_partial_fields(self, chain_migrations):
+ """Only the fields present are renamed; absent ones are not invented."""
+ cfg = {"config_version": _V50, "field_a_old": "a"}
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert result["field_a_new"] == "a"
+ assert "field_b_new" not in result
+ assert "field_c_new" not in result
+
+ def test_downgrade_with_missing_fields(self, chain_migrations):
+ """Downgrade with missing fields: absent fields are simply absent."""
+ cfg = {"config_version": _V53, "field_c_new": "c"}
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert result["config_version"] == _V50
+ assert result["field_c_old"] == "c"
+ assert "field_a_old" not in result
+ assert "field_b_old" not in result
+
+ def test_extra_unknown_fields_are_preserved(self, chain_migrations):
+ """Fields not touched by any migration survive the full chain."""
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "totally_unknown": {"nested": True},
+ }
+ result = migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert result["totally_unknown"] == {"nested": True}
+
+
+# -----------------------------------------------------------------------------
+# Error handling mid-migration
+# -----------------------------------------------------------------------------
+
+
+class TestMidMigrationError:
+ """If a migration function raises, the error should identify the failing step."""
+
+ def test_error_in_second_step_reports_step(self, chain_migrations):
+ """An exception in v51âv52 wraps with the step identifier."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V51, _V52)]
+
+ @register_migration(_V51, _V52, config_type=_CHAIN_CONFIG_TYPE)
+ def _broken_v51_to_v52(config: dict) -> dict:
+ raise RuntimeError("something broke in v51âv52")
+
+ cfg = {"config_version": _V50}
+ with pytest.raises(
+ RuntimeError,
+ match=r"Migration for 'ChainConfig' v51 -> v52 failed.*something broke",
+ ):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+
+ def test_error_preserves_original_config(self, chain_migrations):
+ """Original config is not mutated even when a mid-chain step fails."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V52, _V53)]
+
+ @register_migration(_V52, _V53, config_type=_CHAIN_CONFIG_TYPE)
+ def _broken_v52_to_v53(config: dict) -> dict:
+ raise ValueError("boom")
+
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ }
+ original_copy = cfg.copy()
+ with pytest.raises(ValueError, match="boom"):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert cfg == original_copy
+
+ def test_error_preserves_exception_type(self, chain_migrations):
+ """The re-raised exception keeps its original type (not generic RuntimeError)."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V51, _V52)]
+
+ @register_migration(_V51, _V52, config_type=_CHAIN_CONFIG_TYPE)
+ def _type_error_step(config: dict) -> dict:
+ raise TypeError("wrong type for field X")
+
+ cfg = {"config_version": _V50}
+ with pytest.raises(TypeError, match="Migration for 'ChainConfig' v51 -> v52 failed"):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+
+
+# -----------------------------------------------------------------------------
+# register_migration validation
+# -----------------------------------------------------------------------------
+
+
+class TestRegisterMigrationValidation:
+ """Edge-case validation for the registration decorator."""
+
+ def test_negative_from_version_raises(self):
+ with pytest.raises(ValueError, match="non-negative"):
+
+ @register_migration(-1, 0, config_type=_CHAIN_CONFIG_TYPE)
+ def _bad(config):
+ return config
+
+ def test_negative_to_version_raises(self):
+ with pytest.raises(ValueError, match="non-negative"):
+
+ @register_migration(0, -1, config_type=_CHAIN_CONFIG_TYPE)
+ def _bad(config):
+ return config
+
+ def test_same_version_raises(self):
+ with pytest.raises(ValueError, match="must differ"):
+
+ @register_migration(5, 5, config_type=_CHAIN_CONFIG_TYPE)
+ def _bad(config):
+ return config
+
+ def test_duplicate_registration_raises(self):
+ """Registering the same (config_type, from, to) triple twice raises."""
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _first(config):
+ return config
+
+ with pytest.raises(ValueError, match="Duplicate migration"):
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _second(config):
+ return config
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+
+
+class TestMigrationLogging:
+ """Verify that migration logging reports field changes."""
+
+ def test_upgrade_logs_info_start_and_complete(self, caplog, chain_migrations):
+ """INFO logs report migration start (with config type, direction) and completion."""
+ cfg = {"config_version": _V50, "field_a_old": "a"}
+ with caplog.at_level(logging.INFO, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert "Migrating ChainConfig from version 50 to 53 (upgrade)" in caplog.text
+ assert "Migration complete: ChainConfig is now at version 53" in caplog.text
+
+ def test_downgrade_logs_info_direction(self, caplog, chain_migrations):
+ """INFO logs report 'downgrade' direction."""
+ cfg = {"config_version": _V53, "field_c_new": "c"}
+ with caplog.at_level(logging.INFO, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert "Migrating ChainConfig from version 53 to 50 (downgrade)" in caplog.text
+ assert "Migration complete: ChainConfig is now at version 50" in caplog.text
+
+ def test_same_version_no_log(self, caplog, chain_migrations):
+ """When source == target, nothing is logged."""
+ cfg = {"config_version": _V50}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ assert caplog.text == ""
+
+ def test_debug_logs_field_rename(self, caplog, chain_migrations):
+ """DEBUG logs report removed and added fields for a rename."""
+ cfg = {"config_version": _V50, "field_a_old": "a"}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Removed field 'field_a_old'" in caplog.text
+ assert "Added field 'field_a_new'" in caplog.text
+
+ def test_debug_logs_updated_field(self, caplog, chain_migrations):
+ """DEBUG logs report a field whose value changed in-place."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _mutate_field(config: dict) -> dict:
+ config["keep_me"] = config.get("keep_me", 0) + 100
+ return config
+
+ cfg = {"config_version": _V50, "keep_me": 1}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Updated field 'keep_me': 1 -> 101" in caplog.text
+
+ def test_debug_logs_no_field_changes(self, caplog, chain_migrations):
+ """When migration changes nothing, DEBUG reports 'No field changes'."""
+ cfg = {"config_version": _V50}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "No field changes" in caplog.text
+
+ def test_debug_logs_applying_migration_function_name(self, caplog, chain_migrations):
+ """DEBUG logs include the config type and qualified name of each migration function."""
+ cfg = {"config_version": _V50, "field_a_old": "a"}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Applying migration ChainConfig v50 -> v51" in caplog.text
+
+ def test_multi_step_upgrade_logs_all_steps(self, caplog, chain_migrations):
+ """A multi-step upgrade logs each intermediate step."""
+ cfg = {
+ "config_version": _V50,
+ "field_a_old": "a",
+ "field_b_old": "b",
+ "field_c_old": "c",
+ }
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V53)
+ assert "Applying migration ChainConfig v50 -> v51" in caplog.text
+ assert "Applying migration ChainConfig v51 -> v52" in caplog.text
+ assert "Applying migration ChainConfig v52 -> v53" in caplog.text
+
+ def test_info_not_shown_when_no_migration(self, caplog, chain_migrations):
+ """When no migration is needed, no INFO messages are emitted."""
+ cfg = {"config_version": _V50}
+ with caplog.at_level(logging.INFO, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V50)
+ info_records = [r for r in caplog.records if r.levelno >= logging.INFO]
+ assert len(info_records) == 0
+
+ def test_debug_logs_nested_dict_added_key(self, caplog, chain_migrations):
+ """DEBUG logs report a key added inside a nested dict."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _add_nested(config: dict) -> dict:
+ config.setdefault("video_sets", {})
+ config["video_sets"]["new_key"] = "new_value"
+ return config
+
+ cfg = {"config_version": _V50, "video_sets": {}}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Added field 'video_sets.new_key' = 'new_value'" in caplog.text
+
+ def test_debug_logs_nested_dict_removed_key(self, caplog, chain_migrations):
+ """DEBUG logs report a key removed inside a nested dict."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _remove_nested(config: dict) -> dict:
+ config["opts"].pop("old_opt")
+ return config
+
+ cfg = {"config_version": _V50, "opts": {"old_opt": 1, "keep": 2}}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Removed field 'opts.old_opt' (was: 1)" in caplog.text
+
+ def test_debug_logs_nested_dict_updated_value(self, caplog, chain_migrations):
+ """DEBUG logs report a value changed inside a nested dict."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _update_nested(config: dict) -> dict:
+ config["settings"]["threshold"] = 0.9
+ return config
+
+ cfg = {"config_version": _V50, "settings": {"threshold": 0.5}}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Updated field 'settings.threshold': 0.5 -> 0.9" in caplog.text
+
+ def test_debug_logs_deeply_nested_change(self, caplog, chain_migrations):
+ """DEBUG logs report changes multiple levels deep."""
+ del versioning._MIGRATIONS[(_CHAIN_CONFIG_TYPE, _V50, _V51)]
+
+ @register_migration(_V50, _V51, config_type=_CHAIN_CONFIG_TYPE)
+ def _deep_change(config: dict) -> dict:
+ config["a"]["b"]["c"] = "new"
+ return config
+
+ cfg = {"config_version": _V50, "a": {"b": {"c": "old"}}}
+ with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
+ migrate_config(cfg, config_type=_CHAIN_CONFIG_TYPE, target_version=_V51)
+ assert "Updated field 'a.b.c': 'old' -> 'new'" in caplog.text
+
+
+# -----------------------------------------------------------------------------
+# DLCVersionedConfig: from_yaml / to_yaml integration with migration
+# -----------------------------------------------------------------------------
+
+
+class _FileVersionedConfig(DLCVersionedConfig):
+ config_version: int = _TOY_VERSION_NEW
+ toy_new_field: str = ""
+ other: str = "default"
+
+
+class TestFromYamlMigration:
+ """from_yaml and to_yaml must interact correctly with the migration system."""
+
+ @pytest.fixture(autouse=True)
+ def _file_versioned_migrations(self, register_toy_migrations):
+ register_toy_migrations("_FileVersionedConfig")
+
+ def test_from_yaml_with_old_version_runs_migration(self, tmp_path):
+ """A YAML file at an old config_version is migrated on load."""
+ path = tmp_path / "config.yaml"
+ path.write_text(f"config_version: {_TOY_VERSION_OLD}\n{_LEGACY_FIELD}: migrated_value\nother: kept\n")
+ cfg = _FileVersionedConfig.from_yaml(path)
+ assert cfg.toy_new_field == "migrated_value"
+ assert cfg.other == "kept"
+ assert cfg.config_version == _TOY_VERSION_NEW
+
+ def test_from_yaml_current_version_no_migration(self, tmp_path):
+ """A YAML file already at the current version loads without migration."""
+ path = tmp_path / "config.yaml"
+ path.write_text(f"config_version: {_TOY_VERSION_NEW}\ntoy_new_field: already_current\n")
+ cfg = _FileVersionedConfig.from_yaml(path)
+ assert cfg.toy_new_field == "already_current"
+
+ def test_to_yaml_persists_new_config_version(self, tmp_path):
+ """After migration, to_yaml writes the new config_version to disk."""
+ path = tmp_path / "config.yaml"
+ path.write_text(f"config_version: {_TOY_VERSION_OLD}\n{_LEGACY_FIELD}: value\n")
+ cfg = _FileVersionedConfig.from_yaml(path)
+ cfg.to_yaml(path)
+
+ from deeplabcut.core.config.utils import read_config_as_dict
+
+ saved = read_config_as_dict(path)
+ assert saved["config_version"] == _TOY_VERSION_NEW
+ assert "toy_new_field" in saved
+ assert _LEGACY_FIELD not in saved
+
+ def test_to_yaml_then_from_yaml_roundtrip(self, tmp_path):
+ """Writing a migrated config and reloading it should be a perfect no-op."""
+ path = tmp_path / "config.yaml"
+ path.write_text(f"config_version: {_TOY_VERSION_OLD}\n{_LEGACY_FIELD}: round_trip\nother: preserved\n")
+ cfg_first = _FileVersionedConfig.from_yaml(path)
+ cfg_first.to_yaml(path)
+ cfg_second = _FileVersionedConfig.from_yaml(path)
+ assert cfg_second.toy_new_field == "round_trip"
+ assert cfg_second.other == "preserved"
+ assert cfg_second.config_version == _TOY_VERSION_NEW
+ assert not cfg_second.is_dirty
+
+
+# -----------------------------------------------------------------------------
+# DLCVersionedConfig compatibility with validate_assignment
+# -----------------------------------------------------------------------------
+
+
+class ValidatedMigratingConfig(DLCVersionedConfig):
+ name: str = "default"
+ count: int = 0
+
+
+class TestVersionedConfigValidateAssignment:
+ """Versioned config must not interfere with validate_assignment (regression)."""
+
+ @pytest.fixture(autouse=True)
+ def _default_version(self, monkeypatch):
+ monkeypatch.setattr(versioning, "CURRENT_CONFIG_VERSION", 0)
+
+ def test_valid_assignment_takes_effect(self):
+ cfg = ValidatedMigratingConfig()
+ cfg.count = 42
+ assert cfg.count == 42
+
+ def test_invalid_assignment_raises(self):
+ cfg = ValidatedMigratingConfig()
+ with pytest.raises(ValidationError):
+ cfg.count = "not-an-int"
+ assert cfg.count == 0
+
+ def test_coercion_applied_on_assignment(self):
+ cfg = ValidatedMigratingConfig()
+ cfg.count = True # coerced to 1
+ assert cfg.count == 1
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..b9e54aff7d
--- /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
+
+import deeplabcut.generate_training_dataset.metadata as metadata
+from deeplabcut.core.config.utils import get_yaml_dumper, get_yaml_loader
+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:
+ get_yaml_dumper().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 = get_yaml_loader().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:
+ get_yaml_dumper().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/gui/conftest.py b/tests/gui/conftest.py
new file mode 100644
index 0000000000..606119ac49
--- /dev/null
+++ b/tests/gui/conftest.py
@@ -0,0 +1,71 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Shared fixtures for GUI tests (headless Qt via pytest-qt)."""
+
+import os
+import sys
+
+# Render off-screen so tests do not open windows; must be set before Qt loads.
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+
+@pytest.fixture
+def write_project_config():
+ """Writer for a minimal valid project config.yaml.
+
+ ``project_path`` should be the parent of ``config_path`` so that
+ ``read_config`` does not repair (and rewrite) the file.
+ """
+
+ def _write(config_path, project_path, task: str = "demo", extra: str = "") -> None:
+ lines = [
+ f"Task: {task}",
+ "scorer: tester",
+ "date: Jan1",
+ "multianimalproject: false",
+ f"project_path: {project_path.as_posix()}",
+ "bodyparts: [nose, tail]",
+ "video_sets: {}",
+ ]
+ if extra:
+ lines.append(extra)
+ config_path.write_text("\n".join(lines) + "\n")
+
+ return _write
+
+
+@pytest.fixture
+def main_window(qapp, monkeypatch):
+ """A real MainWindow, constructed off-screen and torn down cleanly."""
+ from PySide6 import QtWidgets
+
+ from deeplabcut.gui.window import MainWindow
+
+ # Keep QSettings written by save_settings() out of the user's real settings.
+ qapp.setOrganizationName("DeepLabCut-Tests")
+ qapp.setApplicationName("DLC-GUI-Tests")
+
+ # closeEvent pops a blocking confirmation dialog; auto-confirm it in tests.
+ monkeypatch.setattr(
+ QtWidgets.QMessageBox,
+ "question",
+ lambda *args, **kwargs: QtWidgets.QMessageBox.Yes,
+ )
+
+ original_stdout = sys.stdout # MainWindow redirects stdout to its writer
+ window = MainWindow(qapp)
+ try:
+ yield window
+ finally:
+ window.close()
+ sys.stdout = original_stdout
diff --git a/tests/gui/test_auto_update.py b/tests/gui/test_auto_update.py
new file mode 100644
index 0000000000..c0c26adedc
--- /dev/null
+++ b/tests/gui/test_auto_update.py
@@ -0,0 +1,141 @@
+import sys
+
+import pytest
+
+pytest.importorskip("PySide6")
+
+from deeplabcut.gui.utils import _build_update_commands, _package_specs_for_update
+
+
+def test_package_specs_for_update_adds_gui_extra_to_deeplabcut():
+ assert _package_specs_for_update(["deeplabcut"]) == ["deeplabcut[gui]"]
+
+
+def test_package_specs_for_update_preserves_other_packages():
+ assert _package_specs_for_update(["napari-deeplabcut"]) == ["napari-deeplabcut"]
+
+
+def test_package_specs_for_update_handles_mixed_packages():
+ assert _package_specs_for_update(["deeplabcut", "napari-deeplabcut"]) == [
+ "deeplabcut[gui]",
+ "napari-deeplabcut",
+ ]
+
+
+def test_package_specs_for_update_strips_whitespace():
+ assert _package_specs_for_update([" deeplabcut ", " napari-deeplabcut "]) == [
+ "deeplabcut[gui]",
+ "napari-deeplabcut",
+ ]
+
+
+@pytest.mark.parametrize(
+ ("available_installers", "expected_backends"),
+ [
+ ({}, ["pip"]),
+ ({"uv": "/mock/bin/uv"}, ["uv", "pip"]),
+ ],
+)
+def test_build_update_commands_backend_order(monkeypatch, available_installers, expected_backends):
+ def fake_which(name):
+ return available_installers.get(name)
+
+ monkeypatch.setattr("deeplabcut.gui.utils.shutil.which", fake_which)
+
+ commands = _build_update_commands(["deeplabcut", "napari-deeplabcut"])
+
+ assert [backend for backend, _program, _args in commands] == expected_backends
+
+
+def test_build_update_commands_uses_uv_when_available(monkeypatch):
+ monkeypatch.setattr(
+ "deeplabcut.gui.utils.shutil.which",
+ lambda name: "/mock/bin/uv" if name == "uv" else None,
+ )
+
+ commands = _build_update_commands(["deeplabcut", "napari-deeplabcut"])
+
+ assert commands == [
+ (
+ "uv",
+ "/mock/bin/uv",
+ [
+ "pip",
+ "install",
+ "--python",
+ sys.executable,
+ "-U",
+ "deeplabcut[gui]",
+ "napari-deeplabcut",
+ ],
+ ),
+ (
+ "pip",
+ sys.executable,
+ [
+ "-m",
+ "pip",
+ "install",
+ "-U",
+ "deeplabcut[gui]",
+ "napari-deeplabcut",
+ ],
+ ),
+ ]
+
+
+def test_build_update_commands_uses_uv_then_pip(monkeypatch):
+ installers = {"uv": "/mock/bin/uv"}
+
+ monkeypatch.setattr(
+ "deeplabcut.gui.utils.shutil.which",
+ lambda name: installers.get(name),
+ )
+
+ commands = _build_update_commands(["deeplabcut"])
+
+ assert commands == [
+ (
+ "uv",
+ "/mock/bin/uv",
+ [
+ "pip",
+ "install",
+ "--python",
+ sys.executable,
+ "-U",
+ "deeplabcut[gui]",
+ ],
+ ),
+ (
+ "pip",
+ sys.executable,
+ [
+ "-m",
+ "pip",
+ "install",
+ "-U",
+ "deeplabcut[gui]",
+ ],
+ ),
+ ]
+
+
+def test_build_update_commands_always_has_pip_fallback(monkeypatch):
+ monkeypatch.setattr("deeplabcut.gui.utils.shutil.which", lambda _name: None)
+
+ commands = _build_update_commands(["deeplabcut"])
+
+ assert commands == [
+ (
+ "pip",
+ sys.executable,
+ [
+ "-m",
+ "pip",
+ "install",
+ "-U",
+ "deeplabcut[gui]",
+ ],
+ )
+ ]
diff --git a/tests/gui/test_config_editor.py b/tests/gui/test_config_editor.py
new file mode 100644
index 0000000000..202d8b1721
--- /dev/null
+++ b/tests/gui/test_config_editor.py
@@ -0,0 +1,57 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for the raw YAML tree editor (ConfigEditor)."""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+from deeplabcut.gui.widgets import ConfigEditor
+from deeplabcut.utils import auxiliaryfunctions
+
+
+def test_editor_displays_project_config_as_tree(qtbot, tmp_path, write_project_config):
+ """Regression: read_config returns a ProjectConfig model, which the tree
+ editor cannot display; the editor must read the raw YAML dict instead."""
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+
+ editor = ConfigEditor(str(config_path))
+ qtbot.addWidget(editor)
+
+ assert isinstance(editor.cfg, dict)
+
+ root = editor.viewer.tree.invisibleRootItem()
+ keys = {root.child(i).text(0) for i in range(root.childCount())}
+ assert {"Task", "bodyparts", "multianimalproject"} <= keys
+
+
+def test_editor_opens_config_that_fails_validation(qtbot, tmp_path, write_project_config):
+ """The editor is a recovery tool: it must open invalid configs so the
+ user can repair them."""
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path, extra="not_a_dlc_setting: 1")
+
+ editor = ConfigEditor(str(config_path)) # must not raise
+ qtbot.addWidget(editor)
+
+ assert editor.cfg["not_a_dlc_setting"] == 1
+
+
+def test_editor_saves_edits_back_to_disk(qtbot, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+
+ editor = ConfigEditor(str(config_path))
+ qtbot.addWidget(editor)
+ editor.cfg["colormap"] = "viridis"
+ editor.accept()
+
+ on_disk = auxiliaryfunctions.read_plainconfig(str(config_path))
+ assert on_disk["colormap"] == "viridis"
diff --git a/tests/gui/test_config_errors.py b/tests/gui/test_config_errors.py
new file mode 100644
index 0000000000..1a9e0a0147
--- /dev/null
+++ b/tests/gui/test_config_errors.py
@@ -0,0 +1,99 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for user-facing formatting of configuration errors."""
+
+import pytest
+
+pytest.importorskip("PySide6") # deeplabcut.gui imports qtpy at package level
+
+from pathlib import Path
+
+from pydantic import ValidationError
+
+from deeplabcut.core.config import ProjectConfig
+from deeplabcut.gui.dialogs.config_errors import (
+ CONFIG_LOAD_ERRORS,
+ format_config_error,
+)
+
+CONFIG_PATH = "C:/projects/demo/config.yaml"
+# The report renders the path in native form (backslashes on Windows).
+CONFIG_PATH_DISPLAY = str(Path(CONFIG_PATH))
+
+
+def _make_validation_error(cfg: dict) -> ValidationError:
+ with pytest.raises(ValidationError) as exc_info:
+ ProjectConfig.from_dict(cfg)
+ return exc_info.value
+
+
+def test_validation_error_report_names_the_field():
+ error = _make_validation_error({"multianimalproject": "banana"})
+
+ report = format_config_error(CONFIG_PATH, error)
+
+ assert report.title == "Invalid project configuration"
+ assert "1 problem" in report.summary
+ assert CONFIG_PATH_DISPLAY in report.details
+ assert "multianimalproject" in report.details
+ assert "'banana'" in report.details # the received value is shown
+ assert report.technical_details # raw pydantic message preserved
+
+
+def test_extra_forbidden_gets_friendly_message():
+ error = _make_validation_error({"not_a_dlc_setting": 1})
+
+ report = format_config_error(CONFIG_PATH, error)
+
+ assert "not_a_dlc_setting" in report.details
+ assert "not supported by the installed DeepLabCut version" in report.details
+
+
+def test_multiple_errors_are_all_listed():
+ error = _make_validation_error({"not_a_dlc_setting": 1, "multianimalproject": "banana"})
+
+ report = format_config_error(CONFIG_PATH, error)
+
+ assert "2 problems" in report.summary
+ assert "not_a_dlc_setting" in report.details
+ assert "multianimalproject" in report.details
+
+
+def test_file_not_found_report():
+ report = format_config_error(CONFIG_PATH, FileNotFoundError(CONFIG_PATH))
+
+ assert report.title == "Project configuration not found"
+ assert CONFIG_PATH_DISPLAY in report.details
+
+
+def test_permission_error_report():
+ report = format_config_error(CONFIG_PATH, PermissionError("denied"))
+
+ assert report.title == "Cannot read project configuration"
+ assert "permission" in report.summary.lower()
+
+
+def test_generic_error_report_includes_message():
+ report = format_config_error(CONFIG_PATH, ValueError("config is empty or null"))
+
+ assert report.title == "Cannot load project configuration"
+ assert "config is empty or null" in report.details
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ FileNotFoundError("x"),
+ PermissionError("x"),
+ OSError("x"),
+ TypeError("x"),
+ ValueError("x"),
+ ],
+)
+def test_config_load_errors_cover_common_failures(error):
+ assert isinstance(error, CONFIG_LOAD_ERRORS)
diff --git a/tests/gui/test_config_file_monitor.py b/tests/gui/test_config_file_monitor.py
new file mode 100644
index 0000000000..393a38e9a6
--- /dev/null
+++ b/tests/gui/test_config_file_monitor.py
@@ -0,0 +1,80 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for the external-edit watcher on the project configuration."""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+from PySide6 import QtWidgets
+
+from deeplabcut.gui.config_file_monitor import ConfigFileMonitor
+
+
+@pytest.fixture
+def status_bar(qtbot):
+ bar = QtWidgets.QStatusBar()
+ qtbot.addWidget(bar)
+ return bar
+
+
+def _make_monitor(status_bar, config_path, on_reload=lambda: True):
+ monitor = ConfigFileMonitor(status_bar, on_reload=on_reload, debounce_ms=50)
+ monitor.set_path(str(config_path))
+ monitor.mark_current()
+ return monitor
+
+
+def test_external_edit_offers_reload(qtbot, tmp_path, status_bar):
+ config = tmp_path / "config.yaml"
+ config.write_text("Task: test\n")
+ monitor = _make_monitor(status_bar, config)
+ assert monitor._reload_button.isHidden()
+
+ config.write_text("Task: test\nedited: true\n")
+
+ qtbot.waitUntil(lambda: not monitor._reload_button.isHidden(), timeout=5000)
+
+
+def test_mark_current_dismisses_reload_offer(qtbot, tmp_path, status_bar):
+ config = tmp_path / "config.yaml"
+ config.write_text("Task: test\n")
+ monitor = _make_monitor(status_bar, config)
+
+ config.write_text("Task: changed\n")
+ qtbot.waitUntil(lambda: not monitor._reload_button.isHidden(), timeout=5000)
+
+ monitor.mark_current() # simulates a successful reload
+ assert monitor._reload_button.isHidden()
+
+
+def test_set_path_resets_pending_notification(qtbot, tmp_path, status_bar):
+ config = tmp_path / "config.yaml"
+ config.write_text("Task: test\n")
+ monitor = _make_monitor(status_bar, config)
+
+ config.write_text("Task: changed\n")
+ qtbot.waitUntil(lambda: not monitor._reload_button.isHidden(), timeout=5000)
+
+ other = tmp_path / "other.yaml"
+ other.write_text("Task: other\n")
+ monitor.set_path(str(other))
+
+ assert monitor._reload_button.isHidden()
+
+
+def test_reload_button_triggers_callback(qtbot, tmp_path, status_bar):
+ config = tmp_path / "config.yaml"
+ config.write_text("Task: test\n")
+ reloads = []
+ monitor = _make_monitor(status_bar, config, on_reload=lambda: reloads.append(True))
+
+ monitor._reload_button.click()
+
+ assert reloads == [True]
diff --git a/tests/gui/test_main_window_config.py b/tests/gui/test_main_window_config.py
new file mode 100644
index 0000000000..3220af2700
--- /dev/null
+++ b/tests/gui/test_main_window_config.py
@@ -0,0 +1,186 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Functional tests for MainWindow config caching and error recovery.
+
+These construct a real (off-screen) MainWindow but stub out ``add_tabs`` and
+the error dialog, so no DLC project on disk and no user interaction is needed.
+"""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+from PySide6 import QtWidgets
+
+pytestmark = pytest.mark.functional
+
+
+class TestCfgCaching:
+ def test_cfg_is_cached_between_accesses(self, main_window, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+
+ main_window.config = str(config_path)
+
+ first = main_window.cfg
+ assert first is not None
+ assert first is main_window.cfg # same validated snapshot
+
+ def test_external_edit_does_not_silently_reload(self, main_window, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+ main_window.config = str(config_path)
+ first = main_window.cfg
+
+ write_project_config(config_path, tmp_path, task="edited")
+
+ # The GUI keeps operating on the loaded snapshot until an explicit reload.
+ assert main_window.cfg is first
+ assert main_window.cfg.Task == "demo"
+
+ def test_assigning_config_invalidates_cache(self, main_window, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+ main_window.config = str(config_path)
+ first = main_window.cfg
+
+ write_project_config(config_path, tmp_path, task="edited")
+ main_window.config = str(config_path) # explicit reload boundary
+
+ reloaded = main_window.cfg
+ assert reloaded is not first
+ assert reloaded.Task == "edited"
+
+ def test_cfg_is_none_without_a_project(self, main_window):
+ main_window.config = None
+ assert main_window.cfg is None
+
+
+class TestRecoveryLoop:
+ """_build_project_ui_from_current_config must never crash the window."""
+
+ @pytest.fixture
+ def stubbed_window(self, main_window):
+ main_window._built_tabs = []
+ main_window.add_tabs = lambda: main_window._built_tabs.append(True)
+ return main_window
+
+ def test_cancel_leaves_welcome_page(self, stubbed_window, tmp_path, write_project_config):
+ from deeplabcut.gui.window import ConfigErrorAction
+
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path, extra="not_a_dlc_setting: 1")
+
+ handled = []
+
+ def cancel(error):
+ handled.append(error)
+ return ConfigErrorAction.CANCEL
+
+ stubbed_window._handle_config_error = cancel
+ stubbed_window.config = str(config_path)
+
+ assert stubbed_window._build_project_ui_from_current_config() is False
+ assert len(handled) == 1
+ assert stubbed_window._built_tabs == []
+
+ def test_retry_succeeds_after_user_fixes_config(self, stubbed_window, tmp_path, write_project_config):
+ from deeplabcut.gui.window import ConfigErrorAction
+
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path, extra="not_a_dlc_setting: 1")
+
+ def fix_file_and_retry(error):
+ write_project_config(config_path, tmp_path, task="repaired")
+ return ConfigErrorAction.RETRY
+
+ stubbed_window._handle_config_error = fix_file_and_retry
+ stubbed_window.config = str(config_path)
+
+ assert stubbed_window._build_project_ui_from_current_config() is True
+ assert stubbed_window._built_tabs == [True]
+ # Each retry re-reads from disk, so the repaired file is what got loaded.
+ assert stubbed_window.cfg.Task == "repaired"
+
+ def test_valid_config_builds_tabs_without_error_handling(self, stubbed_window, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+
+ def unexpected(error): # pragma: no cover - should not run
+ raise AssertionError(f"error handler should not be called: {error}")
+
+ stubbed_window._handle_config_error = unexpected
+ stubbed_window.config = str(config_path)
+
+ assert stubbed_window._build_project_ui_from_current_config() is True
+ assert stubbed_window._built_tabs == [True]
+
+
+class TestConfigCacheInvalidation:
+ def test_invalidate_drops_cache_for_next_access(self, main_window, tmp_path, write_project_config):
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+ main_window.config = str(config_path)
+
+ first = main_window.cfg
+ assert first is not None
+
+ write_project_config(config_path, tmp_path, task="changed-on-disk")
+ main_window.invalidate_config_cache()
+ reloaded = main_window.cfg
+
+ assert reloaded is not first
+ assert reloaded.Task == "changed-on-disk"
+
+ def test_invalidate_is_idempotent(self, main_window):
+ # Must not raise even with no config loaded.
+ main_window.config = None
+ main_window.invalidate_config_cache()
+ assert main_window.cfg is None
+
+
+class TestReloadTimer:
+ def test_named_timer_triggers_reload(self, qapp, qtbot, tmp_path, write_project_config, monkeypatch):
+ """Verify the _reload_timer in ManageProject fires reload_project_config."""
+ from deeplabcut.gui.tabs.manage_project import ManageProject
+
+ # Keep QSettings out of the real user settings.
+ qapp.setOrganizationName("DeepLabCut-Tests")
+ qapp.setApplicationName("DLC-GUI-Tests")
+ monkeypatch.setattr(
+ QtWidgets.QMessageBox,
+ "question",
+ lambda *args, **kwargs: QtWidgets.QMessageBox.Yes,
+ )
+
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+
+ from deeplabcut.gui.window import MainWindow
+
+ window = MainWindow(qapp)
+ try:
+ window.config = str(config_path)
+
+ reloads = []
+ monkeypatch.setattr(
+ window,
+ "reload_project_config",
+ lambda: reloads.append(True),
+ )
+
+ tab = ManageProject(window, window, "")
+ tab._reload_timer.start()
+
+ # The timer has interval=0 so it fires on the next event loop tick.
+ qtbot.wait(50)
+
+ assert len(reloads) == 1
+ finally:
+ window.close()
diff --git a/tests/gui/test_selected_shuffle_display.py b/tests/gui/test_selected_shuffle_display.py
new file mode 100644
index 0000000000..b11e68c007
--- /dev/null
+++ b/tests/gui/test_selected_shuffle_display.py
@@ -0,0 +1,94 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for the shuffle info display degrading gracefully on bad shuffles."""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+import PySide6.QtCore as QtCore
+
+from deeplabcut.core.engine import Engine
+from deeplabcut.gui.displays.selected_shuffle_display import SelectedShuffleDisplay
+
+
+class FakeRoot(QtCore.QObject):
+ """Minimal stand-in for MainWindow as seen by SelectedShuffleDisplay."""
+
+ shuffle_change = QtCore.Signal(int)
+ engine_change = QtCore.Signal(object)
+ shuffle_created = QtCore.Signal(int)
+
+ def __init__(self, pose_cfg_path: str, raise_error: Exception | None = None):
+ super().__init__()
+ self.shuffle_value = 1
+ self.engine = Engine.PYTORCH
+ self._pose_cfg_path = pose_cfg_path
+ self._raise_error = raise_error
+
+ @property
+ def pose_cfg_path(self) -> str:
+ if self._raise_error is not None:
+ raise self._raise_error
+ return self._pose_cfg_path
+
+
+def test_missing_pose_cfg_shows_error_instead_of_crashing(qtbot, tmp_path):
+ missing = tmp_path / "missing" / "pose_cfg.yaml"
+ display = SelectedShuffleDisplay(FakeRoot(str(missing)))
+ qtbot.addWidget(display)
+
+ assert display.pose_cfg is None
+ assert "was not created" in display._label.text()
+
+
+def test_unresolvable_shuffle_shows_error(qtbot, tmp_path):
+ root = FakeRoot(str(tmp_path), raise_error=ValueError("no such shuffle"))
+ display = SelectedShuffleDisplay(root)
+ qtbot.addWidget(display)
+
+ assert display.pose_cfg is None
+ assert "Failed to read shuffle 1" in display._label.text()
+
+
+def test_valid_pose_cfg_is_displayed(qtbot, tmp_path):
+ pose_cfg_path = tmp_path / "pytorch_config.yaml"
+ pose_cfg_path.write_text("net_type: resnet_50\nmethod: TD\n")
+
+ display = SelectedShuffleDisplay(FakeRoot(str(pose_cfg_path)))
+ qtbot.addWidget(display)
+
+ assert display.pose_cfg == {"net_type": "resnet_50", "method": "TD"}
+ assert "resnet_50" in display._label.text()
+ assert "top-down" in display._label.text()
+
+
+def test_pose_cfg_without_method_key_does_not_crash(qtbot, tmp_path):
+ """Regression: pose_cfg.get('method').lower() raised AttributeError."""
+ pose_cfg_path = tmp_path / "pytorch_config.yaml"
+ pose_cfg_path.write_text("net_type: resnet_50\n")
+
+ display = SelectedShuffleDisplay(FakeRoot(str(pose_cfg_path)))
+ qtbot.addWidget(display)
+
+ assert display.pose_cfg == {"net_type": "resnet_50"}
+ assert "top-down" not in display._label.text()
+
+
+def test_pose_cfg_signal_carries_none(qtbot, tmp_path):
+ """Regression: the signal was declared Signal(dict) and mangled None."""
+ missing = tmp_path / "missing" / "pose_cfg.yaml"
+ display = SelectedShuffleDisplay(FakeRoot(str(missing)))
+ qtbot.addWidget(display)
+
+ received = []
+ display.pose_cfg_signal.connect(received.append)
+ display.pose_cfg = None
+
+ assert received == [None]
diff --git a/tests/gui/test_task_error.py b/tests/gui/test_task_error.py
new file mode 100644
index 0000000000..98b5efa375
--- /dev/null
+++ b/tests/gui/test_task_error.py
@@ -0,0 +1,81 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for MainWindow.show_task_error dialog rendering."""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+
+from pydantic import ValidationError
+from PySide6 import QtWidgets
+
+from deeplabcut.core.config import ProjectConfig
+
+
+def _make_validation_error(cfg: dict) -> ValidationError:
+ with pytest.raises(ValidationError) as exc_info:
+ ProjectConfig.from_dict(cfg)
+ return exc_info.value
+
+
+class TestShowTaskError:
+ @pytest.fixture
+ def patched_messagebox(self, monkeypatch):
+ """Intercept every QMessageBox so show_task_error never blocks."""
+ captured = {}
+
+ class _Box(QtWidgets.QMessageBox):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ captured["instance"] = self
+
+ def exec(self):
+ captured["exec_called"] = True
+ return 0
+
+ monkeypatch.setattr(QtWidgets, "QMessageBox", _Box)
+ return captured
+
+ def test_generic_error_shows_task_failed_dialog(
+ self, main_window, tmp_path, write_project_config, patched_messagebox
+ ):
+ """Non-config exceptions get a generic 'Task failed' dialog."""
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+ main_window.config = str(config_path)
+
+ main_window.show_task_error(ValueError("config is empty or null"))
+
+ box = patched_messagebox["instance"]
+ assert box.windowTitle() == "Task failed"
+ assert box.text() == "DeepLabCut could not complete the task."
+ assert "config is empty or null" in box.informativeText()
+
+ button_texts = [b.text() for b in box.buttons()]
+ assert "Open configuration" not in button_texts
+
+ def test_config_validation_error_shows_open_button(
+ self, main_window, tmp_path, write_project_config, patched_messagebox
+ ):
+ """Config validation errors show an 'Open configuration' button."""
+ config_path = tmp_path / "config.yaml"
+ write_project_config(config_path, tmp_path)
+ main_window.config = str(config_path)
+
+ error = _make_validation_error({"multianimalproject": "banana"})
+
+ main_window.show_task_error(error, config_path=str(config_path))
+
+ box = patched_messagebox["instance"]
+ assert box.windowTitle() == "Invalid project configuration"
+ assert "multianimalproject" in box.informativeText()
+
+ button_texts = [b.text() for b in box.buttons()]
+ assert "Open configuration" in button_texts
diff --git a/tests/gui/test_worker.py b/tests/gui/test_worker.py
new file mode 100644
index 0000000000..9953976035
--- /dev/null
+++ b/tests/gui/test_worker.py
@@ -0,0 +1,75 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for worker-thread task execution and error marshalling."""
+
+import pytest
+
+pytest.importorskip("PySide6")
+pytest.importorskip("pytestqt")
+
+from deeplabcut.gui.utils import CaptureWorker, Worker, move_to_separate_thread
+
+
+def _boom():
+ raise ZeroDivisionError("boom")
+
+
+def test_worker_emits_finished_on_success(qtbot):
+ calls = []
+ worker = Worker(lambda: calls.append(True))
+
+ with qtbot.waitSignal(worker.finished, timeout=1000):
+ worker.run()
+
+ assert calls == [True]
+
+
+def test_worker_emits_error_and_finished_on_exception(qtbot):
+ """Regression: a raising task used to kill the worker without any signal,
+ leaving progress bars spinning and buttons disabled forever."""
+ worker = Worker(_boom)
+ errors = []
+ worker.error.connect(errors.append)
+
+ with qtbot.waitSignal(worker.finished, timeout=1000):
+ worker.run()
+
+ assert len(errors) == 1
+ assert isinstance(errors[0], ZeroDivisionError)
+
+
+def test_capture_worker_captures_outputs():
+ worker = CaptureWorker(lambda: "result")
+ worker.run()
+ assert worker.outputs == "result"
+
+
+def test_capture_worker_outputs_none_on_error(qtbot):
+ worker = CaptureWorker(_boom)
+ errors = []
+ worker.error.connect(errors.append)
+
+ with qtbot.waitSignal(worker.finished, timeout=1000):
+ worker.run()
+
+ assert worker.outputs is None
+ assert len(errors) == 1
+
+
+def test_thread_quits_after_exception(qtbot):
+ """Regression: the QThread must stop even when the task raises."""
+ worker, thread = move_to_separate_thread(_boom)
+ errors = []
+ worker.error.connect(errors.append)
+
+ with qtbot.waitSignal(thread.finished, timeout=3000):
+ thread.start()
+
+ qtbot.waitUntil(thread.isFinished, timeout=3000)
+ assert len(errors) == 1
+ assert isinstance(errors[0], ZeroDivisionError)
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..4e18e8aad9
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_apis_export.py
@@ -0,0 +1,324 @@
+#
+# 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
+from deeplabcut.core.config import ProjectConfig
+from deeplabcut.pose_estimation_pytorch import Task
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.runners.snapshots import Snapshot
+
+
+def _minimal_pose_config(tmp_path: Path, *, resume_from: str = "/abs/snapshot.pt") -> PoseConfig:
+ project_config = ProjectConfig(
+ project_path=tmp_path,
+ bodyparts=["nose"],
+ individuals=["mouse"],
+ multianimalproject=False,
+ )
+ pose_config = PoseConfig.build(
+ project_config,
+ tmp_path / "pytorch_config.yaml",
+ top_down=False,
+ net_type="resnet_50",
+ )
+ pose_config.resume_training_from = resume_from
+ return pose_config
+
+
+@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 = ProjectConfig(
+ multianimalproject=True,
+ Task="mock",
+ scorer="mock",
+ video_sets={SQS((video_dir / "vid.mp4").as_posix()): {"crop": "0, 640, 0, 480"}},
+ project_path=project_dir.as_posix(),
+ individuals=["a", "b"],
+ uniquebodyparts=[],
+ multianimalbodyparts=["k1", "k2", "k3"],
+ bodyparts="MULTI!",
+ )
+ cfg_file.to_yaml(project_dir / "config.yaml")
+
+
+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 test_wipe_paths_clears_resume_training_from(tmp_path: Path) -> None:
+ cfg = _minimal_pose_config(tmp_path)
+ export.wipe_paths_from_model_config(cfg)
+ assert cfg.resume_training_from is None
+ assert cfg.metadata.project_path is None
+ assert cfg.metadata.pose_config_path is None
+
+
+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, ProjectConfig)):
+ _loader.project_cfg = config
+ return _loader
+
+ # patch the DLCLoader but also read_config
+ with patch(
+ "deeplabcut.pose_estimation_pytorch.apis.export.dlc3_data.DLCLoader",
+ get_mock_loader,
+ ):
+ # 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_apis_training.py b/tests/pose_estimation_pytorch/apis/test_apis_training.py
new file mode 100644
index 0000000000..e3f2fc2b11
--- /dev/null
+++ b/tests/pose_estimation_pytorch/apis/test_apis_training.py
@@ -0,0 +1,57 @@
+#
+# 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 training API."""
+
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+from deeplabcut.pose_estimation_pytorch.apis.training import train
+from deeplabcut.pose_estimation_pytorch.config import make_pytorch_pose_config
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def _minimal_run_config(tmp_path: Path, *, resume_from: str | None = None) -> PoseConfig:
+ project_cfg = {
+ "multianimalproject": False,
+ "project_path": str(tmp_path),
+ "bodyparts": ["nose"],
+ "uniquebodyparts": [],
+ "individuals": ["mouse"],
+ }
+ cfg_path = tmp_path / "pytorch_config.yaml"
+ pose_config = make_pytorch_pose_config(project_cfg, str(cfg_path), net_type="resnet_50")
+ if resume_from is not None:
+ pose_config.resume_training_from = resume_from
+ return pose_config
+
+
+@patch("deeplabcut.pose_estimation_pytorch.apis.training.build_transforms", return_value=Mock())
+@patch("deeplabcut.pose_estimation_pytorch.apis.training.PoseModel.build", return_value=Mock())
+@patch("deeplabcut.pose_estimation_pytorch.apis.training.build_training_runner", return_value=Mock())
+def test_train_uses_resume_training_from_config(
+ mock_build_runner: Mock,
+ mock_build_model: Mock,
+ mock_build_transforms: Mock,
+ tmp_path: Path,
+) -> None:
+ run_config = _minimal_run_config(tmp_path, resume_from="/train/snapshot-010.pt")
+
+ loader = Mock()
+ loader.model_folder = tmp_path
+ loader.model_cfg = run_config
+ train_dataset = Mock(__len__=Mock(return_value=1))
+ valid_dataset = Mock(__len__=Mock(return_value=1))
+ loader.create_dataset = Mock(side_effect=[train_dataset, valid_dataset])
+
+ train(loader=loader, run_config=run_config, task=Task.BOTTOM_UP, device="cpu", snapshot_path=None)
+
+ assert mock_build_runner.call_args.kwargs["snapshot_path"] == "/train/snapshot-010.pt"
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/fixtures/multianimal_project_v0.yaml b/tests/pose_estimation_pytorch/config/fixtures/multianimal_project_v0.yaml
new file mode 100644
index 0000000000..8191ad124e
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/fixtures/multianimal_project_v0.yaml
@@ -0,0 +1,79 @@
+# Config schema version. Do not edit manually.
+config_version: 0
+# Project definitions (do not edit)
+Task: multi_mouse
+scorer: test
+date: Jun19
+multianimalproject: true
+identity: false
+
+# Project path (change when moving around)
+project_path: /test/fixtures/multianimal_project_v0
+
+# 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:
+ /test/fixtures/multianimal_project_v0/videos/m3v1.mp4:
+ crop: 0, 640, 0, 480
+ /test/fixtures/multianimal_project_v0/videos/m3v2.mp4:
+ crop: 0, 640, 0, 480
+bodyparts: MULTI!
+individuals:
+- mouse1
+- mouse2
+uniquebodyparts:
+- corner1
+- corner2
+multianimalbodyparts:
+- nose
+- tail
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start: 0.0
+stop: 1.0
+numframes2pick: 5
+
+# Plotting configuration
+skeleton:
+- - nose
+ - tail
+skeleton_color: black
+pcutoff: 0.6
+dotsize: 12
+alphavalue: 0.7
+colormap: rainbow
+
+# Training,Evaluation and Analysis configuration
+TrainingFraction:
+- 0.8
+iteration: 1
+default_net_type: resnet_50
+default_augmenter: albumentations
+default_track_method: ellipse
+snapshotindex: -1
+detector_snapshotindex: -1
+batch_size: 8
+detector_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
+
+# Conversion tables to fine-tune SuperAnimal weights
+SuperAnimalConversionTables:
+
+# These are very old parameters that are no longer used They are simply ignored.
+resnet:
+croppedtraining:
diff --git a/tests/pose_estimation_pytorch/config/fixtures/single_animal_project_v0.yaml b/tests/pose_estimation_pytorch/config/fixtures/single_animal_project_v0.yaml
new file mode 100644
index 0000000000..49533afe0c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/fixtures/single_animal_project_v0.yaml
@@ -0,0 +1,71 @@
+# Config schema version. Do not edit manually.
+config_version: 0
+# Project definitions (do not edit)
+Task: openfield
+scorer: test
+date: Oct30
+multianimalproject: false
+identity: false
+
+# Project path (change when moving around)
+project_path: /test/fixtures/single_animal_project_v0
+
+# 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:
+ /test/fixtures/single_animal_project_v0/videos/m1s1.mp4:
+ crop: 0, 640, 0, 480
+ /test/fixtures/single_animal_project_v0/videos/m1s2.mp4:
+ crop: 0, 640, 0, 480
+bodyparts:
+- snout
+- leftear
+- rightear
+- tailbase
+
+# Fraction of video to start/stop when extracting frames for labeling/refinement
+start: 0.0
+stop: 1.0
+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: default
+snapshotindex: -1
+detector_snapshotindex: -1
+batch_size: 4
+detector_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: 0
+y2: 480
+
+# 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:
+
+# These are very old parameters that are no longer used They are simply ignored.
+resnet:
+croppedtraining:
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_ctd_conditions.py b/tests/pose_estimation_pytorch/config/test_ctd_conditions.py
new file mode 100644
index 0000000000..725f055354
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/test_ctd_conditions.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/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for typed CTD conditions config and evaluation loading."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+
+from deeplabcut.pose_estimation_pytorch import data as data_module
+from deeplabcut.pose_estimation_pytorch.apis.ctd import load_conditions_for_evaluation
+from deeplabcut.pose_estimation_pytorch.config.ctd_conditions import (
+ ConditionsConfig,
+ ConditionsFileConfig,
+ ConditionsModelConfig,
+ ConditionsShuffleConfig,
+)
+from deeplabcut.pose_estimation_pytorch.config.inference import InferenceConfig
+from deeplabcut.pose_estimation_pytorch.config.make_pose_config import _add_ctd_conditions
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+from deeplabcut.pose_estimation_pytorch.task import Task
+
+
+def _ctd_model_conditions_config() -> ConditionsModelConfig:
+ return ConditionsModelConfig(
+ config_path=Path("/bu/pytorch_config.yaml"),
+ snapshot_path=Path("/bu/snapshot-best.pt"),
+ scorer="DLC_resnet50",
+ )
+
+
+def _ctd_project(tmp_path: Path) -> dict:
+ return {
+ "multianimalproject": True,
+ "project_path": str(tmp_path),
+ "bodyparts": "MULTI!",
+ "multianimalbodyparts": ["nose", "tail"],
+ "uniquebodyparts": [],
+ "individuals": ["mouse1", "mouse2"],
+ "identity": False,
+ }
+
+
+def _ctd_loader(*, conditions) -> Mock:
+ loader = Mock()
+ loader.pose_task = Task.COND_TOP_DOWN
+ loader.model_cfg = {"inference": {"conditions": conditions}}
+ loader.image_root = Path("/images")
+ return loader
+
+
+class _FakeDLCLoader(data_module.DLCLoader):
+ """Minimal DLCLoader stand-in so isinstance(..., DLCLoader) succeeds."""
+
+ def __init__(self, conditions):
+ self.pose_task = Task.COND_TOP_DOWN
+ self.model_cfg = {"inference": {"conditions": conditions}}
+ self.image_root = Path("/images")
+ self.project_root = Path("/project")
+
+
+# --- ConditionsConfig.build ---------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "raw, expected_type, check",
+ [
+ pytest.param(None, type(None), lambda c: c is None, id="none"),
+ pytest.param(
+ "/path/to/preds.h5",
+ ConditionsFileConfig,
+ lambda c: c.filepath == Path("/path/to/preds.h5"),
+ id="path-str",
+ ),
+ pytest.param(
+ Path("/path/to/preds.json"),
+ ConditionsFileConfig,
+ lambda c: c.filepath == Path("/path/to/preds.json"),
+ id="path-obj",
+ ),
+ pytest.param(
+ {"filepath": "/preds.h5"},
+ ConditionsFileConfig,
+ lambda c: c.filepath == Path("/preds.h5"),
+ id="filepath-dict",
+ ),
+ pytest.param(
+ {"shuffle": 3},
+ ConditionsShuffleConfig,
+ lambda c: c.shuffle == 3,
+ id="shuffle-dict",
+ ),
+ pytest.param(
+ {"shuffle": 1, "snapshot_index": -1},
+ ConditionsShuffleConfig,
+ lambda c: c.shuffle == 1 and c.snapshot_index == -1,
+ id="shuffle-snapshot-index",
+ ),
+ pytest.param(
+ {
+ "config_path": "/bu/pytorch_config.yaml",
+ "snapshot_path": "/bu/snapshot.pt",
+ },
+ ConditionsModelConfig,
+ lambda c: c.config_path == Path("/bu/pytorch_config.yaml"),
+ id="model-dict",
+ ),
+ pytest.param(
+ {"source": "shuffle", "shuffle": 9},
+ ConditionsShuffleConfig,
+ lambda c: c.shuffle == 9,
+ id="explicit-source-shuffle",
+ ),
+ ],
+)
+def test_conditions_config_build(raw, expected_type, check):
+ built = ConditionsConfig.build(raw)
+ assert isinstance(built, expected_type)
+ assert check(built)
+
+
+def test_conditions_config_build_passthrough():
+ ctd_conditions = _ctd_model_conditions_config()
+ assert ConditionsConfig.build(ctd_conditions) is ctd_conditions
+
+
+def test_conditions_config_build_rejects_ambiguous_dict():
+ with pytest.raises(ValueError, match="Cannot determine conditions source"):
+ ConditionsConfig.build({"unexpected": 1})
+
+
+def test_conditions_config_build_rejects_unsupported_type():
+ with pytest.raises(TypeError, match="Cannot build"):
+ ConditionsConfig.build(42) # type: ignore[arg-type]
+
+
+# --- resolve_from_conditions --------------------------------------------------
+
+
+def test_resolve_from_conditions_model_identity():
+ ctd_conditions = _ctd_model_conditions_config()
+ assert ConditionsModelConfig.resolve_from_conditions(ctd_conditions) is ctd_conditions
+
+
+def test_resolve_from_conditions_rejects_file_config():
+ with pytest.raises(ValueError, match="evaluation only"):
+ ConditionsModelConfig.resolve_from_conditions(ConditionsFileConfig(filepath=Path("/preds.h5")))
+
+
+def test_resolve_from_conditions_rejects_filepath_dict():
+ with pytest.raises(ValueError, match="evaluation only"):
+ ConditionsModelConfig.resolve_from_conditions({"filepath": "/preds.h5"})
+
+
+@pytest.mark.parametrize(
+ "conditions",
+ [
+ pytest.param({"shuffle": 1}, id="dict"),
+ pytest.param(ConditionsShuffleConfig(shuffle=1), id="typed"),
+ ],
+)
+def test_resolve_from_conditions_shuffle_requires_config(conditions):
+ with pytest.raises(ValueError, match="no project config"):
+ ConditionsModelConfig.resolve_from_conditions(conditions)
+
+
+@pytest.mark.parametrize(
+ "conditions",
+ [
+ pytest.param(
+ ConditionsShuffleConfig(shuffle=7, snapshot_index=-1),
+ id="typed",
+ ),
+ pytest.param(
+ {"shuffle": 7, "snapshot_index": -1},
+ id="dict",
+ ),
+ ],
+)
+def test_resolve_from_conditions_shuffle_with_config(conditions, monkeypatch):
+ """Shuffle forms are forwarded to from_shuffle with the project config injected."""
+ mock_from_shuffle = Mock(return_value=_ctd_model_conditions_config())
+ monkeypatch.setattr(
+ ConditionsModelConfig,
+ "from_shuffle",
+ classmethod(lambda cls, **kwargs: mock_from_shuffle(**kwargs)),
+ )
+
+ ConditionsModelConfig.resolve_from_conditions(
+ conditions,
+ config="/project/config.yaml",
+ )
+
+ mock_from_shuffle.assert_called_once_with(
+ config=Path("/project/config.yaml"),
+ shuffle=7,
+ trainset_index=0,
+ modelprefix="",
+ snapshot=None,
+ snapshot_index=-1,
+ )
+
+
+def test_resolve_from_conditions_uses_embedded_shuffle_config(monkeypatch):
+ mock_from_shuffle = Mock(return_value=_ctd_model_conditions_config())
+ monkeypatch.setattr(
+ ConditionsModelConfig,
+ "from_shuffle",
+ classmethod(lambda cls, **kwargs: mock_from_shuffle(**kwargs)),
+ )
+
+ ConditionsModelConfig.resolve_from_conditions(
+ ConditionsShuffleConfig(shuffle=3, config=Path("/embedded/config.yaml")),
+ )
+
+ mock_from_shuffle.assert_called_once_with(
+ config=Path("/embedded/config.yaml"),
+ shuffle=3,
+ trainset_index=0,
+ modelprefix="",
+ snapshot=None,
+ snapshot_index=None,
+ )
+
+
+# --- InferenceConfig + _add_ctd_conditions / PoseConfig ------------------------
+
+
+def test_inference_config_accepts_path_string():
+ """Regression: bare path strings must validate (PoseConfig / YAML file form)."""
+ cfg = InferenceConfig(conditions="/path/to/bu_predictions.h5")
+ assert isinstance(cfg.conditions, ConditionsFileConfig)
+ assert cfg.conditions.filepath == Path("/path/to/bu_predictions.h5")
+
+
+@pytest.mark.parametrize(
+ "ctd_conditions, expected",
+ [
+ pytest.param(5, {"shuffle": 5}, id="int"),
+ pytest.param((1, -1), {"shuffle": 1, "snapshot_index": -1}, id="tuple-index"),
+ pytest.param(
+ (2, "snapshot-best-150.pt"),
+ {"shuffle": 2, "snapshot": "snapshot-best-150.pt"},
+ id="tuple-name",
+ ),
+ ],
+)
+def test_add_ctd_conditions_shuffle_forms(ctd_conditions, expected):
+ model_cfg: dict = {"inference": {}}
+ _add_ctd_conditions(model_cfg, ctd_conditions)
+ assert model_cfg["inference"]["conditions"] == expected
+ built = ConditionsConfig.build(model_cfg["inference"]["conditions"])
+ assert isinstance(built, ConditionsShuffleConfig)
+
+
+def test_add_ctd_conditions_file_path(tmp_path: Path):
+ preds = tmp_path / "bu_predictions.h5"
+ preds.write_bytes(b"")
+
+ model_cfg: dict = {"inference": {}}
+ _add_ctd_conditions(model_cfg, preds)
+ assert model_cfg["inference"]["conditions"] == str(preds.resolve())
+
+ built = ConditionsConfig.build(model_cfg["inference"]["conditions"])
+ assert isinstance(built, ConditionsFileConfig)
+ assert built.filepath == preds.resolve()
+
+
+def test_pose_config_build_ctd_with_shuffle(tmp_path: Path):
+ pose_cfg = PoseConfig.build(
+ _ctd_project(tmp_path),
+ tmp_path / "pytorch_config.yaml",
+ top_down=False,
+ net_type="ctd_coam_w32",
+ ctd_conditions=(1, -1),
+ )
+ assert isinstance(pose_cfg.inference.conditions, ConditionsShuffleConfig)
+ assert pose_cfg.inference.conditions.shuffle == 1
+ assert pose_cfg.inference.conditions.snapshot_index == -1
+
+
+def test_pose_config_build_ctd_with_file(tmp_path: Path):
+ preds = tmp_path / "conditions.h5"
+ preds.write_bytes(b"")
+ pose_cfg = PoseConfig.build(
+ _ctd_project(tmp_path),
+ tmp_path / "pytorch_config.yaml",
+ top_down=False,
+ net_type="ctd_coam_w32",
+ ctd_conditions=preds,
+ )
+ assert isinstance(pose_cfg.inference.conditions, ConditionsFileConfig)
+ assert pose_cfg.inference.conditions.filepath == preds.resolve()
+
+
+# --- load_conditions_for_evaluation -------------------------------------------
+
+
+def test_load_conditions_for_evaluation_from_file():
+ loader = _ctd_loader(conditions="/preds.h5") # YAML-like raw path string
+
+ with patch("deeplabcut.pose_estimation_pytorch.apis.ctd.CondFromFile") as mock_cond:
+ load_conditions_for_evaluation(loader, ["img.png"])
+
+ mock_cond.assert_called_once_with(filepath=Path("/preds.h5"))
+ mock_cond.return_value.load_conditions.assert_called_once_with(["img.png"], path_prefix=loader.image_root)
+
+
+def test_load_conditions_for_evaluation_from_shuffle():
+ loader = _ctd_loader(
+ conditions={
+ "shuffle": 4,
+ "config": "/project/config.yaml",
+ "snapshot": "snapshot-100.pt",
+ }
+ )
+
+ with patch("deeplabcut.pose_estimation_pytorch.apis.ctd.CondFromFile") as mock_cond:
+ load_conditions_for_evaluation(loader, ["img.png"])
+
+ mock_cond.assert_called_once_with(
+ config=Path("/project/config.yaml"),
+ shuffle=4,
+ trainset_index=0,
+ modelprefix="",
+ snapshot="snapshot-100.pt",
+ snapshot_index=None,
+ )
+
+
+def test_load_conditions_for_evaluation_injects_dlcloader_project_config():
+ loader = _FakeDLCLoader(conditions={"shuffle": 4})
+
+ with patch("deeplabcut.pose_estimation_pytorch.apis.ctd.CondFromFile") as mock_cond:
+ load_conditions_for_evaluation(loader, ["img.png"])
+
+ mock_cond.assert_called_once_with(
+ config=Path("/project/config.yaml"),
+ shuffle=4,
+ trainset_index=0,
+ modelprefix="",
+ snapshot=None,
+ snapshot_index=None,
+ )
+
+
+def test_load_conditions_for_evaluation_rejects_model():
+ with pytest.raises(ValueError, match="Evaluation accepts file paths or shuffle refs"):
+ load_conditions_for_evaluation(
+ _ctd_loader(conditions=_ctd_model_conditions_config()),
+ ["img.png"],
+ )
+
+
+def test_load_conditions_for_evaluation_rejects_none():
+ with pytest.raises(ValueError, match="Got None"):
+ load_conditions_for_evaluation(_ctd_loader(conditions=None), ["img.png"])
+
+
+def test_load_conditions_for_evaluation_rejects_non_ctd():
+ loader = _ctd_loader(conditions={"shuffle": 1})
+ loader.pose_task = Task.BOTTOM_UP
+ with pytest.raises(ValueError, match="only be loaded for CTD"):
+ load_conditions_for_evaluation(loader, ["img.png"])
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/config/test_pose_config_creation.py b/tests/pose_estimation_pytorch/config/test_pose_config_creation.py
new file mode 100644
index 0000000000..80d7589957
--- /dev/null
+++ b/tests/pose_estimation_pytorch/config/test_pose_config_creation.py
@@ -0,0 +1,217 @@
+#
+# DeepLabCut Toolbox (deeplabcut.org)
+# © A. & M.W. Mathis Labs
+# https://github.com/DeepLabCut/DeepLabCut/blob/main/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for pose config creation via make_pytorch_pose_config."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from typing import Any, NamedTuple
+
+import pytest
+
+from deeplabcut.core.config import read_config_as_dict
+from deeplabcut.core.weight_init import WeightInitialization
+
+try:
+ from deeplabcut.pose_estimation_pytorch.config import PoseConfig
+
+ build_pose_config = PoseConfig.build
+except ImportError:
+ from deeplabcut.pose_estimation_pytorch.config.make_pose_config import make_pytorch_pose_config as build_pose_config
+
+
+FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
+
+
+def _as_dict(cfg: Any) -> dict:
+ if isinstance(cfg, dict):
+ return cfg
+ return cfg.to_dict(normalize=True)
+
+
+SINGLE_ANIMAL_PROJECT = read_config_as_dict(FIXTURES_DIR / "single_animal_project_v0.yaml")
+MULTIANIMAL_PROJECT = read_config_as_dict(FIXTURES_DIR / "multianimal_project_v0.yaml")
+MULTIANIMAL_TD_PROJECT = copy.deepcopy(MULTIANIMAL_PROJECT)
+MULTIANIMAL_TD_PROJECT["uniquebodyparts"] = []
+
+
+class PoseConfigCase(NamedTuple):
+ id: str
+ project: dict
+ net_type: str
+ build_kwargs: dict[str, Any]
+ method: str
+ bodyparts: list[str]
+ individuals: list[str]
+ unique_bodyparts: list[str]
+ backbone_type: str | None
+ detector_type: str | None = None
+ head_type: str | None = None
+
+
+POSE_CONFIG_CASES = [
+ PoseConfigCase(
+ "single_resnet_50_bu",
+ SINGLE_ANIMAL_PROJECT,
+ "resnet_50",
+ {},
+ "bu",
+ ["snout", "leftear", "rightear", "tailbase"],
+ ["individual_1"],
+ [],
+ "ResNet",
+ head_type="HeatmapHead",
+ ),
+ PoseConfigCase(
+ "single_hrnet_w32_bu",
+ SINGLE_ANIMAL_PROJECT,
+ "hrnet_w32",
+ {},
+ "bu",
+ ["snout", "leftear", "rightear", "tailbase"],
+ ["individual_1"],
+ [],
+ "HRNet",
+ head_type="HeatmapHead",
+ ),
+ PoseConfigCase(
+ "multi_resnet_50_bu",
+ MULTIANIMAL_PROJECT,
+ "resnet_50",
+ {},
+ "bu",
+ ["nose", "tail"],
+ ["mouse1", "mouse2"],
+ ["corner1", "corner2"],
+ "ResNet",
+ head_type="DLCRNetHead",
+ ),
+ PoseConfigCase(
+ "multi_dlcrnet_stride16_ms5_bu",
+ MULTIANIMAL_PROJECT,
+ "dlcrnet_stride16_ms5",
+ {},
+ "bu",
+ ["nose", "tail"],
+ ["mouse1", "mouse2"],
+ ["corner1", "corner2"],
+ "DLCRNet",
+ head_type="DLCRNetHead",
+ ),
+ PoseConfigCase(
+ "multi_dekr_w18_bu",
+ MULTIANIMAL_PROJECT,
+ "dekr_w18",
+ {},
+ "bu",
+ ["nose", "tail"],
+ ["mouse1", "mouse2"],
+ ["corner1", "corner2"],
+ "HRNet",
+ head_type="DEKRHead",
+ ),
+ PoseConfigCase(
+ "multi_resnet_50_td_ssdlite",
+ MULTIANIMAL_TD_PROJECT,
+ "resnet_50",
+ {"top_down": True, "detector_type": "ssdlite"},
+ "td",
+ ["nose", "tail"],
+ ["mouse1", "mouse2"],
+ [],
+ "ResNet",
+ detector_type="SSDLite",
+ head_type="HeatmapHead",
+ ),
+]
+
+
+@pytest.mark.parametrize("case", POSE_CONFIG_CASES, ids=lambda c: c.id)
+def test_pose_config_creation(case: PoseConfigCase, tmp_path: Path) -> None:
+ build_kwargs = dict(case.build_kwargs)
+ cfg = _as_dict(
+ build_pose_config(
+ case.project,
+ tmp_path / "pytorch_config.yaml",
+ net_type=case.net_type,
+ save=False,
+ top_down=build_kwargs.pop("top_down", False),
+ **build_kwargs,
+ )
+ )
+ meta = cfg["metadata"]
+
+ assert str(cfg["net_type"]) == case.net_type
+ assert str(cfg["method"]).lower() == case.method
+ assert meta["bodyparts"] == case.bodyparts
+ if case.project["multianimalproject"]:
+ assert meta["individuals"] == case.individuals
+ else:
+ assert len(meta["individuals"]) == 1
+ assert (meta.get("unique_bodyparts") or []) == case.unique_bodyparts
+
+ if case.backbone_type is not None:
+ assert cfg["model"]["backbone"]["type"] == case.backbone_type
+
+ if case.head_type is not None:
+ assert cfg["model"]["heads"]["bodypart"]["type"] == case.head_type
+
+ if case.detector_type is not None:
+ detector = cfg["detector"]
+ assert detector is not None
+ assert detector["model"]["type"] == case.detector_type
+ assert detector["device"] == "auto"
+ assert detector["model"].get("box_score_thresh") is None
+ assert cfg["data"]["train"].get("top_down_crop") is not None
+ else:
+ assert cfg.get("detector") is None
+
+
+def test_pose_config_save_writes_yaml(tmp_path: Path) -> None:
+ config_path = tmp_path / "pytorch_config.yaml"
+ cfg = build_pose_config(
+ SINGLE_ANIMAL_PROJECT,
+ config_path,
+ net_type="resnet_50",
+ top_down=False,
+ save=True,
+ )
+ assert config_path.is_file()
+ saved = read_config_as_dict(config_path)
+ cfg_dict = _as_dict(cfg)
+ assert saved["net_type"] == cfg_dict["net_type"]
+ assert saved["metadata"]["bodyparts"] == cfg_dict["metadata"]["bodyparts"]
+
+
+@pytest.mark.parametrize("project", [SINGLE_ANIMAL_PROJECT, MULTIANIMAL_PROJECT], ids=["single", "multi"])
+def test_default_net_type_used_when_net_type_is_none(project: dict) -> None:
+ cfg = build_pose_config(project, "pytorch_config.yaml", net_type=None, top_down=False)
+ assert str(_as_dict(cfg)["net_type"]) == "resnet_50"
+
+
+def test_weight_init_is_written_to_train_settings() -> None:
+ project_config = {
+ "project_path": "/test/project",
+ "multianimalproject": False,
+ "identity": False,
+ "bodyparts": ["nose", "ear"],
+ }
+ snapshot_path = Path("/tmp/snapshot-010.pt")
+ weight_init = WeightInitialization(snapshot_path=snapshot_path)
+
+ cfg = build_pose_config(
+ project_config,
+ "pytorch_config.yaml",
+ net_type="resnet_50",
+ weight_init=weight_init,
+ top_down=False,
+ )
+
+ saved = _as_dict(cfg)["train_settings"]["weight_init"]
+ assert saved["snapshot_path"] == str(snapshot_path)
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..354f63348c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/data/test_postprocessor.py
@@ -0,0 +1,423 @@
+#
+# 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)
+
+
+def test_predict_keypoint_identities_handles_nan_keypoints():
+ import warnings
+
+ p = PredictKeypointIdentities(
+ identity_key="keypoint_identity",
+ identity_map_key="identity_map",
+ pose_key="bodyparts",
+ keep_id_maps=True,
+ )
+
+ # PAF-style output: (num_individuals, num_bodyparts, 5); missing joint is all-NaN
+ bodyparts = np.array(
+ [
+ [
+ [3.1, 1.0, 0.8, 0.0, 0.5], # valid
+ [np.nan, np.nan, np.nan, np.nan, np.nan], # missing (assembler default)
+ [1.0, 0.0, 0.9, 1.0, 0.5], # valid
+ ],
+ ]
+ )
+ id_heatmap = np.array(
+ [
+ [[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]],
+ ]
+ )
+ predictions_in = {"bodyparts": bodyparts, "identity_map": id_heatmap}
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", RuntimeWarning)
+ predictions, _ = p(predictions_in, {})
+
+ expected = np.zeros((1, 3, 2))
+ expected[0, 0] = id_heatmap[1, 3] # rint(3.1, 1.0) -> (3, 1)
+ expected[0, 1] = 0.0 # NaN keypoint: leave identity scores at zero
+ expected[0, 2] = id_heatmap[0, 1] # rint(1.0, 0.0) -> (1, 0)
+
+ np.testing.assert_array_equal(predictions["keypoint_identity"], expected)
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/pose_estimation_pytorch/modelzoo/test_download.py b/tests/pose_estimation_pytorch/modelzoo/test_download.py
new file mode 100644
index 0000000000..06cc9857e1
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_download.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
+#
+import os
+
+import dlclibrary
+import pytest
+from dlclibrary.dlcmodelzoo.modelzoo_download import MODELOPTIONS
+
+
+def test_download_huggingface_model(tmp_path_factory, model="full_cat"):
+ folder = tmp_path_factory.mktemp("temp")
+ dlclibrary.download_huggingface_model(model, str(folder))
+
+ assert os.path.exists(folder / "pose_cfg.yaml")
+ assert any(f.startswith("snapshot-") for f in os.listdir(folder))
+ # Verify that the Hugging Face folder was removed
+ assert not any(f.startswith("models--") for f in os.listdir(folder))
+
+
+def test_download_huggingface_wrong_model():
+ with pytest.raises(ValueError):
+ dlclibrary.download_huggingface_model("wrong_model_name")
+
+
+@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_generalized_data_converter_config.py b/tests/pose_estimation_pytorch/modelzoo/test_generalized_data_converter_config.py
new file mode 100644
index 0000000000..23463c7ee7
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_generalized_data_converter_config.py
@@ -0,0 +1,162 @@
+#
+# 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 capturing the current state of the generalized_data_converter configuration.
+
+These tests document default values and behavior so that a migration to a new
+configuration system can preserve existing behavior. Add or adjust assertions
+if you intentionally change defaults.
+"""
+
+import pytest
+
+from deeplabcut.core.config import read_config_as_dict, write_config
+from deeplabcut.modelzoo.generalized_data_converter.datasets.materialize import (
+ MaDLC_config,
+ SingleDLC_config,
+ modify_train_test_cfg,
+)
+
+# Modelzoo SingleDLC_config defaults
+# 2026-01-29 config version 0, before centralized typed configs were added
+SINGLE_DLC_DEFAULTS = {
+ "Task": "",
+ "project_path": "",
+ "scorer": "",
+ "date": "",
+ "video_sets": "",
+ "skeleton": "",
+ "bodyparts": [],
+ "start": 0,
+ "stop": 1,
+ "numframes2pick": 42,
+ "skeleton_color": "black",
+ "pcutoff": 0.6,
+ "dotsize": 8,
+ "alphavalue": 0.7,
+ "colormap": "rainbow",
+ "TrainingFraction": "",
+ "iteration": 0,
+ "default_net_type": "resnet_50",
+ "default_augmenter": "imgaug",
+ "snapshotindex": -1,
+ "batch_size": 8,
+ "cropping": False,
+ "croppedtraining": False,
+ "multianimalproject": False,
+ "uniquebodyparts": [],
+ "x1": 0,
+ "x2": 640,
+ "y1": 277,
+ "y2": 624,
+ "corner2move2": [50, 50],
+ "move2corner": True,
+ "identity": False,
+}
+
+
+# MaDLC_config defaults; only differences from Single are listed in tests
+MA_DLC_DEFAULTS_OVERRIDES = {
+ "default_augmenter": "multi-animal-imgaug",
+ "bodyparts": "MULTI!",
+ "croppedtraining": True,
+ "multianimalproject": True,
+}
+MA_DLC_EXTRA_KEYS = {"individuals", "multianimalbodyparts"}
+
+
+class TestModelzooProjectConfigDefaults:
+ def test_single_dlc_config_defaults(self):
+ config = SingleDLC_config()
+ assert set(config.cfg.keys()) == set(SINGLE_DLC_DEFAULTS.keys())
+ for key, expected in SINGLE_DLC_DEFAULTS.items():
+ assert config.cfg[key] == expected, f"SingleDLC_config.cfg[{key!r}]"
+
+ def test_ma_dlc_config_has_all_single_keys_plus_ma_specific(self):
+ single_keys = set(SINGLE_DLC_DEFAULTS.keys())
+ ma_config = MaDLC_config()
+ ma_keys = set(ma_config.cfg.keys())
+ assert single_keys <= ma_keys
+ assert MA_DLC_EXTRA_KEYS <= ma_keys
+
+ def test_ma_dlc_config_overrides_vs_single(self):
+ single_config = SingleDLC_config()
+ ma_config = MaDLC_config()
+ for key, expected in MA_DLC_DEFAULTS_OVERRIDES.items():
+ assert ma_config.cfg[key] == expected, f"MaDLC_config.cfg[{key!r}]"
+ assert ma_config.cfg[key] != single_config.cfg[key], f"MaDLC should differ from Single for {key!r}"
+
+ def test_ma_dlc_config_shared_defaults_match_single(self):
+ """Keys not overridden in Ma should match Single defaults."""
+ single_config = SingleDLC_config()
+ ma_config = MaDLC_config()
+ for key in SINGLE_DLC_DEFAULTS:
+ if key in MA_DLC_DEFAULTS_OVERRIDES:
+ continue
+ assert ma_config.cfg[key] == single_config.cfg[key], f"MaDLC_config.cfg[{key!r}] should match Single"
+
+
+class TestModelzooCreateProjectConfig:
+ """Behavior of create_cfg: file location, format, and update semantics."""
+
+ def test_single_dlc_create_cfg_writes_config_yaml(self, tmp_path):
+ config = SingleDLC_config()
+ config.create_cfg(tmp_path, {"Task": "mytask"})
+ path = tmp_path / "config.yaml"
+ assert path.exists()
+ data = read_config_as_dict(path)
+ assert data["Task"] == "mytask"
+ assert data["default_net_type"] == "resnet_50"
+
+ def test_create_cfg_overwrites_with_kwargs(self, tmp_path):
+ config = SingleDLC_config()
+ config.create_cfg(tmp_path, {"Task": "mytask", "batch_size": 16})
+ path = tmp_path / "config.yaml"
+ data = read_config_as_dict(path)
+ assert data["Task"] == "mytask"
+ assert data["batch_size"] == 16
+
+ def test_ma_dlc_create_cfg_writes_config_yaml(self, tmp_path):
+ config = MaDLC_config()
+ config.create_cfg(tmp_path, {"Task": "matask"})
+ path = tmp_path / "config.yaml"
+ assert path.exists()
+ data = read_config_as_dict(path)
+ assert data["Task"] == "matask"
+ assert data["multianimalproject"] is True
+
+
+class TestModifyTrainTestCfg:
+ """Behavior of modify_train_test_cfg (train/test pose config updates)."""
+
+ def test_modify_train_test_cfg_requires_existing_config_path(self, tmp_path):
+ config_path = tmp_path / "config.yaml"
+ # Non-existent config must not silently succeed
+ with pytest.raises(FileNotFoundError):
+ modify_train_test_cfg(config_path)
+
+ def test_modify_train_test_cfg_sets_expected_values(self, tmp_path, monkeypatch):
+ train_path = tmp_path / "train" / "pytorch_config.yaml"
+ test_path = tmp_path / "test" / "pose_cfg.yaml"
+ snapshot_folder = tmp_path / "train"
+ train_path.parent.mkdir(parents=True)
+ test_path.parent.mkdir(parents=True)
+ write_config(train_path, {"batch_size": 1, "multi_stage": False, "gradient_masking": False})
+ write_config(test_path, {"batch_size": 1, "multi_stage": False, "gradient_masking": False})
+ monkeypatch.setattr(
+ "deeplabcut.modelzoo.generalized_data_converter.datasets.materialize.compat.return_train_network_path",
+ lambda *args, **kwargs: (train_path, test_path, snapshot_folder),
+ )
+ modify_train_test_cfg(tmp_path / "config.yaml") # ignored by mock
+ for path in (train_path, test_path):
+ data = read_config_as_dict(path)
+ assert data["multi_stage"] is True
+ assert data["batch_size"] == 8
+ assert data["gradient_masking"] is True
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..aeb5131f65
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_inference_helpers.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
+#
+
+import copy
+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_from_any(config):
+ read_calls.append(config)
+ return cfg
+
+ monkeypatch.setattr(helpers.PoseConfig, "from_any", fake_from_any)
+ 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_does_not_mutate_custom_dict(monkeypatch):
+ custom_cfg = _dummy_cfg("TD")
+ original_bodyparts = list(custom_cfg["metadata"]["bodyparts"])
+
+ monkeypatch.setattr(
+ helpers.PoseConfig,
+ "from_any",
+ lambda config: copy.deepcopy(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"] == original_bodyparts
+ assert model_cfg is not custom_cfg
+
+
+@pytest.mark.parametrize("input_device", ["auto", None])
+def test_create_superanimal_inference_runners_auto_device_selection(monkeypatch, input_device):
+ captured = {}
+
+ def fake_build_for_superanimal_inference(
+ cls,
+ super_animal,
+ *,
+ model_name,
+ detector_name=None,
+ max_individuals=30,
+ device=None,
+ ):
+ captured["device"] = device
+ return _dummy_cfg("TD")
+
+ monkeypatch.setattr(
+ helpers.PoseConfig,
+ "build_for_superanimal_inference",
+ classmethod(fake_build_for_superanimal_inference),
+ )
+ 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=None,
+ 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,
+):
+ def fake_build_for_superanimal_inference(cls, *args, **kwargs):
+ raise ValueError("Unsupported dataset for model zoo config")
+
+ monkeypatch.setattr(
+ helpers.PoseConfig,
+ "build_for_superanimal_inference",
+ classmethod(fake_build_for_superanimal_inference),
+ )
+
+ 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..544502dee8
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_modelzoo_utils.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
+#
+
+import pytest
+
+import deeplabcut.pose_estimation_pytorch.modelzoo as modelzoo
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+
+# 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, PoseConfig)
+ if detector_name is None:
+ assert model_config["method"].lower() == "bu"
+ assert model_config.detector is None
+ else:
+ assert model_config["method"].lower() == "td"
+ assert model_config.detector is not None
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..2390b12a0c
--- /dev/null
+++ b/tests/pose_estimation_pytorch/modelzoo/test_webapp.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
+#
+
+import numpy as np
+import pytest
+
+from deeplabcut.modelzoo.webapp.inference import SuperanimalPyTorchInference
+from deeplabcut.pose_estimation_pytorch.config.pose import PoseConfig
+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, PoseConfig)
+ 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..a2e06b3abb
--- /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(reason="This behaviour is not implemented yet")
+@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(reason="This behaviour is not implemented yet")
+@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..9185523843
--- /dev/null
+++ b/tests/pose_estimation_pytorch/other/test_dataset.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
+#
+import os
+import random
+from pathlib import Path
+
+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 _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..0df42715be
--- /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(reason="This behaviour is not implemented yet")
+@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..116f5a1e6a
--- /dev/null
+++ b/tests/pose_estimation_pytorch/runners/test_inference_directml_no_grad.py
@@ -0,0 +1,68 @@
+#
+# 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
+from deeplabcut.pose_estimation_pytorch.config.inference import InferenceConfig, MultithreadingConfig
+
+
+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=InferenceConfig(
+ multithreading=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..af745016a9
--- /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(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
new file mode 100644
index 0000000000..0684da7651
--- /dev/null
+++ b/tests/test_auxfun_models.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 unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from deeplabcut.utils.auxfun_models import MODELTYPE_FILEPATH_MAP, check_for_weights
+
+
+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:
+ 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)
+ else:
+ mocked_download.assert_called_with(modeltype, tmpdir / expected_path)
+
+ def test_bad_modeltype(self):
+ 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 c7e9f3aa38..8e8b7d28dc 100644
--- a/tests/test_auxfun_multianimal.py
+++ b/tests/test_auxfun_multianimal.py
@@ -1,9 +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/master/AUTHORS
+#
+# 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():
@@ -19,7 +31,8 @@ def test_prune_paf_graph():
for degree in (4, 6, 8):
pruned_edges = auxfun_multianimal.prune_paf_graph(
- edges, average_degree=degree,
+ edges,
+ average_degree=degree,
)
G = nx.Graph(pruned_edges)
assert np.mean(list(dict(G.degree).values())) == degree
@@ -43,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)
\ No newline at end of file
+ pd.testing.assert_frame_equal(df, df_inverse_reordering)
diff --git a/tests/test_auxiliaryfunctions.py b/tests/test_auxiliaryfunctions.py
index b24777e028..b610cdfbc9 100644
--- a/tests/test_auxiliaryfunctions.py
+++ b/tests/test_auxiliaryfunctions.py
@@ -1,43 +1,61 @@
+#
+# 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 unittest.mock import patch
+
+import pytest
+
from deeplabcut.utils import auxiliaryfunctions
from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS
-def test_find_analyzed_data(tmpdir_factory):
- import os
- import pytest
-
- fake_folder = tmpdir_factory.mktemp('videos')
- SUPPORTED_VIDEOS=['avi']
- n_ext = len(SUPPORTED_VIDEOS)
+@pytest.mark.parametrize("path_type", [str, Path])
+def test_find_analyzed_data(tmpdir_factory, path_type):
+ fake_folder = tmpdir_factory.mktemp("videos")
+ SUPPORTED_VIDEOS = ["avi"]
+ len(SUPPORTED_VIDEOS)
- SCORER = 'DLC_dlcrnetms5_multi_mouseApr11shuffle1_5'
- WRONG_SCORER = 'DLC_dlcrnetms5_multi_mouseApr11shuffle3_5'
+ SCORER = "DLC_dlcrnetms5_multi_mouseApr11shuffle1_5"
+ WRONG_SCORER = "DLC_dlcrnetms5_multi_mouseApr11shuffle3_5"
def _create_fake_file(filename):
- path = str(fake_folder.join(filename))
+ path = Path(fake_folder) / filename
with open(path, "w") as f:
f.write("")
return path
for ind, ext in enumerate(SUPPORTED_VIDEOS):
- vname="video"+str(ind)
- _ = _create_fake_file(vname+'.'+ext)
- _ = _create_fake_file(vname+SCORER+".pickle")
- _ = _create_fake_file(vname+SCORER+".h5")
+ vname = "video" + str(ind)
+ _ = _create_fake_file(vname + "." + ext)
+ _ = _create_fake_file(vname + SCORER + ".pickle")
+ _ = _create_fake_file(vname + SCORER + ".h5")
+ # Test for both str and Path types (convert from tmpdir.LocalPath object)
+ fake_folder = path_type(fake_folder)
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')
+ fake_folder = tmpdir_factory.mktemp("videos")
n_ext = len(SUPPORTED_VIDEOS)
def _create_fake_file(filename):
@@ -61,40 +79,273 @@ def _create_fake_file(filename):
# By default, all videos with common extensions are taken from a directory
videos = auxiliaryfunctions.get_list_of_videos(
- str(fake_folder), videotype="",
+ str(fake_folder),
+ videotype="",
)
assert len(videos) == n_ext
# A list of extensions can also be passed in
videos = auxiliaryfunctions.get_list_of_videos(
- str(fake_folder), videotype=SUPPORTED_VIDEOS,
+ str(fake_folder),
+ videotype=SUPPORTED_VIDEOS,
)
assert len(videos) == n_ext
for ext in SUPPORTED_VIDEOS:
videos = auxiliaryfunctions.get_list_of_videos(
- str(fake_folder), videotype=ext,
+ str(fake_folder),
+ videotype=ext,
)
assert len(videos) == 1
videos = auxiliaryfunctions.get_list_of_videos(
- str(fake_folder), videotype="unknown",
+ str(fake_folder),
+ videotype="unknown",
)
assert not len(videos)
videos = auxiliaryfunctions.get_list_of_videos(
- fake_videos, videotype="",
+ fake_videos,
+ videotype="",
)
assert len(videos) == n_ext
for video in fake_videos:
- videos = auxiliaryfunctions.get_list_of_videos(
- [video], videotype=""
- )
+ videos = auxiliaryfunctions.get_list_of_videos([video], videotype="")
assert len(videos) == 1
for ext in SUPPORTED_VIDEOS:
videos = auxiliaryfunctions.get_list_of_videos(
- fake_videos, videotype=ext,
+ fake_videos,
+ videotype=ext,
)
assert len(videos) == 1
+
+
+def test_write_config_has_skeleton(tmpdir_factory):
+ """Required for backward compatibility."""
+ fake_folder = tmpdir_factory.mktemp("fakeConfigs")
+ fake_config_file = fake_folder / Path("fakeConfig")
+ auxiliaryfunctions.write_config(fake_config_file, {})
+ config_data = auxiliaryfunctions.read_config(fake_config_file)
+ assert "skeleton" in config_data
+
+
+@pytest.mark.parametrize(
+ "multianimal, bodyparts, ma_bpts, unique_bpts, comparison_bpts, expected_bpts",
+ [
+ (
+ False,
+ ["head", "shoulders", "knees", "toes"],
+ None,
+ None,
+ {"knees", "others", "toes"},
+ ["knees", "toes"],
+ ),
+ (
+ True,
+ None,
+ ["head", "shoulders", "knees"],
+ ["toes"],
+ {"knees", "others", "toes"},
+ ["knees", "toes"],
+ ),
+ ],
+)
+def test_intersection_of_body_parts_and_ones_given_by_user(
+ multianimal, bodyparts, ma_bpts, unique_bpts, comparison_bpts, expected_bpts
+):
+ cfg = {
+ "multianimalproject": multianimal,
+ "bodyparts": bodyparts,
+ "multianimalbodyparts": ma_bpts,
+ "uniquebodyparts": unique_bpts,
+ }
+
+ if multianimal:
+ all_bodyparts = list(set(ma_bpts + unique_bpts))
+ else:
+ all_bodyparts = bodyparts
+
+ 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,
+ )
+ print(filtered_bpts)
+ assert len(expected_bpts) == len(filtered_bpts)
+ assert all([bpt in expected_bpts for bpt in filtered_bpts])
+
+
+class MockPath:
+ def __init__(self, path: Path, st_mtime: int):
+ self.path = path
+ self.parent = self.path.parent
+ self.st_mtime = st_mtime
+
+ def lstat(self):
+ return self
+
+
+# labeled_folders: (has_H5, H5_st_mtime, folder_name)
+@pytest.mark.parametrize(
+ "labeled_folders, next_folder_name",
+ [
+ ([(True, 1, "a"), (False, None, "b"), (False, None, "c")], "b"),
+ ([(False, None, "a"), (True, 123, "d"), (False, None, "f")], "f"),
+ ],
+)
+def test_find_next_unlabeled_folder(
+ tmpdir_factory,
+ monkeypatch,
+ labeled_folders,
+ next_folder_name,
+):
+ project_folder = tmpdir_factory.mktemp("project")
+ fake_cfg = Path(project_folder / "cfg.yaml")
+ auxiliaryfunctions.write_config(fake_cfg, {"project_path": str(project_folder)})
+
+ data_folder = project_folder / "labeled-data"
+ data_folder.mkdir()
+ rglob_results = []
+ for has_h5, h5_last_mod_time, folder_name in labeled_folders:
+ labeled_folder_path = Path(data_folder / folder_name)
+ labeled_folder_path.mkdir()
+ if has_h5:
+ h5_path = Path(labeled_folder_path / "data.h5")
+ rglob_results.append(MockPath(h5_path, h5_last_mod_time))
+
+ def get_rglob_results(*args, **kwargs):
+ return rglob_results
+
+ 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)
+
+
+# ---------------------------------------------------------------------------
+# Tests for safe_resolve() and read_config() network-drive path safety
+# https://github.com/DeepLabCut/DeepLabCut/issues/3348
+# ---------------------------------------------------------------------------
+
+
+class TestSafeResolve:
+ """safe_resolve() must return a Path whose str() representation can be
+ opened by plain string-based I/O â i.e. it must not return Windows 11 SMB
+ Volume GUID paths like \\\\?\\Volume{...}\\...
+ """
+
+ def test_normal_path_is_returned_unchanged(self, tmp_path):
+ """On a normal local filesystem, safe_resolve returns the resolved path."""
+ f = tmp_path / "config.yaml"
+ f.touch()
+ result = auxiliaryfunctions.safe_resolve(f)
+ assert result.exists()
+ open(result).close()
+
+ def test_fallback_when_resolve_produces_unusable_path(self, tmp_path):
+ """When resolve() returns a path that cannot be opened as a string,
+ safe_resolve must fall back to abspath."""
+ f = tmp_path / "config.yaml"
+ f.write_text("project_path: .")
+
+ fake_volume_guid = Path(r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\fake")
+
+ with patch.object(Path, "resolve", return_value=fake_volume_guid):
+ result = auxiliaryfunctions.safe_resolve(f)
+
+ # Must NOT return the unusable Volume GUID path
+ assert "Volume{" not in str(result)
+ # Fallback must be the absolute (non-resolved) path, which exists and is openable
+ assert result == f.absolute()
+ open(result).close()
+
+
+class TestReadConfigProjectPath:
+ """read_config() must never persist a \\\\?\\Volume{GUID}\\... path into
+ project_path, even on Windows 11 SMB network drives."""
+
+ def test_project_path_corrected_when_persisted_as_volume_guid(self, tmp_path):
+ """Regression test for https://github.com/DeepLabCut/DeepLabCut/issues/3348.
+
+ A config.yaml whose project_path was previously corrupted to a
+ \\\\?\\Volume{GUID}\\... form (e.g. by an older buggy read_config())
+ must be corrected to the real directory on the next read.
+ """
+ project_dir = tmp_path / "my_project"
+ project_dir.mkdir()
+ config_file = project_dir / "config.yaml"
+
+ bad_path = r"\\?\Volume{DEADBEEF-0000-0000-0000-000000000000}\my_project"
+ auxiliaryfunctions.write_config(config_file, {"project_path": bad_path})
+
+ cfg = auxiliaryfunctions.read_config(config_file)
+
+ assert "Volume{" not in str(cfg["project_path"])
+ assert cfg["project_path"].exists()
+
+ def test_project_path_updated_when_moved(self, tmp_path):
+ """read_config() must still update project_path when a project is moved
+ to a new directory (the original feature that resolve() was meant for)."""
+ project_dir = tmp_path / "original_location"
+ project_dir.mkdir()
+ config_file = project_dir / "config.yaml"
+
+ auxiliaryfunctions.write_config(config_file, {"project_path": "/some/old/path/that/no/longer/exists"})
+
+ cfg = auxiliaryfunctions.read_config(config_file)
+
+ expected = project_dir.absolute()
+ assert cfg["project_path"] == expected
diff --git a/tests/test_conversioncode.py b/tests/test_conversioncode.py
index fcdfdafa6a..e4074adf62 100644
--- a/tests/test_conversioncode.py
+++ b/tests/test_conversioncode.py
@@ -1,11 +1,22 @@
+#
+# 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 os
+
import pandas as pd
-from conftest import TEST_DATA_DIR
+
from deeplabcut.utils import conversioncode
-def test_guarantee_multiindex_rows():
- df_unix = pd.read_hdf(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5"))
+def test_guarantee_multiindex_rows(test_data_dir):
+ df_unix = pd.read_hdf(os.path.join(test_data_dir, "trimouse_calib.h5"))
df_posix = df_unix.copy()
df_posix.index = df_posix.index.str.replace("/", "\\")
nrows = len(df_unix)
diff --git a/tests/test_crossvalutils.py b/tests/test_crossvalutils.py
index 9680b6e40f..5c821bd03e 100644
--- a/tests/test_crossvalutils.py
+++ b/tests/test_crossvalutils.py
@@ -1,7 +1,18 @@
-import numpy as np
+#
+# 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 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]
@@ -11,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
@@ -25,7 +34,9 @@ def test_get_n_best_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc)
data, metadata = evaluation_data_and_metadata_montblanc
params = crossvalutils._set_up_evaluation(data)
paf_inds, dict_ = crossvalutils._get_n_best_paf_graphs(
- data, metadata, params["paf_graph"],
+ data,
+ metadata,
+ params["paf_graph"],
)
assert len(paf_inds) == 4
assert len(dict_) == len(params["paf_graph"])
@@ -55,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
@@ -71,7 +80,12 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc):
cfg = {
"individuals": [f"bird{i}" for i in range(1, 9)],
"uniquebodyparts": ["center"],
- "multianimalbodyparts": ["head", "tail", "leftwing", "rightwing",],
+ "multianimalbodyparts": [
+ "head",
+ "tail",
+ "leftwing",
+ "rightwing",
+ ],
}
inference_cfg = {"topktoretain": 8, "pcutoff": 0.1, "pafthreshold": 0.1}
results = crossvalutils._benchmark_paf_graphs(
@@ -81,11 +95,14 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc):
[BEST_GRAPH_MONTBLANC],
split_inds=[metadata["data"]["trainIndices"], metadata["data"]["testIndices"]],
)
- with open("tests/data/montblanc_map.pickle", "rb") as file:
- results_gt = pickle.load(file)
+ with open("tests/data/montblanc_map.pickle", "rb") as f:
+ results_gt = pickle.load(f)
np.testing.assert_equal(
results[1].loc["purity"].to_numpy().squeeze(),
- results_gt[0].loc["purity", 6].to_numpy(),
+ [
+ results_gt[0][6][("purity", "mean")],
+ results_gt[0][6][("purity", "std")],
+ ],
)
vals = [
results[2][0][0]["mAP"],
@@ -94,5 +111,11 @@ def test_benchmark_paf_graphs_montblanc(evaluation_data_and_metadata_montblanc):
results[2][0][1]["mAR"],
]
np.testing.assert_equal(
- vals, results_gt[0].iloc[-4:, -1].to_numpy(),
+ 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")],
+ ],
)
diff --git a/tests/test_dataset_augmentation.py b/tests/test_dataset_augmentation.py
index d14d846cf3..9e935c7259 100644
--- a/tests/test_dataset_augmentation.py
+++ b/tests/test_dataset_augmentation.py
@@ -1,15 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
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", [(200, 200), (300, 300), (400, 400),])
+@pytest.mark.parametrize(
+ "width, height",
+ [
+ (200, 200),
+ (300, 300),
+ (400, 400),
+ ],
+)
def test_keypoint_aware_cropping(
- sample_image, sample_keypoints, width, height,
+ sample_image,
+ sample_keypoints,
+ width,
+ height,
):
- aug = augmentation.KeypointAwareCropToFixedSize(width=width, height=height,)
+ aug = augmentation.KeypointAwareCropToFixedSize(width=width, height=height)
images_aug, keypoints_aug = aug(
- images=[sample_image], keypoints=[sample_keypoints],
+ images=[sample_image],
+ keypoints=[sample_keypoints],
)
assert len(images_aug) == len(keypoints_aug) == 1
assert all(im.shape[:2] == (height, width) for im in images_aug)
@@ -19,14 +47,25 @@ def test_keypoint_aware_cropping(
# Test passing in a batch of frames
n_samples = 8
images_aug, keypoints_aug = aug(
- images=[sample_image] * n_samples, keypoints=[sample_keypoints] * n_samples,
+ images=[sample_image] * n_samples,
+ keypoints=[sample_keypoints] * n_samples,
)
assert len(images_aug) == len(keypoints_aug) == n_samples
-@pytest.mark.parametrize("width, height", [(200, 200), (300, 300), (400, 400),])
+@pytest.mark.parametrize(
+ "width, height",
+ [
+ (200, 200),
+ (300, 300),
+ (400, 400),
+ ],
+)
def test_sequential(
- sample_image, sample_keypoints, width, height,
+ sample_image,
+ sample_keypoints,
+ width,
+ height,
):
# Guarantee that images smaller than crop size are handled fine
very_small_image = sample_image[:50, :50]
@@ -37,7 +76,8 @@ def test_sequential(
]
)
images_aug, keypoints_aug = aug(
- images=[very_small_image], keypoints=[sample_keypoints],
+ images=[very_small_image],
+ keypoints=[sample_keypoints],
)
assert len(images_aug) == len(keypoints_aug) == 1
assert all(im.shape[:2] == (height, width) for im in images_aug)
@@ -47,6 +87,53 @@ def test_sequential(
# Test passing in a batch of frames
n_samples = 8
images_aug, keypoints_aug = aug(
- images=[very_small_image] * n_samples, keypoints=[sample_keypoints] * n_samples,
+ images=[very_small_image] * n_samples,
+ keypoints=[sample_keypoints] * n_samples,
)
assert len(images_aug) == len(keypoints_aug) == n_samples
+
+
+def test_keypoint_horizontal_flip(
+ sample_image,
+ sample_keypoints,
+):
+ keypoints_flipped = sample_keypoints.copy()
+ keypoints_flipped[:, 0] = sample_image.shape[1] - keypoints_flipped[:, 0]
+ pairs = [(0, 1), (2, 3), (4, 5), (6, 7), (8, 9), (10, 11)]
+ aug = augmentation.KeypointFliplr(
+ keypoints=list(map(str, range(12))),
+ symmetric_pairs=pairs,
+ )
+ 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]]
+ keypoints_unaug = temp.reshape((-1, 2))
+ np.testing.assert_allclose(keypoints_unaug, keypoints_flipped)
+
+
+def test_keypoint_horizontal_flip_with_nans(
+ sample_image,
+ sample_keypoints,
+):
+ sample_keypoints[::12] = np.nan
+ sample_keypoints[2::12] = np.nan
+ keypoints_flipped = sample_keypoints.copy()
+ keypoints_flipped[:, 0] = sample_image.shape[1] - keypoints_flipped[:, 0]
+ pairs = [(0, 1), (2, 3)]
+ aug = augmentation.KeypointFliplr(
+ keypoints=list(map(str, range(12))),
+ symmetric_pairs=pairs,
+ )
+ 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]]
+ keypoints_unaug = temp.reshape((-1, 2))
+ np.testing.assert_allclose(keypoints_unaug, keypoints_flipped)
diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py
new file mode 100644
index 0000000000..69ea13d7c6
--- /dev/null
+++ b/tests/test_evaluate.py
@@ -0,0 +1,233 @@
+#
+# 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 numpy as np
+import pandas as pd
+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(
+ bodyparts,
+ train_indices,
+ test_indices,
+ error_data=None,
+) -> pd.DataFrame:
+ if error_data is None:
+ error_data = np.ones((len(train_indices) + len(test_indices), len(bodyparts)))
+ return pd.DataFrame(error_data, columns=bodyparts)
+
+
+def make_multi_animal_rmse_df(
+ scorer,
+ individuals,
+ bodyparts,
+ train_indices,
+ test_indices,
+ error_data=None,
+) -> pd.DataFrame:
+ columns = pd.MultiIndex.from_product(
+ [[scorer], individuals, bodyparts],
+ names=["scorer", "individuals", "bodyparts"],
+ )
+ if error_data is None:
+ error_data = np.ones((len(train_indices) + len(test_indices), len(individuals) * len(bodyparts)))
+ return pd.DataFrame(error_data, columns=columns)
+
+
+KEYPOINT_ERROR_NAMES = [
+ "Train error (px)",
+ "Test error (px)",
+ "Train error (px) with p-cutoff",
+ "Test error (px) with p-cutoff",
+]
+
+KEYPOINT_ERROR_TEST_DATA = [
+ (
+ {
+ "df_error": make_single_animal_rmse_df(
+ bodyparts=["leg", "arm", "head"],
+ train_indices=[0, 1, 3],
+ test_indices=[2, 4],
+ ),
+ "train_indices": [0, 1, 3],
+ "test_indices": [2, 4],
+ },
+ {
+ "leg": [1.0, 1.0], # train, test
+ "arm": [1.0, 1.0], # train, test
+ "head": [1.0, 1.0], # train, test
+ },
+ ),
+ (
+ {
+ "df_error": make_single_animal_rmse_df(
+ bodyparts=["leftHand", "rightHand"],
+ train_indices=[0, 2],
+ test_indices=[1, 3],
+ error_data=[
+ [1.0, np.nan],
+ [1.0, 0.0],
+ [0.0, 10.0],
+ [5.0, 5.0],
+ ],
+ ),
+ "train_indices": [0, 2],
+ "test_indices": [1, 3],
+ },
+ {
+ "leftHand": [0.5, 3.0], # train, test
+ "rightHand": [10.0, 2.5], # train, test
+ },
+ ),
+ (
+ {
+ "df_error": make_single_animal_rmse_df(
+ bodyparts=["leg", "arm", "head"],
+ train_indices=[0, 1, 3],
+ test_indices=[2, 4],
+ ),
+ "train_indices": [0, 1, 3],
+ "test_indices": [2, 4],
+ },
+ {
+ "leg": [1.0, 1.0], # train, test
+ "arm": [1.0, 1.0], # train, test
+ "head": [1.0, 1.0], # train, test
+ },
+ ),
+ (
+ {
+ "df_error": make_multi_animal_rmse_df(
+ scorer="john",
+ individuals=["individual_1", "individual_2"],
+ bodyparts=["leftArm", "rightArm"],
+ train_indices=[0, 1, 3],
+ test_indices=[2],
+ error_data=[
+ # individual_1, individual2
+ # leftArm, rightArm, leftArm, rightArm
+ [1.0, np.nan, 1.0, 2.0],
+ [2.0, 0.0, 1.0, np.nan],
+ [3.0, 10.0, 1.0, np.nan],
+ [10.0, 4.0, np.nan, np.nan],
+ ],
+ ),
+ "train_indices": [0, 1, 3],
+ "test_indices": [2],
+ },
+ {
+ "leftArm": [3.0, 2.0], # train, test
+ "rightArm": [2.0, 10.0], # train, test
+ },
+ ),
+]
+
+
+@pytest.mark.parametrize("inputs, expected_values", KEYPOINT_ERROR_TEST_DATA)
+def test_evaluate_keypoint_error(inputs, expected_values):
+ keypoint_error = pet.keypoint_error(
+ inputs["df_error"],
+ inputs["df_error"],
+ inputs["train_indices"],
+ inputs["test_indices"],
+ )
+ print(inputs["df_error"])
+ print(keypoint_error)
+ for bodypart, mean_errors in expected_values.items():
+ for error_name in KEYPOINT_ERROR_NAMES:
+ if "train" in error_name.lower():
+ mean_error = mean_errors[0]
+ else:
+ 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
new file mode 100644
index 0000000000..17b615ad61
--- /dev/null
+++ b/tests/test_frame_selection_tools.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+"""Tests for frame selection tools."""
+
+import math
+from unittest.mock import Mock
+
+import pytest
+
+import deeplabcut.utils.frameselectiontools as fst
+
+
+@pytest.mark.parametrize(
+ "fps, duration, n_to_pick, start, end, index",
+ [
+ (32, 10, 10, 0, 1, None),
+ (16, 100, 50, 0, 1, list(range(100, 500, 5))),
+ (16, 100, 5, 0.25, 0.3, list(range(100, 500, 5))),
+ ],
+)
+def test_uniform_frames(fps, duration, n_to_pick, start, end, index):
+ start_idx = int(math.floor(start * duration * fps))
+ end_idx = int(math.ceil(end * duration * fps))
+ if index is None:
+ valid_indices = list(range(start_idx, end_idx))
+ else:
+ valid_indices = [idx for idx in index if start_idx <= idx <= end_idx]
+
+ clip = Mock()
+ clip.fps = fps
+ clip.duration = duration
+ frames = fst.UniformFrames(clip, n_to_pick, start, end, index)
+ print(f"FPS: {fps}")
+ print(f"Duration: {duration}")
+ print(f"Selected Frames: {frames}")
+ print(f"Valid Indices: {valid_indices}")
+
+ # Check that we get the number of frames we asked for
+ assert len(frames) == n_to_pick, f"Wrong nb. of frames: {n_to_pick}!={len(frames)}"
+ # Check that all indices are valid
+ for index in frames:
+ assert index in valid_indices, f"Invalid index: {index} not in {valid_indices}"
+ # Check that all frames are unique
+ assert len(set(frames)) == len(frames), "Duplicate indices found"
+
+
+@pytest.mark.parametrize(
+ "fps, nframes, n_to_pick, start, end, index",
+ [
+ (32, 320, 10, 0, 1, None),
+ (16, 1600, 50, 0, 1, list(range(100, 500, 5))),
+ (16, 1600, 5, 0.25, 0.3, list(range(100, 500, 5))),
+ ],
+)
+def test_uniform_frames_cv2(fps, nframes, n_to_pick, start, end, index):
+ start_idx = int(math.floor(start * nframes))
+ end_idx = int(math.ceil(end * nframes))
+ if index is None:
+ valid_indices = list(range(start_idx, end_idx))
+ else:
+ valid_indices = [idx for idx in index if start_idx <= idx <= end_idx]
+
+ cap = Mock()
+ cap.fps = fps
+ cap.__len__ = Mock(return_value=nframes)
+ frames = fst.UniformFramescv2(cap, n_to_pick, start, end, index)
+ print(f"FPS: {fps}")
+ print(f"Nframes: {nframes}")
+ print(f"Selected Frames: {frames}")
+ print(f"Valid Indices: {valid_indices}")
+
+ # Check that we get the number of frames we asked for
+ assert len(frames) == n_to_pick, f"Wrong nb. of frames: {n_to_pick}!={len(frames)}"
+ # Check that all indices are valid
+ for index in frames:
+ assert index in valid_indices, f"Invalid index: {index} not in {valid_indices}"
+ # Check that all frames are unique
+ assert len(set(frames)) == len(frames), "Duplicate indices found"
diff --git a/tests/test_inferenceutils.py b/tests/test_inferenceutils.py
index 4ed4ff00a9..d91fb6c4a1 100644
--- a/tests/test_inferenceutils.py
+++ b/tests/test_inferenceutils.py
@@ -1,12 +1,23 @@
-import numpy as np
+#
+# 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 os
import pickle
-import pytest
-from conftest import TEST_DATA_DIR
from copy import deepcopy
-from deeplabcut.pose_estimation_tensorflow.lib import inferenceutils
+
+import numpy as np
+import pytest
from scipy.spatial.distance import squareform
+from deeplabcut.core import inferenceutils
+
def test_conv_square_to_condensed_indices():
n = 5
@@ -15,7 +26,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)
@@ -26,9 +37,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
@@ -41,36 +50,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
@@ -97,7 +97,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]
@@ -131,8 +131,8 @@ def test_assembly():
assert len(ass3) == 2
-def test_assembler(tmpdir_factory, real_assemblies):
- with open(os.path.join(TEST_DATA_DIR, "trimouse_full.pickle"), "rb") as file:
+def test_assembler(tmpdir_factory, real_assemblies, test_data_dir):
+ with open(os.path.join(test_data_dir, "trimouse_full.pickle"), "rb") as file:
data = pickle.load(file)
with pytest.warns(UserWarning):
ass = inferenceutils.Assembler(
@@ -162,17 +162,15 @@ 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):
- with open(os.path.join(TEST_DATA_DIR, "trimouse_full.pickle"), "rb") as file:
+def test_assembler_with_single_bodypart(real_assemblies, test_data_dir):
+ with open(os.path.join(test_data_dir, "trimouse_full.pickle"), "rb") as file:
temp = pickle.load(file)
data = {"metadata": temp.pop("metadata")}
for k, dict_ in temp.items():
@@ -180,7 +178,11 @@ def test_assembler_with_single_bodypart(real_assemblies):
"coordinates": (dict_["coordinates"][0][:1],),
"confidence": dict_["confidence"][:1],
}
- ass = inferenceutils.Assembler(data, max_n_individuals=3, n_multibodyparts=1,)
+ ass = inferenceutils.Assembler(
+ data,
+ max_n_individuals=3,
+ n_multibodyparts=1,
+ )
ass.metadata["joint_names"] = ass.metadata["joint_names"][:1]
ass.metadata["num_joints"] = 1
ass.metadata["paf_graph"] = []
@@ -193,11 +195,15 @@ def test_assembler_with_single_bodypart(real_assemblies):
assert all(len(a) == 3 for a in ass.assemblies.values())
-def test_assembler_with_unique_bodypart(real_assemblies_montblanc):
- with open(os.path.join(TEST_DATA_DIR, "montblanc_full.pickle"), "rb") as file:
+def test_assembler_with_unique_bodypart(real_assemblies_montblanc, test_data_dir):
+ with open(os.path.join(test_data_dir, "montblanc_full.pickle"), "rb") as file:
data = pickle.load(file)
ass = inferenceutils.Assembler(
- data, max_n_individuals=3, n_multibodyparts=4, pcutoff=0.1, min_affinity=0.1,
+ data,
+ max_n_individuals=3,
+ n_multibodyparts=4,
+ pcutoff=0.1,
+ min_affinity=0.1,
)
assert len(ass.metadata["imnames"]) == 180
assert ass.n_keypoints == 5
@@ -205,21 +211,15 @@ 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)
-def test_assembler_with_identity(tmpdir_factory, real_assemblies):
- with open(os.path.join(TEST_DATA_DIR, "trimouse_full.pickle"), "rb") as file:
+def test_assembler_with_identity(tmpdir_factory, real_assemblies, test_data_dir):
+ with open(os.path.join(test_data_dir, "trimouse_full.pickle"), "rb") as file:
data = pickle.load(file)
# Generate fake identity predictions
@@ -252,9 +252,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
@@ -270,16 +268,16 @@ 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):
- with open(os.path.join(TEST_DATA_DIR, "trimouse_full.pickle"), "rb") as file:
+def test_assembler_calibration(real_assemblies, test_data_dir):
+ with open(os.path.join(test_data_dir, "trimouse_full.pickle"), "rb") as file:
data = pickle.load(file)
ass = inferenceutils.Assembler(data, max_n_individuals=3, n_multibodyparts=12)
- ass.calibrate(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5"))
+ ass.calibrate(os.path.join(test_data_dir, "trimouse_calib.h5"))
assert ass._kde is not None
assert ass.safe_edge
diff --git a/tests/test_pose_multianimal_imgaug.py b/tests/test_pose_multianimal_imgaug.py
index 312d3c5240..bc4c4f8899 100644
--- a/tests/test_pose_multianimal_imgaug.py
+++ b/tests/test_pose_multianimal_imgaug.py
@@ -1,13 +1,29 @@
-import numpy as np
+#
+# 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 os
+
+import numpy as np
import pytest
-from conftest import TEST_DATA_DIR
+
+from deeplabcut.core.config import read_config_as_dict, write_config
from deeplabcut.pose_estimation_tensorflow.datasets import (
Batch,
- pose_multianimal_imgaug,
PoseDatasetFactory,
+ pose_multianimal_imgaug,
+)
+
+tf = pytest.importorskip(
+ "tensorflow",
+ reason="TensorFlow not installed (use a project extra such as .[tf])",
)
-from deeplabcut.utils import read_plainconfig
def mock_imread(path, mode):
@@ -18,19 +34,39 @@ def mock_imread(path, mode):
@pytest.fixture()
-def ma_dataset():
- cfg = read_plainconfig(os.path.join(TEST_DATA_DIR, "pose_cfg.yaml"))
- cfg["project_path"] = TEST_DATA_DIR
+def ma_dataset(test_data_dir):
+ ## TODO @deruyter92 2026-06-15: this test config is currently invalid and needs to be
+ # updated. For now it is updated in place. (see https://github.com/DeepLabCut/UnitTestData/issues/4)
+ for cfg_name in ("config.yaml", "pose_cfg.yaml"):
+ cfg_path = os.path.join(test_data_dir, cfg_name)
+ cfg = read_config_as_dict(cfg_path)
+ if len(cfg.get("bodyparts", [])) > 0 and len(cfg.get("multianimalbodyparts", [])) > 0:
+ cfg["bodyparts"] = "MULTI!"
+ write_config(cfg_path, cfg, overwrite=True)
+
+ cfg = read_config_as_dict(os.path.join(test_data_dir, "pose_cfg.yaml"))
+ cfg["project_path"] = test_data_dir
cfg["dataset"] = "trimouse_train_data.pickle"
return PoseDatasetFactory.create(cfg)
@pytest.mark.parametrize(
"scale, stride",
- [(0.6, 2), (0.6, 4), (0.6, 8), (0.8, 4), (1.0, 8), (1.2, 8), (0.6, 4), (0.8, 8),],
+ [
+ (0.6, 2),
+ (0.6, 4),
+ (0.6, 8),
+ (0.8, 4),
+ (1.0, 8),
+ (1.2, 8),
+ (0.6, 4),
+ (0.8, 8),
+ ],
)
def test_calc_target_and_scoremap_sizes(
- ma_dataset, scale, stride,
+ ma_dataset,
+ scale,
+ stride,
):
ma_dataset.cfg["global_scale"] = scale
ma_dataset.cfg["stride"] = stride
@@ -45,18 +81,19 @@ def test_calc_target_and_scoremap_sizes(
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 in zip(data_items, joint_ids):
+ 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, strict=False):
assert len(data_item.joints) == len(joint_id)
- for joints, id_ in zip(data_item.joints.values(), joint_id):
- np.testing.assert_equal(joints[:, 0], 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, strict=False):
+ inds = id_ + start
+ mask_ = mask[inds]
+ np.testing.assert_equal(joints[:, 0], id_[mask_])
+ np.testing.assert_equal(joints[:, 1:], batch_joint[inds][mask_])
+ start += id_.size
def test_build_augmentation_pipeline(ma_dataset):
@@ -72,22 +109,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 9118be1c3d..4646a6ca93 100644
--- a/tests/test_predict_multianimal.py
+++ b/tests/test_predict_multianimal.py
@@ -1,7 +1,22 @@
+#
+# 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 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
@@ -19,7 +34,9 @@ def test_extract_detections(model_outputs, ground_truth_detections):
pos_gt = np.concatenate(ground_truth_detections[0]["coordinates"][0])
prob_gt = np.concatenate(ground_truth_detections[0]["confidence"])
inds = predict_multianimal.find_local_peak_indices_maxpool_nms(
- scmaps, RADIUS, THRESHOLD,
+ scmaps,
+ RADIUS,
+ THRESHOLD,
)
with tf.compat.v1.Session() as sess:
inds = sess.run(inds)
@@ -35,7 +52,9 @@ def test_extract_detections(model_outputs, ground_truth_detections):
def test_association_costs(model_outputs, ground_truth_detections):
costs_gt = ground_truth_detections[0]["costs"]
peak_inds = predict_multianimal.find_local_peak_indices_maxpool_nms(
- model_outputs[0], RADIUS, THRESHOLD,
+ model_outputs[0],
+ RADIUS,
+ THRESHOLD,
)
with tf.compat.v1.Session() as sess:
peak_inds = sess.run(peak_inds)
@@ -52,19 +71,17 @@ 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):
peak_inds = predict_multianimal.find_local_peak_indices_maxpool_nms(
- model_outputs[0], RADIUS, THRESHOLD,
+ model_outputs[0],
+ RADIUS,
+ THRESHOLD,
)
with tf.compat.v1.Session() as sess:
peak_inds = sess.run(peak_inds)
diff --git a/tests/test_predict_supermodel.py b/tests/test_predict_supermodel.py
new file mode 100644
index 0000000000..1453984620
--- /dev/null
+++ b/tests/test_predict_supermodel.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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
+import numpy as np
+import pytest
+
+from deeplabcut.pose_estimation_tensorflow.modelzoo.api import superanimal_inference
+
+
+def test_get_multi_scale_frames():
+ fake_img = (255 * np.random.rand(600, 800, 3)).astype(np.uint8)
+ ar = fake_img.shape[1] / fake_img.shape[0]
+ heights = list(range(100, 1000, 100))
+ frames, shapes = superanimal_inference.get_multi_scale_frames(
+ fake_img,
+ heights,
+ )
+ assert len(frames) == len(shapes) == len(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)
+
+
+@pytest.mark.parametrize("scale", [0.7, 1.5, 2])
+def test_project_pred_to_original_size(scale):
+ old_shape = 400, 600, 3
+ new_shape = old_shape[0] // scale, old_shape[1] // scale, 3
+ xs = [10, 25, 50, 100]
+ conf = [[1] for _ in range(len(xs))]
+ coords = [[np.array([[x, x]]) for x in xs]]
+ preds = {
+ "coordinates": coords,
+ "confidence": conf,
+ }
+ preds_orig = superanimal_inference._project_pred_to_original_size(
+ preds,
+ old_shape,
+ new_shape,
+ )
+ 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, 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 040e7c27e9..699ba96d7f 100644
--- a/tests/test_stitcher.py
+++ b/tests/test_stitcher.py
@@ -1,8 +1,18 @@
+#
+# 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 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
@@ -90,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)
@@ -192,7 +202,10 @@ def test_stitcher_real(tmpdir_factory, real_tracklets):
def test_stitcher_montblanc(real_tracklets_montblanc):
- stitcher = TrackletStitcher.from_dict_of_dict(real_tracklets_montblanc, n_tracks=3,)
+ stitcher = TrackletStitcher.from_dict_of_dict(
+ real_tracklets_montblanc,
+ n_tracks=3,
+ )
assert len(stitcher) == 5
assert all(tracklet.is_continuous for tracklet in stitcher.tracklets)
assert all(tracklet.identity == -1 for tracklet in stitcher.tracklets)
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 f4eebe7d1c..b3dac6d263 100644
--- a/tests/test_trackingutils.py
+++ b/tests/test_trackingutils.py
@@ -1,6 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import numpy as np
import pytest
-from deeplabcut.pose_estimation_tensorflow.lib import trackingutils
+
+from deeplabcut.core import trackingutils
@pytest.fixture()
@@ -11,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):
@@ -23,7 +32,7 @@ def test_ellipse_similarity(ellipse):
def test_ellipse_fitter():
fitter = trackingutils.EllipseFitter()
assert fitter.fit(np.random.rand(2, 2)) is None
- xy = np.asarray([[-2, 0], [2, 0], [0, 1], [0, -1]], dtype=np.float)
+ xy = np.asarray([[-2, 0], [2, 0], [0, 1], [0, -1]], dtype=float)
assert fitter.fit(xy) is not None
fitter.sd = 0
el = fitter.fit(xy)
@@ -32,9 +41,8 @@ def test_ellipse_fitter():
def test_ellipse_tracker(ellipse):
tracker1 = trackingutils.EllipseTracker(ellipse.parameters)
- assert tracker1.id == 0
tracker2 = trackingutils.EllipseTracker(ellipse.parameters)
- assert tracker2.id == 1
+ assert tracker1.id != tracker2.id
tracker1.update(ellipse.parameters)
assert tracker1.hit_streak == 1
state = tracker1.predict()
@@ -45,9 +53,9 @@ def test_ellipse_tracker(ellipse):
def test_sort_ellipse():
tracklets = dict()
- mot = trackingutils.SORTEllipse(1, 1, 0.6)
+ mot_tracker = trackingutils.SORTEllipse(1, 1, 0.6)
poses = np.random.rand(2, 10, 3)
- trackers = mot.track(poses[..., :2])
+ trackers = mot_tracker.track(poses[..., :2])
assert trackers.shape == (2, 7)
trackingutils.fill_tracklets(tracklets, trackers, poses, imname=0)
assert all(id_ in tracklets for id_ in trackers[:, -2])
@@ -64,20 +72,15 @@ 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():
bbox = 0, 0, 100, 100
tracker1 = trackingutils.BoxTracker(bbox)
- assert tracker1.id == 0
tracker2 = trackingutils.BoxTracker(bbox)
- assert tracker2.id == 1
+ assert tracker1.id != tracker2.id
tracker1.update(bbox)
assert tracker1.hit_streak == 1
state = tracker1.predict()
@@ -97,16 +100,13 @@ 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(
- real_assemblies_montblanc, real_tracklets_montblanc,
+ real_assemblies_montblanc,
+ real_tracklets_montblanc,
):
tracklets_ref = real_tracklets_montblanc.copy()
_ = tracklets_ref.pop("header", None)
@@ -118,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():
@@ -131,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
@@ -151,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 99b0de0bf3..c93445f19b 100644
--- a/tests/test_trainingsetmanipulation.py
+++ b/tests/test_trainingsetmanipulation.py
@@ -1,23 +1,35 @@
-import numpy as np
+#
+# 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 os
+
+import numpy as np
import pandas as pd
-from conftest import TEST_DATA_DIR
+import pytest
+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):
- path_rgb_image = os.path.join(TEST_DATA_DIR, "image.png")
+def test_read_image_shape_fast(tmp_path, test_data_dir):
+ path_rgb_image = os.path.join(test_data_dir, "image.png")
img = imread(path_rgb_image, mode="skimage")
shape = img.shape
assert read_image_shape_fast(path_rgb_image) == (shape[2], shape[0], shape[1])
@@ -31,7 +43,9 @@ def test_split_trials():
train_fractions = np.arange(50, 96) / 100
for frac in train_fractions:
train_inds, test_inds = SplitTrials(
- range(n_rows), frac, enforce_train_fraction=True,
+ range(n_rows),
+ frac,
+ enforce_train_fraction=True,
)
assert (len(train_inds) / (len(train_inds) + len(test_inds))) == frac
train_inds = train_inds[train_inds != -1]
@@ -39,38 +53,33 @@ def test_split_trials():
assert (len(train_inds) + len(test_inds)) == n_rows
-def test_format_training_data(monkeypatch):
+def test_format_training_data(monkeypatch, test_data_dir):
fake_shape = 3, 480, 640
monkeypatch.setattr(
- trainingsetmanipulation, "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
+ trainingsetmanipulation,
+ "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)
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):
+def test_format_multianimal_training_data(monkeypatch, test_data_dir):
fake_shape = 3, 480, 640
monkeypatch.setattr(
multiple_individuals_trainingsetmanipulation,
"read_image_shape_fast",
lambda _: fake_shape,
)
- df = pd.read_hdf(os.path.join(TEST_DATA_DIR, "trimouse_calib.h5"))
+ df = pd.read_hdf(os.path.join(test_data_dir, "trimouse_calib.h5"))
guarantee_multiindex_rows(df)
train_inds = list(range(10))
n_decimals = 1
@@ -79,8 +88,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, test_data_dir):
+ 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 e1e975d3ff..a1b2fe382c 100644
--- a/tests/test_triangulation.py
+++ b/tests/test_triangulation.py
@@ -1,6 +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/master/AUTHORS
+#
+# Licensed under GNU Lesser General Public License v3.0
+#
import numpy as np
import pandas as pd
import pytest
+
from deeplabcut.pose_estimation_3d import triangulation
@@ -30,7 +41,7 @@ def test_undistort_points(stereo_params):
@pytest.mark.parametrize(
"n_view_pairs, is_multi",
- [(i, flag) for i in range(1, 7, 2) for flag in (False, True)]
+ [(i, flag) for i in range(1, 7, 2) for flag in (False, True)],
)
def test_undistort_views(n_view_pairs, is_multi, stereo_params):
df = pd.read_hdf("tests/data/montblanc_tracks.h5")
@@ -38,8 +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 e0991dcb11..7ec4373e88 100644
--- a/tests/test_video.py
+++ b/tests/test_video.py
@@ -1,15 +1,25 @@
+#
+# 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 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
@pytest.fixture()
-def video_clip():
- return VideoWriter(os.path.join(TEST_DATA_DIR, "vid.avi"))
+def video_clip(test_data_dir):
+ return VideoWriter(os.path.join(test_data_dir, "vid.avi"))
def test_reader_wrong_inputs(tmp_path):
@@ -27,10 +37,10 @@ def test_reader_check_integrity(video_clip):
assert os.path.getsize(log_file) == 0
-def test_reader_video_path(video_clip):
+def test_reader_video_path(video_clip, test_data_dir):
assert video_clip.name == "vid"
assert video_clip.format == ".avi"
- assert video_clip.directory == TEST_DATA_DIR
+ assert video_clip.directory == test_data_dir
def test_reader_metadata(video_clip):
@@ -47,9 +57,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):
@@ -83,9 +91,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..c2fcc5cc7c
--- /dev/null
+++ b/tests/tools/docs_and_notebooks_checks/test_check_contracts.py
@@ -0,0 +1,674 @@
+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.REPORT_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
+
+
+@pytest.mark.parametrize(
+ ("rel", "kind"),
+ [
+ ("docs/page.md", "md"),
+ ("docs/nbs/nb.ipynb", "ipynb"),
+ ],
+)
+def test_metadata_sync_warning_populates_report_targets_and_command(tool, repo: Path, cfg, rel: str, kind: str):
+ """
+ Out-of-sync embedded last_content_updated should:
+ - add metadata_sync_needed warning
+ - appear in Report.metadata_sync_targets
+ - generate a non-empty metadata_sync_command
+ """
+ embedded_date = "2000-01-01"
+
+ if kind == "md":
+ _write(
+ repo,
+ rel,
+ f"---\ndeeplabcut:\n last_content_updated: {embedded_date}\n---\n# hello\n",
+ )
+ else:
+ nb = tool.nbformat.v4.new_notebook(metadata={tool.DLC_NAMESPACE: {"last_content_updated": embedded_date}})
+ _write(
+ repo,
+ rel,
+ tool.nbformat.writes(nb, version=4, indent=2, ensure_ascii=False) + "\n",
+ )
+
+ # Git-derived content date differs from embedded metadata date
+ _git_commit(repo, "docs: add file", "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
+ rec = records[0]
+
+ # Sanity check: embedded metadata is parsed, but differs from git-derived date
+ assert rec.meta is not None
+ assert rec.meta.last_content_updated == date(2000, 1, 1)
+ assert rec.last_content_updated == date(2020, 1, 1)
+
+ # New warning should be present
+ assert "metadata_sync_needed" in rec.warnings
+
+ metadata_sync_targets = tool.collect_metadata_sync_targets(records)
+ metadata_sync_command = tool.build_metadata_sync_command(
+ "tools/docs_and_notebooks_report_config.yml",
+ metadata_sync_targets,
+ )
+
+ report = tool.Report(
+ generated_at=datetime.now(timezone.utc),
+ repo_root=str(repo),
+ config_path="tools/docs_and_notebooks_report_config.yml",
+ totals=tool.summarize(records),
+ records=records,
+ metadata_sync_targets=metadata_sync_targets,
+ metadata_sync_command=metadata_sync_command,
+ )
+
+ assert report.metadata_sync_targets == [rel]
+ assert report.metadata_sync_command
+ assert "--set-content-date-from-git" in report.metadata_sync_command
+ assert "--targets" in report.metadata_sync_command
+ assert rel in report.metadata_sync_command
+
+ # Optional: also verify the markdown report renders the guidance section
+ rendered = tool.to_markdown(report, tool_cfg)
+ assert "## Metadata sync suggestions" in rendered
+ assert f"- `{rel}`" in rendered
+
+
+@pytest.mark.parametrize(
+ ("rel", "kind"),
+ [
+ ("docs/page.md", "md"),
+ ("docs/nbs/nb.ipynb", "ipynb"),
+ ],
+)
+def test_enforce_fails_when_metadata_sync_needed_is_configured(tool, repo: Path, cfg, rel: str, kind: str):
+ """
+ If policy.fail_if_metadata_sync_needed=True, an out-of-sync file should
+ become a policy violation in check/enforcement mode.
+ """
+ embedded_date = "2000-01-01"
+
+ if kind == "md":
+ _write(
+ repo,
+ rel,
+ f"---\ndeeplabcut:\n last_content_updated: {embedded_date}\n---\n# hello\n",
+ )
+ else:
+ nb = tool.nbformat.v4.new_notebook(metadata={tool.DLC_NAMESPACE: {"last_content_updated": embedded_date}})
+ _write(
+ repo,
+ rel,
+ tool.nbformat.writes(nb, version=4, indent=2, ensure_ascii=False) + "\n",
+ )
+
+ _git_commit(repo, "docs: add file", "2020-01-01T12:00:00+00:00")
+
+ tool_cfg = cfg(include=[rel], fail_if_metadata_sync_needed=True)
+ records = tool.scan_files(repo, tool_cfg, targets=[rel])
+
+ violations = tool.enforce(tool_cfg, records)
+
+ assert violations == [
+ f"{rel}: embedded last_content_updated is missing or out of sync with git content update date"
+ ]
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..a7c40cb0a5
--- /dev/null
+++ b/tests/tools/test_selector/test_selector_decision.py
@@ -0,0 +1,394 @@
+# tests/tools/test_selector/test_selector_decision.py
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pydantic import ValidationError
+
+from tools import test_selector
+from tools.test_selector import decide, order_functional_scripts
+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 == []
+
+
+def test_3d_script_includes_tensorflow_prerequisite():
+ scripts = order_functional_scripts({"examples/testscript_3d.py"})
+
+ assert scripts == [
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_3d.py",
+ ]
+
+
+def test_explicit_dependency_is_not_duplicated():
+ scripts = order_functional_scripts(
+ {
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_3d.py",
+ }
+ )
+
+ assert scripts == [
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_3d.py",
+ ]
+
+
+def test_functional_script_dependency_cycle(monkeypatch):
+ monkeypatch.setattr(
+ test_selector,
+ "FUNC_SCRIPT_DEPENDENCIES",
+ {
+ "a.py": ("b.py",),
+ "b.py": ("a.py",),
+ },
+ )
+
+ with pytest.raises(ValueError, match="Cyclic"):
+ order_functional_scripts({"a.py"})
+
+
+def test_3d_changes_select_scripts_in_dependency_order():
+ result = decide(["deeplabcut/pose_estimation_3d/plotting3D.py"])
+
+ assert result.functional_scripts == [
+ "examples/testscript_tensorflow_single_animal.py",
+ "examples/testscript_3d.py",
+ ]
+ assert (
+ "dependency:examples/testscript_3d.py"
+ in result.provenance.scripts["examples/testscript_tensorflow_single_animal.py"]
+ )
+ assert "3d_pose_estimation" in result.provenance.scripts["examples/testscript_3d.py"]
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..c964db9918
--- /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.core.deprecation import DLCDeprecationWarning
+from deeplabcut.utils.auxfun_videos import SUPPORTED_VIDEOS, collect_video_paths
+
+
+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..6b97b06190
--- /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.core.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..deba4d3c07
--- /dev/null
+++ b/tests/utils/test_skeleton.py
@@ -0,0 +1,378 @@
+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._ax = builder.fig.add_subplot(111)
+ builder._ax.set_xlim(-5, 25)
+ builder._ax.set_ylim(-5, 5)
+ 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, caplog):
+ 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 caplog.at_level("INFO"):
+ builder.export()
+ assert "Not all bodyparts are connected" in caplog.text
+
+ 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/tests/utils/test_video_processor.py b/tests/utils/test_video_processor.py
new file mode 100644
index 0000000000..81a97a09fe
--- /dev/null
+++ b/tests/utils/test_video_processor.py
@@ -0,0 +1,194 @@
+import cv2
+import numpy as np
+import pytest
+
+from deeplabcut.utils.video_processor import VideoProcessorCV
+
+
+def _make_test_video(path, nframes=3, width=8, height=6, fps=10.0, codec="mp4v"):
+ """Create a small RGB-ish test video using OpenCV's BGR writer."""
+ fourcc = cv2.VideoWriter_fourcc(*codec)
+ writer = cv2.VideoWriter(str(path), fourcc, fps, (width, height), True)
+ assert writer.isOpened(), "Could not create temporary test video."
+
+ frames_rgb = []
+ for i in range(nframes):
+ frame_rgb = np.zeros((height, width, 3), dtype=np.uint8)
+ frame_rgb[..., 0] = 10 + i # R
+ frame_rgb[..., 1] = 20 + i # G
+ frame_rgb[..., 2] = 30 + i # B
+ frames_rgb.append(frame_rgb.copy())
+
+ # OpenCV expects BGR.
+ writer.write(np.flip(frame_rgb, axis=2))
+
+ writer.release()
+ return frames_rgb
+
+
+def test_video_processor_cv_reads_basic_metadata(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=4, width=12, height=10, fps=15.0)
+
+ clip = VideoProcessorCV(fname=str(video_path))
+
+ try:
+ assert clip.width == 12
+ assert clip.height == 10
+ assert clip.nframes == 4
+ assert clip.i == 0
+ assert clip.fps > 0
+ assert clip.nc == 3
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_load_frame_returns_rgb_and_increments_counter(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=2, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path))
+
+ try:
+ frame = clip.load_frame()
+
+ assert frame is not None
+ assert frame.shape == (6, 8, 3)
+ assert clip.i == 1
+
+ # Compression can slightly alter values, so verify channel ordering by relative values.
+ assert (
+ frame[..., 0].mean() < frame[..., 1].mean() < frame[..., 2].mean()
+ or frame[..., 0].mean() != frame[..., 2].mean()
+ )
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_load_frame_eof_does_not_increment_counter(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=1, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path))
+
+ try:
+ assert clip.load_frame() is not None
+ assert clip.i == 1
+
+ eof_frame = clip.load_frame()
+ assert eof_frame is None
+ assert clip.i == 1
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_respects_nframes_cap(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=5, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path), nframes=2)
+
+ try:
+ assert clip.nframes == 2
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_nframes_minus_one_uses_all_frames(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=3, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path), nframes=-1)
+
+ try:
+ assert clip.nframes == 3
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_fps_override(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=3, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path), fps=123.0)
+
+ try:
+ assert clip.fps == 123.0
+ finally:
+ clip.close()
+
+
+def test_video_processor_cv_can_write_video_with_default_dimensions(tmp_path):
+ input_path = tmp_path / "input.mp4"
+ output_path = tmp_path / "output.mp4"
+ _make_test_video(input_path, nframes=2, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(
+ fname=str(input_path),
+ sname=str(output_path),
+ codec="mp4v",
+ )
+
+ try:
+ frame = clip.load_frame()
+ assert frame is not None
+ clip.save_frame(frame)
+ finally:
+ clip.close()
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+
+def test_video_processor_cv_can_write_video_with_explicit_dimensions(tmp_path):
+ input_path = tmp_path / "input.mp4"
+ output_path = tmp_path / "output.mp4"
+ _make_test_video(input_path, nframes=2, width=10, height=8, fps=10.0)
+
+ clip = VideoProcessorCV(
+ fname=str(input_path),
+ sname=str(output_path),
+ codec="mp4v",
+ sw=6,
+ sh=4,
+ )
+
+ try:
+ frame = clip.load_frame()
+ frame = frame[:4, :6]
+ clip.save_frame(frame)
+ finally:
+ clip.close()
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+
+def test_close_is_idempotent(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=1, width=8, height=6, fps=10.0)
+
+ clip = VideoProcessorCV(fname=str(video_path))
+ clip.close()
+ clip.close()
+
+
+@pytest.mark.xfail(reason="For backwards compatibility, VideoProcessorCV does not raise on invalid input videos.")
+def test_invalid_input_video_raises_file_not_found_or_io_error(tmp_path):
+ missing_path = tmp_path / "missing.mp4"
+
+ with pytest.raises((FileNotFoundError, OSError)):
+ VideoProcessorCV(fname=str(missing_path))
+
+
+def test_processor_passes_filename_as_str(tmp_path):
+ video_path = tmp_path / "input.mp4"
+ _make_test_video(video_path, nframes=1, width=8, height=6, fps=10.0)
+
+ # Should not raise even if a Path object is passed.
+ clip = VideoProcessorCV(fname=video_path)
+
+ try:
+ assert clip.fname == str(video_path)
+ finally:
+ clip.close()
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
new file mode 100644
index 0000000000..20d1b29f8b
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,209 @@
+# Developer tools useful for maintaining the repository
+
+This document summarizes the developer tooling and workflows used in this repo.
+
+```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/test_selector.py --json
+```
+
+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
+
+- 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_audit.py b/tools/docs_and_notebooks_audit.py
new file mode 100644
index 0000000000..e4dbc0b861
--- /dev/null
+++ b/tools/docs_and_notebooks_audit.py
@@ -0,0 +1,581 @@
+"""
+DeepLabCut docs audit export tool (validated / extensible).
+
+Purpose
+-------
+Read audit metadata from the `deeplabcut` namespace in Markdown frontmatter and
+notebook-level metadata, validate selected fields against enums/schema, and
+export a CSV register with docs metadata and review notes to help drive documentation maintenance.
+
+Supported metadata fields
+-------------------------
+From `deeplabcut:` this tool currently validates and exports:
+- visibility
+- status
+- recommendation (with fallback alias: review_decision)
+- last_verified (pass-through)
+- notes (pass-through, but preserved from previous CSV if present even if updated in source)
+
+Example metadata
+----------------
+Markdown frontmatter:
+
+---
+deeplabcut:
+ visibility: online
+ status: viable
+ recommendation: keep
+ last_verified: 2026-04-15
+---
+
+Notebook metadata:
+{
+ "metadata": {
+ "deeplabcut": {
+ "visibility": "online",
+ "status": "viable",
+ "recommendation": "keep",
+ "last_verified": "2026-04-15"
+ }
+ }
+}
+
+Usage
+-----
+python tools/docs_and_notebooks_audit.py \
+ --config tools/docs_and_notebooks_report_config.yml \
+ --out docs/_meta/docs_audit_register.csv
+
+python tools/docs_and_notebooks_audit.py \
+ --targets docs/gui/ docs/recipes/*.md examples/COLAB/*.ipynb
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import fnmatch
+import os
+import re
+import subprocess
+from collections.abc import Callable, Iterable, Sequence
+from enum import Enum
+from pathlib import Path
+from typing import Any, Literal, TypedDict
+
+import nbformat
+import yaml
+from pydantic import BaseModel, ConfigDict, ValidationError
+
+# -----------------------------------------------------------------------------
+# Constants / defaults
+# -----------------------------------------------------------------------------
+DLC_NAMESPACE = "deeplabcut"
+DEFAULT_CONFIG = Path("tools/docs_and_notebooks_report_config.yml")
+DEFAULT_OUTPUT = Path("docs/_meta/docs_audit_register.csv")
+GLOB_CHARS = set("*?[")
+FRONTMATTER_RE = re.compile(r"^---\s*$")
+
+# Generated columns owned by this tool. Any additional columns present in an
+# existing CSV are preserved as human columns.
+GENERATED_COLUMNS = [
+ "path",
+ "kind",
+ "metadata_present",
+ "visibility",
+ "status",
+ "recommendation",
+ "last_verified",
+ "parse_error",
+ "validation_error",
+ "notes",
+]
+
+DEFAULT_INCLUDE = [
+ "docs/**/*.md",
+ "docs/**/*.markdown",
+ "docs/**/*.ipynb",
+ "examples/**/*.ipynb",
+ "README.md",
+ "CONTRIBUTING.md",
+]
+DEFAULT_EXCLUDE = [
+ ".git/**",
+ ".github/**",
+ "**/.ipynb_checkpoints/**",
+ "**/node_modules/**",
+ "**/.venv/**",
+]
+
+FileKind = Literal["md", "ipynb", "other"]
+TargetKind = Literal["invalid", "file", "dir", "glob"]
+
+
+# -----------------------------------------------------------------------------
+# Enums / schema
+# -----------------------------------------------------------------------------
+class Visibility(str, Enum):
+ """How discoverable the page is in the documentation surface."""
+
+ ONLINE = "online" # current docs surface / discoverable
+ UNLISTED = "unlisted" # intentionally available but not surfaced in nav
+ ARCHIVED = "archived" # only discoverable via archive/historical area
+ ORPHANED = "orphaned" # not listed and no supported inbound links
+
+
+class Status(str, Enum):
+ """Current health / lifecycle state of the page."""
+
+ VIABLE = "viable" # current and acceptable
+ REVIEW_NEEDED = "review_needed" # needs human review before decision
+ OUTDATED = "outdated" # content exists but is stale / drifted
+ DEPRECATED = "deprecated" # not preferred; replacement exists/coming
+ ARCHIVED = "archived" # kept for historical or niche reference
+ REMOVED = "removed" # removed from active docs surface
+
+
+class Recommendation(str, Enum):
+ """Recommended next action for the page."""
+
+ KEEP = "keep" # content is fine as-is; no action needed
+ VERIFY = "verify" # content and/or formatting could use verification
+ UPDATE = "update" # requires content update to be considered viable
+ MOVE = "move" # move to a more appropriate location
+ MERGE = "merge" # merge into another page
+ ARCHIVE = "archive" # if deprecated, archive before removal
+ REMOVE = "remove" # remove from repository (can be resurrected from git history if needed)
+
+
+class AuditMetadata(BaseModel):
+ """
+ Strictly validate only the fields this exporter owns.
+
+ Keep extra metadata allowed so the deeplabcut namespace can still contain
+ other fields used by the main checks tool or future workflows.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ visibility: Visibility | None = None
+ status: Status | None = None
+ recommendation: Recommendation | None = None
+ last_verified: str | None = None
+ notes: str | None = None
+
+
+class TargetSpec(TypedDict):
+ raw: str
+ normalized: str
+ kind: TargetKind
+
+
+class FieldSpec(BaseModel):
+ """Describes how a CSV column maps from deeplabcut metadata."""
+
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
+ column: str
+ source_keys: list[str]
+ extractor: Callable[[AuditMetadata, dict[str, Any]], str] | None = None
+
+
+FIELD_SPECS: list[FieldSpec] = [
+ FieldSpec(column="visibility", source_keys=["visibility"]),
+ FieldSpec(column="status", source_keys=["status"]),
+ FieldSpec(column="recommendation", source_keys=["recommendation", "review_decision"]),
+ FieldSpec(column="last_verified", source_keys=["last_verified"]),
+ FieldSpec(column="notes", source_keys=["notes"]),
+]
+
+
+# -----------------------------------------------------------------------------
+# Path / target helpers
+# -----------------------------------------------------------------------------
+def normalize_target_spec(spec: str, repo_root: Path) -> str:
+ s = spec.strip()
+ if not s:
+ return s
+ s = s.replace("\\", "/")
+ while s.startswith("./"):
+ s = s[2:]
+ p = Path(s)
+ if p.is_absolute():
+ try:
+ s = str(p.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ except ValueError:
+ s = str(p).replace(os.sep, "/")
+ s = re.sub(r"/+", "/", s)
+ if len(s) > 1:
+ s = s.rstrip("/")
+ return s
+
+
+def compile_target_specs(targets: list[str] | None, repo_root: Path) -> list[TargetSpec] | None:
+ 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
+ if raw.endswith(("/", "\\")):
+ specs.append({"raw": raw, "normalized": normalized, "kind": "dir"})
+ continue
+ candidate = repo_root / normalized
+ specs.append(
+ {
+ "raw": raw,
+ "normalized": normalized,
+ "kind": "dir" if candidate.exists() and candidate.is_dir() else "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":
+ return fnmatch.fnmatchcase(rel_path, normalized)
+ return False
+
+
+def target_matches(rel_path: str, specs: list[TargetSpec] | None) -> bool:
+ return True if specs is None else any(target_spec_matches_path(rel_path, spec) for spec in specs)
+
+
+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
+ proc = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=str(start), capture_output=True, text=True)
+ if proc.returncode == 0 and proc.stdout.strip():
+ return Path(proc.stdout.strip()).resolve()
+ raise RuntimeError("Could not locate repository root")
+
+
+def file_kind(path: Path) -> FileKind:
+ suffix = path.suffix.lower()
+ if suffix in {".md", ".markdown"}:
+ return "md"
+ if suffix == ".ipynb":
+ return "ipynb"
+ return "other"
+
+
+# -----------------------------------------------------------------------------
+# Config / discovery
+# -----------------------------------------------------------------------------
+def load_scan_patterns(config_path: Path | None) -> tuple[list[str], list[str]]:
+ if not config_path or not config_path.exists():
+ return DEFAULT_INCLUDE, DEFAULT_EXCLUDE
+
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ scan = raw.get("scan") or {}
+ include = scan.get("include") or DEFAULT_INCLUDE
+ exclude = scan.get("exclude") or DEFAULT_EXCLUDE
+ return include, exclude
+
+
+def is_excluded(rel_path: str, exclude_patterns: list[str]) -> bool:
+ return any(fnmatch.fnmatch(rel_path, pat) for pat in exclude_patterns)
+
+
+def iter_candidate_paths(
+ repo_root: Path,
+ include_patterns: list[str],
+ exclude_patterns: list[str],
+ targets: list[str] | None = None,
+) -> list[Path]:
+ specs = compile_target_specs(targets, repo_root)
+ matches: dict[str, Path] = {}
+ for pattern in include_patterns:
+ for path in repo_root.glob(pattern):
+ if not path.is_file():
+ continue
+ rel = str(path.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ if is_excluded(rel, exclude_patterns):
+ continue
+ if not target_matches(rel, specs):
+ continue
+ matches[rel] = path.resolve()
+ return [matches[k] for k in sorted(matches)]
+
+
+# -----------------------------------------------------------------------------
+# Metadata readers (minimal duplication via namespace dispatch)
+# -----------------------------------------------------------------------------
+def read_md_frontmatter(text: str) -> tuple[dict | None, str | None]:
+ lines = text.splitlines(keepends=True)
+ if not lines or not FRONTMATTER_RE.match(lines[0]):
+ return None, 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, "unterminated_markdown_frontmatter"
+
+ fm_text = "".join(lines[1:end_idx])
+ try:
+ fm = yaml.safe_load(fm_text) if fm_text.strip() else {}
+ except Exception as exc:
+ return None, f"markdown_frontmatter_yaml_error: {exc}"
+ if not isinstance(fm, dict):
+ return None, "markdown_frontmatter_not_mapping"
+ return fm, None
+
+
+def read_container(path: Path) -> tuple[dict | None, str | None]:
+ """
+ Return the top-level metadata container for a file.
+ - Markdown: YAML frontmatter mapping
+ - Notebook: notebook.metadata mapping
+ """
+ kind = file_kind(path)
+
+ if kind == "md":
+ try:
+ text = path.read_text(encoding="utf-8")
+ except Exception as exc:
+ return None, f"read_failed: {exc}"
+ return read_md_frontmatter(text)
+
+ if kind == "ipynb":
+ try:
+ nb = nbformat.read(str(path), as_version=4)
+ meta = getattr(nb, "metadata", {}) or {}
+ except Exception as exc:
+ return None, f"notebook_read_failed: {exc}"
+ if not isinstance(meta, dict):
+ return None, "notebook_metadata_not_mapping"
+ return meta, None
+
+ return None, None
+
+
+def read_dlc_namespace(path: Path) -> tuple[dict | None, str | None]:
+ container, error = read_container(path)
+ if error:
+ return None, error
+ if container is None:
+ return None, None
+ raw = container.get(DLC_NAMESPACE)
+ if raw is None:
+ return None, None
+ if not isinstance(raw, dict):
+ return None, "deeplabcut_namespace_not_mapping"
+ return raw, None
+
+
+# -----------------------------------------------------------------------------
+# Validation / normalization
+# -----------------------------------------------------------------------------
+def build_validation_input(raw_meta: dict[str, Any]) -> dict[str, Any]:
+ """
+ Map raw deeplabcut metadata into the schema-owned keys.
+ This is where aliases are resolved to canonical names.
+ """
+ payload: dict[str, Any] = {}
+ for spec in FIELD_SPECS:
+ for key in spec.source_keys:
+ if key in raw_meta and raw_meta.get(key) not in {None, ""}:
+ payload[spec.column] = raw_meta.get(key)
+ break
+ return payload
+
+
+def validate_metadata(raw_meta: dict[str, Any] | None) -> tuple[AuditMetadata | None, str | None]:
+ if raw_meta is None:
+ return None, None
+ try:
+ validated = AuditMetadata.model_validate(build_validation_input(raw_meta))
+ return validated, None
+ except ValidationError as exc:
+ messages = []
+ for err in exc.errors():
+ loc = ".".join(str(x) for x in err.get("loc", []))
+ msg = err.get("msg", "invalid value")
+ messages.append(f"{loc}: {msg}" if loc else msg)
+ return None, "; ".join(messages)
+
+
+def extract_field_value(spec: FieldSpec, validated: AuditMetadata | None, raw_meta: dict[str, Any]) -> str:
+ if spec.extractor is not None:
+ return spec.extractor(validated, raw_meta)
+ if validated is not None:
+ value = getattr(validated, spec.column, None)
+ if isinstance(value, Enum):
+ return value.value
+ return "" if value is None else str(value)
+ # If validation failed, still emit raw/aliased value when present for easier triage.
+ for key in spec.source_keys:
+ if key in raw_meta and raw_meta.get(key) is not None:
+ return str(raw_meta.get(key))
+ return ""
+
+
+# -----------------------------------------------------------------------------
+# CSV merge / preserve human annotations
+# -----------------------------------------------------------------------------
+def load_existing_rows(csv_path: Path) -> tuple[dict[str, dict[str, str]], list[str]]:
+ if not csv_path.exists():
+ return {}, []
+ with csv_path.open("r", newline="", encoding="utf-8") as fh:
+ reader = csv.DictReader(fh)
+ rows = {row.get("path", ""): row for row in reader if row.get("path")}
+ existing_columns = reader.fieldnames or []
+ extra_columns = [c for c in existing_columns if c not in GENERATED_COLUMNS]
+ return rows, extra_columns
+
+
+def merged_row(
+ base: dict[str, Any],
+ previous: dict[str, str] | None,
+ extra_columns: Iterable[str],
+ force_overwrite_notes: bool = False,
+) -> dict[str, Any]:
+ row = dict(base)
+
+ if previous:
+ prev_notes = (previous.get("notes") or "").strip()
+ scanned_notes = (row.get("notes") or "").strip()
+
+ if prev_notes and scanned_notes and prev_notes != scanned_notes:
+ print(f"WARNING: Notes conflict for {row['path']}:")
+ print(f"- Previous: {prev_notes}")
+ print(f"- Scanned: {scanned_notes}")
+
+ if force_overwrite_notes:
+ print("Force overwrite enabled; using scanned notes.")
+ else:
+ print("Preserving previous notes and ignoring scanned notes.")
+
+ if force_overwrite_notes:
+ row["notes"] = scanned_notes
+ else:
+ row["notes"] = prev_notes if prev_notes else scanned_notes
+
+ for col in extra_columns:
+ row[col] = previous.get(col, "") if previous else ""
+
+ return row
+
+
+# -----------------------------------------------------------------------------
+# Row building / export
+# -----------------------------------------------------------------------------
+def build_row(repo_root: Path, path: Path) -> dict[str, Any]:
+ rel = str(path.resolve().relative_to(repo_root)).replace(os.sep, "/")
+ kind = file_kind(path)
+ raw_meta, parse_error = read_dlc_namespace(path)
+ raw_meta = raw_meta or {}
+ metadata_present = bool(raw_meta)
+ validated, validation_error = validate_metadata(raw_meta if metadata_present else None)
+
+ row: dict[str, Any] = {
+ "path": rel,
+ "kind": kind,
+ "metadata_present": "true" if metadata_present else "false",
+ "parse_error": parse_error or "",
+ "validation_error": validation_error or "",
+ # "notes": "",
+ }
+
+ for spec in FIELD_SPECS:
+ row[spec.column] = extract_field_value(spec, validated, raw_meta)
+ return row
+
+
+def export_csv(
+ repo_root: Path,
+ include: list[str],
+ exclude: list[str],
+ out_path: Path,
+ targets: list[str] | None,
+ force_overwrite_notes: bool = False,
+) -> int:
+ candidates = iter_candidate_paths(repo_root, include, exclude, targets=targets)
+ existing_rows, extra_columns = load_existing_rows(out_path)
+
+ rows = []
+ for path in candidates:
+ base = build_row(repo_root, path)
+ previous = existing_rows.get(base["path"])
+ rows.append(merged_row(base, previous, extra_columns, force_overwrite_notes=force_overwrite_notes))
+
+ fieldnames = list(GENERATED_COLUMNS) + [c for c in extra_columns if c not in GENERATED_COLUMNS]
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ with out_path.open("w", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=fieldnames)
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+
+ print(f"Wrote {len(rows)} records to {out_path}")
+ invalid = sum(1 for row in rows if row.get("validation_error"))
+ parse_fail = sum(1 for row in rows if row.get("parse_error"))
+ if invalid or parse_fail:
+ print(f"Validation issues: {invalid}; parse issues: {parse_fail}")
+ return 0
+
+
+# -----------------------------------------------------------------------------
+# CLI
+# -----------------------------------------------------------------------------
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Export DeepLabCut audit metadata to CSV")
+ parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Optional path to scan config YAML")
+ parser.add_argument("--root", default=".", help="Repository root or path inside the repository")
+ parser.add_argument("--out", default=str(DEFAULT_OUTPUT), help="CSV output path")
+ parser.add_argument(
+ "--targets",
+ nargs="*",
+ help=(
+ "Optional repo-relative targets to limit the export. Supports exact files, "
+ "directories, and glob patterns (e.g. docs/page.md, docs/gui/, 'docs/**/*.md')."
+ ),
+ )
+ parser.add_argument(
+ "--force-overwrite-notes",
+ action="store_true",
+ help=(
+ "By default, if a record already exists in the CSV and has notes, those notes are preserved even if the "
+ "scanned metadata contains notes. This flag forces the scanned notes to overwrite existing notes, "
+ "which can be useful for bulk updates but may lead to loss of manually curated information."
+ ),
+ )
+ args = parser.parse_args(list(argv) if argv is not None else None)
+
+ repo_root = find_repo_root(Path(args.root))
+ config_path = Path(args.config)
+ include, exclude = load_scan_patterns(config_path)
+ out_path = Path(args.out)
+ if not out_path.is_absolute():
+ out_path = repo_root / out_path
+
+ return export_csv(
+ repo_root, include, exclude, out_path, targets=args.targets, force_overwrite_notes=args.force_overwrite_notes
+ )
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/docs_and_notebooks_check.py b/tools/docs_and_notebooks_check.py
new file mode 100644
index 0000000000..d7d9690e8c
--- /dev/null
+++ b/tools/docs_and_notebooks_check.py
@@ -0,0 +1,1388 @@
+"""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 shlex
+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
+
+REPORT_SCHEMA_VERSION: Literal[1, 2] = 2
+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
+ fail_if_metadata_sync_needed: 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: Literal[1] = 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: Literal[1, 2] = REPORT_SCHEMA_VERSION
+ generated_at: datetime
+ repo_root: str
+ config_path: str
+
+ totals: dict[str, int]
+ records: list[FileRecord]
+
+ # New: actionable metadata-sync guidance for maintainers
+ metadata_sync_targets: list[str] = Field(default_factory=list)
+ metadata_sync_command: str | None = None
+
+
+# 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)
+
+
+# -----------------------------
+# CI enforcement utils
+# -----------------------------
+def record_needs_metadata_sync(rec: FileRecord) -> bool:
+ if "invalid_metadata" in rec.warnings:
+ # avoid mixing "invalid metadata" issues with "metadata sync needed" guidance,
+ # since the former may require manual fixes
+ return False
+
+ # If metadata could not be read/parsed/validated reliably, just return False.
+ # These are scan/repair issues first, not "missing/out-of-sync metadata".
+ metadata_sync_blocking_error_prefixes = (
+ "metadata_read_failed:",
+ "markdown_frontmatter_invalid:",
+ "nbformat_invalid:",
+ )
+ if any(err.startswith(metadata_sync_blocking_error_prefixes) for err in (rec.errors or [])):
+ return False
+
+ if rec.kind not in {"md", "ipynb"}:
+ return False
+ if rec.meta and rec.meta.ignore:
+ return False
+
+ embedded = rec.meta.last_content_updated if rec.meta else None
+ computed = rec.last_content_updated
+
+ if computed is None:
+ return False
+
+ return embedded != computed
+
+
+def collect_metadata_sync_targets(records: list[FileRecord]) -> list[str]:
+ paths: list[str] = []
+ for rec in records:
+ if record_needs_metadata_sync(rec):
+ paths.append(rec.path)
+ return sorted(set(paths))
+
+
+def build_metadata_sync_command(config_path: str, paths: list[str]) -> str | None:
+ if not paths:
+ return None
+ config_path = shlex.quote(config_path)
+ paths = [shlex.quote(p) for p in paths]
+
+ target_lines = " \\\n ".join(paths)
+ return (
+ "python tools/docs_and_notebooks_check.py \\\n"
+ f" --config {config_path} \\\n"
+ " update \\\n"
+ " --write \\\n"
+ " --set-content-date-from-git \\\n"
+ " --targets \\\n"
+ f" {target_lines} \\\n"
+ " --ack-meta-commit-marker"
+ )
+
+
+def build_git_add_command(paths: list[str]) -> str | None:
+ if not paths:
+ return None
+ paths = [shlex.quote(p) for p in paths]
+
+ path_lines = " \\\n ".join(paths)
+ return f"git add \\\n {path_lines}"
+
+
+# -----------------------------
+# 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
+
+ if record_needs_metadata_sync(rec):
+ rec.warnings.append("metadata_sync_needed")
+
+ 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)),
+ "metadata_sync_needed": sum(1 for r in records if "metadata_sync_needed" 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")
+ lines.append(f"- Metadata sync needed: **{t['metadata_sync_needed']}**\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")
+
+ if report.metadata_sync_targets:
+ lines.append("## Metadata sync suggestions\n\n")
+ lines.append(
+ "The following files have embedded `deeplabcut.last_content_updated` metadata "
+ "that is missing or out of sync with the git-derived content date:\n\n"
+ )
+ for p in report.metadata_sync_targets:
+ lines.append(f"- `{p}`\n")
+ lines.append("\n")
+
+ if report.metadata_sync_command:
+ lines.append("Run this locally:\n\n")
+ lines.append("```bash\n")
+ lines.append(report.metadata_sync_command + "\n")
+ lines.append("```\n\n")
+
+ git_add = build_git_add_command(report.metadata_sync_targets)
+ if git_add:
+ lines.append("Then commit with:\n\n")
+ lines.append("```bash\n")
+ lines.append(git_add + "\n")
+ lines.append(f'git commit -m "{SUGGESTED_TAGGED_COMMIT}"\n')
+ lines.append("```\n\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 pol.fail_if_metadata_sync_needed and record_needs_metadata_sync(r):
+ violations.append(
+ f"{r.path}: embedded last_content_updated is missing or out of sync with git content update date"
+ )
+
+ 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")
+
+ metadata_sync_targets = collect_metadata_sync_targets(records)
+ metadata_sync_command = build_metadata_sync_command(str(config_path), metadata_sync_targets)
+
+ report = Report(
+ generated_at=datetime.now(timezone.utc),
+ repo_root=str(repo_root),
+ config_path=str(config_path),
+ totals=summarize(records),
+ records=records,
+ metadata_sync_targets=metadata_sync_targets,
+ metadata_sync_command=metadata_sync_command,
+ )
+
+ json_path, md_path = write_outputs(report, cfg, out_dir)
+
+ if metadata_sync_targets and args.cmd in {"report", "check"}:
+ print(f"\nMetadata sync needed for {len(metadata_sync_targets)} file(s):")
+ for p in metadata_sync_targets:
+ print(f"- {p}")
+
+ if metadata_sync_command:
+ print("\nRun this locally:")
+ print(metadata_sync_command)
+
+ git_add = build_git_add_command(metadata_sync_targets)
+ if git_add:
+ print("\nThen commit with:")
+ print(git_add)
+ print(f'git commit -m "{SUGGESTED_TAGGED_COMMIT}"')
+
+ # 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..ee7995b742
--- /dev/null
+++ b/tools/docs_and_notebooks_report_config.yml
@@ -0,0 +1,28 @@
+version: 1
+
+scan:
+ include:
+ - "examples/COLAB/**/*.ipynb"
+ - "examples/JUPYTER/**/*.ipynb"
+ - "docs/**/*.md"
+ - "docs/**/*.ipynb" # if notebooks get added to docs (Jupyter Book supports this)
+ - "tools/**/*.md"
+ exclude:
+ - "**/.ipynb_checkpoints/**"
+ - "**/_build/**"
+ - "**/build/**"
+ - "tools/docs_audits/**"
+
+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/docs_audits/april-2026/audit_metadata.csv b/tools/docs_audits/april-2026/audit_metadata.csv
new file mode 100644
index 0000000000..d809f7ec6d
--- /dev/null
+++ b/tools/docs_audits/april-2026/audit_metadata.csv
@@ -0,0 +1,91 @@
+path,kind,metadata_present,visibility,status,recommendation,last_verified,parse_error,validation_error,notes,review_decision,review_priority
+docs/Governance.md,md,true,,,,,,,,,
+docs/HelperFunctions.md,md,true,online,outdated,archive,,,,"I would suggest using API docs over pages like this to avoid drift. The advice below promises updates that are not being made, and the content is already quite outdated. Automating API docs generation and putting usage info for obtaining commands info in ipython in a basic 'evergreen' page would be more sustainable than trying to maintain this page.",,
+docs/MISSION_AND_VALUES.md,md,true,,,,,,,,,
+docs/ModelZoo.md,md,true,,,,,,,,,
+docs/Overviewof3D.md,md,true,online,review_needed,update,,,,"Contents seem up-to-date as the codebase has not evolved drastically for 3D, but formatting and organization could be improved. Separate basic/advanced sections could help, as well as more admonitions/dropdowns to streamline.",,
+docs/README.md,md,true,,,,,,,,,
+docs/UseOverviewGuide.md,md,true,,,,,,,,,
+docs/beginner-guides/Training-Evaluation.md,md,true,online,viable,move,,,,"As mentioned on other beginner-guides/ docs, this should be part of the GUI section.",,
+docs/beginner-guides/beginners-guide.md,md,true,online,outdated,move,,,,Move to GUI section.,,
+docs/beginner-guides/labeling.md,md,true,online,viable,move,,,,Move to GUI section. Updated to link directly to the napari plugin docs. Making the link specific to the workflow section of the napari docs could help.,,
+docs/beginner-guides/manage-project.md,md,true,online,viable,move,,,,Move to a dedicated GUI section. Making the config edit tool slightly easier to work with and updating the docs below to include additional fields would be helpful.,,
+docs/beginner-guides/video-analysis.md,md,true,online,viable,move,,,,"As mentioned on other beginner-guides/ docs, this should be part of the GUI section.",,
+docs/benchmark.md,md,true,,,,,,,,,
+docs/citation.md,md,true,,,,,,,,,
+docs/convert_maDLC.md,md,true,,,,,,,,,
+docs/course.md,md,true,online,outdated,archive,,,,,,
+docs/dlc-live/deeplabcutlive.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/index.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/quickstart/install.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/aravis_backend.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/basler_backend.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/camera_support.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/gentl_backend.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/cameras_backends/opencv_backend.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/misc/misc_landing.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/misc/modelzoo_downloads.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/misc/timestamp_format.md,md,true,,,,,,,,,
+docs/dlc-live/dlc-live-gui/user_guide/overview.md,md,true,,,,,,,,,
+docs/dlc-utils/XROMM/usage.md,md,false,,,,,,,,,
+docs/dlc-utils/index.md,md,true,,,,2026-05-06,,,,,
+docs/docker.md,md,true,online,viable,,2026-05-22,,,,,
+docs/gui/PROJECT_GUI.md,md,true,online,review_needed,update,,,,"While the content is generally accurate, repeating installation instructions is not ideal. I would suggest linking to the installation guide instead of re-suggesting commands but then still saying to read the install page... Also, the GUI is likely used by the majority of users, so I would even consider making this a full section in the TOC, and maybe even having one file per GUI tab, which would make tracking code/docs sync easier. Addendum: it seems the beginner guide section is more of a GUI step-by-step, as mentioned earlier in this comment. I would suggest merging/moving and adding links in the present doc, which would make it less of a video list and more of a proper GUI guide.",,
+docs/gui/index.md,md,false,,,,,,,,,
+docs/gui/napari/advanced_usage.md,md,true,,,,2026-04-09,,,,,
+docs/gui/napari/basic_usage.md,md,true,,,,2026-04-09,,,,,
+docs/gui/napari/tracking/basic_usage.md,md,true,,,,2026-05-08,,,,,
+docs/gui/napari_GUI.md,md,true,online,outdated,archive,2026-04-09,,,Being updated in a separate PR (#3280),,
+docs/installation.md,md,true,online,viable,move,2026-04-21,,,Could be moved to a core/installation folder for clarity.,,
+docs/intro.md,md,true,,,,,,,,,
+docs/maDLC_UserGuide.md,md,true,online,review_needed,verify,,,,"Could use a small formatting pass. Contents are 4-5y old in some places, recommend to review for accuracy.",,
+docs/notebooks/extra.md,md,false,,,,,,,,,
+docs/notebooks/main_demos.md,md,false,,,,,,,,,
+docs/notebooks/your_data.md,md,false,,,,,,,,,
+docs/pytorch/Benchmarking_shuffle_guide.md,md,true,online,viable,move,,,,"Useful and well-written, but it could be better grouped with other tutorials/guides rather than being a PyTorch docs only page, as its contents are somewhat in between the two backends.",,
+docs/pytorch/architectures.md,md,true,online,viable,keep,,,,,,
+docs/pytorch/index.md,md,false,,,,,,,,,
+docs/pytorch/pytorch_config.md,md,true,online,review_needed,verify,,,,"Check for accuracy and completeness of content, and update as needed. Formatting is fairly consistent and does not need an urgent update.",,
+docs/pytorch/user_guide.md,md,true,,,,,,,,,
+docs/pytorch_dlc.md,md,true,orphaned,viable,move,,,,Unclear why this is unlisted in TOC; recommend updating and moving to PyTorch section.,,
+docs/quick-start/index.md,md,true,online,viable,keep,2026-05-12,,,,,
+docs/quick-start/single_animal_quick_guide.md,md,true,online,viable,archive,,,,"This is a bit stuck between minimal guide and quick start, as the lack of explanations makes it more into a catalogue of commands (which is an API docs responsibility), and a proper quick start guide that gives users a proper sense of the workflow. This should either be expanded greatly or simply archived. For simplicity, I recommend archiving.",,
+docs/quick-start/tutorial_maDLC.md,md,true,online,viable,keep,,,,,,
+docs/recipes/BatchProcessing.md,md,true,,,,,,,,,
+docs/recipes/ClusteringNapari.md,md,true,,,,,,,,,
+docs/recipes/DLCMethods.md,md,true,,,,,,,,,
+docs/recipes/MegaDetectorDLCLive.md,md,true,orphaned,outdated,archive,,,,,,
+docs/recipes/OpenVINO.md,md,true,,,,,,,,,
+docs/recipes/OtherData.md,md,true,,,,,,,,,
+docs/recipes/TechHardware.md,md,true,online,outdated,update,,,,Useful but needs to be updated and clarified.,,
+docs/recipes/UsingModelZooPupil.md,md,true,,,,,,,,,
+docs/recipes/external_data_import.md,md,true,,,,2026-05-22,,,,,
+docs/recipes/flip_and_rotate.ipynb,ipynb,true,,,,,,,,,
+docs/recipes/index.md,md,false,,,,,,,,,
+docs/recipes/installTips.md,md,true,online,outdated,archive,,,,Should be removed in favor of the main installation guide.,,
+docs/recipes/io.md,md,true,,,,,,,,,
+docs/recipes/nn.md,md,true,,,,,,,,,
+docs/recipes/pose_cfg_file_breakdown.md,md,true,,,,,,,,,
+docs/recipes/post.md,md,true,,,,,,,,,
+docs/recipes/publishing_notebooks_into_the_DLC_main_cookbook.md,md,true,online,review_needed,verify,,,,"Slightly redundant with CONTRIBUTING.md, style may need adjusted based on the rest of the repo.",,
+docs/roadmap.md,md,true,,,,,,,,,
+docs/standardDeepLabCut_UserGuide.md,md,true,online,review_needed,update,,,,"This is a crucial piece of the doc, but it is rather long and verbose. Recommend breaking it up into smaller sections, and adding more visuals (e.g. screenshots of the GUI, etc.) to make it more engaging and easier to read. Also, consider adding a table of contents at the beginning for easier navigation.",,
+examples/COLAB/COLAB_3miceDemo.ipynb,ipynb,true,,,,2026-05-11,,,"This notebook is a demo for the TensorFlow-based pipeline described in the Nature Methods publication (https://doi.org/10.1038/s41592-022-01443-0). However, the corresponding DeepLabCut version does not run anymore on Colab (which requires Python >= 3.11). The notebook has been adapted to use the latest TensorFlow-enabled version of DeepLabCut (namely, 3.0), which supports both PyTorch and TensorFlow.",,
+examples/COLAB/COLAB_BUCTD_and_CTD_tracking.ipynb,ipynb,true,,,,,,,,,
+examples/COLAB/COLAB_DEMO_SuperAnimal.ipynb,ipynb,true,,,,2026-05-11,,,,,
+examples/COLAB/COLAB_DEMO_mouse_openfield.ipynb,ipynb,true,,,,2026-05-11,,,,,
+examples/COLAB/COLAB_DLC_ModelZoo.ipynb,ipynb,true,,,,2026-05-11,,,,,
+examples/COLAB/COLAB_HumanPose_with_RTMPose.ipynb,ipynb,false,,,,,,,,,
+examples/COLAB/COLAB_YOURDATA_SuperAnimal.ipynb,ipynb,true,,,,2026-05-11,,,,,
+examples/COLAB/COLAB_YOURDATA_TrainNetwork_VideoAnalysis.ipynb,ipynb,true,,,,2026-05-11,,,"This notebook demos the primary generic/PyTorch API for single-animal DeepLabCut projects. Note that it is a bit outdated and may need revisions after dropping TensorFlow support. Also it does not reflect any of the planned refactors currently in the works (e.g. structured configs, keypoints).",,
+examples/COLAB/COLAB_YOURDATA_maDLC_TrainNetwork_VideoAnalysis.ipynb,ipynb,true,,,,2026-05-11,,,"This notebook demos the primary generic/PyTorch API for multi-animal DeepLabCut (maDLC) projects. Note that it may need revisions after dropping TensorFlow support. And does not reflect any of the planned refactors currently in the works (e.g. structured configs, keypoints).",,
+examples/COLAB/COLAB_transformer_reID.ipynb,ipynb,false,,,,,,,,,
+examples/JUPYTER/Demo_3D_DeepLabCut.ipynb,ipynb,true,,,,,,,,,
+examples/JUPYTER/Demo_labeledexample_MouseReaching.ipynb,ipynb,true,,,,,,,,,
+examples/JUPYTER/Demo_labeledexample_Openfield.ipynb,ipynb,true,,,,,,,,,
+examples/JUPYTER/Demo_napari.ipynb,ipynb,true,,,,,,,,,
+examples/JUPYTER/Demo_yourowndata.ipynb,ipynb,true,,,,,,,,,
+examples/JUPYTER/Docker_TrainNetwork_VideoAnalysis.ipynb,ipynb,true,,,,,,,,,
+tools/README.md,md,false,,,,,,,,,
+tools/docs_and_notebooks_tool_README.md,md,false,,,,,,,,,
+tools/ruff_cleanup_helpers.md,md,false,,,,,,,,,
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 100644
index 0000000000..5083d77d6a
--- /dev/null
+++ b/tools/test_selector.py
@@ -0,0 +1,1032 @@
+#!/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,
+ FUNC_SCRIPT_DEPENDENCIES,
+ 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,
+ FUNC_SCRIPT_DEPENDENCIES,
+ 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 resolve_functional_scripts(
+ selected: set[str],
+) -> tuple[list[str], dict[str, set[str]]]:
+ """Expand and dependency-order functional scripts.
+
+ Returns:
+ ordered
+ Selected scripts and their transitive dependencies, with each
+ dependency appearing before scripts that depend on it.
+ dependency_sources
+ Provenance entries for every dependency added during expansion.
+ """
+ ordered: list[str] = []
+ dependency_sources: dict[str, set[str]] = defaultdict(set)
+ visited: set[str] = set()
+ visiting: set[str] = set()
+
+ def visit(script: str) -> None:
+ if script in visited:
+ return
+
+ if script in visiting:
+ raise ValueError(f"Cyclic functional-script dependency involving {script!r}")
+
+ visiting.add(script)
+
+ for dependency in FUNC_SCRIPT_DEPENDENCIES.get(
+ script,
+ (),
+ ):
+ dependency_sources[dependency].add(f"dependency:{script}")
+ visit(dependency)
+
+ visiting.remove(script)
+ visited.add(script)
+ ordered.append(script)
+
+ for script in sorted(selected):
+ visit(script)
+
+ return ordered, dependency_sources
+
+
+def order_functional_scripts(
+ selected: set[str],
+) -> list[str]:
+ """Expand and order functional scripts after their dependencies."""
+ ordered: list[str] = []
+ visited: set[str] = set()
+ visiting: set[str] = set()
+
+ def visit(script: str) -> None:
+ if script in visited:
+ return
+
+ if script in visiting:
+ raise ValueError(f"Cyclic functional-script dependency involving {script!r}")
+
+ visiting.add(script)
+
+ for dependency in FUNC_SCRIPT_DEPENDENCIES.get(
+ script,
+ (),
+ ):
+ visit(dependency)
+
+ visiting.remove(script)
+ visited.add(script)
+ ordered.append(script)
+
+ for script in sorted(selected):
+ visit(script)
+
+ return ordered
+
+
+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
+
+ ordered_functional_scripts, dependency_sources = resolve_functional_scripts(functional_set)
+ for script, srcs in dependency_sources.items():
+ script_sources[script].update(srcs)
+
+ return SelectorResult(
+ lanes=lanes,
+ pytest_paths=sorted(pytest_paths_set),
+ functional_scripts=ordered_functional_scripts,
+ provenance=SelectionProvenance(
+ pytest={path: sorted(sources) for path, sources in sorted(pytest_sources.items())},
+ scripts={path: sorted(sources) for path, sources 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..d9116cb122
--- /dev/null
+++ b/tools/test_selector_config.py
@@ -0,0 +1,345 @@
+"""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]
+
+FUNC_SCRIPT_DEPENDENCIES: dict[str, tuple[str, ...]] = {
+ "examples/testscript_3d.py": ("examples/testscript_tensorflow_single_animal.py",),
+}
+
+
+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
new file mode 100644
index 0000000000..ca5381deb2
--- /dev/null
+++ b/tools/update_license_headers.py
@@ -0,0 +1,63 @@
+"""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.
+"""
+
+import fnmatch
+import glob
+import subprocess
+import tempfile
+
+import yaml
+
+
+def load_config(filename):
+ with open(filename) as fh:
+ config = yaml.safe_load(fh)
+ return config
+
+
+def walk_directory(entry):
+ """Talk the directory."""
+
+ if "header" not in entry:
+ raise ValueError("Current entry does not have a header.")
+ if "include" not in entry:
+ raise ValueError("Current entry does not have an include list.")
+
+ def _list_include():
+ """List all files specified in the include list."""
+ for include_pattern in entry["include"]:
+ yield from glob.iglob(include_pattern, recursive=True)
+
+ def _filter_exclude(iterable):
+ """Filter filenames from an iterator by the exclude patterns."""
+ for filename in iterable:
+ for exclude_pattern in entry.get("exclude", []):
+ if fnmatch.fnmatch(filename, exclude_pattern):
+ break
+ else:
+ yield filename
+
+ files = _filter_exclude(set(_list_include()))
+ return list(files)
+
+
+def main(input_file="NOTICE.yml"):
+ config = load_config(input_file)
+ for entry in config:
+ filelist = list(walk_directory(entry))
+ with tempfile.NamedTemporaryFile(mode="w") as header_file:
+ header_file.write(entry["header"])
+ header_file.flush()
+ header_file.seek(0)
+ command = ["licenseheaders", "-t", str(header_file.name), "-f"] + filelist
+ result = subprocess.run(command, capture_output=True)
+ if result.returncode != 0:
+ print(result.stdout.decode())
+ print(result.stderr.decode())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000000..d6da389dad
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,9006 @@
+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 = "backrefs"
+version = "7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" },
+ { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" },
+ { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" },
+]
+
+[[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 = "black"
+version = "26.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "mypy-extensions" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+ { name = "pytokens" },
+ { 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 = "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/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" },
+ { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" },
+ { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" },
+ { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" },
+ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" },
+]
+
+[[package]]
+name = "bleach"
+version = "6.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" },
+]
+
+[package.optional-dependencies]
+css = [
+ { name = "tinycss2" },
+]
+
+[[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 = "cairocffi"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" },
+]
+
+[[package]]
+name = "cairosvg"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cairocffi" },
+ { name = "cssselect2" },
+ { name = "defusedxml" },
+ { name = "pillow" },
+ { name = "tinycss2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/07/e8412a13019b3f737972dea23a2c61ca42becafc16c9338f4ca7a0caa993/cairosvg-2.9.0.tar.gz", hash = "sha256:1debb00cd2da11350d8b6f5ceb739f1b539196d71d5cf5eb7363dbd1bfbc8dc5", size = 40877, upload-time = "2026-03-13T15:42:00.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl", hash = "sha256:4b82d07d145377dffdfc19d9791bd5fb65539bb4da0adecf0bdbd9cd4ffd7c68", size = 45962, upload-time = "2026-03-14T13:56:33.512Z" },
+]
+
+[[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 = "cssselect2"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tinycss2" },
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
+]
+
+[[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 (sys_platform != 'darwin' 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')" },
+]
+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.3.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" },
+]
+
+[package.optional-dependencies]
+cublas = [
+ { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+cudart = [
+ { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+cufft = [
+ { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+cufile = [
+ { name = "nvidia-cufile", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' 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') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' 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-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')" },
+]
+cupti = [
+ { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+curand = [
+ { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+cusolver = [
+ { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+cusparse = [
+ { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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 = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+nvjitlink = [
+ { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+nvrtc = [
+ { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+nvtx = [
+ { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-apple-mchips') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-fmpose3d') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-10-deeplabcut-tf') 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 == 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-cu12') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (sys_platform != 'linux' and sys_platform != 'win32' 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-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 (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')" },
+]
+
+[[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.1"
+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.13.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.28.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'" },
+]
+dev-docs = [
+ { name = "black" },
+ { name = "mike" },
+ { name = "mkdocs" },
+ { name = "mkdocs-api-autonav" },
+ { name = "mkdocs-autorefs" },
+ { name = "mkdocs-jupyter" },
+ { name = "mkdocs-material", extra = ["imaging"] },
+ { name = "mkdocstrings", extra = ["python"] },
+]
+docs = [
+ { name = "jupyter-book" },
+ { 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" },
+]
+gui-dev = [
+ { name = "pytest-qt" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "albumentations", specifier = "<=1.4.3" },
+ { name = "black", marker = "extra == 'dev-docs'", specifier = ">=24" },
+ { 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 = "mike", marker = "extra == 'dev-docs'", specifier = ">=2.1" },
+ { name = "mkdocs", marker = "extra == 'dev-docs'", specifier = ">=1.6" },
+ { name = "mkdocs-api-autonav", marker = "extra == 'dev-docs'", specifier = ">=0.1" },
+ { name = "mkdocs-autorefs", marker = "extra == 'dev-docs'", specifier = ">=1.2" },
+ { name = "mkdocs-jupyter", marker = "extra == 'dev-docs'", specifier = ">=0.25" },
+ { name = "mkdocs-material", extras = ["imaging"], marker = "extra == 'dev-docs'", specifier = ">=9.5" },
+ { name = "mkdocstrings", extras = ["python"], marker = "extra == 'dev-docs'", specifier = ">=0.27" },
+ { 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 = "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 = "platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'gui'", specifier = "==3.1" },
+ { name = "qdarkstyle", marker = "(platform_machine != 'x86_64' and extra == 'gui') or (sys_platform != 'linux' and 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", "dev-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" },
+]
+gui-dev = [{ name = "pytest-qt" }]
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
+[[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.13.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "torchvision", version = "0.28.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 = "ghp-import"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
+]
+
+[[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 = "griffelib"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
+]
+
+[[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", marker = "sys_platform == 'darwin' or extra == 'extra-10-deeplabcut-tf' or extra == 'extra-10-deeplabcut-tf-cu11' or extra == 'extra-10-deeplabcut-tf-cu12' or extra == 'extra-10-deeplabcut-tf-latest'" },
+]
+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 = "jupyterlab-pygments"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" },
+]
+
+[[package]]
+name = "jupytext"
+version = "1.19.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "mdit-py-plugins" },
+ { name = "nbformat" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { 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/ef/2d/15624c3d9440d85a280ff13d2d23afd989802f25470ac59932f4fef6f0c6/jupytext-1.19.3.tar.gz", hash = "sha256:713c3ed4441afe0f31474d28ea2e6b61a268c04c40fd78e5ccfd7f7ac9e9f766", size = 4305350, upload-time = "2026-05-17T09:09:29.294Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/ec/d9be3bd1db141e76b2f525c265f70e66edd30a51a3307d8edf0ef1909c54/jupytext-1.19.3-py3-none-any.whl", hash = "sha256:acf75492f80895ad8e664fd8db1708b617008dd0e71c341a1abc3d0d07310ed0", size = 170579, upload-time = "2026-05-17T09:09:27.478Z" },
+]
+
+[[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 = "mergedeep"
+version = "1.3.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
+]
+
+[[package]]
+name = "mike"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "mkdocs" },
+ { name = "pyparsing" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "verspec" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" },
+]
+
+[[package]]
+name = "mistune"
+version = "3.2.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/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { 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 = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mergedeep" },
+ { name = "mkdocs-get-deps" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
+]
+
+[[package]]
+name = "mkdocs-api-autonav"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+ { name = "mkdocstrings-python" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/b0/20960ee733a419a349877d09712d02e8ec2bda031672e5f0d0a15fc020b3/mkdocs_api_autonav-0.4.0.tar.gz", hash = "sha256:3527b0e5cf1b682bd374a3ce699ac12d6288f5fcaf93877f34a6b14c79740637", size = 17987, upload-time = "2025-09-09T12:42:02.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/37/e1413281aec69994a0ecb8baaff523b7b7da3119ae7d495b7dc659e630b0/mkdocs_api_autonav-0.4.0-py3-none-any.whl", hash = "sha256:87474e7919664fca75648a05e79de238dd5b39a0f711910d3638626b016acfe3", size = 13130, upload-time = "2025-09-09T12:42:00.731Z" },
+]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "1.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
+]
+
+[[package]]
+name = "mkdocs-get-deps"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mergedeep" },
+ { name = "platformdirs" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
+]
+
+[[package]]
+name = "mkdocs-jupyter"
+version = "0.26.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ipykernel" },
+ { name = "jupytext" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material" },
+ { name = "nbconvert" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/aa/f8d15409a9a3112486994a80d5a975694c7d145c4f8b5b484aeb383420ef/mkdocs_jupyter-0.26.3.tar.gz", hash = "sha256:e1e8bd48a1b96542e84e3028e3066112bac7b94d95ab69f8b91305c84003ca26", size = 1628353, upload-time = "2026-04-17T18:56:31.517Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl", hash = "sha256:cd6644fb578131157194d750fd4d10fc2fd8f1e84e00036ee62df3b5b4b84c82", size = 1459740, upload-time = "2026-04-17T18:56:30.031Z" },
+]
+
+[[package]]
+name = "mkdocs-material"
+version = "9.7.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "babel" },
+ { name = "backrefs" },
+ { name = "colorama" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material-extensions" },
+ { name = "paginate" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" },
+]
+
+[package.optional-dependencies]
+imaging = [
+ { name = "cairosvg" },
+ { name = "pillow" },
+]
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+ { name = "mkdocs-autorefs" },
+ { name = "pymdown-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" },
+]
+
+[package.optional-dependencies]
+python = [
+ { name = "mkdocstrings-python" },
+]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "2.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+ { name = "mkdocs-autorefs" },
+ { name = "mkdocstrings" },
+ { 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/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" },
+]
+
+[[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.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.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.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" },
+ { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" },
+ { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" },
+ { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" },
+ { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" },
+ { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" },
+ { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" },
+ { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
+ { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
+ { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
+ { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
+ { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
+ { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
+ { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
+ { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" },
+ { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" },
+ { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" },
+ { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" },
+ { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" },
+ { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" },
+]
+
+[[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 = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[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 = "nbconvert"
+version = "7.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "beautifulsoup4" },
+ { name = "bleach", extra = ["css"] },
+ { name = "defusedxml" },
+ { name = "jinja2" },
+ { name = "jupyter-core" },
+ { name = "jupyterlab-pygments" },
+ { name = "markupsafe" },
+ { name = "mistune" },
+ { name = "nbclient" },
+ { name = "nbformat" },
+ { name = "packaging" },
+ { name = "pandocfilters" },
+ { name = "pygments" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" },
+]
+
+[[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 (sys_platform != 'darwin' 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')" },
+]
+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 (sys_platform != 'darwin' 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')" },
+]
+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 (sys_platform != 'darwin' 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')" },
+]
+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 (sys_platform != 'darwin' 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-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 (sys_platform != 'darwin' 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-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 (sys_platform != 'darwin' 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')" },
+]
+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 (sys_platform != 'darwin' 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')" },
+]
+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 = "paginate"
+version = "0.5.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+]
+
+[[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 = "pandocfilters"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" },
+]
+
+[[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 = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[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.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/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 = "pymdown-extensions"
+version = "10.21.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
+]
+
+[[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 = "pytest-qt"
+version = "4.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pluggy" },
+ { name = "pytest" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" },
+]
+
+[[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 = "pytokens"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" },
+ { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" },
+ { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" },
+ { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
+ { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
+ { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
+ { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
+]
+
+[[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 = "pyyaml-env-tag"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
+]
+
+[[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.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
+]
+
+[[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.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.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 != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and platform_machine != 'aarch64' 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 != 'aarch64' 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 platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.12' and platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.12' and platform_machine == 'aarch64' 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-cu11') or (python_full_version >= '3.12' and sys_platform == 'darwin' 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 != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' 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') 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 == 'aarch64' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-apple-mchips' 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-apple-mchips' 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-apple-mchips' 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-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' 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-apple-mchips' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'aarch64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine == 'x86_64' and extra == 'extra-10-deeplabcut-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine == 'aarch64' 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') or (platform_machine == 'x86_64' 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') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra != 'extra-10-deeplabcut-apple-mchips' 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-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 == '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-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-cu12' 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 = "tensorflow-io-gcs-filesystem", version = "0.37.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf-cu11') or (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 == 'darwin' and extra != 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (platform_machine != 'aarch64' and 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 != 'aarch64' and 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 != 'aarch64' and 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 != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-latest') or (platform_machine != 'aarch64' and 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 != 'aarch64' and 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 != 'aarch64' and 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-cu11') 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-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-cu12') 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-cu12' and extra == 'extra-10-deeplabcut-tf-latest') 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')" },
+ { 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 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 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.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.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/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 = "sys_platform == 'darwin'" },
+ { name = "wheel", marker = "sys_platform == 'darwin'" },
+]
+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.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 = "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.13.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.28.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 = "tinycss2"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
+]
+
+[[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.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.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 = "(python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.15' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' 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-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-fmpose3d' 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-fmpose3d' 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-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12' and extra == 'extra-10-deeplabcut-tf-latest')" },
+ { name = "cuda-toolkit", extra = ["cublas", "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-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.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-apple-mchips') or (python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-fmpose3d') or (python_full_version < '3.15' and sys_platform == 'linux' and extra == 'extra-10-deeplabcut-tf') or (python_full_version < '3.15' and sys_platform == 'linux' and extra != 'extra-10-deeplabcut-tf-cu11' and extra != 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-apple-mchips' and extra == 'extra-10-deeplabcut-tf-latest') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-fmpose3d' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu11') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' and extra == 'extra-10-deeplabcut-tf-cu12') or (python_full_version >= '3.15' and extra == 'extra-10-deeplabcut-tf' 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-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-fmpose3d' 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-fmpose3d' 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-fmpose3d' and extra != 'extra-10-deeplabcut-tf' and 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/7f/e7/19894fdb51c7dbaf94f5a79bb0871da0992e8e4241e579cb006da46d2e58/torch-2.13.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:94f0de129916f77b8dc2c7a8eff644cfeddfe59e39c9f55e9f6e17543410281d", size = 111178962, upload-time = "2026-07-08T16:05:49.855Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/5c/b1d5de470c54e339b30a92d96683a71bcebd78f5f2a7fc714cd6dc6bbd68/torch-2.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0ab4b69f3ee03a62a002cfbf77b1ca5e88aceb4ea64cb4388bb28f638ddbb045", size = 427198333, upload-time = "2026-07-08T16:05:36.847Z" },
+ { url = "https://files.pythonhosted.org/packages/50/c0/68a84105e1fcb8970144b388ff3d3e5dc15a3be28c1e247841f7d7247e41/torch-2.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c78b7b4d04461855a764cf01bae9a462bb88bc93defcfa11235cbc8fdf3e12c4", size = 526555154, upload-time = "2026-07-08T16:05:06.507Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/c9/0bb9d097b03cbaf96bb75b15e867347b8e41bfcdfe0539452d17d9e63993/torch-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:2bd30b6b730d987fa386ce3898933762c5cb8cc82eb0535211d787cc3ce2dfeb", size = 122015602, upload-time = "2026-07-08T16:05:45.25Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/59/1e3160e18e12aa3038390efab3ce02b36a9d4d6a527ecdd8520dca2e68d8/torch-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c", size = 427199369, upload-time = "2026-07-08T16:04:51.054Z" },
+ { url = "https://files.pythonhosted.org/packages/01/79/1f2d34ad7034ee1c7ffc1cf8bf0f8213af2a81df6ecdb3997ecec107c09d/torch-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7", size = 526574961, upload-time = "2026-07-08T16:04:07.075Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fd/0f2ce40f58aefbdb3392f9acce3c8171940943ae2d661f70558bfa73befb/torch-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330", size = 122015870, upload-time = "2026-07-08T16:05:27.59Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" },
+ { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" },
+ { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" },
+ { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" },
+ { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" },
+ { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" },
+]
+
+[[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.28.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.13.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/b4/df/1ba039ad6cfe6e69209c36766b9b6e8c6fe92481c6d4e4ca52296f5f699d/torchvision-0.28.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:2a1ef4b6f4bf5828b48cfad97372c8982db906830884b2868ba5c3df937a7d81", size = 1856019, upload-time = "2026-07-08T16:07:59.283Z" },
+ { url = "https://files.pythonhosted.org/packages/88/ea/5c70ecf86f8e95174a85061cea78683a7bb7f422f09c3f3d4f30b7600fa9/torchvision-0.28.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:546fd85345cf8652f6cd099d4f9884b0ca5c2f3fae78689a21dd2f35ea6b622f", size = 7838211, upload-time = "2026-07-08T16:07:27.023Z" },
+ { url = "https://files.pythonhosted.org/packages/46/22/2f7ff1997d793e45d85fafa8374ee25348b7dae9ac521ba8751d7e1c75d5/torchvision-0.28.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6dfb0f45e2b4ceb4e76f158c3fbb5f44387099f3c466e3423a09ab665a194aba", size = 7669419, upload-time = "2026-07-08T16:07:41.648Z" },
+ { url = "https://files.pythonhosted.org/packages/42/d0/2b3c30834ff23acd3854d0ff59bc580711f4b36d725de40105a852ed3719/torchvision-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:7fad44dc9582570c7d92c4487d36ac46998f40cc39b438e8b8f5111a935ce4e8", size = 3500355, upload-time = "2026-07-08T16:07:56.865Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b2/1e010052079e4c577007b789db336ea7075f1a426e84d17121fbc3745516/torchvision-0.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8", size = 1856017, upload-time = "2026-07-08T16:07:55.533Z" },
+ { url = "https://files.pythonhosted.org/packages/27/be/1b9c5de9c655ca2df4a74100fa671a7b848532ff787e077ccde14a7dea2a/torchvision-0.28.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5a38bc6da3d72621be003400b66f66a2b4c6d644fde05f680c2cb7ca8cf8dd6c", size = 7841822, upload-time = "2026-07-08T16:07:49.207Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/9b/f1e68e861d4462e3e195a642c2b448e7b7d3fad5f209487162b9a2133d9b/torchvision-0.28.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7e80f543b22503d9415e126db5f0ff3917036925e38560ee6b9ae38c571a4002", size = 7670718, upload-time = "2026-07-08T16:07:46.525Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/de/1494610ff54cbb154beb55033cc2cd50f3de04dac132fa2dd00e4f2b2556/torchvision-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:9a45ea67235d965ef52187130d20002a4de20c54ea3d927a24286961d268dc37", size = 3814319, upload-time = "2026-07-08T16:07:37.153Z" },
+ { url = "https://files.pythonhosted.org/packages/15/49/c1cab1ecbb3ff1a380a3f99283db1dee61b8afe354f6352c643b65937130/torchvision-0.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e9f54c30cd52e3ef7fd034cc69b7bb7e0964e1c8f8743e018ab92e95b40f9eee", size = 1856020, upload-time = "2026-07-08T16:07:52.182Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/4c/95233776e2def960e5abb7a07931230a545f43717a56a1e1140162033598/torchvision-0.28.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5cf78ebc401ce64ae19b8c55de866bb836797d559a4de9c25ccbe74cfa642d3a", size = 7842127, upload-time = "2026-07-08T16:07:53.446Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e4/e9b2495d0d57b9f60d63c57d0a910410a81b4b073bf70917bef815291119/torchvision-0.28.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:028a3d481b37d785605620d7cdad897064c5a55bae2aa1f2658766333e291940", size = 7675040, upload-time = "2026-07-08T16:07:58.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/9c/55ed9cb6dfe3ee9c837df5cd0e758372e5829aa38b8dd71343aa632cc4e2/torchvision-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:87dc16b2df427c1318ad335f1e2be2b3b15b2cf20f7934c83b0505a48425ee5d", size = 4085785, upload-time = "2026-07-08T16:07:50.928Z" },
+ { url = "https://files.pythonhosted.org/packages/20/55/08a726c14c67b37c8aca04b077766909f1c7ed23f76116884fe63b9bd033/torchvision-0.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:d483b4aa3f5237569053f749cd1a2b5bb548ca456e40461a5dd087f21149d123", size = 1856021, upload-time = "2026-07-08T16:07:45.386Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/40beacd53809194f5259e590d1afaeaa8ad57da15f77c646e6560bcc4616/torchvision-0.28.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:bb6dd6918460ed89cc7644adcc2402991474d6933cf1ce92b390641cb233fddf", size = 7797014, upload-time = "2026-07-08T16:07:43.04Z" },
+ { url = "https://files.pythonhosted.org/packages/32/db/062cdb5a84380a60439775311fff34d89229760d2a50680393dc18699956/torchvision-0.28.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad7b3a439265cc3739a4ab5b4c998c0e38ea99c0ee7ca4dea35c5d0b099ec237", size = 7674669, upload-time = "2026-07-08T16:07:38.91Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/a6/b4081e2d04e1541abf82785ac9e5178a494c19330391f551356c8c18b7b3/torchvision-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b", size = 4157380, upload-time = "2026-07-08T16:07:40.22Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b9/da40eca5bbe9596c12ae9899ab7abaf887f5e20f29d08b924b4633714821/torchvision-0.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3bd9dba55224a9db4a2d77f6feaa5651770d8c8e86d3d0ddb0fa6bec54c8712b", size = 1856014, upload-time = "2026-07-08T16:07:44.282Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d6/313aafd3df4eaf5f330211bd4e75b7598bddbfee4f55580d3b58536e1b20/torchvision-0.28.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:89f90e29b0966352811b12589f3a3c61943bf2bb9487b9d7bbec10efb1096bb5", size = 7796873, upload-time = "2026-07-08T16:07:30.907Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/41/31f8e959ab8f942600b6357f8999c21d779d5fd3304b0fd204ff4b518239/torchvision-0.28.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:36beb0782976906069ca03d4c9aacaf4b6b838b06ed6c20960ea9c51cce7acdd", size = 7674634, upload-time = "2026-07-08T16:07:29.657Z" },
+ { url = "https://files.pythonhosted.org/packages/15/15/4c5115253fd470672cdac0a1cf139e06b4f3e29d041238a2b255937f63be/torchvision-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:3557cc7b539f46dabcda2b6f2b14017ccbeef024de466d4fc5835fc3f287f769", size = 4184005, upload-time = "2026-07-08T16:07:35.805Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/80/822a6163da716f8a78141cf6678d74e26a572285d4ea866ef8aa657bb307/torchvision-0.28.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:09ce8f56e81f19b9c378ae7bb109f83f6659fd8bc3cd14241a48e4af46e9ed49", size = 1856011, upload-time = "2026-07-08T16:07:33.404Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/d1/cd3f9463b39a790ec8c0c2f6e6c8061edb1562114d04fcdfa786ed889345/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:62c7d110f86a039245b587e4fae60278c649f3bd42ff79cfbc1178eca4e72542", size = 7796742, upload-time = "2026-07-08T16:07:28.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/82/3e0a7ad18e99831e2d7f4713d3be717b7159ff5a920862dd5c23c454aa71/torchvision-0.28.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:904cf89af220f8c6b2ed0296bb5065b474ce43b77558e48b2bf9de8b0ba17204", size = 7675526, upload-time = "2026-07-08T16:07:34.572Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/23aea03b28297bc66a4461f55ae4296368a9d85fa9a454bafcb2a5348bd7/torchvision-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:46f581979c010ad6da6bd85ee602aa707e1ff44312670223b7a0ee517ad06d47", size = 4291452, upload-time = "2026-07-08T16:07:32.236Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
+ { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
+ { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
+ { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
+ { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
+]
+
+[[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.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 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/ec/ea/629cc37436ca5df93ce98956d09cd2ca1498bfee8ef4972d2fe48b9f958c/triton-3.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64", size = 184551013, upload-time = "2026-06-17T20:03:37.551Z" },
+ { url = "https://files.pythonhosted.org/packages/15/76/c79c34311625227a288df3e483fc5cdf3d596624cbd4b4758c4cbdc14af3/triton-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e", size = 197596267, upload-time = "2026-06-17T19:53:06.898Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" },
+ { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" },
+ { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" },
+ { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" },
+]
+
+[[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 = "verspec"
+version = "0.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
+]
+
+[[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 = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
+ { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
+ { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
+ { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
+ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
+ { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[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 = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
+[[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" },
+]
|