diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..667744e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,86 @@ +name: Build EXE on PR + +# Run only on pull requests targeting develop or main +on: + pull_request: + branches: [develop, main] + +# Concurrency: one run per commit in a PR +concurrency: + group: pr-exe-${{ github.event.pull_request.head.sha }} + cancel-in-progress: true + +# Least-privilege permissions +permissions: + contents: read + +# Global environment variables +env: + PYTHON_VERSION: "3.12" + APP_NAME: "uq_desktop_processor" + SPEC_PATH: "UrbanQualityAI.spec" + +# Job: build Windows EXE with PyInstaller +jobs: + build-windows-exe: + name: Build Windows EXE (PyInstaller) + runs-on: windows-latest + timeout-minutes: 30 + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONDONTWRITEBYTECODE: "1" + + steps: + # Get repository code + - name: Checkout + uses: actions/checkout@v4 + + # Install chosen Python version + cache pip + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: | + pyproject.toml + poetry.lock + + # Install all dependencies except CLIP, which needs a legacy-style build path. + - name: Install base deps (without CLIP) + shell: pwsh + run: | + python -m pip install --upgrade pip "setuptools<81" wheel + Get-Content requirements.txt | Where-Object { $_ -notmatch '^clip @ ' } | Set-Content requirements-ci.txt + Get-Content requirements-dev.txt | Where-Object { $_ -notmatch '^clip @ ' } | Set-Content requirements-dev-ci.txt + pip install -r requirements-ci.txt -r requirements-dev-ci.txt + + # Install CLIP separately without isolated PEP 517 build and without overriding pinned deps. + - name: Install CLIP separately + run: pip install --no-deps --no-build-isolation "clip @ git+https://github.com/openai/CLIP.git@dcba3cb2e2827b402d2701e7e1c7d9fed8a20ef1" + + # Ensure PyInstaller exists (exe builder). + - name: Install PyInstaller + run: python -m pip install pyinstaller + + # Verify .spec file exists, otherwise fail with error + - name: Verify .spec exists + run: | + if (-not (Test-Path "${{ env.SPEC_PATH }}")) { + Write-Error "Missing PyInstaller spec file '${{ env.SPEC_PATH }}'" + } + + # Build EXE using PyInstaller with the .spec file + # Then list the contents of dist/ for debugging/logging + - name: Build exe with PyInstaller (.spec only) + run: | + python -m PyInstaller --noconfirm --clean "${{ env.SPEC_PATH }}" + if (Test-Path dist) { Get-ChildItem -Recurse dist | Format-Table -AutoSize } + + # Upload the dist/ folder as artifact for download + - name: Upload artifact (dist) + uses: actions/upload-artifact@v4 + with: + name: ${{ env.APP_NAME }}-dist + path: dist/** + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7cad9b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +# Triggers: on any push (all branches), ignore tags, also on PRs +on: + push: + branches: ["**"] + tags-ignore: ["*"] + pull_request: + +# Concurrency: one run per branch/PR, cancel old if new starts +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege permissions for this workflow +permissions: + contents: read + +# Global environment +env: + PYTHON_VERSION: "3.12" + +# Job: linting, type checks, and tests +jobs: + checks: + name: Lint, types & tests + runs-on: windows-latest + timeout-minutes: 15 + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONDONTWRITEBYTECODE: "1" + + steps: + # Get repository code + - name: Checkout + uses: actions/checkout@v4 + + # Install chosen Python version + enable pip cache + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: | + pyproject.toml + poetry.lock + + # Install Poetry itself (via pip) + - name: Install Poetry + run: python -m pip install --upgrade pip poetry + + # Keep virtualenv inside repo for easier caching + - name: Enable in-project venv for Poetry + run: poetry config virtualenvs.in-project true + + # Cache the .venv folder based on lockfile + Python version + - name: Cache Poetry venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('poetry.lock') }} + restore-keys: | + venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}- + + # Install all dependencies except CLIP, which needs a legacy-style build path. + - name: Install base deps (without CLIP) + shell: pwsh + run: | + python -m pip install --upgrade pip "setuptools<81" wheel + Get-Content requirements.txt | Where-Object { $_ -notmatch '^clip @ ' } | Set-Content requirements-ci.txt + Get-Content requirements-dev.txt | Where-Object { $_ -notmatch '^clip @ ' } | Set-Content requirements-dev-ci.txt + pip install -r requirements-ci.txt -r requirements-dev-ci.txt + + # Install CLIP separately without isolated PEP 517 build and without overriding pinned deps. + - name: Install CLIP separately + run: pip install --no-deps --no-build-isolation "clip @ git+https://github.com/openai/CLIP.git@dcba3cb2e2827b402d2701e7e1c7d9fed8a20ef1" + + # Run linter (Ruff) with GitHub-friendly output + - name: Ruff (lint) + run: poetry run ruff check --output-format=github . + + # Run formatter (Black) in check-only mode, with diff if fails + - name: Black (format check) + run: poetry run black --check --diff . + + # Cache mypy cache folder to speed up type checking + - name: Cache mypy + uses: actions/cache@v4 + with: + path: .mypy_cache + key: mypy-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + mypy-${{ runner.os }}-${{ env.PYTHON_VERSION }}- + + # Run static type checker (Mypy) with auto-install types + - name: Mypy (type check) + continue-on-error: true + run: poetry run mypy --install-types --non-interactive --pretty . \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..142e6e9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Build & Publish EXE (Release or Tag) + +# Run workflow when: +# - a release is published in GitHub UI +# - any tag is pushed (tags: ["*"]) +on: + release: + types: [published] + push: + tags: ["*"] + +# Permissions: allow writing release assets to the repository +permissions: + contents: write + +# Concurrency: group runs per tag/release, don't cancel older runs +concurrency: + group: rel-${{ github.event_name == 'release' && github.event.release.tag_name || github.ref_name }} + cancel-in-progress: false + +# Global environment variables +env: + PYTHON_VERSION: "3.12" + APP_NAME: "uq_desktop_processor" + SPEC_PATH: "UrbanQualityAI.spec" + TAG_NAME: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref_name }} + +jobs: + build-and-publish: + name: Build & Publish Windows EXE + runs-on: windows-latest + timeout-minutes: 30 + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PYTHONDONTWRITEBYTECODE: "1" + + steps: + # Get repository code + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # fetch full history (needed for branch ancestry check) + ref: ${{ env.TAG_NAME }} + + # Verify that the tag commit is part of the main branch history (PowerShell) + - name: Verify tag points to commit on main + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'release' + run: | + git fetch origin +refs/heads/main:refs/remotes/origin/main + $TagSha = (git rev-parse HEAD).Trim() + git merge-base --is-ancestor $TagSha origin/main + if ($LASTEXITCODE -ne 0) { + Write-Error "Tag '${{ env.TAG_NAME }}' does not point to a commit on the 'main' branch. Publishing blocked." + } + + # Install a chosen Python version + enable pip cache (helps to install Poetry) + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: | + pyproject.toml + poetry.lock + + # Install Poetry itself (via pip) + - name: Install Poetry + run: python -m pip install --upgrade pip poetry + + # Keep virtualenv inside repo for easier caching + - name: Enable in-project venv for Poetry + run: poetry config virtualenvs.in-project true + + # Cache the .venv folder based on lockfile + Python version + - name: Cache Poetry venv + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('poetry.lock') }} + restore-keys: | + venv-${{ runner.os }}-${{ env.PYTHON_VERSION }}- + + # Install all dependencies (main + dev) from pyproject.toml + - name: Install deps (poetry) + run: poetry install --no-interaction --with dev + + # Verify .spec file exists, otherwise fail with error + - name: Verify .spec exists + run: | + if (-not (Test-Path "${{ env.SPEC_PATH }}")) { + Write-Error "Missing PyInstaller spec file '${{ env.SPEC_PATH }}'" + } + + # Build EXE using PyInstaller with the .spec file + # Then list the contents of dist/ for debugging/logging + - name: Build exe with PyInstaller (.spec only) + run: | + poetry run pyinstaller --noconfirm --clean "${{ env.SPEC_PATH }}" + if (Test-Path dist) { Get-ChildItem -Recurse dist | Format-Table -AutoSize } + + # Sanity check: ensure at least one .exe exists before publishing + - name: Ensure at least one EXE exists + run: | + $files = Get-ChildItem -Path dist -Filter *.exe -Recurse -ErrorAction SilentlyContinue + if (-not $files) { Write-Error "No .exe files found in dist/**" } + + # Package all built EXE files into a single ZIP archive + - name: Package EXE(s) into ZIP + run: | + $zipName = "dist/${{ env.APP_NAME }}-${{ env.TAG_NAME }}.zip" + if (Test-Path $zipName) { Remove-Item $zipName -Force } + $exeFiles = Get-ChildItem -Path dist -Filter *.exe -Recurse | Select-Object -ExpandProperty FullName + if (-not $exeFiles) { Write-Error "No .exe files to zip"; exit 1 } + Compress-Archive -Path $exeFiles -DestinationPath $zipName -Force + Get-ChildItem -Path dist -Filter *.zip -Recurse | ForEach-Object { Write-Host $_.FullName } + + # Generate a SHA256 checksum file for the created ZIP + - name: Create sha256 + run: | + $zipPath = "dist/${{ env.APP_NAME }}-${{ env.TAG_NAME }}.zip" + $hash = Get-FileHash $zipPath -Algorithm SHA256 | Select-Object -ExpandProperty Hash + $hash | Out-File -FilePath "dist/${{ env.APP_NAME }}-${{ env.TAG_NAME }}.sha256" -Encoding ascii + Get-ChildItem -Path dist -Filter *.sha256 -Recurse | ForEach-Object { Write-Host $_.FullName } + + # Publish release metadata on GitHub (create or update release without assets) + - name: Create/Update GitHub Release (no files) + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG_NAME }} + name: ${{ env.TAG_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Upload the generated ZIP file as a release asset + - name: Upload ZIP asset + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ env.TAG_NAME }} + file: dist/${{ env.APP_NAME }}-${{ env.TAG_NAME }}.zip + overwrite: true + file_glob: false + + # Upload the SHA256 checksum file as a release asset + - name: Upload SHA256 asset + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ env.TAG_NAME }} + file: dist/${{ env.APP_NAME }}-${{ env.TAG_NAME }}.sha256 + overwrite: true + file_glob: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..894231d --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Ignore PyCharm project files +.idea/ +*.iml + +# Ignore virtual environment directories +venv/ +env/ +ENV/ +.venv/ +.ENV/ +.venv.bak/ +venv.bak/ + +# Ignore Python bytecode files +__pycache__/ +*.py[cod] +*$py.class + +# Ignore temporary and log files +*.log +*.tmp +*.swp +*.swo +cache + +# Ignore system files (macOS / Windows / Linux) +.DS_Store +Thumbs.db +ehthumbs.db +Icon? +desktop.ini + +# Ignore test-related files +.coverage +.coverage.* +.hypothesis/ +coverage.xml +htmlcov/ +.tox/ +.nox/ +.cache/ +.pytest_cache/ + +# Ignore pip logs +pip-log.txt +pip-delete-this-directory.txt + +# Ignore Jupyter Notebook checkpoints +.ipynb_checkpoints/ + +# Ignore editor-specific configuration files +.vscode/ + +# Secrets and local environment variables +.env +.env.local +.env.* + +# Ignore build artifacts +build/ +dist/ +*.egg-info/ +.eggs/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# ruff +.ruff_cache/ + +# pyright / pylance +.pyright/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2224e9d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# Minimum version of pre-commit required +minimum_pre_commit_version: "3.6.0" + +# Set the default Python version to be used by hooks +default_language_version: + python: python3.12 + +repos: + # Basic pre-commit hooks for common issues (whitespace, newlines, merge conflicts, YAML validity) + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace # Removes trailing whitespace + - id: end-of-file-fixer # Ensures files end with a newline + - id: check-merge-conflict # Detects merge conflict markers + - id: check-yaml # Validates YAML syntax + + # Black formatter for Python code + - repo: https://github.com/psf/black + rev: "25.1.0" + hooks: + - id: black # Automatically formats Python code + + # Ruff linter and code analyzer + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.12.12" + hooks: + - id: ruff # Lints Python code using Ruff + + # Mypy static type checker + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.17.1" + hooks: + - id: mypy # Performs type checking with mypy + pass_filenames: false # Keep behavior aligned with CI (check full project) + args: ["--install-types", "--non-interactive", "--pretty", "."] diff --git a/UrbanQualityAI.spec b/UrbanQualityAI.spec new file mode 100644 index 0000000..323abaf --- /dev/null +++ b/UrbanQualityAI.spec @@ -0,0 +1,75 @@ +# -*- mode: python ; coding: utf-8 -*- + +import os + +from PyInstaller.utils.hooks import collect_data_files, collect_dynamic_libs, collect_submodules + + +hiddenimports = [] + +assets_src = os.path.join("src", "uq_desktop_processor", "assets") +assets_icon = os.path.join(assets_src, "img", "icon.ico") +assets_datas = [] +if os.path.exists(assets_src): + assets_datas.append((assets_src, os.path.join("uq_desktop_processor", "assets"))) + +# Qt WebEngine and friends are notorious for dynamic imports; help PyInstaller a bit. +hiddenimports += collect_submodules("PySide6.QtWebEngineCore") +hiddenimports += collect_submodules("PySide6.QtWebEngineWidgets") +# pyogrio uses compiled extension modules loaded dynamically. +hiddenimports += collect_submodules("pyogrio") + + +a = Analysis( + ["src\\uq_desktop_processor\\__main__.py"], + pathex=["src"], + binaries=[ + # Bundle GDAL-related native libs shipped with the pyogrio wheel. + *collect_dynamic_libs("pyogrio"), + ], + datas=[ + *assets_datas, + # openai/CLIP tokenizer vocab (and other clip assets) must be bundled as data files, + # otherwise the packaged app will crash trying to open `bpe_simple_vocab_16e6.txt.gz`. + *collect_data_files("clip"), + # Include pyogrio package data files required by GDAL/OGR runtime. + *collect_data_files("pyogrio"), + ], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # hook-torch tries to import tensorboard via torch.utils.tensorboard; + # exclude it unless you explicitly depend on TensorBoard. + "tensorboard", + "torch.utils.tensorboard", + ], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="uq_desktop_processor", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=assets_icon if os.path.exists(assets_icon) else None, +) + diff --git a/docs/css/mkdocstrings.css b/docs/css/mkdocstrings.css new file mode 100644 index 0000000..3b16ced --- /dev/null +++ b/docs/css/mkdocstrings.css @@ -0,0 +1,23 @@ +/* + Documentation layout styles for MkDocs Material. + Defines spacing, typography, and table formatting + to improve readability and consistency across docs. +*/ + +/* Add vertical spacing between documentation objects */ +.md-typeset .doc .doc-object { margin: 1.1rem 0; } + +/* Allow function/method signatures to wrap across lines */ +.md-typeset .doc .sig { white-space: normal; word-break: break-word; } + +/* Adjust table cell padding and align text to the top */ +.md-typeset .doc .table th, +.md-typeset .doc .table td { + padding: .5rem .75rem; + vertical-align: top; +} + +/* Apply alternating background color to table rows for readability */ +.md-typeset .doc .table tr:nth-child(odd) td { + background: var(--md-code-bg-color); +} diff --git a/docs/css/theme-variants.css b/docs/css/theme-variants.css new file mode 100644 index 0000000..b4707d4 --- /dev/null +++ b/docs/css/theme-variants.css @@ -0,0 +1,146 @@ +/* + Documentation theme styles for MkDocs Material. + Defines heading sizes, documentation object layout, + code signature blocks, and table formatting. + Includes multiple color/spacing schemes (brand, compact, comfy) + for different presentation preferences. +*/ + +/* Style level-1 headings */ +.md-typeset h1 { + font-size: 1.4rem; + color: var(--md-default-fg-color--light); + font-weight: 800; + letter-spacing: .01em; + margin-top: 1.2rem; + margin-bottom: .6rem; +} + +/* Style level-2 headings */ +.md-typeset h2 { + font-size: 1rem; + color: var(--md-default-fg-color--light); + font-weight: 700; + letter-spacing: .02em; + margin-top: .8rem; + margin-bottom: .4rem; +} + +/* Style level-3 headings */ +.md-typeset h3 { + font-size: 0.9rem; + color: var(--md-default-fg-color--light); + font-weight: 600; + letter-spacing: .01em; + margin-top: .6rem; + margin-bottom: .3rem; +} + +/* Card-like container for documentation objects */ +.md-typeset .doc .doc-object { + margin: 1rem 0 1.25rem; + padding: 1rem 1.25rem; + border: 1px solid var(--md-default-fg-color--lighter); + border-radius: .75rem; + background: var(--md-default-bg-color); + box-shadow: 0 1px 0 var(--md-default-fg-color--lighter); +} + +/* Heading inside a documentation object */ +.md-typeset .doc .doc-object > .doc-heading { + font-weight: 700; + margin-bottom: .5rem; +} + +/* Code signature block inside documentation */ +.md-typeset .doc .sig { + display: block; + white-space: pre-wrap; + word-break: break-word; + background: var(--md-code-bg-color); + padding: .5rem .75rem; + border-radius: .5rem; + line-height: 1.45; +} + +/* Section titles inside documentation */ +.md-typeset .doc .doc-section-title { + font-weight: 800; + text-transform: uppercase; + font-size: .78rem; + letter-spacing: .04em; + color: var(--md-default-fg-color--light); + margin-top: .9rem; + border-top: 1px solid var(--md-default-fg-color--lighter); + padding-top: .5rem; +} + +/* Table base styles */ +.md-typeset .doc .table table { + width: 100%; + border-collapse: collapse; +} +.md-typeset .doc .table thead th { + font-weight: 700; + border-bottom: 1px solid var(--md-default-fg-color--lighter); +} +.md-typeset .doc .table th, +.md-typeset .doc .table td { + padding: .45rem .6rem; + vertical-align: top; +} +/* Alternating row background */ +.md-typeset .doc .table tr:nth-child(odd) td { + background: var(--md-code-bg-color); +} + +/* ============================= */ +/* BRAND color scheme */ +/* ============================= */ +[data-md-color-scheme="brand"] { + --md-primary-fg-color: #6e56cf; + --md-accent-fg-color: #00c2a8; + --md-code-bg-color: rgba(110, 86, 207, .08); +} +[data-md-color-scheme="brand"] .md-typeset .doc .doc-object { + border-color: rgba(110, 86, 207, .35); + box-shadow: 0 1px 0 rgba(110, 86, 207, .25); +} + +/* ============================= */ +/* COMPACT color scheme */ +/* ============================= */ +[data-md-color-scheme="compact"] .md-typeset { + font-size: 0.94rem; + line-height: 1.55; +} +[data-md-color-scheme="compact"] .md-typeset .doc .doc-object { + margin: .75rem 0 1rem; + padding: .75rem .9rem; +} +[data-md-color-scheme="compact"] .md-typeset .doc .table th, +[data-md-color-scheme="compact"] .md-typeset .doc .table td { + padding: .35rem .5rem; +} +[data-md-color-scheme="compact"] .md-typeset .sig { + line-height: 1.35; +} + +/* ============================= */ +/* COMFY color scheme */ +/* ============================= */ +[data-md-color-scheme="comfy"] .md-typeset { + font-size: 1.05rem; + line-height: 1.7; +} +[data-md-color-scheme="comfy"] .md-typeset .doc .doc-object { + margin: 1.2rem 0 1.5rem; + padding: 1.1rem 1.35rem; +} +[data-md-color-scheme="comfy"] .md-typeset .doc .table th, +[data-md-color-scheme="comfy"] .md-typeset .doc .table td { + padding: .55rem .75rem; +} +[data-md-color-scheme="comfy"] .md-typeset .sig { + line-height: 1.55; +} diff --git a/docs/gen_ref_pages/__pycache__/config.cpython-312.pyc b/docs/gen_ref_pages/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..f4c45d1 Binary files /dev/null and b/docs/gen_ref_pages/__pycache__/config.cpython-312.pyc differ diff --git a/docs/gen_ref_pages/__pycache__/context.cpython-312.pyc b/docs/gen_ref_pages/__pycache__/context.cpython-312.pyc new file mode 100644 index 0000000..d6485fa Binary files /dev/null and b/docs/gen_ref_pages/__pycache__/context.cpython-312.pyc differ diff --git a/docs/gen_ref_pages/__pycache__/generate.cpython-312.pyc b/docs/gen_ref_pages/__pycache__/generate.cpython-312.pyc new file mode 100644 index 0000000..3c56c4c Binary files /dev/null and b/docs/gen_ref_pages/__pycache__/generate.cpython-312.pyc differ diff --git a/docs/gen_ref_pages/__pycache__/helpers.cpython-312.pyc b/docs/gen_ref_pages/__pycache__/helpers.cpython-312.pyc new file mode 100644 index 0000000..bd3adcb Binary files /dev/null and b/docs/gen_ref_pages/__pycache__/helpers.cpython-312.pyc differ diff --git a/docs/gen_ref_pages/__pycache__/traverse.cpython-312.pyc b/docs/gen_ref_pages/__pycache__/traverse.cpython-312.pyc new file mode 100644 index 0000000..4f29175 Binary files /dev/null and b/docs/gen_ref_pages/__pycache__/traverse.cpython-312.pyc differ diff --git a/docs/gen_ref_pages/config.py b/docs/gen_ref_pages/config.py new file mode 100644 index 0000000..bb20e78 --- /dev/null +++ b/docs/gen_ref_pages/config.py @@ -0,0 +1,60 @@ +from pathlib import Path + +# Global configuration flags and constants +INCLUDE_PRIVATE = True # Whether to include private packages (names starting with "_") +SOURCE_DIR = Path("src") # Root source directory to search for packages + +# Mapping from source subdirectories > human-readable section titles +SECTION_TITLE_MAP = { + "services": "Services", + "threads": "Threads", + "ui": "UI", + "utils": "Utils", +} + +# Explicit ordering for sections when displaying documentation or indexes +SECTION_ORDER = {"Ui": 1, "Services": 2, "Threads": 3, "Utils": 4} + +# File extensions that should be recognized as linkable images +LINKABLE_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".ico", ".gif"} + + +def _is_pkg_dir(path: Path) -> bool: + """ + Check whether a given path points to a valid Python package directory. + + A valid package directory must: + 1. Be a directory. + 2. Contain an __init__.py file. + + :param path: Filesystem path to check. + :return: True if the path is a Python package directory, False otherwise. + """ + return path.is_dir() and (path / "__init__.py").exists() + + +def find_package_dir(include_private: bool = INCLUDE_PRIVATE) -> tuple[Path, str]: + """ + Locate the first valid Python package directory under SOURCE_DIR. + + - Private packages (names starting with "_") can be excluded unless explicitly allowed. + - If multiple candidates exist, the package with the lexicographically smallest name is chosen. + + :param include_private: Whether to allow private packages (default: global INCLUDE_PRIVATE). + :return: Tuple (package_path, package_name). + :raises SystemExit: If no valid package directory is found. + """ + # Gather all package directories under SOURCE_DIR + candidates = [ + package_path + for package_path in SOURCE_DIR.iterdir() + if _is_pkg_dir(package_path) and (include_private or not package_path.name.startswith("_")) + ] + + # No valid package found > stop execution with an error message + if not candidates: + raise SystemExit("No package found in src/. Make sure you have something like " "src/yourpkg/__init__.py") + + # Pick the lexicographically smallest directory (deterministic choice) + package_dir = min(candidates, key=lambda package_path: package_path.name) + return package_dir, package_dir.name diff --git a/docs/gen_ref_pages/context.py b/docs/gen_ref_pages/context.py new file mode 100644 index 0000000..3a841b1 --- /dev/null +++ b/docs/gen_ref_pages/context.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field + +# Record structure: +# - tuple: arbitrary metadata (e.g., file info) +# - tuple[str, ...]: path parts (e.g., ("pkg", "subpkg")) +# - str: identifier/name +# - bool: status flag +Record = tuple[tuple, tuple[str, ...], str, bool] + + +@dataclass +class Context: + """ + Holds the in-memory representation of a package hierarchy. (tree structure) + + Tracks: + - Created folder paths. + - Relationships between parent folders → child directories/modules. + - Records associated with discovered entities. + """ + + # Set of folder paths already created (each as a tuple of path parts). + created_folders: set[tuple[str, ...]] = field(default_factory=set) + + # Map: parent folder > set of child directories. + children_directories: dict[tuple[str, ...], set[tuple[str, ...]]] = field(default_factory=dict) + + # Map: parent folder > list of child modules. + children_modules: dict[tuple[str, ...], list[tuple[str, ...]]] = field(default_factory=dict) + + # Collected records for all discovered items (see Record type alias). + records: list[Record] = field(default_factory=list) + + def ensure_folder(self, parts: list[str]) -> None: + """ + Ensure that a folder path is registered in the context. + + If the folder path is not yet known: + - Add it to created_folders. + - Initialize its entry in children_directories and children_modules. + + :param parts: Path components for the folder (e.g., ["pkg", "subpkg"]). + :return: None + """ + key = tuple(parts) + + if key not in self.created_folders: + self.created_folders.add(key) + self.children_directories.setdefault(key, set()) + self.children_modules.setdefault(key, []) diff --git a/docs/gen_ref_pages/gen_ref_pages.py b/docs/gen_ref_pages/gen_ref_pages.py new file mode 100644 index 0000000..71a0da6 --- /dev/null +++ b/docs/gen_ref_pages/gen_ref_pages.py @@ -0,0 +1,150 @@ +""" +Automatic API Documentation Generator for MkDocs. + +This script integrates with the `mkdocs-gen-files` plugin to dynamically +generate API reference documentation from a Python package located in +the source directory (by default `src/`, but this can be changed in `config.py` +via the `SOURCE_DIR` setting). + +Workflow: +1. Detects the main package directory under `SOURCE_DIR` (logic in `config.py`). +2. Traverses the package structure (using `traverse.py`) to discover + modules, subpackages, and static files. +3. Generates Markdown pages for: + - Each Python module (using `::: module.path` blocks for mkdocstrings). + - Each package/directory (with backlinks, subdirectory lists, + module lists, and static file sections). +4. Stores navigation metadata in a `Context` object (`context.py`). +5. Builds navigation files: + - `reference/index.md` → top-level reference index page, + - `reference/SUMMARY.md` → literate navigation for MkDocs. + +Usage (short form): +- Run this script as part of the MkDocs build process (`mkdocs build` or `mkdocs serve`). +- The script will automatically: + - detect the target package, + - create a documentation tree in the `reference/` directory, + - prepare navigation for integration with MkDocs. + +Usage (with MkDocs): +1) Install required packages: + pip install mkdocs mkdocs-gen-files "mkdocstrings[python]" + +2) Ensure your project layout looks for example like this: + . + ├─ mkdocs.yml + ├─ docs/ + │ └─ gen_ref_pages/ + │ ├─ gen_ref_pages.py # this script + │ ├─ config.py + │ ├─ context.py + │ ├─ generate.py + │ ├─ helpers.py + │ └─ traverse.py + ├─ src/ # or another source dir set in config.SOURCE_DIR + │ └─ /... + +3) Configure `mkdocs.yml` with plugins and navigation, e.g.: + site_name: Your Documentation + plugins: + - search + - gen-files: + scripts: + - docs/gen_ref_pages/gen_ref_pages.py # path to this script + - mkdocstrings: + handlers: + python: + options: + show_source: true + docstring_style: google # or "sphinx"/"numpy", depending on your style + nav: + - Reference: reference/ # generated reference section + +4) Run: + - Live preview: mkdocs serve + - Build static site: mkdocs build + +Customization: +- Configuration is located in `config.py`: + - `SOURCE_DIR`: source directory where the package is located (default: `Path("src")`). + You can change this to any other directory (e.g. `Path("packages")`). + - `INCLUDE_PRIVATE`: whether to include private modules/packages (path parts starting with `_`). + - `SECTION_TITLE_MAP` and `SECTION_ORDER`: control naming and ordering of sections in navigation. + - `LINKABLE_IMAGE_EXTENSIONS`: which static files (in source directories) should be linked as clickable images. + +This script is intended to be run automatically as part of the MkDocs build process. +You can place it anywhere in your repository and reference its path under `gen-files.scripts`. +""" + +import sys +from pathlib import Path + +# Ensure the current directory is on sys.path so local imports work correctly +THIS_DIR = Path(__file__).resolve().parent +THIS_DIR_STR = str(THIS_DIR) +if THIS_DIR_STR not in sys.path: + sys.path.insert(0, THIS_DIR_STR) + +import mkdocs_gen_files # noqa E402 +from config import INCLUDE_PRIVATE, find_package_dir # noqa E402 +from context import Context # noqa E402 +from generate import generate_directory_pages, generate_module_pages # noqa E402 +from traverse import traverse_directories # noqa E402 + + +def _build_nav(package_name: str, ctx: Context) -> None: + """ + Build MkDocs navigation files from collected documentation records. + + - Creates `reference/index.md` with a title and introduction. + - Creates `reference/SUMMARY.md` containing a literate navigation structure. + + :param package_name: Name of the discovered package. + :param ctx: Context holding collected records from traversal. + :return: None + """ + nav = mkdocs_gen_files.Nav() + + # Sort collected records and populate the navigation + for _, display, doc_rel_path, _ in sorted(ctx.records, key=lambda record: record[0]): + nav[display] = doc_rel_path + + # Generate top-level index page + with mkdocs_gen_files.open("reference/index.md", "w") as file_handle: + file_handle.write(f"# Reference – `{package_name}`\n\n") + file_handle.write("This section contains API documentation automatically " "generated from code.\n\n") + + # Generate SUMMARY.md for MkDocs navigation + with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) + + +def main() -> None: + """ + Entry point for documentation generation. + + - Finds the main package under `src/`. + - Traverses directories to collect modules/folders. + - Generates module and directory pages. + - Builds MkDocs navigation files. + + :return: None + """ + package_dir, package_name = find_package_dir(INCLUDE_PRIVATE) + + # Initialize a context object to hold traversal state and records + ctx = Context() + + # Walk through directories and collect module/folder information + traverse_directories(package_dir, package_name, INCLUDE_PRIVATE, ctx) + + # Generate documentation pages for modules and directories + generate_module_pages(package_dir, ctx) + generate_directory_pages(ctx) + + # Build navigation index files + _build_nav(package_name, ctx) + + +# Run the main process when the script is executed +main() diff --git a/docs/gen_ref_pages/generate.py b/docs/gen_ref_pages/generate.py new file mode 100644 index 0000000..2fa6e0b --- /dev/null +++ b/docs/gen_ref_pages/generate.py @@ -0,0 +1,289 @@ +import ast +from pathlib import Path +from typing import TextIO + +import mkdocs_gen_files +from config import INCLUDE_PRIVATE, LINKABLE_IMAGE_EXTENSIONS, SOURCE_DIR +from context import Context +from helpers import display_parts_for, is_private, sort_key_for + + +def _defined_public_members(source_file: Path, include_private: bool) -> list[str]: + try: + tree = ast.parse(source_file.read_text(encoding="utf-8"), filename=str(source_file)) + except SyntaxError: + return [] + names: list[str] = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + if include_private or not node.name.startswith("_"): + names.append(node.name) + return names + + +def _iter_public_python_files(package_dir: Path) -> list[Path]: + """ + Recursively collect all public Python files within a package directory. + + - Excludes __init__.py files. + - Excludes private files (names starting with "_"). + + :param package_dir: Base package directory. + :return: Sorted list of Python file paths. + """ + files: list[Path] = [] + for python_file in package_dir.rglob("*.py"): + if python_file.name == "__init__.py": + continue + if is_private(python_file): + continue + files.append(python_file) + return sorted(files) + + +def _parts_from_source(python_file: Path) -> tuple[str, ...]: + """ + Convert a Python file path under SOURCE_DIR into module parts. + + Example: + src/pkg/module.py > ("pkg", "module") + + :param python_file: Path to a Python file under SOURCE_DIR. + :return: Tuple of path components without an extension. + """ + return tuple(python_file.relative_to(SOURCE_DIR).with_suffix("").parts) + + +def _write_backlink_if_needed(fh: TextIO, parts: list[str]) -> None: + """ + Write a backlink to the parent index if the page is not top-level. + + :param fh: File handle to write to. + :param parts: Current parts representing this page. + :return: None + """ + if len(parts) > 1: + parent_label = display_parts_for(parts[:-1])[-1] + fh.write(f"[⬅ Back to {parent_label}](../index.md)\n\n") + + +def _record_page(ctx: Context, display_parts: list[str], doc_path: Path, is_directory: bool) -> None: + """ + Add a record of a generated page to the context. + + :param ctx: Shared Context object. + :param display_parts: Human-readable parts for display. + :param doc_path: Path to the generated documentation file. + :param is_directory: Whether the record refers to a directory index. + :return: None + """ + display_tuple = tuple(display_parts) + doc_rel_path = doc_path.relative_to("reference").as_posix() + ctx.records.append((sort_key_for(display_parts), display_tuple, doc_rel_path, is_directory)) + + +def _display_sort_key(parts_like: tuple[str, ...]) -> tuple: + """ + Compute a sort key for display purposes. + + :param parts_like: Tuple of path parts. + :return: Sort key tuple. + """ + return sort_key_for(display_parts_for(list(parts_like))) + + +def _write_module_page(doc_path: Path, module_path: str, parent_parts: tuple[str, ...], source_file: Path) -> None: + """ + Generate a documentation page for a single module. + + :param doc_path: Target documentation path (index.md). + :param module_path: Dotted module path (e.g., "pkg.module"). + :param parent_parts: Path parts for the parent package. + :param source_file: Path to the source Python file. + :return: None + """ + mkdocs_gen_files.set_edit_path(doc_path, source_file) + + members = _defined_public_members(source_file, INCLUDE_PRIVATE) + + with mkdocs_gen_files.open(doc_path, "w") as fh: + if parent_parts: + _write_backlink_if_needed(fh, list(parent_parts) + ["_placeholder_"]) + fh.write(f"::: {module_path}\n") + if members: + fh.write(" options:\n") + fh.write(" members:\n") + for name in members: + fh.write(f" - {name}\n") + + +def _collect_static_files(source_folder_fs: Path) -> list[Path]: + """ + Collect non-Python static files in a source folder. + + - Skips private files (names starting with "_"). + - Includes only non-.py files. + + :param source_folder_fs: Filesystem path to the source folder. + :return: List of static file paths. + """ + static_files: list[Path] = [] + if source_folder_fs.exists(): + for source_file in sorted(source_folder_fs.iterdir()): + if source_file.name.startswith("_"): + continue + if source_file.is_file() and source_file.suffix != ".py": + static_files.append(source_file) + return static_files + + +def _emit_static_files_list( + parts: list[str], + static_files: list[Path], + file_handle: TextIO, +) -> None: + """ + Emit a section listing static files for a given folder. + + - Copies static files into `reference/.../_files/`. + - Links images if the extension is in LINKABLE_IMAGE_EXTENSIONS. + + :param parts: Path parts of the folder. + :param static_files: List of static file paths. + :param file_handle: File handle to write documentation into. + :return: None + """ + if not static_files: + return + + file_handle.write("## 🗃️ Static Files\n\n") + + for file_path in static_files: + destination = Path("reference", *parts, "_files", file_path.name) + + # Copy file content into the documentation tree + with mkdocs_gen_files.open(destination, "wb") as out_file: + out_file.write(file_path.read_bytes()) + + # Build relative link + relative_link = f"_files/{file_path.name}" + ext = file_path.suffix.lower() + + # Display images as clickable links + if ext in LINKABLE_IMAGE_EXTENSIONS: + file_handle.write(f"- [{file_path.name}]({relative_link})\n") + else: + file_handle.write(f"- {file_path.name}\n") + + file_handle.write("\n") + + +def _write_directory_page( + ctx: Context, + parts: list[str], + subdirectories: list[tuple[str, ...]], + modules: list[tuple[str, ...]], + static_files: list[Path], +) -> Path: + """ + Generate a documentation index page for a directory. + + Includes: + - Backlink to parent if applicable. + - Subdirectory links. + - Module links. + - Static file listings. + + :param ctx: Context to update with record. + :param parts: Path parts of the directory. + :param subdirectories: List of subdirectory paths. + :param modules: List of module paths. + :param static_files: List of static file paths in this directory. + :return: Path to generated index.md file. + """ + doc_path = Path("reference", *parts, "index.md") + + mkdocs_gen_files.set_edit_path(doc_path, SOURCE_DIR.joinpath(*parts, "__init__.py")) + + with mkdocs_gen_files.open(doc_path, "w") as file_handle: + _write_backlink_if_needed(file_handle, parts) + + file_handle.write(f"# `{'.'.join(parts)}`\n\n") + + if subdirectories: + file_handle.write("## 📁 Subdirectories\n\n") + for child in subdirectories: + label = display_parts_for(list(child))[-1] + file_handle.write(f"- [{label}]({child[-1]}/index.md)\n") + file_handle.write("\n") + + if modules: + file_handle.write("## 📄 Modules\n\n") + for child in modules: + label = display_parts_for(list(child))[-1] + file_handle.write(f"- [{label}]({child[-1]}/index.md)\n") + file_handle.write("\n") + + _emit_static_files_list(parts, static_files, file_handle) + + # Fallback if directory is empty + if not subdirectories and not modules and not static_files: + file_handle.write("_This section has no subdirectories, modules, or static files yet._\n") + + _record_page(ctx, display_parts_for(parts), doc_path, is_directory=True) + return doc_path + + +def generate_module_pages(package_dir: Path, ctx: Context) -> None: + """ + Generate documentation pages for all public modules under a package. + + :param package_dir: Root package directory. + :param ctx: Context object to update. + :return: None + """ + for python_file in _iter_public_python_files(package_dir): + parts = _parts_from_source(python_file) + parent_parts = parts[:-1] + + # Ensure parent folder exists in context + ctx.ensure_folder(list(parent_parts)) + + # Add this module under its parent + ctx.children_modules.setdefault(parent_parts, []).append(parts) + + module_path = ".".join(parts) + doc_path = Path("reference", *parts, "index.md") + + # Write module page and record it + _write_module_page(doc_path, module_path, parent_parts, python_file) + _record_page(ctx, display_parts_for(list(parts)), doc_path, is_directory=False) + + +def generate_directory_pages(ctx: Context) -> None: + """ + Generate documentation index pages for all discovered directories. + + :param ctx: Context object holding traversal state. + :return: None + """ + for key in sorted(ctx.created_folders): + parts = list(key) + parts_tuple = tuple(parts) + + source_folder_fs = SOURCE_DIR.joinpath(*parts) + source_folder_fs = SOURCE_DIR.joinpath(*parts) + static_files = _collect_static_files(source_folder_fs) + + # Gather subdirectories and modules for this folder + subdirectories = sorted( + ctx.children_directories.get(parts_tuple, set()), + key=_display_sort_key, + ) + modules = sorted( + ctx.children_modules.get(parts_tuple, []), + key=_display_sort_key, + ) + + # Write directory index page + _write_directory_page(ctx, parts, subdirectories, modules, static_files) diff --git a/docs/gen_ref_pages/helpers.py b/docs/gen_ref_pages/helpers.py new file mode 100644 index 0000000..7710695 --- /dev/null +++ b/docs/gen_ref_pages/helpers.py @@ -0,0 +1,83 @@ +from collections.abc import Iterable +from pathlib import Path + +from config import INCLUDE_PRIVATE, SECTION_ORDER, SECTION_TITLE_MAP + + +def is_private(path: Path) -> bool: + """ + Determine whether a given path should be considered private. + + Rules: + - If INCLUDE_PRIVATE is True > nothing is private. + - Otherwise, any path component starting with "_" marks it as private. + + :param path: Filesystem path. + :return: True if the path is private, False otherwise. + """ + return False if INCLUDE_PRIVATE else any(package_path.startswith("_") for package_path in path.parts) + + +def prettify(label: str) -> str: + """ + Convert an identifier string into a user-friendly label. + + - Underscores are replaced with spaces. + - Each word is capitalized. + + Example: + "my_module" > "My Module" + + :param label: Original string. + :return: Prettified version. + """ + return label.replace("_", " ").title() + + +def display_parts_for(parts: list[str]) -> list[str]: + """ + Build display-friendly parts for navigation from raw path parts. + + - Second element may be replaced by a mapped section title (SECTION_TITLE_MAP). + - Remaining elements are prettified. + + Example: + ["mypkg", "ui", "main_window"] + > ["mypkg", "UI", "Main Window"] + + :param parts: Raw path components. + :return: List of human-readable display parts. + """ + display = list(parts) + + # Replace known section keys (e.g., "ui" > "UI") + if len(display) >= 2 and display[1] in SECTION_TITLE_MAP: + display[1] = SECTION_TITLE_MAP[display[1]] + + # Prettify remaining elements + for i in range(1, len(display)): + display[i] = prettify(display[i]) + + return display + + +def sort_key_for(display_parts: Iterable[str]) -> tuple: + """ + Build a sort key for consistent ordering of navigation items. + + Criteria: + 1. Section order (from SECTION_ORDER, default 999). + 2. Path length (shorter first). + 3. Alphabetical (case-insensitive). + + :param display_parts: Human-readable path parts. + :return: Tuple usable as the sort key. + """ + path_parts = list(display_parts) + section = path_parts[1] if len(path_parts) >= 2 else "" + + return ( + SECTION_ORDER.get(section, 999), + len(path_parts), + tuple(part.lower() for part in path_parts), + ) diff --git a/docs/gen_ref_pages/traverse.py b/docs/gen_ref_pages/traverse.py new file mode 100644 index 0000000..1c13896 --- /dev/null +++ b/docs/gen_ref_pages/traverse.py @@ -0,0 +1,127 @@ +import os +from collections.abc import Iterable +from pathlib import Path + +from context import Context +from helpers import is_private + + +def _walk_dirs(package_dir: Path, include_private: bool) -> Iterable[tuple[Path, list[str]]]: + """ + Walk the filesystem tree starting at package_dir and yield + (relative_dir, subdirs) pairs. + + Rules: + - Private subdirectories (names starting with "_") are removed in-place + unless include_private=True. + - Entire directories marked private are skipped completely. + + :param package_dir: Path to the root package (e.g., src/mypkg). + :param include_private: Whether to include private directories. + :return: Iterator of (relative_dir, subdirs). + + Example: + "src/mypkg" > [(Path("mypkg"), ["subdir1", "subdir2"])] + """ + source_dir = package_dir.parent + + # Walk filesystem tree starting at package_dir + for current_dirpath, subdirs, _ in os.walk(package_dir): + relative_dir = Path(current_dirpath).relative_to(source_dir) + + # Remove unnecessary catalogues (pycache, build, dist) + subdirs[:] = [dirname for dirname in subdirs if dirname != "__pycache__" and dirname not in {"build", "dist"}] + + # Remove private subdirectories in-place if not including them + if not include_private: + subdirs[:] = [dirname for dirname in subdirs if not dirname.startswith("_")] + + # Skip the current directory if it's marked private + if is_private(relative_dir): + continue + + yield relative_dir, subdirs + + +def _parts_for(relative_dir: Path, package_name: str) -> list[str]: + """ + Convert a relative path into parts; fallback to package_name for the root. + + :param relative_dir: Path relative to the source directory. + :param package_name: Top-level package name. + :return: List of path components. + + Examples: + "mypkg/subdir" > ["mypkg", "subdir"] + "." (root) > ["mypkg"] + """ + return list(relative_dir.parts) or [package_name] + + +def _register_folder(ctx: Context, folder_parts: list[str]) -> tuple[str, ...]: + """ + Ensure the given folder exists in Context and return it as a tuple key. + + :param ctx: Shared Context object. + :param folder_parts: List of folder path components. + :return: Tuple representation of the folder path. + + Example: + ["mypkg", "subdir"] > ("mypkg", "subdir") + """ + ctx.ensure_folder(folder_parts) + return tuple(folder_parts) + + +def _register_children(ctx: Context, parent_parts: list[str], child_names: Iterable[str]) -> None: + """ + Register each child subdirectory of a given parent folder in Context. + + :param ctx: Shared Context object. + :param parent_parts: Parent folder path components. + :param child_names: Iterable of subdirectory names. + :return: None + + Example: + ["mypkg"], ["a", "b"] > {("mypkg", "a"), ("mypkg", "b")} + """ + parent_t = tuple(parent_parts) + # Register each child subdirectory + for dirname in sorted(child_names): + child_parts = parent_parts + [dirname] + ctx.ensure_folder(child_parts) + ctx.children_directories[parent_t].add(tuple(child_parts)) + + +def traverse_directories( + package_dir: Path, + package_name: str, + include_private: bool, + ctx: Context, +) -> None: + """ + Walk through the package directory tree and populate the Context + with discovered folders and their relationships. + + Rules: + - Private directories (names starting with "_") are skipped unless + include_private=True. + - Each valid folder is registered in ctx.created_folders. + - Parent > child directory relationships are stored in ctx.children_directories. + + :param package_dir: Path to the root package (e.g., src/mypkg). + :param package_name: Top-level package name. + :param include_private: Whether to include private directories. + :param ctx: Shared Context object to update. + :return: None + + Example: + "src/mypkg" > {("mypkg",)} + """ + for relative_dir, subdirs in _walk_dirs(package_dir, include_private=include_private): + # Convert the path into parts; fallback to package_name for root + folder_parts = _parts_for(relative_dir, package_name) + _register_folder(ctx, folder_parts) + + # Register each child subdirectory + _register_children(ctx, folder_parts, subdirs) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..754b4ea --- /dev/null +++ b/docs/index.md @@ -0,0 +1,105 @@ +# UQ Desktop Processor + +`uq-desktop-processor` is a desktop app and Python package for visual urban assessment from street-level imagery. +It combines road-network sampling, optional Mapillary downloads (or your own images), CLIP-based prefiltering and scoring, optional ViT workflows, and GIS-friendly export formats. + +> Package name: `uq_desktop_processor` +> Python: `3.12-3.14` + +--- + +## Key Features + +- Chinese postman / drive routes with GPX export for efficient field coverage. +- Road-based sampling points with configurable spacing and minimum separation. +- Mapillary image download near points, or processing of local image folders. +- CLIP prefilter that moves low-relevance images to `rejected`. +- CLIP prompt scoring on urban-quality axes (for example: beauty, safety, wealth). +- Optional ViT / finetuned evaluation path. +- Export to GeoJSON, GPKG, SHP, and Parquet via GeoPandas. + +--- + +## Quick Start + +### Run with Poetry (recommended) + +```bash +poetry install +poetry run uq_desktop_processor +``` + +### Run with pip + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install --upgrade pip +pip install -e . +python -m uq_desktop_processor +``` + +--- + +## Mapillary Token + +For Mapillary downloads, set `MAPILLARY_ACCESS_TOKEN` (or provide the token directly in the GUI). + +PowerShell: + +```powershell +$env:MAPILLARY_ACCESS_TOKEN = "YOUR_TOKEN_HERE" +``` + +Bash: + +```bash +export MAPILLARY_ACCESS_TOKEN="YOUR_TOKEN_HERE" +``` + +--- + +## Typical Data Layout + +```text +data/ +|- images/ +| |- raw/ +| `- rejected/ +`- results/ + |- sampling_points.geojson + `- urban_quality_ai_output.geojson +``` + +--- + +## Documentation and Development + +- Build docs locally: + +```bash +mkdocs serve +mkdocs build +``` + +- Common checks: + +```bash +poetry run pytest +poetry run mypy src/ +poetry run ruff check . +poetry run black --check . +pre-commit run --all-files +``` + +--- + +## Project Source + +Main package: `src/uq_desktop_processor/` + +- `gui/` - PySide6 application shell and pages +- `pipeline/` - pipeline orchestration and defaults +- `evaluation/` - CLIP prefilter/evaluator and finetuned evaluator +- `street_view_analysis/` - sampling, road graph, Mapillary, drive routes +- `layer_creation/` - vector outputs for GIS workflows diff --git a/docs/readme_images/gui-drive-routes.png b/docs/readme_images/gui-drive-routes.png new file mode 100644 index 0000000..9b63dfa Binary files /dev/null and b/docs/readme_images/gui-drive-routes.png differ diff --git a/docs/readme_images/gui-sampling-points.png b/docs/readme_images/gui-sampling-points.png new file mode 100644 index 0000000..d320d9c Binary files /dev/null and b/docs/readme_images/gui-sampling-points.png differ diff --git a/docs/readme_images/gui-vit-evaluation.png b/docs/readme_images/gui-vit-evaluation.png new file mode 100644 index 0000000..7339abd Binary files /dev/null and b/docs/readme_images/gui-vit-evaluation.png differ diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..e067bd0 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,109 @@ +# Project information +site_name: "UrbanQuality-AI: desktop_processor" # The title of the documentation site +site_description: Docs for UrbanQuality-AI desktop_processor # Short description of the site (used in metadata) +repo_url: https://github.com/UrbanQuality-AI/uq-desktop-processor # Link to the GitHub repository +repo_name: UrbanQuality-AI/uq-desktop-processor # Name of the repository displayed in the UI + +# Theme configuration +theme: + name: material # Use the "Material for MkDocs" theme + language: en # Interface language + font: + text: Inter # Font used for body text + code: JetBrains Mono # Font used for code blocks + features: # Enable additional theme features + - navigation.tabs # Show navigation tabs at the top + - navigation.tabs.sticky # Keep tabs visible while scrolling + - navigation.top # Back-to-top button + - navigation.sections # Group navigation items into sections + - navigation.indexes # Allow section index pages + - toc.integrate # Integrate table of contents into the navigation + - toc.follow # Highlight current section in the table of contents + - content.code.copy # Add a "copy" button to code blocks + - search.suggest # Enable search suggestions + - search.highlight # Highlight search matches in content + palette: # Define color palettes and UI themes + - media: "(prefers-color-scheme: light)" # Default light mode + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night # Icon for switching to dark mode + name: Dark mode + - media: "(prefers-color-scheme: dark)" # Default dark mode + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny # Icon for switching back to light mode + name: Light mode + - scheme: brand # Custom "brand" color scheme + primary: deep purple + accent: cyan + toggle: + icon: material/palette + name: Brand look + - scheme: compact # Compact layout variant + primary: indigo + accent: indigo + toggle: + icon: material/format-line-spacing + name: Compact spacing + - scheme: comfy # Comfortable spacing variant + primary: indigo + accent: indigo + toggle: + icon: material/format-size + name: Comfortable size + +# Plugins extend MkDocs functionality +plugins: + - search # Built-in search engine + - autorefs # Automatic cross-references between documents + - gen-files: # Generate files dynamically + scripts: + - docs/gen_ref_pages/gen_ref_pages.py # Script for generating reference pages + - literate-nav # Define navigation structure from Markdown files + - mkdocstrings: # Auto-generate API documentation from docstrings + handlers: + python: # Handler for Python code + paths: [src] # Source code directory + options: # Rendering options + docstring_style: sphinx + docstring_section_style: table + separate_signature: true + show_signature_annotations: true + line_length: 88 + group_by_category: true + show_category_heading: true + merge_init_into_class: true + members_order: source + inherited_members: true + show_if_no_docstring: true + show_root_heading: true + show_root_toc_entry: true + show_source: false # Do not show source code links + + +# Markdown extensions +markdown_extensions: + - admonition # Support for callout/admonition blocks + - footnotes # Support for footnotes + - attr_list # Allow attributes inside Markdown elements + - pymdownx.details # Collapsible details blocks + - pymdownx.superfences # Enhanced fenced code blocks + - pymdownx.tabbed: # Tabbed content + alternate_style: true + - pymdownx.highlight # Syntax highlighting for code blocks + - toc: # Table of contents + permalink: true # Add anchor links to TOC headings + +# Extra custom CSS +extra_css: + - css/mkdocstrings.css # Custom styles for mkdocstrings + - css/theme-variants.css # Custom styles for theme variants + +# Navigation (menu structure) +nav: + - Home: index.md + - Reference: reference/SUMMARY.md \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..a3c62c7 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,3488 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backrefs" +version = "6.1" +description = "A wrapper around re and regex that adds additional back references." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1"}, + {file = "backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7"}, + {file = "backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a"}, + {file = "backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05"}, + {file = "backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853"}, + {file = "backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0"}, + {file = "backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231"}, +] + +[package.extras] +extras = ["regex"] + +[[package]] +name = "black" +version = "25.11.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, + {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, + {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"}, + {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"}, + {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"}, + {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"}, + {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"}, + {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"}, + {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"}, + {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"}, + {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"}, + {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"}, + {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"}, + {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"}, + {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"}, + {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"}, + {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"}, + {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"}, + {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"}, + {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"}, + {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"}, + {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"}, + {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"}, + {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"}, + {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"}, + {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2025.11.12" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +description = "An extension module for click to enable registering CLI commands via setuptools entry-points." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6"}, + {file = "click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] + +[[package]] +name = "cligj" +version = "0.7.2" +description = "Click params for commmand line interfaces to GeoJSON" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, <4" +groups = ["main"] +files = [ + {file = "cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df"}, + {file = "cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27"}, +] + +[package.dependencies] +click = ">=4.0" + +[package.extras] +test = ["pytest-cov"] + +[[package]] +name = "clip" +version = "1.0" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [] +develop = false + +[package.dependencies] +ftfy = "*" +packaging = "*" +regex = "*" +torch = "*" +torchvision = "*" +tqdm = "*" + +[package.extras] +dev = ["pytest"] + +[package.source] +type = "git" +url = "https://github.com/openai/CLIP.git" +reference = "HEAD" +resolved_reference = "dcba3cb2e2827b402d2701e7e1c7d9fed8a20ef1" + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\""} + +[[package]] +name = "contourpy" +version = "1.3.3" +description = "Python library for calculating contours of 2D quadrilateral grids" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, + {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a"}, + {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db"}, + {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620"}, + {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f"}, + {file = "contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff"}, + {file = "contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42"}, + {file = "contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470"}, + {file = "contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb"}, + {file = "contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea"}, + {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1"}, + {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7"}, + {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411"}, + {file = "contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69"}, + {file = "contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b"}, + {file = "contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc"}, + {file = "contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5"}, + {file = "contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67"}, + {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9"}, + {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659"}, + {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7"}, + {file = "contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d"}, + {file = "contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263"}, + {file = "contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9"}, + {file = "contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d"}, + {file = "contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99"}, + {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b"}, + {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a"}, + {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e"}, + {file = "contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3"}, + {file = "contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8"}, + {file = "contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301"}, + {file = "contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a"}, + {file = "contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36"}, + {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3"}, + {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b"}, + {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36"}, + {file = "contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d"}, + {file = "contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd"}, + {file = "contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339"}, + {file = "contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772"}, + {file = "contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f"}, + {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0"}, + {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4"}, + {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f"}, + {file = "contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae"}, + {file = "contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc"}, + {file = "contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989"}, + {file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"}, + {file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"}, +] + +[package.dependencies] +numpy = ">=1.25" + +[package.extras] +bokeh = ["bokeh", "selenium"] +docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] +mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "types-Pillow"] +test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] +test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] + +[[package]] +name = "coverage" +version = "7.12.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, + {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, + {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, + {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, + {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, + {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, + {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, + {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, + {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, + {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, + {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, + {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, + {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, + {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, + {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, + {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, + {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, + {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, + {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, + {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, + {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, + {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, + {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, + {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "cycler" +version = "0.12.1" +description = "Composable style cycles" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, + {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, +] + +[package.extras] +docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] +tests = ["pytest", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + +[[package]] +name = "filelock" +version = "3.20.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["main", "dev"] +files = [ + {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, + {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, +] + +[[package]] +name = "fiona" +version = "1.10.1" +description = "Fiona reads and writes spatial data files" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fiona-1.10.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:6e2a94beebda24e5db8c3573fe36110d474d4a12fac0264a3e083c75e9d63829"}, + {file = "fiona-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc7366f99bdc18ec99441b9e50246fdf5e72923dc9cbb00267b2bf28edd142ba"}, + {file = "fiona-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c32f424b0641c79f4036b96c2e80322fb181b4e415c8cd02d182baef55e6730"}, + {file = "fiona-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:9a67bd88918e87d64168bc9c00d9816d8bb07353594b5ce6c57252979d5dc86e"}, + {file = "fiona-1.10.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:98fe556058b370da07a84f6537c286f87eb4af2343d155fbd3fba5d38ac17ed7"}, + {file = "fiona-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:be29044d4aeebae92944b738160dc5f9afc4cdf04f551d59e803c5b910e17520"}, + {file = "fiona-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94bd3d448f09f85439e4b77c38b9de1aebe3eef24acc72bd631f75171cdfde51"}, + {file = "fiona-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:30594c0cd8682c43fd01e7cdbe000f94540f8fa3b7cb5901e805c88c4ff2058b"}, + {file = "fiona-1.10.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7338b8c68beb7934bde4ec9f49eb5044e5e484b92d940bc3ec27defdb2b06c67"}, + {file = "fiona-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c77fcfd3cdb0d3c97237965f8c60d1696a64923deeeb2d0b9810286cbe25911"}, + {file = "fiona-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:537872cbc9bda7fcdf73851c91bc5338fca2b502c4c17049ccecaa13cde1f18f"}, + {file = "fiona-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:41cde2c52c614457e9094ea44b0d30483540789e62fe0fa758c2a2963e980817"}, + {file = "fiona-1.10.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:a00b05935c9900678b2ca660026b39efc4e4b916983915d595964eb381763ae7"}, + {file = "fiona-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f78b781d5bcbbeeddf1d52712f33458775dbb9fd1b2a39882c83618348dd730f"}, + {file = "fiona-1.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29ceeb38e3cd30d91d68858d0817a1bb0c4f96340d334db4b16a99edb0902d35"}, + {file = "fiona-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:15751c90e29cee1e01fcfedf42ab85987e32f0b593cf98d88ed52199ef5ca623"}, + {file = "fiona-1.10.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:6f1242f872dc33d3b4269dcaebf1838a359f9097e1cc848b0e11367bce010e4d"}, + {file = "fiona-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:65308b7a7e57fcc533de8a5855b0fce798faabc736d1340192dd8673ff61bc4e"}, + {file = "fiona-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:632bc146355af5ff0d77e34ebd1be5072d623b4aedb754b94a3d8c356c4545ac"}, + {file = "fiona-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:b7b4c3c97b1d64a1b3321577e9edaebbd36b64006e278f225f300c497cc87c35"}, + {file = "fiona-1.10.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:b62aa8d5a0981bd33d81c247219b1eaa1e655e0a0682b3a4759fccc40954bb30"}, + {file = "fiona-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f4b19cb5bd22443ef439b39239272349023556994242a8f953a0147684e1c47f"}, + {file = "fiona-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa7e7e5ad252ef29905384bf92e7d14dd5374584b525632652c2ab8925304670"}, + {file = "fiona-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:4e82d18acbe55230e9cf8ede2a836d99ea96b7c0cc7d2b8b993e6c9f0ac14dc2"}, + {file = "fiona-1.10.1.tar.gz", hash = "sha256:b00ae357669460c6491caba29c2022ff0acfcbde86a95361ea8ff5cd14a86b68"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +certifi = "*" +click = ">=8.0,<9.0" +click-plugins = ">=1.0" +cligj = ">=0.5" + +[package.extras] +all = ["fiona[calc,s3,test]"] +calc = ["pyparsing", "shapely"] +s3 = ["boto3 (>=1.3.1)"] +test = ["aiohttp", "fiona[s3]", "fsspec", "pytest (>=7)", "pytest-cov", "pytz"] + +[[package]] +name = "fonttools" +version = "4.60.1" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28"}, + {file = "fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15"}, + {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c"}, + {file = "fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea"}, + {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652"}, + {file = "fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a"}, + {file = "fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce"}, + {file = "fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038"}, + {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f"}, + {file = "fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2"}, + {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914"}, + {file = "fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1"}, + {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d"}, + {file = "fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa"}, + {file = "fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258"}, + {file = "fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf"}, + {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc"}, + {file = "fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877"}, + {file = "fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c"}, + {file = "fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401"}, + {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903"}, + {file = "fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed"}, + {file = "fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6"}, + {file = "fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383"}, + {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb"}, + {file = "fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4"}, + {file = "fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c"}, + {file = "fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77"}, + {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199"}, + {file = "fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c"}, + {file = "fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272"}, + {file = "fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac"}, + {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3"}, + {file = "fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85"}, + {file = "fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537"}, + {file = "fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003"}, + {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08"}, + {file = "fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99"}, + {file = "fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6"}, + {file = "fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987"}, + {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299"}, + {file = "fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01"}, + {file = "fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801"}, + {file = "fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc"}, + {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc"}, + {file = "fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed"}, + {file = "fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259"}, + {file = "fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c"}, + {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2"}, + {file = "fonttools-4.60.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036"}, + {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856"}, + {file = "fonttools-4.60.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7"}, + {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854"}, + {file = "fonttools-4.60.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da"}, + {file = "fonttools-4.60.1-cp39-cp39-win32.whl", hash = "sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a"}, + {file = "fonttools-4.60.1-cp39-cp39-win_amd64.whl", hash = "sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217"}, + {file = "fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb"}, + {file = "fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9"}, +] + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=15.1.0) ; python_version <= \"3.12\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.23.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + +[[package]] +name = "fsspec" +version = "2025.10.0" +description = "File-system specification" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d"}, + {file = "fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff (>=0.5)"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] +tqdm = ["tqdm"] + +[[package]] +name = "ftfy" +version = "6.3.1" +description = "Fixes mojibake and other problems with Unicode, after the fact" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083"}, + {file = "ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "geopandas" +version = "1.1.1" +description = "Geographic pandas extensions" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "geopandas-1.1.1-py3-none-any.whl", hash = "sha256:589e61aaf39b19828843df16cb90234e72897e2579be236f10eee0d052ad98e8"}, + {file = "geopandas-1.1.1.tar.gz", hash = "sha256:1745713f64d095c43e72e08e753dbd271678254b24f2e01db8cdb8debe1d293d"}, +] + +[package.dependencies] +numpy = ">=1.24" +packaging = "*" +pandas = ">=2.0.0" +pyogrio = ">=0.7.2" +pyproj = ">=3.5.0" +shapely = ">=2.0.0" + +[package.extras] +all = ["GeoAlchemy2", "SQLAlchemy (>=2.0)", "folium", "geopy", "mapclassify (>=2.5)", "matplotlib (>=3.7)", "psycopg[binary] (>=3.1.0)", "pyarrow (>=10.0.0)", "scipy", "xyzservices"] +dev = ["codecov", "pre-commit", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist", "ruff"] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "gpxpy" +version = "1.6.2" +description = "GPX file parser and GPS track manipulation library" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "gpxpy-1.6.2-py3-none-any.whl", hash = "sha256:289bc2d80f116c988d0a1e763fda22838f83005573ece2bbc6521817b26fb40a"}, + {file = "gpxpy-1.6.2.tar.gz", hash = "sha256:a72c484b97ec42b80834353b029cc8ee1b79f0ffca1179b2210bb3baf26c01ae"}, +] + +[[package]] +name = "griffe" +version = "1.15.0" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3"}, + {file = "griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea"}, +] + +[package.dependencies] +colorama = ">=0.4" + +[package.extras] +pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "hf-xet" +version = "1.2.0" +description = "Fast transfer of large files with the Hugging Face Hub." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" +files = [ + {file = "hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649"}, + {file = "hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813"}, + {file = "hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc"}, + {file = "hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5"}, + {file = "hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f"}, + {file = "hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832"}, + {file = "hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382"}, + {file = "hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e"}, + {file = "hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8"}, + {file = "hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0"}, + {file = "hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090"}, + {file = "hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a"}, + {file = "hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f"}, + {file = "hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc"}, + {file = "hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848"}, + {file = "hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4"}, + {file = "hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd"}, + {file = "hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c"}, + {file = "hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737"}, + {file = "hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865"}, + {file = "hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69"}, + {file = "hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "huggingface-hub" +version = "1.1.5" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "huggingface_hub-1.1.5-py3-none-any.whl", hash = "sha256:e88ecc129011f37b868586bbcfae6c56868cae80cd56a79d61575426a3aa0d7d"}, + {file = "huggingface_hub-1.1.5.tar.gz", hash = "sha256:40ba5c9a08792d888fde6088920a0a71ab3cd9d5e6617c81a797c657f1fd9968"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +hf-xet = {version = ">=1.2.0,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} +httpx = ">=0.23.0,<1" +packaging = ">=20.9" +pyyaml = ">=5.1" +shellingham = "*" +tqdm = ">=4.42.1" +typer-slim = "*" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +dev = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0)", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "ty", "types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-xet = ["hf-xet (>=1.1.3,<2.0.0)"] +mcp = ["mcp (>=1.8.0)"] +oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] +quality = ["libcst (>=1.4.0)", "mypy (==1.15.0)", "ruff (>=0.9.0)", "ty"] +testing = ["Jinja2", "Pillow", "authlib (>=1.3.2)", "fastapi", "fastapi", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.4.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures (<16.0)", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + +[[package]] +name = "identify" +version = "2.6.15" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, + {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "joblib" +version = "1.5.2" +description = "Lightweight pipelining with Python functions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, + {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +description = "A fast implementation of the Cassowary constraint solver" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f"}, + {file = "kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b"}, + {file = "kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586"}, + {file = "kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611"}, + {file = "kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089"}, + {file = "kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872"}, + {file = "kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a"}, + {file = "kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2"}, + {file = "kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77"}, + {file = "kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2"}, + {file = "kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54"}, + {file = "kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2"}, + {file = "kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525"}, + {file = "kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3"}, + {file = "kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d"}, + {file = "kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07"}, + {file = "kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552"}, + {file = "kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df"}, + {file = "kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5"}, + {file = "kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7"}, + {file = "kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891"}, + {file = "kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9"}, + {file = "kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f"}, + {file = "kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1"}, + {file = "kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d"}, +] + +[[package]] +name = "markdown" +version = "3.10" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, + {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, +] + +[package.extras] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +description = "Python plotting package" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380"}, + {file = "matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d"}, + {file = "matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297"}, + {file = "matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42"}, + {file = "matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7"}, + {file = "matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3"}, + {file = "matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a"}, + {file = "matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6"}, + {file = "matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a"}, + {file = "matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1"}, + {file = "matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc"}, + {file = "matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e"}, + {file = "matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9"}, + {file = "matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748"}, + {file = "matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f"}, + {file = "matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0"}, + {file = "matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695"}, + {file = "matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65"}, + {file = "matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee"}, + {file = "matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8"}, + {file = "matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f"}, + {file = "matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c"}, + {file = "matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1"}, + {file = "matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632"}, + {file = "matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84"}, + {file = "matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815"}, + {file = "matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7"}, + {file = "matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355"}, + {file = "matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b"}, + {file = "matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67"}, + {file = "matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67"}, + {file = "matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84"}, + {file = "matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2"}, + {file = "matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf"}, + {file = "matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100"}, + {file = "matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f"}, + {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715"}, + {file = "matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1"}, + {file = "matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722"}, + {file = "matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866"}, + {file = "matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb"}, + {file = "matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1"}, + {file = "matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4"}, + {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318"}, + {file = "matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca"}, + {file = "matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc"}, + {file = "matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8"}, + {file = "matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657"}, + {file = "matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68"}, + {file = "matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91"}, + {file = "matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7"}, +] + +[package.dependencies] +contourpy = ">=1.0.1" +cycler = ">=0.10" +fonttools = ">=4.22.0" +kiwisolver = ">=1.3.1" +numpy = ">=1.23" +packaging = ">=20.0" +pillow = ">=8" +pyparsing = ">=3" +python-dateutil = ">=2.7" + +[package.extras] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.3" +description = "Automatically link across pages in MkDocs." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9"}, + {file = "mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75"}, +] + +[package.dependencies] +Markdown = ">=3.3" +markupsafe = ">=2.0.1" +mkdocs = ">=1.1" + +[[package]] +name = "mkdocs-gen-files" +version = "0.5.0" +description = "MkDocs plugin to programmatically generate documentation pages during the build" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "mkdocs_gen_files-0.5.0-py3-none-any.whl", hash = "sha256:7ac060096f3f40bd19039e7277dd3050be9a453c8ac578645844d4d91d7978ea"}, + {file = "mkdocs_gen_files-0.5.0.tar.gz", hash = "sha256:4c7cf256b5d67062a788f6b1d035e157fc1a9498c2399be9af5257d4ff4d19bc"}, +] + +[package.dependencies] +mkdocs = ">=1.0.3" + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.2" +description = "MkDocs plugin to specify the navigation in Markdown instead of YAML" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630"}, + {file = "mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75"}, +] + +[package.dependencies] +mkdocs = ">=1.4.1" + +[[package]] +name = "mkdocs-material" +version = "9.7.0" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_material-9.7.0-py3-none-any.whl", hash = "sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887"}, + {file = "mkdocs_material-9.7.0.tar.gz", hash = "sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec"}, +] + +[package.dependencies] +babel = ">=2.10" +backrefs = ">=5.7.post1" +colorama = ">=0.4" +jinja2 = ">=3.1" +markdown = ">=3.2" +mkdocs = ">=1.6" +mkdocs-material-extensions = ">=1.3" +paginate = ">=0.5" +pygments = ">=2.16" +pymdown-extensions = ">=10.2" +requests = ">=2.26" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<12.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mkdocstrings" +version = "0.25.2" +description = "Automatic documentation from sources, for MkDocs." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"}, + {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"}, +] + +[package.dependencies] +click = ">=7.0" +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.4" +mkdocs-autorefs = ">=0.3.1" +platformdirs = ">=2.2.0" +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "1.10.9" +description = "A Python handler for mkdocstrings." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mkdocstrings_python-1.10.9-py3-none-any.whl", hash = "sha256:cbe98710a6757dfd4dff79bf36cb9731908fb4c69dd2736b15270ae7a488243d"}, + {file = "mkdocstrings_python-1.10.9.tar.gz", hash = "sha256:f344aaa47e727d8a2dc911e063025e58e2b7fb31a41110ccc3902aa6be7ca196"}, +] + +[package.dependencies] +griffe = ">=0.49" +mkdocs-autorefs = ">=1.0" +mkdocstrings = ">=0.25" + +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +tests = ["pytest (>=4.6)"] + +[[package]] +name = "mypy" +version = "1.18.2" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, + {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, + {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, + {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, + {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, + {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, + {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, + {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, + {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, + {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, + {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, + {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, + {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, + {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, + {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, + {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, + {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, + {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, + {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, + {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, + {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, + {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, + {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, + {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, + {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, + {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, + {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "networkx" +version = "3.5" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec"}, + {file = "networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037"}, +] + +[package.extras] +default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] +developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] +doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] +example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] +extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] +test-extras = ["pytest-mpl", "pytest-randomly"] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.1.3.1" +description = "CUBLAS native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cublas_cu12-12.1.3.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:ee53ccca76a6fc08fb9701aa95b6ceb242cdaab118c3bb152af4e579af792728"}, + {file = "nvidia_cublas_cu12-12.1.3.1-py3-none-win_amd64.whl", hash = "sha256:2b964d60e8cf11b5e1073d179d85fa340c120e99b3067558f3cf98dd69d02906"}, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.1.105" +description = "CUDA profiling tools runtime libs." +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_cupti_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:e54fde3983165c624cb79254ae9818a456eb6e87a7fd4d56a2352c24ee542d7e"}, + {file = "nvidia_cuda_cupti_cu12-12.1.105-py3-none-win_amd64.whl", hash = "sha256:bea8236d13a0ac7190bd2919c3e8e6ce1e402104276e6f9694479e48bb0eb2a4"}, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.1.105" +description = "NVRTC native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:339b385f50c309763ca65456ec75e17bbefcbbf2893f462cb8b90584cd27a1c2"}, + {file = "nvidia_cuda_nvrtc_cu12-12.1.105-py3-none-win_amd64.whl", hash = "sha256:0a98a522d9ff138b96c010a65e145dc1b4850e9ecb75a0172371793752fd46ed"}, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.1.105" +description = "CUDA Runtime native Libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cuda_runtime_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:6e258468ddf5796e25f1dc591a31029fa317d97a0a94ed93468fc86301d61e40"}, + {file = "nvidia_cuda_runtime_cu12-12.1.105-py3-none-win_amd64.whl", hash = "sha256:dfb46ef84d73fababab44cf03e3b83f80700d27ca300e537f85f636fac474344"}, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.1.0.70" +description = "cuDNN runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:165764f44ef8c61fcdfdfdbe769d687e06374059fbb388b6c89ecb0e28793a6f"}, + {file = "nvidia_cudnn_cu12-9.1.0.70-py3-none-win_amd64.whl", hash = "sha256:6278562929433d68365a07a4a1546c237ba2849852c0d4b2262a486e805b977a"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.0.2.54" +description = "CUFFT native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cufft_cu12-11.0.2.54-py3-none-manylinux1_x86_64.whl", hash = "sha256:794e3948a1aa71fd817c3775866943936774d1c14e7628c74f6f7417224cdf56"}, + {file = "nvidia_cufft_cu12-11.0.2.54-py3-none-win_amd64.whl", hash = "sha256:d9ac353f78ff89951da4af698f80870b1534ed69993f10a4cf1d96f21357e253"}, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.2.106" +description = "CURAND native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_curand_cu12-10.3.2.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:9d264c5036dde4e64f1de8c50ae753237c12e0b1348738169cd0f8a536c0e1e0"}, + {file = "nvidia_curand_cu12-10.3.2.106-py3-none-win_amd64.whl", hash = "sha256:75b6b0c574c0037839121317e17fd01f8a69fd2ef8e25853d826fec30bdba74a"}, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.4.5.107" +description = "CUDA solver native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cusolver_cu12-11.4.5.107-py3-none-manylinux1_x86_64.whl", hash = "sha256:8a7ec542f0412294b15072fa7dab71d31334014a69f953004ea7a118206fe0dd"}, + {file = "nvidia_cusolver_cu12-11.4.5.107-py3-none-win_amd64.whl", hash = "sha256:74e0c3a24c78612192a74fcd90dd117f1cf21dea4822e66d89e8ea80e3cd2da5"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" +nvidia-cusparse-cu12 = "*" +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.1.0.106" +description = "CUSPARSE native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_cusparse_cu12-12.1.0.106-py3-none-manylinux1_x86_64.whl", hash = "sha256:f3b50f42cf363f86ab21f720998517a659a48131e8d538dc02f8768237bd884c"}, + {file = "nvidia_cusparse_cu12-12.1.0.106-py3-none-win_amd64.whl", hash = "sha256:b798237e81b9719373e8fae8d4f091b70a0cf09d9d85c95a557e11df2d8e9a5a"}, +] + +[package.dependencies] +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.20.5" +description = "NVIDIA Collective Communication Library (NCCL) Runtime" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_nccl_cu12-2.20.5-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1fc150d5c3250b170b29410ba682384b14581db722b2531b0d8d33c595f33d01"}, + {file = "nvidia_nccl_cu12-2.20.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:057f6bf9685f75215d0c53bf3ac4a10b3e6578351de307abad9e18a99182af56"}, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +description = "Nvidia JIT LTO Library" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88"}, + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7"}, + {file = "nvidia_nvjitlink_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:bd93fbeeee850917903583587f4fc3a4eafa022e34572251368238ab5e6bd67f"}, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.1.105" +description = "NVIDIA Tools Extension" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\"" +files = [ + {file = "nvidia_nvtx_cu12-12.1.105-py3-none-manylinux1_x86_64.whl", hash = "sha256:dc21cf308ca5691e7c04d962e213f8a4aa9bbfa23d95412f452254c2caeb09e5"}, + {file = "nvidia_nvtx_cu12-12.1.105-py3-none-win_amd64.whl", hash = "sha256:65f4d98982b31b60026e0e6de73fbdfc09d08a96f4656dd3665ca616a11e1e82"}, +] + +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b"}, + {file = "opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca"}, +] + +[package.dependencies] +numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} + +[[package]] +name = "osmnx" +version = "1.9.3" +description = "Download, model, analyze, and visualize street networks and other geospatial features from OpenStreetMap" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "osmnx-1.9.3-py3-none-any.whl", hash = "sha256:ac67bea77b521941af648ef641ae1d006101948d1112475c256ea23ef31b426a"}, + {file = "osmnx-1.9.3.tar.gz", hash = "sha256:22548d86d68d36edff3cf9ab76c45745cda86a4ea0b28442e107d6b42992a426"}, +] + +[package.dependencies] +geopandas = ">=0.12" +networkx = ">=2.5" +numpy = ">=1.20" +pandas = ">=1.1" +requests = ">=2.27" +shapely = ">=2.0" + +[package.extras] +entropy = ["scipy (>=1.5)"] +neighbors = ["scikit-learn (>=0.23)", "scipy (>=1.5)"] +raster = ["gdal", "rasterio (>=1.3)"] +visualization = ["matplotlib (>=3.5)"] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "paginate" +version = "0.5.7" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, + {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, +] + +[package.extras] +dev = ["pytest", "tox"] +lint = ["black"] + +[[package]] +name = "pandas" +version = "2.3.3" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, + {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, + {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, + {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, + {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, + {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, + {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, + {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, + {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, + {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, + {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, + {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, + {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, + {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, + {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, + {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, + {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, + {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, + {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, + {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, + {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, + {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, + {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, + {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, + {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, + {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, + {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, + {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, + {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, + {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, + {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, + {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, +] + +[package.dependencies] +numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "piexif" +version = "1.1.3" +description = "To simplify exif manipulations with python. Writing, reading, and more..." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "piexif-1.1.3-py2.py3-none-any.whl", hash = "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6"}, + {file = "piexif-1.1.3.zip", hash = "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b"}, +] + +[[package]] +name = "pillow" +version = "10.4.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, + {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, + {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, + {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, + {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, + {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, + {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, + {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, + {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, + {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, + {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, + {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, + {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, + {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, + {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, + {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, + {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, + {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, + {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, + {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, + {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, + {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, + {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, + {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, + {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, + {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, + {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, + {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, + {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, + {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, + {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, + {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, + {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, + {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, + {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, + {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, + {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, + {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, + {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.5.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, + {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.8.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "py360convert" +version = "0.1.0" +description = "Convertion between cubemap and equirectangular and also to perspective planar." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "py360convert-0.1.0.tar.gz", hash = "sha256:ffde46646db828119ca7e23da68c0e25ccf3c9c7295c619aef8a29ba86af7e55"}, +] + +[package.dependencies] +numpy = "*" +scipy = "*" + +[[package]] +name = "pydeck" +version = "0.9.1" +description = "Widget for deck.gl maps" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydeck-0.9.1-py2.py3-none-any.whl", hash = "sha256:b3f75ba0d273fc917094fa61224f3f6076ca8752b93d46faf3bcfd9f9d59b038"}, + {file = "pydeck-0.9.1.tar.gz", hash = "sha256:f74475ae637951d63f2ee58326757f8d4f9cd9f2a457cf42950715003e2cb605"}, +] + +[package.dependencies] +jinja2 = ">=2.10.1" +numpy = ">=1.16.4" + +[package.extras] +carto = ["pydeck-carto"] +jupyter = ["ipykernel (>=5.1.2) ; python_version >= \"3.4\"", "ipython (>=5.8.0) ; python_version < \"3.4\"", "ipywidgets (>=7,<8)", "traitlets (>=4.3.2)"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymdown-extensions" +version = "10.17.1" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pymdown_extensions-10.17.1-py3-none-any.whl", hash = "sha256:1f160209c82eecbb5d8a0d8f89a4d9bd6bdcbde9a8537761844cfc57ad5cd8a6"}, + {file = "pymdown_extensions-10.17.1.tar.gz", hash = "sha256:60d05fe55e7fb5a1e4740fc575facad20dc6ee3a748e8d3d36ba44142e75ce03"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.19.1)"] + +[[package]] +name = "pyogrio" +version = "0.9.0" +description = "Vectorized spatial vector file format I/O using GDAL/OGR" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyogrio-0.9.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:1a495ca4fb77c69595747dd688f8f17bb7d2ea9cd86603aa71c7fc98cc8b4174"}, + {file = "pyogrio-0.9.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:6dc94a67163218581c7df275223488ac9b31dc582ccd756da607c3338908566c"}, + {file = "pyogrio-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e38c3c6d37cf2cc969407e4d051dcb507cfd948eb26c7b0840c4f7d7d4a71bd4"}, + {file = "pyogrio-0.9.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f47c9b6818cc0f420015b672d5dcc488530a5ee63e5ba35a184957b21ea3922a"}, + {file = "pyogrio-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb04bd80964428491951766452f0071b0bc37c7d38c45ef02502dbd83e5d74a0"}, + {file = "pyogrio-0.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f5d80eb846be4fc4e642cbedc1ed0c143e8d241653382ecc76a7620bbd2a5c3a"}, + {file = "pyogrio-0.9.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:2f2ec57ab74785db9c2bf47c0a6731e5175595a13f8253f06fa84136adb310a9"}, + {file = "pyogrio-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a289584da6df7ca318947301fe0ba9177e7f863f63110e087c80ac5f3658de8"}, + {file = "pyogrio-0.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:13642608a1cd67797ae8b5d792b0518d8ef3eb76506c8232ab5eaa1ea1159dff"}, + {file = "pyogrio-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:9440466c0211ac81f3417f274da5903f15546b486f76b2f290e74a56aaf0e737"}, + {file = "pyogrio-0.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2e98913fa183f7597c609e774820a149e9329fd2a0f8d33978252fbd00ae87e6"}, + {file = "pyogrio-0.9.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f8bf193269ea9d347ac3ddada960a59f1ab2e4a5c009be95dc70e6505346b2fc"}, + {file = "pyogrio-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f964002d445521ad5b8e732a6b5ef0e2d2be7fe566768e5075c1d71398da64a"}, + {file = "pyogrio-0.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:083351b258b3e08b6c6085dac560bd321b68de5cb4a66229095da68d5f3d696b"}, + {file = "pyogrio-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:796e4f6a4e769b2eb6fea9a10546ea4bdee16182d1e29802b4d6349363c3c1d7"}, + {file = "pyogrio-0.9.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:7fcafed24371fe6e23bcf5abebbb29269f8d79915f1dd818ac85453657ea714a"}, + {file = "pyogrio-0.9.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:30cbeeaedb9bced7012487e7438919aa0c7dfba18ac3d4315182b46eb3139b9d"}, + {file = "pyogrio-0.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4da0b9deb380bd9a200fee13182c4f95b02b4c554c923e2e0032f32aaf1439ed"}, + {file = "pyogrio-0.9.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4e0f90a6c3771ee1f1fea857778b4b6a1b64000d851b819f435f9091b3c38c60"}, + {file = "pyogrio-0.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:959022f3ad04053f8072dc9a2ad110c46edd9e4f92352061ba835fc91df3ca96"}, + {file = "pyogrio-0.9.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:2829615cf58b1b24a9f96fea42abedaa1a800dd351c67374cc2f6341138608f3"}, + {file = "pyogrio-0.9.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:17420febc17651876d5140b54b24749aa751d482b5f9ef6267b8053e6e962876"}, + {file = "pyogrio-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a2fcaa269031dbbc8ebd91243c6452c5d267d6df939c008ab7533413c9cf92d"}, + {file = "pyogrio-0.9.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:019731a856a9abfe909e86f50eb13f8362f6742337caf757c54b7c8acfe75b89"}, + {file = "pyogrio-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:d668cb10f2bf6ccd7c402f91e8b06290722dd09dbe265ae95b2c13db29ebeba0"}, + {file = "pyogrio-0.9.0.tar.gz", hash = "sha256:6a6fa2e8cf95b3d4a7c0fac48bce6e5037579e28d3eb33b53349d6e11f15e5a8"}, +] + +[package.dependencies] +certifi = "*" +numpy = "*" +packaging = "*" + +[package.extras] +benchmark = ["pytest-benchmark"] +dev = ["Cython"] +geopandas = ["geopandas"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "pyparsing" +version = "3.2.5" +description = "pyparsing - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e"}, + {file = "pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyproj" +version = "3.7.2" +description = "Python interface to PROJ (cartographic projections and coordinate transformations library)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5"}, + {file = "pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a"}, + {file = "pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25"}, + {file = "pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a"}, + {file = "pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc"}, + {file = "pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5"}, + {file = "pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a"}, + {file = "pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433"}, + {file = "pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71"}, + {file = "pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab"}, + {file = "pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68"}, + {file = "pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a"}, + {file = "pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630"}, + {file = "pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260"}, + {file = "pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9"}, + {file = "pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d"}, + {file = "pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128"}, + {file = "pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3"}, + {file = "pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1"}, + {file = "pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7"}, + {file = "pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa"}, + {file = "pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681"}, + {file = "pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5"}, + {file = "pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67"}, + {file = "pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3"}, + {file = "pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7"}, + {file = "pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100"}, + {file = "pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279"}, + {file = "pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6"}, + {file = "pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220"}, + {file = "pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c"}, + {file = "pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c"}, + {file = "pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69"}, + {file = "pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2"}, + {file = "pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3"}, + {file = "pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd"}, + {file = "pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02"}, + {file = "pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08"}, + {file = "pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b"}, + {file = "pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281"}, + {file = "pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516"}, + {file = "pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e"}, + {file = "pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25"}, + {file = "pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112"}, + {file = "pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6"}, + {file = "pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37"}, + {file = "pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b"}, + {file = "pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357"}, + {file = "pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81"}, + {file = "pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888"}, + {file = "pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59"}, + {file = "pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa"}, + {file = "pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c"}, + {file = "pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4"}, + {file = "pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c"}, +] + +[package.dependencies] +certifi = "*" + +[[package]] +name = "pyside6" +version = "6.11.0" +description = "Python bindings for the Qt cross-platform application and UI framework" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"}, + {file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"}, + {file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"}, + {file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"}, + {file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"}, +] + +[package.dependencies] +PySide6_Addons = "6.11.0" +PySide6_Essentials = "6.11.0" +shiboken6 = "6.11.0" + +[[package]] +name = "pyside6-addons" +version = "6.11.0" +description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"}, + {file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"}, +] + +[package.dependencies] +PySide6_Essentials = "6.11.0" +shiboken6 = "6.11.0" + +[[package]] +name = "pyside6-essentials" +version = "6.11.0" +description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"}, + {file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"}, +] + +[package.dependencies] +shiboken6 = "6.11.0" + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytokens" +version = "0.3.0" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"}, + {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pytz" +version = "2025.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +description = "A custom YAML tag for referencing environment variables in YAML files." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04"}, + {file = "pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "regex" +version = "2025.11.3" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"}, + {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"}, + {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"}, + {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"}, + {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"}, + {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"}, + {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"}, + {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"}, + {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"}, + {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"}, + {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"}, + {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"}, + {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"}, + {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"}, + {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"}, + {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"}, + {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"}, + {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"}, + {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"}, + {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"}, + {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"}, + {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"}, + {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"}, + {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"}, + {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"}, + {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"}, + {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"}, + {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"}, + {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"}, + {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"}, + {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"}, + {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"}, + {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"}, + {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"}, + {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"}, + {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"}, + {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"}, + {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"}, + {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"}, + {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"}, + {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"}, + {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"}, + {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"}, + {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"}, + {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"}, + {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"}, + {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"}, + {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"}, + {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"}, + {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.12.12" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"}, + {file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"}, + {file = "ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45"}, + {file = "ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5"}, + {file = "ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4"}, + {file = "ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23"}, + {file = "ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489"}, + {file = "ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee"}, + {file = "ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1"}, + {file = "ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d"}, + {file = "ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093"}, + {file = "ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6"}, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517"}, + {file = "safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48"}, + {file = "safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0"}, + {file = "safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4"}, + {file = "safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba"}, + {file = "safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4729811a6640d019a4b7ba8638ee2fd21fa5ca8c7e7bdf0fed62068fcaac737"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12f49080303fa6bb424b362149a12949dfbbf1e06811a88f2307276b0c131afd"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0071bffba4150c2f46cae1432d31995d77acfd9f8db598b5d1a2ce67e8440ad2"}, + {file = "safetensors-0.7.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:473b32699f4200e69801bf5abf93f1a4ecd432a70984df164fc22ccf39c4a6f3"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b95a3fa7b3abb9b5b0e07668e808364d0d40f6bbbf9ae0faa8b5b210c97b140"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cfdead2f57330d76aa7234051dadfa7d4eedc0e5a27fd08e6f96714a92b00f09"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc92bc2db7b45bda4510e4f51c59b00fe80b2d6be88928346e4294ce1c2abe7c"}, + {file = "safetensors-0.7.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6999421eb8ba9df4450a16d9184fcb7bef26240b9f98e95401f17af6c2210b71"}, + {file = "safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0"}, +] + +[package.extras] +all = ["safetensors[jax]", "safetensors[numpy]", "safetensors[paddlepaddle]", "safetensors[pinned-tf]", "safetensors[quality]", "safetensors[testing]", "safetensors[torch]"] +dev = ["safetensors[all]"] +jax = ["flax (>=0.6.3)", "jax (>=0.3.25)", "jaxlib (>=0.3.25)", "safetensors[numpy]"] +mlx = ["mlx (>=0.0.9)"] +numpy = ["numpy (>=1.21.6)"] +paddlepaddle = ["paddlepaddle (>=2.4.1)", "safetensors[numpy]"] +pinned-tf = ["safetensors[numpy]", "tensorflow (==2.18.0)"] +quality = ["ruff"] +tensorflow = ["safetensors[numpy]", "tensorflow (>=2.11.0)"] +testing = ["h5py (>=3.7.0)", "huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +testingfree = ["huggingface-hub (>=0.12.1)", "hypothesis (>=6.70.2)", "pytest (>=7.2.0)", "pytest-benchmark (>=4.0.0)", "safetensors[numpy]", "setuptools-rust (>=1.5.2)"] +torch = ["packaging", "safetensors[numpy]", "torch (>=1.10)"] + +[[package]] +name = "scipy" +version = "1.16.3" +description = "Fundamental algorithms for scientific computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005"}, + {file = "scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb"}, + {file = "scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876"}, + {file = "scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2"}, + {file = "scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e"}, + {file = "scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733"}, + {file = "scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78"}, + {file = "scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9"}, + {file = "scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686"}, + {file = "scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203"}, + {file = "scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1"}, + {file = "scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe"}, + {file = "scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70"}, + {file = "scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc"}, + {file = "scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9"}, + {file = "scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4"}, + {file = "scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959"}, + {file = "scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88"}, + {file = "scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234"}, + {file = "scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d"}, + {file = "scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304"}, + {file = "scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a"}, + {file = "scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119"}, + {file = "scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c"}, + {file = "scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e"}, + {file = "scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135"}, + {file = "scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6"}, + {file = "scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc"}, + {file = "scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26"}, + {file = "scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc"}, + {file = "scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22"}, + {file = "scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc"}, + {file = "scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0"}, + {file = "scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800"}, + {file = "scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d"}, + {file = "scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d"}, + {file = "scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa"}, + {file = "scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8"}, + {file = "scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353"}, + {file = "scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146"}, + {file = "scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d"}, + {file = "scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7"}, + {file = "scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562"}, + {file = "scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb"}, +] + +[package.dependencies] +numpy = ">=1.25.2,<2.6" + +[package.extras] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "shapely" +version = "2.1.2" +description = "Manipulation and analysis of geometric objects" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "shapely-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7ae48c236c0324b4e139bea88a306a04ca630f49be66741b340729d380d8f52f"}, + {file = "shapely-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eba6710407f1daa8e7602c347dfc94adc02205ec27ed956346190d66579eb9ea"}, + {file = "shapely-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef4a456cc8b7b3d50ccec29642aa4aeda959e9da2fe9540a92754770d5f0cf1f"}, + {file = "shapely-2.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e38a190442aacc67ff9f75ce60aec04893041f16f97d242209106d502486a142"}, + {file = "shapely-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:40d784101f5d06a1fd30b55fc11ea58a61be23f930d934d86f19a180909908a4"}, + {file = "shapely-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f6cd5819c50d9bcf921882784586aab34a4bd53e7553e175dece6db513a6f0"}, + {file = "shapely-2.1.2-cp310-cp310-win32.whl", hash = "sha256:fe9627c39c59e553c90f5bc3128252cb85dc3b3be8189710666d2f8bc3a5503e"}, + {file = "shapely-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:1d0bfb4b8f661b3b4ec3565fa36c340bfb1cda82087199711f86a88647d26b2f"}, + {file = "shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618"}, + {file = "shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d"}, + {file = "shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09"}, + {file = "shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26"}, + {file = "shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7"}, + {file = "shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2"}, + {file = "shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6"}, + {file = "shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc"}, + {file = "shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94"}, + {file = "shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359"}, + {file = "shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3"}, + {file = "shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b"}, + {file = "shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc"}, + {file = "shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d"}, + {file = "shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454"}, + {file = "shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179"}, + {file = "shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8"}, + {file = "shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a"}, + {file = "shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e"}, + {file = "shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6"}, + {file = "shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af"}, + {file = "shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd"}, + {file = "shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350"}, + {file = "shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715"}, + {file = "shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40"}, + {file = "shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b"}, + {file = "shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801"}, + {file = "shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0"}, + {file = "shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c"}, + {file = "shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99"}, + {file = "shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf"}, + {file = "shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c"}, + {file = "shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223"}, + {file = "shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c"}, + {file = "shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df"}, + {file = "shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf"}, + {file = "shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4"}, + {file = "shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc"}, + {file = "shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566"}, + {file = "shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c"}, + {file = "shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a"}, + {file = "shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076"}, + {file = "shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1"}, + {file = "shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0"}, + {file = "shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26"}, + {file = "shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0"}, + {file = "shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735"}, + {file = "shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9"}, + {file = "shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9"}, +] + +[package.dependencies] +numpy = ">=1.21" + +[package.extras] +docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] +test = ["pytest", "pytest-cov", "scipy-doctest"] + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "shiboken6" +version = "6.11.0" +description = "Python/C++ bindings helper module" +optional = false +python-versions = "<3.15,>=3.10" +groups = ["main"] +files = [ + {file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"}, + {file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"}, + {file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"}, + {file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"}, + {file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"}, +] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "termcolor" +version = "3.2.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "termcolor-3.2.0-py3-none-any.whl", hash = "sha256:a10343879eba4da819353c55cb8049b0933890c2ebf9ad5d3ecd2bb32ea96ea6"}, + {file = "termcolor-3.2.0.tar.gz", hash = "sha256:610e6456feec42c4bcd28934a8c87a06c3fa28b01561d46aa09a9881b8622c58"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "timm" +version = "1.0.22" +description = "PyTorch Image Models" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "timm-1.0.22-py3-none-any.whl", hash = "sha256:888981753e65cbaacfc07494370138b1700a27b1f0af587f4f9b47bc024161d0"}, + {file = "timm-1.0.22.tar.gz", hash = "sha256:14fd74bcc17db3856b1a47d26fb305576c98579ab9d02b36714a5e6b25cde422"}, +] + +[package.dependencies] +huggingface_hub = "*" +pyyaml = "*" +safetensors = "*" +torch = "*" +torchvision = "*" + +[[package]] +name = "torch" +version = "2.4.1" +description = "Tensors and Dynamic neural networks in Python with strong GPU acceleration" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "torch-2.4.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:362f82e23a4cd46341daabb76fba08f04cd646df9bfaf5da50af97cb60ca4971"}, + {file = "torch-2.4.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:e8ac1985c3ff0f60d85b991954cfc2cc25f79c84545aead422763148ed2759e3"}, + {file = "torch-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:91e326e2ccfb1496e3bee58f70ef605aeb27bd26be07ba64f37dcaac3d070ada"}, + {file = "torch-2.4.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:d36a8ef100f5bff3e9c3cea934b9e0d7ea277cb8210c7152d34a9a6c5830eadd"}, + {file = "torch-2.4.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:0b5f88afdfa05a335d80351e3cea57d38e578c8689f751d35e0ff36bce872113"}, + {file = "torch-2.4.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:ef503165f2341942bfdf2bd520152f19540d0c0e34961232f134dc59ad435be8"}, + {file = "torch-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:092e7c2280c860eff762ac08c4bdcd53d701677851670695e0c22d6d345b269c"}, + {file = "torch-2.4.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:ddddbd8b066e743934a4200b3d54267a46db02106876d21cf31f7da7a96f98ea"}, + {file = "torch-2.4.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:fdc4fe11db3eb93c1115d3e973a27ac7c1a8318af8934ffa36b0370efe28e042"}, + {file = "torch-2.4.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:18835374f599207a9e82c262153c20ddf42ea49bc76b6eadad8e5f49729f6e4d"}, + {file = "torch-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:ebea70ff30544fc021d441ce6b219a88b67524f01170b1c538d7d3ebb5e7f56c"}, + {file = "torch-2.4.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:72b484d5b6cec1a735bf3fa5a1c4883d01748698c5e9cfdbeb4ffab7c7987e0d"}, + {file = "torch-2.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:c99e1db4bf0c5347107845d715b4aa1097e601bdc36343d758963055e9599d93"}, + {file = "torch-2.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b57f07e92858db78c5b72857b4f0b33a65b00dc5d68e7948a8494b0314efb880"}, + {file = "torch-2.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:f18197f3f7c15cde2115892b64f17c80dbf01ed72b008020e7da339902742cf6"}, + {file = "torch-2.4.1-cp38-none-macosx_11_0_arm64.whl", hash = "sha256:5fc1d4d7ed265ef853579caf272686d1ed87cebdcd04f2a498f800ffc53dab71"}, + {file = "torch-2.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:40f6d3fe3bae74efcf08cb7f8295eaddd8a838ce89e9d26929d4edd6d5e4329d"}, + {file = "torch-2.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c9299c16c9743001ecef515536ac45900247f4338ecdf70746f2461f9e4831db"}, + {file = "torch-2.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:6bce130f2cd2d52ba4e2c6ada461808de7e5eccbac692525337cfb4c19421846"}, + {file = "torch-2.4.1-cp39-none-macosx_11_0_arm64.whl", hash = "sha256:a38de2803ee6050309aac032676536c3d3b6a9804248537e38e098d0e14817ec"}, +] + +[package.dependencies] +filelock = "*" +fsspec = "*" +jinja2 = "*" +networkx = "*" +nvidia-cublas-cu12 = {version = "12.1.3.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-cupti-cu12 = {version = "12.1.105", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-nvrtc-cu12 = {version = "12.1.105", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-runtime-cu12 = {version = "12.1.105", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cudnn-cu12 = {version = "9.1.0.70", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cufft-cu12 = {version = "11.0.2.54", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-curand-cu12 = {version = "10.3.2.106", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusolver-cu12 = {version = "11.4.5.107", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusparse-cu12 = {version = "12.1.0.106", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nccl-cu12 = {version = "2.20.5", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nvtx-cu12 = {version = "12.1.105", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +setuptools = "*" +sympy = "*" +triton = {version = "3.0.0", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version < \"3.13\""} +typing-extensions = ">=4.8.0" + +[package.extras] +opt-einsum = ["opt-einsum (>=3.3)"] +optree = ["optree (>=0.11.0)"] + +[[package]] +name = "torchvision" +version = "0.19.1" +description = "image and video datasets and models for torch deep learning" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "torchvision-0.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54e8513099e6f586356c70f809d34f391af71ad182fe071cc328a28af2c40608"}, + {file = "torchvision-0.19.1-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:20a1f5e02bfdad7714e55fa3fa698347c11d829fa65e11e5a84df07d93350eed"}, + {file = "torchvision-0.19.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:7b063116164be52fc6deb4762de7f8c90bfa3a65f8d5caf17f8e2d5aadc75a04"}, + {file = "torchvision-0.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:f40b6acabfa886da1bc3768f47679c61feee6bde90deb979d9f300df8c8a0145"}, + {file = "torchvision-0.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:40514282b4896d62765b8e26d7091c32e17c35817d00ec4be2362ea3ba3d1787"}, + {file = "torchvision-0.19.1-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:5a91be061ae5d6d5b95e833b93e57ca4d3c56c5a57444dd15da2e3e7fba96050"}, + {file = "torchvision-0.19.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d71a6a6fe3a5281ca3487d4c56ad4aad20ff70f82f1d7c79bcb6e7b0c2af00c8"}, + {file = "torchvision-0.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:70dea324174f5e9981b68e4b7cd524512c106ba64aedef560a86a0bbf2fbf62c"}, + {file = "torchvision-0.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27ece277ff0f6cdc7fed0627279c632dcb2e58187da771eca24b0fbcf3f8590d"}, + {file = "torchvision-0.19.1-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:c659ff92a61f188a1a7baef2850f3c0b6c85685447453c03d0e645ba8f1dcc1c"}, + {file = "torchvision-0.19.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:c07bf43c2a145d792ecd9d0503d6c73577147ece508d45600d8aac77e4cdfcf9"}, + {file = "torchvision-0.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4283d283675556bb0eae31d29996f53861b17cbdcdf3509e6bc050414ac9289"}, + {file = "torchvision-0.19.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4c4e4f5b24ea6b087b02ed492ab1e21bba3352c4577e2def14248cfc60732338"}, + {file = "torchvision-0.19.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9281d63ead929bb19143731154cd1d8bf0b5e9873dff8578a40e90a6bec3c6fa"}, + {file = "torchvision-0.19.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:4d10bc9083c4d5fadd7edd7b729700a7be48dab4f62278df3bc73fa48e48a155"}, + {file = "torchvision-0.19.1-cp38-cp38-win_amd64.whl", hash = "sha256:ccf085ef1824fb9e16f1901285bf89c298c62dfd93267a39e8ee42c71255242f"}, + {file = "torchvision-0.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:731f434d91586769e255b5d70ed1a4457e0a1394a95f4aacf0e1e7e21f80c098"}, + {file = "torchvision-0.19.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:febe4f14d4afcb47cc861d8be7760ab6a123cd0817f97faf5771488cb6aa90f4"}, + {file = "torchvision-0.19.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:e328309b8670a2e889b2fe76a1c2744a099c11c984da9a822357bd9debd699a5"}, + {file = "torchvision-0.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:6616f12e00a22e7f3fedbd0fccb0804c05e8fe22871668f10eae65cf3f283614"}, +] + +[package.dependencies] +numpy = "*" +pillow = ">=5.3.0,<8.3.dev0 || >=8.4.dev0" +torch = "2.4.1" + +[package.extras] +gdown = ["gdown (>=4.7.3)"] +scipy = ["scipy"] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "triton" +version = "3.0.0" +description = "A language and compiler for custom Deep Learning operations" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\" and python_version == \"3.12\"" +files = [ + {file = "triton-3.0.0-1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e1efef76935b2febc365bfadf74bcb65a6f959a9872e5bddf44cc9e0adce1e1a"}, + {file = "triton-3.0.0-1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ce8520437c602fb633f1324cc3871c47bee3b67acf9756c1a66309b60e3216c"}, + {file = "triton-3.0.0-1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e509deb77f1c067d8640725ef00c5cbfcb2052a1a3cb6a6d343841f92624eb"}, + {file = "triton-3.0.0-1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bcbf3b1c48af6a28011a5c40a5b3b9b5330530c3827716b5fbf6d7adcc1e53e9"}, + {file = "triton-3.0.0-1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6e5727202f7078c56f91ff13ad0c1abab14a0e7f2c87e91b12b6f64f3e8ae609"}, +] + +[package.dependencies] +filelock = "*" + +[package.extras] +build = ["cmake (>=3.20)", "lit"] +tests = ["autopep8", "flake8", "isort", "llnl-hatchet", "numpy", "pytest", "scipy (>=1.7.1)"] +tutorials = ["matplotlib", "pandas", "tabulate"] + +[[package]] +name = "typer-slim" +version = "0.20.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d"}, + {file = "typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3"}, +] + +[package.dependencies] +click = ">=8.0.0" +typing-extensions = ">=3.7.4.3" + +[package.extras] +standard = ["rich (>=10.11.0)", "shellingham (>=1.3.0)"] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1"}, + {file = "types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.35.4" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"}, + {file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcwidth" +version = "0.2.14" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, + {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, +] + +[[package]] +name = "yaspin" +version = "3.3.0" +description = "Yet Another Terminal Spinner" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "yaspin-3.3.0-py3-none-any.whl", hash = "sha256:ab5113be4b34ef33f7d4d97be9b6867101ad020c2fb02bc92e3137c75b06d712"}, + {file = "yaspin-3.3.0.tar.gz", hash = "sha256:505c9a44c6e3723a1bee8f7a17a055b17475176b74dd93e468fa8db48c172a41"}, +] + +[package.dependencies] +termcolor = ">=3.1,<4.0" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12,<3.15" +content-hash = "7ffbc7895146e77cf680414205531433181a8ae70b594d696e7dbba4a28e42cb" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..095118d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[tool.poetry] +package-mode = true +packages = [{ include = "uq_desktop_processor", from = "src" }] + +[project] +name = "uq_desktop_processor" +version = "1.0.0" +description = "AI-assisted visual urban assessment from street-level imagery: road-network sampling, Mapillary downloads, CLIP/ViT scoring, and GIS-ready exports (GeoJSON/GPKG)." +readme = "README.md" +requires-python = ">=3.12,<3.15" +license = "LicenseRef-UNLICENSED" +license-files = ["LICENSE"] +authors = [{ name = "Antek-N", email = "antek.nikolajdu@gmail.com" }] +dynamic = ["dependencies", "optional-dependencies"] + +[project.scripts] +uq_desktop_processor = "uq_desktop_processor.__main__:main" +uq-desktop-processor = "uq_desktop_processor.__main__:main" + +[tool.poetry.dependencies] +python = ">=3.12,<3.15" +torch = "^2.4.0" +torchvision = "^0.19.0" +timm = "^1.0.0" +numpy = "<2.0" +pillow = "^10.0.0" +tqdm = "^4.66.0" +tabulate = "^0.9.0" +joblib = "^1.4.0" +clip = { git = "https://github.com/openai/CLIP.git" } +ftfy = "^6.3.1" +regex = "^2025.9.1" +geopandas = "^1.0.0" +shapely = "^2.0.0" +pyproj = "^3.6.0" +fiona = "^1.9.0" +pyogrio = "^0.9.0" +osmnx = "^1.9.0" +yaspin = "^3.0.0" +matplotlib = "^3.8.0" +requests = "^2.32.0" +opencv-python-headless = "^4.9.0" +piexif = "^1.1.3" +py360convert = "^0.1.0" +pyside6 = "^6.11.0" +gpxpy = "^1.6.2" +pydeck = "^0.9.1" + +[tool.poetry.group.dev.dependencies] +black = "^25.1.0" +ruff = "^0.12.12" +mypy = "^1.17.1" +pytest = "^8.3.0" +pytest-cov = "^5.0.0" +pre-commit = "^3.6.0" +mkdocs = "^1.6" +mkdocs-material = "^9.5" +mkdocs-autorefs = "^1.2" +mkdocs-gen-files = "^0.5" +mkdocs-literate-nav = "^0.6" +mkdocstrings = "^0.25" +mkdocstrings-python = "^1.10" +pymdown-extensions = "^10.8" +types-requests = "^2.32.4.20250913" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 120 +target-version = ["py312"] + +[tool.ruff] +line-length = 120 +target-version = "py312" +fix = false + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.12" +strict = false +warn_unused_configs = true +disallow_untyped_defs = true +ignore_missing_imports = true +exclude = "^(tests/|site/|\\.venv/|out/)" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b9e5403 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,124 @@ +anyio==4.11.0 ; python_version >= "3.12" and python_version < "3.15" +attrs==25.4.0 ; python_version >= "3.12" and python_version < "3.15" +babel==2.17.0 ; python_version >= "3.12" and python_version < "3.15" +backrefs==6.1 ; python_version >= "3.12" and python_version < "3.15" +black==25.11.0 ; python_version >= "3.12" and python_version < "3.15" +certifi==2025.11.12 ; python_version >= "3.12" and python_version < "3.15" +cfgv==3.5.0 ; python_version >= "3.12" and python_version < "3.15" +charset-normalizer==3.4.4 ; python_version >= "3.12" and python_version < "3.15" +click-plugins==1.1.1.2 ; python_version >= "3.12" and python_version < "3.15" +click==8.3.1 ; python_version >= "3.12" and python_version < "3.15" +cligj==0.7.2 ; python_version >= "3.12" and python_version < "3.15" +clip @ git+https://github.com/openai/CLIP.git@dcba3cb2e2827b402d2701e7e1c7d9fed8a20ef1 ; python_version >= "3.12" and python_version < "3.15" +colorama==0.4.6 ; python_version >= "3.12" and python_version < "3.15" +contourpy==1.3.3 ; python_version >= "3.12" and python_version < "3.15" +coverage==7.12.0 ; python_version >= "3.12" and python_version < "3.15" +cycler==0.12.1 ; python_version >= "3.12" and python_version < "3.15" +distlib==0.4.0 ; python_version >= "3.12" and python_version < "3.15" +filelock==3.20.0 ; python_version >= "3.12" and python_version < "3.15" +fiona==1.10.1 ; python_version >= "3.12" and python_version < "3.15" +fonttools==4.60.1 ; python_version >= "3.12" and python_version < "3.15" +fsspec==2025.10.0 ; python_version >= "3.12" and python_version < "3.15" +ftfy==6.3.1 ; python_version >= "3.12" and python_version < "3.15" +geopandas==1.1.1 ; python_version >= "3.12" and python_version < "3.15" +ghp-import==2.1.0 ; python_version >= "3.12" and python_version < "3.15" +gpxpy==1.6.2 ; python_version >= "3.12" and python_version < "3.15" +griffe==1.15.0 ; python_version >= "3.12" and python_version < "3.15" +h11==0.16.0 ; python_version >= "3.12" and python_version < "3.15" +hf-xet==1.2.0 ; python_version >= "3.12" and python_version < "3.15" and (platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "arm64" or platform_machine == "aarch64") +httpcore==1.0.9 ; python_version >= "3.12" and python_version < "3.15" +httpx==0.28.1 ; python_version >= "3.12" and python_version < "3.15" +huggingface-hub==1.1.5 ; python_version >= "3.12" and python_version < "3.15" +identify==2.6.15 ; python_version >= "3.12" and python_version < "3.15" +idna==3.11 ; python_version >= "3.12" and python_version < "3.15" +iniconfig==2.3.0 ; python_version >= "3.12" and python_version < "3.15" +jinja2==3.1.6 ; python_version >= "3.12" and python_version < "3.15" +joblib==1.5.2 ; python_version >= "3.12" and python_version < "3.15" +kiwisolver==1.4.9 ; python_version >= "3.12" and python_version < "3.15" +markdown==3.10 ; python_version >= "3.12" and python_version < "3.15" +markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "3.15" +matplotlib==3.10.7 ; python_version >= "3.12" and python_version < "3.15" +mergedeep==1.3.4 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-autorefs==1.4.3 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-gen-files==0.5.0 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-get-deps==0.2.0 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-literate-nav==0.6.2 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-material-extensions==1.3.1 ; python_version >= "3.12" and python_version < "3.15" +mkdocs-material==9.7.0 ; python_version >= "3.12" and python_version < "3.15" +mkdocs==1.6.1 ; python_version >= "3.12" and python_version < "3.15" +mkdocstrings-python==1.10.9 ; python_version >= "3.12" and python_version < "3.15" +mkdocstrings==0.25.2 ; python_version >= "3.12" and python_version < "3.15" +mpmath==1.3.0 ; python_version >= "3.12" and python_version < "3.15" +mypy-extensions==1.1.0 ; python_version >= "3.12" and python_version < "3.15" +mypy==1.18.2 ; python_version >= "3.12" and python_version < "3.15" +networkx==3.5 ; python_version >= "3.12" and python_version < "3.15" +nodeenv==1.9.1 ; python_version >= "3.12" and python_version < "3.15" +numpy==1.26.4 ; python_version >= "3.12" and python_version < "3.15" +nvidia-cublas-cu12==12.1.3.1 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-cupti-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-nvrtc-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-runtime-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cudnn-cu12==9.1.0.70 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cufft-cu12==11.0.2.54 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-curand-cu12==10.3.2.106 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cusolver-cu12==11.4.5.107 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cusparse-cu12==12.1.0.106 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nccl-cu12==2.20.5 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nvjitlink-cu12==12.8.93 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nvtx-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +opencv-python-headless==4.11.0.86 ; python_version >= "3.12" and python_version < "3.15" +osmnx==1.9.3 ; python_version >= "3.12" and python_version < "3.15" +packaging==25.0 ; python_version >= "3.12" and python_version < "3.15" +paginate==0.5.7 ; python_version >= "3.12" and python_version < "3.15" +pandas==2.3.3 ; python_version >= "3.12" and python_version < "3.15" +pathspec==0.12.1 ; python_version >= "3.12" and python_version < "3.15" +piexif==1.1.3 ; python_version >= "3.12" and python_version < "3.15" +pillow==10.4.0 ; python_version >= "3.12" and python_version < "3.15" +platformdirs==4.5.0 ; python_version >= "3.12" and python_version < "3.15" +pluggy==1.6.0 ; python_version >= "3.12" and python_version < "3.15" +pre-commit==3.8.0 ; python_version >= "3.12" and python_version < "3.15" +py360convert==0.1.0 ; python_version >= "3.12" and python_version < "3.15" +pydeck==0.9.1 ; python_version >= "3.12" and python_version < "3.15" +pygments==2.19.2 ; python_version >= "3.12" and python_version < "3.15" +pymdown-extensions==10.17.1 ; python_version >= "3.12" and python_version < "3.15" +pyogrio==0.9.0 ; python_version >= "3.12" and python_version < "3.15" +pyparsing==3.2.5 ; python_version >= "3.12" and python_version < "3.15" +pyproj==3.7.2 ; python_version >= "3.12" and python_version < "3.15" +pyside6-addons==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +pyside6-essentials==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +pyside6==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +pytest-cov==5.0.0 ; python_version >= "3.12" and python_version < "3.15" +pytest==8.4.2 ; python_version >= "3.12" and python_version < "3.15" +python-dateutil==2.9.0.post0 ; python_version >= "3.12" and python_version < "3.15" +pytokens==0.3.0 ; python_version >= "3.12" and python_version < "3.15" +pytz==2025.2 ; python_version >= "3.12" and python_version < "3.15" +pyyaml-env-tag==1.1 ; python_version >= "3.12" and python_version < "3.15" +pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "3.15" +regex==2025.11.3 ; python_version >= "3.12" and python_version < "3.15" +requests==2.32.5 ; python_version >= "3.12" and python_version < "3.15" +ruff==0.12.12 ; python_version >= "3.12" and python_version < "3.15" +safetensors==0.7.0 ; python_version >= "3.12" and python_version < "3.15" +scipy==1.16.3 ; python_version >= "3.12" and python_version < "3.15" +setuptools==80.9.0 ; python_version >= "3.12" and python_version < "3.15" +shapely==2.1.2 ; python_version >= "3.12" and python_version < "3.15" +shellingham==1.5.4 ; python_version >= "3.12" and python_version < "3.15" +shiboken6==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +six==1.17.0 ; python_version >= "3.12" and python_version < "3.15" +sniffio==1.3.1 ; python_version >= "3.12" and python_version < "3.15" +sympy==1.14.0 ; python_version >= "3.12" and python_version < "3.15" +tabulate==0.9.0 ; python_version >= "3.12" and python_version < "3.15" +termcolor==3.2.0 ; python_version >= "3.12" and python_version < "3.15" +timm==1.0.22 ; python_version >= "3.12" and python_version < "3.15" +torch==2.4.1 ; python_version >= "3.12" and python_version < "3.15" +torchvision==0.19.1 ; python_version >= "3.12" and python_version < "3.15" +tqdm==4.67.1 ; python_version >= "3.12" and python_version < "3.15" +triton==3.0.0 ; python_version == "3.12" and platform_system == "Linux" and platform_machine == "x86_64" +typer-slim==0.20.0 ; python_version >= "3.12" and python_version < "3.15" +types-requests==2.32.4.20250913 ; python_version >= "3.12" and python_version < "3.15" +typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "3.15" +tzdata==2025.2 ; python_version >= "3.12" and python_version < "3.15" +urllib3==2.5.0 ; python_version >= "3.12" and python_version < "3.15" +virtualenv==20.35.4 ; python_version >= "3.12" and python_version < "3.15" +watchdog==6.0.0 ; python_version >= "3.12" and python_version < "3.15" +wcwidth==0.2.14 ; python_version >= "3.12" and python_version < "3.15" +yaspin==3.3.0 ; python_version >= "3.12" and python_version < "3.15" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75722c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,85 @@ +anyio==4.11.0 ; python_version >= "3.12" and python_version < "3.15" +attrs==25.4.0 ; python_version >= "3.12" and python_version < "3.15" +certifi==2025.11.12 ; python_version >= "3.12" and python_version < "3.15" +charset-normalizer==3.4.4 ; python_version >= "3.12" and python_version < "3.15" +click-plugins==1.1.1.2 ; python_version >= "3.12" and python_version < "3.15" +click==8.3.1 ; python_version >= "3.12" and python_version < "3.15" +cligj==0.7.2 ; python_version >= "3.12" and python_version < "3.15" +clip @ git+https://github.com/openai/CLIP.git@dcba3cb2e2827b402d2701e7e1c7d9fed8a20ef1 ; python_version >= "3.12" and python_version < "3.15" +colorama==0.4.6 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Windows" +contourpy==1.3.3 ; python_version >= "3.12" and python_version < "3.15" +cycler==0.12.1 ; python_version >= "3.12" and python_version < "3.15" +filelock==3.20.0 ; python_version >= "3.12" and python_version < "3.15" +fiona==1.10.1 ; python_version >= "3.12" and python_version < "3.15" +fonttools==4.60.1 ; python_version >= "3.12" and python_version < "3.15" +fsspec==2025.10.0 ; python_version >= "3.12" and python_version < "3.15" +ftfy==6.3.1 ; python_version >= "3.12" and python_version < "3.15" +geopandas==1.1.1 ; python_version >= "3.12" and python_version < "3.15" +gpxpy==1.6.2 ; python_version >= "3.12" and python_version < "3.15" +h11==0.16.0 ; python_version >= "3.12" and python_version < "3.15" +hf-xet==1.2.0 ; python_version >= "3.12" and python_version < "3.15" and (platform_machine == "x86_64" or platform_machine == "amd64" or platform_machine == "AMD64" or platform_machine == "arm64" or platform_machine == "aarch64") +httpcore==1.0.9 ; python_version >= "3.12" and python_version < "3.15" +httpx==0.28.1 ; python_version >= "3.12" and python_version < "3.15" +huggingface-hub==1.1.5 ; python_version >= "3.12" and python_version < "3.15" +idna==3.11 ; python_version >= "3.12" and python_version < "3.15" +jinja2==3.1.6 ; python_version >= "3.12" and python_version < "3.15" +joblib==1.5.2 ; python_version >= "3.12" and python_version < "3.15" +kiwisolver==1.4.9 ; python_version >= "3.12" and python_version < "3.15" +markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "3.15" +matplotlib==3.10.7 ; python_version >= "3.12" and python_version < "3.15" +mpmath==1.3.0 ; python_version >= "3.12" and python_version < "3.15" +networkx==3.5 ; python_version >= "3.12" and python_version < "3.15" +numpy==1.26.4 ; python_version >= "3.12" and python_version < "3.15" +nvidia-cublas-cu12==12.1.3.1 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-cupti-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-nvrtc-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cuda-runtime-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cudnn-cu12==9.1.0.70 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cufft-cu12==11.0.2.54 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-curand-cu12==10.3.2.106 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cusolver-cu12==11.4.5.107 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-cusparse-cu12==12.1.0.106 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nccl-cu12==2.20.5 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nvjitlink-cu12==12.8.93 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +nvidia-nvtx-cu12==12.1.105 ; python_version >= "3.12" and python_version < "3.15" and platform_system == "Linux" and platform_machine == "x86_64" +opencv-python-headless==4.11.0.86 ; python_version >= "3.12" and python_version < "3.15" +osmnx==1.9.3 ; python_version >= "3.12" and python_version < "3.15" +packaging==25.0 ; python_version >= "3.12" and python_version < "3.15" +pandas==2.3.3 ; python_version >= "3.12" and python_version < "3.15" +piexif==1.1.3 ; python_version >= "3.12" and python_version < "3.15" +pillow==10.4.0 ; python_version >= "3.12" and python_version < "3.15" +py360convert==0.1.0 ; python_version >= "3.12" and python_version < "3.15" +pydeck==0.9.1 ; python_version >= "3.12" and python_version < "3.15" +pyogrio==0.9.0 ; python_version >= "3.12" and python_version < "3.15" +pyparsing==3.2.5 ; python_version >= "3.12" and python_version < "3.15" +pyproj==3.7.2 ; python_version >= "3.12" and python_version < "3.15" +pyside6-addons==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +pyside6-essentials==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +pyside6==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +python-dateutil==2.9.0.post0 ; python_version >= "3.12" and python_version < "3.15" +pytz==2025.2 ; python_version >= "3.12" and python_version < "3.15" +pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "3.15" +regex==2025.11.3 ; python_version >= "3.12" and python_version < "3.15" +requests==2.32.5 ; python_version >= "3.12" and python_version < "3.15" +safetensors==0.7.0 ; python_version >= "3.12" and python_version < "3.15" +scipy==1.16.3 ; python_version >= "3.12" and python_version < "3.15" +setuptools==80.9.0 ; python_version >= "3.12" and python_version < "3.15" +shapely==2.1.2 ; python_version >= "3.12" and python_version < "3.15" +shellingham==1.5.4 ; python_version >= "3.12" and python_version < "3.15" +shiboken6==6.11.0 ; python_version >= "3.12" and python_version < "3.15" +six==1.17.0 ; python_version >= "3.12" and python_version < "3.15" +sniffio==1.3.1 ; python_version >= "3.12" and python_version < "3.15" +sympy==1.14.0 ; python_version >= "3.12" and python_version < "3.15" +tabulate==0.9.0 ; python_version >= "3.12" and python_version < "3.15" +termcolor==3.2.0 ; python_version >= "3.12" and python_version < "3.15" +timm==1.0.22 ; python_version >= "3.12" and python_version < "3.15" +torch==2.4.1 ; python_version >= "3.12" and python_version < "3.15" +torchvision==0.19.1 ; python_version >= "3.12" and python_version < "3.15" +tqdm==4.67.1 ; python_version >= "3.12" and python_version < "3.15" +triton==3.0.0 ; python_version == "3.12" and platform_system == "Linux" and platform_machine == "x86_64" +typer-slim==0.20.0 ; python_version >= "3.12" and python_version < "3.15" +typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "3.15" +tzdata==2025.2 ; python_version >= "3.12" and python_version < "3.15" +urllib3==2.5.0 ; python_version >= "3.12" and python_version < "3.15" +wcwidth==0.2.14 ; python_version >= "3.12" and python_version < "3.15" +yaspin==3.3.0 ; python_version >= "3.12" and python_version < "3.15" diff --git a/src/uq_desktop_processor/__init__.py b/src/uq_desktop_processor/__init__.py new file mode 100644 index 0000000..0d488c5 --- /dev/null +++ b/src/uq_desktop_processor/__init__.py @@ -0,0 +1,8 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("UrbanQuality-AI") +except PackageNotFoundError: # pragma: no cover + __version__ = "0.0.0" + +__all__ = ["__version__"] diff --git a/src/uq_desktop_processor/__main__.py b/src/uq_desktop_processor/__main__.py new file mode 100644 index 0000000..8508bff --- /dev/null +++ b/src/uq_desktop_processor/__main__.py @@ -0,0 +1,47 @@ +""" +Application entry point for `python -m uq_desktop_processor` (logging setup and GUI launch). +""" + +import logging +import sys + +from uq_desktop_processor.gui import main as start_gui +from uq_desktop_processor.logging_config import configure_logging + + +def main() -> int: + """ + Main entry point for the City-Lens pipeline. + + Configures logging, starts the application, and returns the exit code. + """ + # 1. Configure logging system + configure_logging(level=logging.DEBUG) + + # 2. Get the main logger + log = logging.getLogger(__name__) + log.debug("Logging configured.") + + # 3. Log application launch + log.info("Launching UrbanQuality-AI application pipeline...") + + # 4. Run the main pipeline function and get its exit code + try: + exit_code = start_gui() + + if exit_code == 0: + log.info("Application pipeline finished successfully (Exit Code 0).") + else: + log.warning("Application pipeline finished with errors (Exit Code %d).", exit_code) + + return exit_code + + except Exception as e: + # Catch any critical, unhandled exceptions + log.critical("An unhandled exception occurred: %s", e, exc_info=True) + return 1 + + +if __name__ == "__main__": + # Call main() and exit with its proper return code + sys.exit(main()) diff --git a/src/uq_desktop_processor/assets/img/icon.ico b/src/uq_desktop_processor/assets/img/icon.ico new file mode 100644 index 0000000..693aea5 Binary files /dev/null and b/src/uq_desktop_processor/assets/img/icon.ico differ diff --git a/src/uq_desktop_processor/assets/img/icon.png b/src/uq_desktop_processor/assets/img/icon.png new file mode 100644 index 0000000..610137d Binary files /dev/null and b/src/uq_desktop_processor/assets/img/icon.png differ diff --git a/src/uq_desktop_processor/evaluation/__init__.py b/src/uq_desktop_processor/evaluation/__init__.py new file mode 100644 index 0000000..4ee0c1f --- /dev/null +++ b/src/uq_desktop_processor/evaluation/__init__.py @@ -0,0 +1,15 @@ +""" +Evaluation module: init . +""" + +from .clip_common import print_results +from .clip_evaluator import evaluate_images_with_clip +from .clip_prefilter import prefilter_folder +from .finetuned_evaluator import evaluate_images_with_finetuned + +__all__ = [ + "evaluate_images_with_clip", + "evaluate_images_with_finetuned", + "prefilter_folder", + "print_results", +] diff --git a/src/uq_desktop_processor/evaluation/clip_common/__init__.py b/src/uq_desktop_processor/evaluation/clip_common/__init__.py new file mode 100644 index 0000000..6320109 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_common/__init__.py @@ -0,0 +1,50 @@ +""" +Evaluation module: init . +""" + +from .directions import encode_image, prepare_directions +from .model import encode_texts, load_model, sigmoid_probability +from .printer import print_results +from .validate import ( + # Classes and main functions + ValidationError, + all_nonempty_strings, + as_string_set, + check_beta_sigmoid, + check_device, + check_image_folder, + check_keyset_consistency, + check_model_names, + check_order, + check_prompts, + check_weights, + # Low-level validation functions (for flexibility) + is_nonempty_string, + is_percentage, + list_or_empty, + validate_config, +) + +__all__ = [ + "ValidationError", + "all_nonempty_strings", + "as_string_set", + "check_beta_sigmoid", + "check_device", + "check_image_folder", + "check_keyset_consistency", + "check_model_names", + "check_order", + "check_prompts", + "check_weights", + "encode_image", + "encode_texts", + "is_nonempty_string", + "is_percentage", + "list_or_empty", + "load_model", + "prepare_directions", + "print_results", + "sigmoid_probability", + "validate_config", +] diff --git a/src/uq_desktop_processor/evaluation/clip_common/directions.py b/src/uq_desktop_processor/evaluation/clip_common/directions.py new file mode 100644 index 0000000..a3865ce --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_common/directions.py @@ -0,0 +1,101 @@ +""" +Builds CLIP embedding direction vectors from positive/negative prompt pairs per category. +""" + +import logging +from collections.abc import Callable, Iterable, Mapping +from typing import Any, cast + +import torch +from PIL import Image +from torch import Tensor, nn + +from .model import encode_texts + +log = logging.getLogger(__name__) + + +def _normalize(vector: Tensor) -> Tensor: + """ + Normalize a vector to unit length (L2 norm). + Returns the original vector if its norm is zero. + + Example:: + In: _normalize(vector) + Out: function result returned for provided inputs + """ + vector_norm = vector.norm(p=2) + return vector / vector_norm if vector_norm > 0 else vector + + +def prepare_directions( + prompts: Mapping[str, Mapping[str, Iterable[str]]], + model: nn.Module, + device: torch.device | str, +) -> dict[str, Tensor]: + """ + Compute semantic direction vectors for each category based on + positive and negative prompt examples. + + :param prompts: Dictionary containing categories with "pos" and "neg" prompt lists. + :param model: Text encoding model. + :param device: Torch device for computations. + :return: Dictionary mapping category names to normalized direction vectors. + + Example:: + In: prepare_directions(prompts, model, device) + Out: function result returned for provided inputs + """ + directions: dict[str, Tensor] = {} + + for category, prompt_sides in prompts.items(): + log.debug("Encoding prompts for category: '%s'", category) + + try: + # Encode positive prompts and compute their average embedding + positive_prompt_embedding = encode_texts(prompt_sides["pos"], model, device).mean(dim=0) + # Encode negative prompts and compute their average embedding + negative_prompt_embedding = encode_texts(prompt_sides["neg"], model, device).mean(dim=0) + + # Compute difference and normalize to get a direction vector + direction = _normalize(positive_prompt_embedding - negative_prompt_embedding) + directions[category] = direction + except Exception as error: + log.error("Failed to compute direction for category '%s': %s", category, error) + raise + + log.debug("All direction vectors computed successfully.") + + return directions + + +@torch.no_grad() +def encode_image( + img: Image.Image, + preprocess: Callable[[Image.Image], Tensor], + model: nn.Module, + device: torch.device | str, +) -> Tensor: + """ + Encode an image into a normalized feature vector using the model. + + :param img: Input PIL image. + :param preprocess: Preprocessing function for the model. + :param model: Vision encoder model. + :param device: Torch device for computations. + :return: Normalized image embedding tensor. + + Example:: + In: encode_image(img, preprocess, model, device) + Out: function result returned for provided inputs + """ + if log.isEnabledFor(logging.DEBUG): + log.debug("Encoding image tensor (size: %s)...", img.size) + + # Convert image to RGB, preprocess, and move to device + tensor = preprocess(img.convert("RGB")).unsqueeze(0).to(device) + # Extract image features + model_any = cast(Any, model) + features = model_any.encode_image(tensor) + # Normalize the feature vector + return (features / features.norm(dim=-1, keepdim=True)).squeeze() diff --git a/src/uq_desktop_processor/evaluation/clip_common/model.py b/src/uq_desktop_processor/evaluation/clip_common/model.py new file mode 100644 index 0000000..b21a80d --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_common/model.py @@ -0,0 +1,108 @@ +""" +Loads OpenAI CLIP models and provides text/image encoding for evaluation pipelines. +""" + +import logging +import math +from collections.abc import Callable, Iterable +from typing import Any, cast + +import clip +import torch +from PIL import Image + +log = logging.getLogger(__name__) + + +def load_model( + model_names: Iterable[str], + device: str | torch.device = "cpu", +) -> tuple[torch.nn.Module, Callable[[Image.Image], torch.Tensor], str]: + """ + Try loading a CLIP model from a list of model names. + Returns the first successfully loaded model along with its preprocess function + and the model name that was used. + + :param model_names: Iterable of CLIP model names to try loading. + :param device: Device to load the model onto. + :return: (model, preprocess_function, loaded_model_name) + + Example:: + In: load_model(model_names, device) + Out: function result returned for provided inputs + """ + log.info("Attempting to load CLIP model on device: %s", device) + + last_error = None + for name in model_names: + try: + # Attempt to load a model using the CLIP API + model, preprocess = clip.load(name, device=device, jit=False) + return model.eval(), preprocess, name + except RuntimeError as runtime_error: + # Store the last error in case all models fail + last_error = runtime_error + # Raise an error if none of the models could be loaded + raise RuntimeError("No model could be loaded into memory.") from last_error + + +@torch.no_grad() +def encode_texts( + texts: str | Iterable[str], + model: torch.nn.Module, + device: str | torch.device, +) -> torch.Tensor: + """ + Tokenize and encode one or multiple text inputs using a CLIP model. + Returns L2-normalized text embeddings. + + :param texts: A single string or a sequence of text strings. + :param model: The CLIP model used for encoding. + :param device: Device to run the encoding on. + :return: Normalized text embedding tensor of shape (N, D). + + Example:: + In: encode_texts(texts, model, device) + Out: function result returned for provided inputs + """ + # Ensure texts is a materialized sequence. + text_list = [texts] if isinstance(texts, str) else list(texts) + + if log.isEnabledFor(logging.DEBUG): + log.debug("Encoding %s text prompt(s)...", len(text_list)) + + # Tokenize text and move to device + tokens = clip.tokenize(text_list, truncate=True).to(device) + + # Encode text using CLIP + model_any = cast(Any, model) + features = model_any.encode_text(tokens) + + # Return normalized embeddings + return features / features.norm(dim=-1, keepdim=True) + + +def sigmoid_probability( + delta: float | torch.Tensor, + beta: float | torch.Tensor, +) -> float | torch.Tensor: + """ + Compute a sigmoid-based probability scaled to 0–100 range. + Accepts both scalars and torch tensors. + + :param delta: Input value (difference or score). + :param beta: Scaling factor controlling steepness. + :return: Probability between 0 and 100. + + Example:: + In: sigmoid_probability(delta, beta) + Out: function result returned for provided inputs + """ + # Tensor-based implementation if either input is a tensor + if isinstance(delta, torch.Tensor) or isinstance(beta, torch.Tensor): + delta_t = delta if isinstance(delta, torch.Tensor) else torch.tensor(delta) + beta_t = beta if isinstance(beta, torch.Tensor) else torch.tensor(beta) + return 100.0 / (1.0 + torch.exp(-beta_t * delta_t)) + + # Fallback to pure Python math for scalar values + return 100.0 / (1.0 + math.exp(-beta * delta)) diff --git a/src/uq_desktop_processor/evaluation/clip_common/printer.py b/src/uq_desktop_processor/evaluation/clip_common/printer.py new file mode 100644 index 0000000..0c1029e --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_common/printer.py @@ -0,0 +1,157 @@ +""" +Prints CLIP evaluation summaries and validation output to the console (e.g. tabulated). +""" + +import logging + +from tabulate import tabulate # type: ignore[import-untyped] + +log = logging.getLogger(__name__) + + +def _handle_errors(result: dict) -> bool: + """ + Check whether the result dictionary contains validation errors. + If errors exist, print them along with any warnings. + + :param result: Result dictionary potentially containing "errors" and "warnings". + :return: True if errors were found (and processing should stop), otherwise False. + + Example:: + In: _handle_errors(result) + Out: function result returned for provided inputs + """ + errors = result.get("errors") + if not errors: + return False + + # Log validation errors + log.error("Configuration validation failed:") + for error_message in errors: + log.error(" - %s", error_message) + + # Log warnings if present + warnings = result.get("warnings") or [] + if warnings: + log.warning("Warnings:") + for warning_message in warnings: + log.warning(" - %s", warning_message) + + return True + + +def _build_rows(images: list, order: list[str]) -> list[list[str]]: + """ + Build table rows for output based on image results and category order. + + :param images: List of image result dictionaries. + :param order: List of category names in display order. + :return: List of formatted table rows. + + Example:: + In: _build_rows(images, order) + Out: function result returned for provided inputs + """ + if log.isEnabledFor(logging.DEBUG): + log.debug("Formatting results table for %s images...", len(images)) + + rows = [] + for image_result in images: + # Start each row with the filename + row = [image_result["filename"]] + + # Add delta and probability for each category + for category_name in order: + category_data = image_result["categories"][category_name] + delta = category_data.get("delta") + delta_cell = f"{delta:+.3f}" if isinstance(delta, int | float) else " n/a" + row += [ + delta_cell, # Signed delta value (or n/a for non-CLIP pipelines) + f"{category_data['probability_pct']:6.1f}%", # Probability percentage + ] + + # Add overall score + row.append(f"{image_result['overall_pct']:6.1f}%") + rows.append(row) + + # Sort rows by overall score in descending order + rows.sort(key=lambda row_values: float(row_values[-1].rstrip("%")), reverse=True) + return rows + + +def _build_headers(order: list[str]) -> list[str]: + """ + Build column headers for the results table based on category order. + + :param order: List of category names. + :return: List of formatted column headers. + + Example:: + In: _build_headers(order) + Out: function result returned for provided inputs + """ + return ( + ["filename"] + + [ + # Even index > value column, odd index > percentage column + f"{category_name} value" if index % 2 == 0 else f"{category_name} %" + for index, category_name in enumerate(order * 2) + ] + + ["overall %"] + ) + + +def _log_warnings(warnings: list[str] | None) -> None: + """ + Log warnings if any exist. + + :param warnings: List of warning messages or None. + + Example:: + In: _log_warnings(warnings) + Out: function result returned for provided inputs + """ + if not warnings: + return + + log.warning("[WARNINGS]") + for warning_message in warnings: + log.warning(" - %s", warning_message) + + +def print_results(result: dict) -> None: + """ + Print formatted results using 'tabulate', including scores per image, + model name, overall average, and warnings. + + :param result: Dictionary containing processed evaluation results. + + Example:: + In: print_results(result) + Out: function result returned for provided inputs + """ + # Stop if validation errors were detected + if _handle_errors(result): + return + + images = result.get("images", []) + order = result.get("order", []) + model_name = result.get("model_name", "unknown") + + if not images: + print("No results to display.") + return + + rows = _build_rows(images, order) + headers = _build_headers(order) + + print(f"\nResults using model: {model_name}") + print(tabulate(rows, headers=headers, tablefmt="github")) + + # Print average overall score if available + average_overall_pct = result.get("average_overall_pct") + if average_overall_pct is not None: + print(f"\nAverage overall score: {average_overall_pct:.1f}%") + + # Print any warnings + _log_warnings(result.get("warnings")) diff --git a/src/uq_desktop_processor/evaluation/clip_common/validate.py b/src/uq_desktop_processor/evaluation/clip_common/validate.py new file mode 100644 index 0000000..eca7377 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_common/validate.py @@ -0,0 +1,526 @@ +""" +Validates CLIP evaluation configuration and prepares per-category direction tensors. +""" + +import logging +import os +from collections.abc import Iterable, Mapping +from typing import Any + +import torch + +try: + import clip +except ImportError: + clip = None + _HAS_CLIP = False +else: + _HAS_CLIP = True + +log = logging.getLogger(__name__) + + +class ValidationError(Exception): + """Custom exception storing validation errors and warnings. + + Example:: + In: raise ValidationError(["missing token"], ["beta is high"]) + Out: exception with .errors and .warnings attributes populated + """ + + def __init__(self, errors: list[str], warnings: list[str] | None = None): + """ + Initialize validation error with collected errors and optional warnings. + + :param errors: Validation error messages. + :param warnings: Validation warnings gathered during checks. + + Example:: + In: ValidationError(["missing token"], ["beta is high"]) + Out: exception with .errors and .warnings attributes populated + """ + super().__init__("\n".join(errors)) + self.errors = errors + self.warnings = warnings or [] + + +# Expected keys for prompt categories +SIDES: tuple[str, str] = ("pos", "neg") + + +def is_nonempty_string(value: Any) -> bool: + """ + Check whether a value is a non-empty string. + + :param value: Any value to test. + :return: True if value is a non-empty string, else False. + + Example:: + In: is_nonempty_string(value) + Out: function result returned for provided inputs + """ + return isinstance(value, str) and value.strip() != "" + + +def all_nonempty_strings(values: Iterable[Any]) -> bool: + """ + Check whether all values in an iterable are non-empty strings. + + :param values: Iterable of values. + :return: True if all are non-empty strings. + + Example:: + In: all_nonempty_strings(values) + Out: function result returned for provided inputs + """ + return all(is_nonempty_string(value) for value in values) + + +def as_string_set(values: Iterable[str]) -> set[str]: + """ + Convert an iterable of strings into a cleaned set (trimmed, non-empty). + + :param values: Iterable of strings. + :return: A set of stripped, valid strings. + + Example:: + In: as_string_set(values) + Out: function result returned for provided inputs + """ + return {value.strip() for value in values if is_nonempty_string(value)} + + +def list_or_empty(values: Iterable[Any] | None) -> list[Any]: + """ + Convert an iterable to a list, or return an empty list if None. + + :param values: Iterable or None. + :return: List of items or empty list. + + Example:: + In: list_or_empty(values) + Out: function result returned for provided inputs + """ + return list(values) if values is not None else [] + + +def is_percentage(value: Any) -> bool: + """ + Check whether a value is a numeric percentage between 0 and 100. + + :param value: Any value. + :return: True if within a valid percentage range. + + Example:: + In: is_percentage(value) + Out: function result returned for provided inputs + """ + return isinstance(value, int | float) and 0.0 <= float(value) <= 100.0 + + +def check_image_folder(image_folder_path: str) -> tuple[list[str], list[str]]: + """ + Validate a folder containing images. + + :param image_folder_path: Path to folder. + :return: (errors, warnings) + + Example:: + In: check_image_folder(image_folder_path) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + log.debug("Validating image folder path: %s", image_folder_path) + + # Validate path input + if not is_nonempty_string(image_folder_path): + error_message = "image_folder: empty path." + log.error(error_message) + errors_list.append(error_message) + return errors_list, warnings_list + + # Check folder existence + if not os.path.isdir(image_folder_path): + error_message = f"image_folder: folder does not exist: {image_folder_path!r}." + log.error(error_message) + errors_list.append(error_message) + return errors_list, warnings_list + + # Check folder readability and image files + try: + file_names = [ + file_name + for file_name in os.listdir(image_folder_path) + if file_name.lower().endswith((".png", ".jpg", ".jpeg")) + ] + log.debug("Found %s valid image files in folder.", len(file_names)) + except PermissionError: + error_message = f"image_folder: no read permission: {image_folder_path!r}." + log.error(error_message) + errors_list.append(error_message) + return errors_list, warnings_list + + if not file_names: + error_message = f"image_folder: no .png/.jpg/.jpeg files found in {image_folder_path!r}." + log.error(error_message) + errors_list.append(error_message) + + return errors_list, warnings_list + + +def check_device(device_name: str) -> tuple[list[str], list[str]]: + """ + Validate a PyTorch device string (CPU or CUDA). + + :param device_name: Device identifier string. + :return: (errors, warnings) + + Example:: + In: check_device(device_name) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + log.debug("Validating device configuration: %s", device_name) + + if not is_nonempty_string(device_name): + errors_list.append("device: empty string.") + return errors_list, warnings_list + + # CPU always valid + if device_name == "cpu": + return errors_list, warnings_list + + # CUDA validation + if device_name.startswith("cuda"): + if not torch.cuda.is_available(): + error_message = "device: 'cuda' specified but CUDA is not available." + log.error(error_message) + errors_list.append(error_message) + return errors_list, warnings_list + + # Check GPU index if provided (e.g., 'cuda:1') + if ":" in device_name: + _, _, index_str = device_name.partition(":") + try: + index_int = int(index_str) + gpu_count = torch.cuda.device_count() + if not (0 <= index_int < gpu_count): + error_message = f"device: GPU index out of range (0..{gpu_count - 1})." + log.error(error_message) + errors_list.append(error_message) + except ValueError: + errors_list.append("device: invalid format after 'cuda:'.") + return errors_list, warnings_list + + # Unknown device > warning only + warning_message = f"device: unknown identifier '{device_name}', PyTorch will attempt to use it automatically." + log.warning(warning_message) + warnings_list.append(warning_message) + return errors_list, warnings_list + + +def check_model_names(model_name_iterable: Iterable[str]) -> tuple[list[str], list[str]]: + """ + Validate model name list for CLIP models. + + :param model_name_iterable: Iterable of model names. + :return: (errors, warnings) + + Example:: + In: check_model_names(model_name_iterable) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + names_list = list(model_name_iterable) if model_name_iterable is not None else [] + log.debug("Validating model names: %s", names_list) + + if not names_list: + errors_list.append("model_names: empty list or tuple.") + return errors_list, warnings_list + + if not all_nonempty_strings(names_list): + errors_list.append("model_names: all elements must be non-empty strings.") + + # Check availability if CLIP is installed + if _HAS_CLIP: + try: + available_models_set = set(clip.available_models()) + unknown_names = [name for name in names_list if name not in available_models_set] + if unknown_names: + warning_message = f"model_names: unsupported by clip: {', '.join(unknown_names)}." + log.warning(warning_message) + warnings_list.append(warning_message) + except (OSError, RuntimeError, AttributeError): + log.warning("Failed to retrieve available CLIP models list.") + warnings_list.append("model_names: failed to read clip.available_models().") + else: + log.debug("CLIP module not loaded, skipping model name availability check.") + warnings_list.append("model_names: 'clip' module not loaded — skipping availability check.") + + return errors_list, warnings_list + + +def check_beta_sigmoid(beta_value: float) -> tuple[list[str], list[str]]: + """ + Validate the beta parameter used in the sigmoid probability function. + + :param beta_value: Numeric beta parameter. + :return: (errors, warnings) + + Example:: + In: check_beta_sigmoid(beta_value) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + if not isinstance(beta_value, int | float): + errors_list.append("beta_sigmoid: must be a number.") + return errors_list, warnings_list + + if beta_value <= 0: + errors_list.append("beta_sigmoid: must be > 0.") + elif beta_value > 100: + warning_message = f"beta_sigmoid: {beta_value} > 100 — the curve will be extremely steep." + log.warning(warning_message) + warnings_list.append(warning_message) + + return errors_list, warnings_list + + +def check_order(category_order: Iterable[str]) -> tuple[list[str], list[str]]: + """ + Validate the order of categories used in scoring. + + :param category_order: Iterable of category names. + :return: (errors, warnings) + + Example:: + In: check_order(category_order) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + order_list = list_or_empty(category_order) + + if not order_list: + errors_list.append("order: cannot be empty.") + return errors_list, warnings_list + + if not all_nonempty_strings(order_list): + errors_list.append("order: all category names must be non-empty strings.") + + if len(set(order_list)) != len(order_list): + errors_list.append("order: duplicate categories are not allowed.") + + return errors_list, warnings_list + + +def check_weights(weight_mapping: Mapping[str, float]) -> tuple[list[str], list[str]]: + """ + Validate category weight configuration. + + :param weight_mapping: Mapping category → weight value. + :return: (errors, warnings) + + Example:: + In: check_weights(weight_mapping) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + if not isinstance(weight_mapping, Mapping): + errors_list.append("weights: must be Mapping[str, float].") + return errors_list, warnings_list + + if not weight_mapping: + errors_list.append("weights: cannot be empty.") + return errors_list, warnings_list + + # Validate each weight + for category_key, weight_value in weight_mapping.items(): + if not is_nonempty_string(category_key): + errors_list.append("weights: category key cannot be empty.") + if not isinstance(weight_value, int | float): + errors_list.append(f"weights[{category_key!r}]: must be a number.") + elif weight_value <= 0: + errors_list.append(f"weights[{category_key!r}]: must be > 0 (geometric mean).") + elif not (float(weight_value) < float("inf")): + errors_list.append(f"weights[{category_key!r}]: Inf/NaN values are not allowed.") + + # Aggregate checks + try: + total_weight = sum(float(value) for value in weight_mapping.values()) + if total_weight <= 0: + errors_list.append("weights: the sum of weights must be > 0.") + + # Warn if one weight dominates the rest + if weight_mapping and max(weight_mapping.values()) >= 10 * (total_weight / len(weight_mapping)): + warning_message = "weights: one weight dominates (>= 10× the average)." + log.warning(warning_message) + warnings_list.append(warning_message) + except (ValueError, TypeError): + pass + + return errors_list, warnings_list + + +def check_prompts(prompt_mapping: Mapping[str, dict[str, Iterable[str]]]) -> tuple[list[str], list[str]]: + """ + Validate the prompt structure (categories with 'pos'/'neg' lists). + + :param prompt_mapping: Mapping category → {'pos': [...], 'neg': [...]} + :return: (errors, warnings) + + Example:: + In: check_prompts(prompt_mapping) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + if not isinstance(prompt_mapping, Mapping) or not prompt_mapping: + errors_list.append("prompts: must be a non-empty Mapping.") + return errors_list, warnings_list + + for category_name, side_mapping in prompt_mapping.items(): + if not isinstance(side_mapping, Mapping): + errors_list.append(f"prompts[{category_name!r}]: value must be a dict.") + continue + + # Validate 'pos' and 'neg' + for side_name in SIDES: + if side_name not in side_mapping: + errors_list.append(f"prompts[{category_name!r}]: missing key '{side_name}'.") + continue + + values_list = list_or_empty(side_mapping[side_name]) + + if len(values_list) == 0: + errors_list.append(f"prompts[{category_name!r}]['{side_name}']: list cannot be empty.") + elif not all_nonempty_strings(values_list): + errors_list.append( + f"prompts[{category_name!r}]['{side_name}']: all elements must be non-empty strings." + ) + + # Warn about extremely long prompts (CLIP has token limits) + very_long_prompts = [prompt for prompt in values_list if isinstance(prompt, str) and len(prompt) > 512] + if very_long_prompts: + warning_message = f"prompts[{category_name!r}]['{side_name}']: {len(very_long_prompts)} very long prompts (>512 characters)." + log.warning(warning_message) + warnings_list.append(warning_message) + + return errors_list, warnings_list + + +def check_keyset_consistency( + category_order: Iterable[str], + weight_mapping: Mapping[str, float], + prompt_mapping: Mapping[str, dict[str, Iterable[str]]], +) -> tuple[list[str], list[str]]: + """ + Ensure that the category names across 'order', 'weights', and 'prompts' match. + + :param category_order: Iterable of category names. + :param weight_mapping: Mapping category → weight. + :param prompt_mapping: Mapping category → prompt pairs. + :return: (errors, warnings) + + Example:: + In: check_keyset_consistency(category_order, weight_mapping, prompt_mapping) + Out: function result returned for provided inputs + """ + errors_list: list[str] = [] + warnings_list: list[str] = [] + + order_set = as_string_set(category_order) + weights_set = as_string_set(weight_mapping.keys()) + prompts_set = as_string_set(prompt_mapping.keys()) + + # All three sets must match exactly + if not (order_set == weights_set == prompts_set): + error_message = f"inconsistent keys: order={sorted(order_set)}, weights={sorted(weights_set)}, prompts={sorted(prompts_set)} — sets should match." + log.error(error_message) + errors_list.append(error_message) + + return errors_list, warnings_list + + +def validate_config( + *, + image_folder: str, + device: str, + model_names: Iterable[str], + beta_sigmoid: float, + order: Iterable[str], + weights: Mapping[str, float], + prompts: Mapping[str, dict[str, Iterable[str]]], + raise_on_error: bool = True, +) -> tuple[list[str], list[str]]: + """ + Validate all configuration components and optionally raise an error. + + :param image_folder: Path to folder with images. + :param device: PyTorch device string. + :param model_names: List of CLIP model names. + :param beta_sigmoid: Sigmoid scaling factor. + :param order: Category order. + :param weights: Category weights. + :param prompts: Category → prompt structure. + :param raise_on_error: Raise exception if errors found. + :return: (errors, warnings) + + Example:: + In: validate_config() + Out: function result returned for provided inputs + """ + log.info("Starting configuration validation...") + errors_accumulated: list[str] = [] + warnings_accumulated: list[str] = [] + + # List of validator functions to run + check_functions = [ + lambda: check_image_folder(image_folder), + lambda: check_device(device), + lambda: check_model_names(model_names), + lambda: check_beta_sigmoid(beta_sigmoid), + lambda: check_order(order), + lambda: check_weights(weights), + lambda: check_prompts(prompts), + lambda: check_keyset_consistency(order, weights, prompts), + ] + + # Aggregate errors and warnings from all checks + for check_function in check_functions: + check_errors, check_warnings = check_function() + errors_accumulated.extend(check_errors) + warnings_accumulated.extend(check_warnings) + + # Remove duplicates while preserving order + errors_accumulated = list(dict.fromkeys(errors_accumulated)) + warnings_accumulated = list(dict.fromkeys(warnings_accumulated)) + + if warnings_accumulated: + log.info( + "Configuration validation produced %s warning(s).", + len(warnings_accumulated), + ) + + # Raise exception if configured to do so + if errors_accumulated: + log.error( + "Configuration validation failed with %s error(s).", + len(errors_accumulated), + ) + if raise_on_error: + raise ValidationError(errors_accumulated, warnings_accumulated) + else: + log.info("Configuration validation passed.") + + return errors_accumulated, warnings_accumulated diff --git a/src/uq_desktop_processor/evaluation/clip_evaluator/__init__.py b/src/uq_desktop_processor/evaluation/clip_evaluator/__init__.py new file mode 100644 index 0000000..cea62ea --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_evaluator/__init__.py @@ -0,0 +1,7 @@ +""" +Evaluation module: init . +""" + +from .run import evaluate_images_with_clip + +__all__ = ["evaluate_images_with_clip"] diff --git a/src/uq_desktop_processor/evaluation/clip_evaluator/defaults.py b/src/uq_desktop_processor/evaluation/clip_evaluator/defaults.py new file mode 100644 index 0000000..7f9d2d3 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_evaluator/defaults.py @@ -0,0 +1,72 @@ +""" +Default paths, model names, and hyperparameters for the CLIP image evaluator. +""" + +from types import MappingProxyType + +DEFAULT_IMAGE_FOLDER = "images/to_evaluate" +DEFAULT_DEVICE = "cuda" +DEFAULT_MODEL_NAMES = ("ViT-L/14@336px",) +DEFAULT_BETA_SIGMOID = 30.0 + +DEFAULT_ORDER = ("wealth", "safety", "beauty") +DEFAULT_WEIGHTS = MappingProxyType( + { + "wealth": 0.01, + "safety": 1.5, + "beauty": 0.01, + } +) + +DEFAULT_PROMPTS = MappingProxyType( + { + "wealth": { + "pos": ( + "modern upscale neighborhood with well-maintained houses and clean sidewalks", + "affluent residential street with manicured lawns and luxury cars parked neatly", + "elegant suburban area with detached villas and landscaped gardens", + "wealthy quiet street with new facades, large windows, and decorative lighting", + "high-income residential zone with stone fences and premium architecture", + ), + "neg": ( + "run-down street with peeling paint and old damaged houses", + "poor urban area with graffiti and broken sidewalks", + "low-income neighborhood with abandoned or boarded-up buildings", + "crowded residential block with cluttered yards and visible decay", + "street showing signs of poverty, trash, and disrepair", + ), + }, + "safety": { + "pos": ( + "peaceful residential street with clear visibility and no signs of danger", + "calm, well-lit suburban neighborhood with families walking and children playing", + "clean area with maintained houses and no graffiti or broken glass", + "quiet street with orderly parked cars and good street lighting", + "safe environment with tidy gardens, fences, and visible community care", + ), + "neg": ( + "dark alley with broken lights, graffiti, and litter on the ground", + "street showing vandalism, smashed windows, or police tape", + "abandoned neighborhood with derelict buildings and no people", + "unsafe area with heavy shadows and visible neglect", + "crime-ridden urban zone with damaged cars and boarded houses", + ), + }, + "beauty": { + "pos": ( + "beautiful tree-lined residential street under warm sunlight", + "charming neighborhood with colorful facades and blooming gardens", + "scenic avenue with tidy sidewalks, greenery, and balanced composition", + "aesthetically pleasing street with clean architecture and natural light", + "picturesque suburban scene with flowers, cafés, and cozy atmosphere", + ), + "neg": ( + "dreary street under grey overcast sky with bare trees", + "dirty industrial-looking road with trash and potholes", + "unappealing neighborhood with dull colors and visual clutter", + "ugly or neglected street lacking greenery and harmony", + "bleak winter street with mud, slush, and poor lighting", + ), + }, + } +) diff --git a/src/uq_desktop_processor/evaluation/clip_evaluator/run.py b/src/uq_desktop_processor/evaluation/clip_evaluator/run.py new file mode 100644 index 0000000..bebbb77 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_evaluator/run.py @@ -0,0 +1,216 @@ +""" +Batch-scores images with CLIP against configured semantic directions and writes JSON results. +""" + +import logging +import os +from collections.abc import Mapping +from typing import Any + +from uq_desktop_processor.evaluation.clip_common import ValidationError, load_model, prepare_directions, validate_config +from uq_desktop_processor.evaluation.evaluation_utils import make_evaluation_result + +from . import defaults +from .scoring import score_image + +log = logging.getLogger(__name__) + + +def _get_image_files(folder: str) -> list[str]: + """ + Return a sorted list of image filenames in the specified folder. + Only .png, .jpg, and .jpeg extensions are included. + + :param folder: Path to image folder. + :return: Sorted list of filenames. + + Example:: + In: _get_image_files(folder) + Out: function result returned for provided inputs + """ + files = sorted(filename for filename in os.listdir(folder) if filename.lower().endswith((".png", ".jpg", ".jpeg"))) + log.debug("Scanned folder '%s': found %s valid image files.", folder, len(files)) + return files + + +def _format_single_result( + filename: str, image_path: str, raw_result: dict[str, Any], order: tuple[str, ...] +) -> dict[str, Any]: + """ + Format raw scoring output for a single image into a clean result structure. + + :param filename: Image filename. + :param raw_result: Raw scoring dictionary from score_image(). + :param order: Category order tuple. + :return: Formatted result dictionary. + + Example:: + In: _format_single_result(filename, raw_result, order) + Out: function result returned for provided inputs + """ + return { + "filename": filename, + "image_path": image_path, + "categories": { + category_name: { + "delta": float(f"{raw_result[category_name]['delta']:.6f}"), + "probability_pct": float(f"{raw_result[category_name]['probability']:.1f}"), + } + for category_name in order + }, + "overall_pct": float(f"{raw_result['overall']:.1f}"), + } + + +def _create_error_response( + validation_exception: ValidationError, order: tuple, weights: Mapping, beta_sigmoid_value: float +) -> dict[str, Any]: + """ + Build a consistent response dictionary when validation fails. + + :param validation_exception: ValidationError instance. + :param order: Category order. + :param weights: Weight mapping. + :param beta_sigmoid_value: Sigmoid beta value. + :return: Error response structure. + + Example:: + In: _create_error_response(validation_exception, order, weights, beta_sigmoid_value) + Out: function result returned for provided inputs + """ + return { + "model_name": None, + "order": order, + "weights": dict(weights), + "beta_sigmoid": beta_sigmoid_value, + "warnings": list(validation_exception.warnings), + "images": [], + "average_overall_pct": None, + "errors": list(validation_exception.errors), + } + + +def evaluate_images_with_clip( + image_folder: str = defaults.DEFAULT_IMAGE_FOLDER, + device: str = defaults.DEFAULT_DEVICE, + model_names: tuple[str, ...] = defaults.DEFAULT_MODEL_NAMES, + beta_sigmoid: float = defaults.DEFAULT_BETA_SIGMOID, + order: tuple[str, ...] = defaults.DEFAULT_ORDER, + weights: Mapping[str, float] = defaults.DEFAULT_WEIGHTS, + prompts: Mapping[str, dict] = defaults.DEFAULT_PROMPTS, + *, + raise_on_validation_error: bool = True, +) -> dict[str, Any]: + """ + Evaluate all images in a folder using CLIP-based scoring. + Performs validation, loads the model, prepares direction vectors, + scores each image, and produces a final aggregated result. + + :param image_folder: Folder containing image files. + :param device: PyTorch device string ("cpu" or "cuda"). + :param model_names: Models to attempt loading. + :param beta_sigmoid: Sigmoid scaling factor. + :param order: Category order tuple. + :param weights: Weight mapping for categories. + :param prompts: Prompt mapping for positive/negative directions. + :param raise_on_validation_error: Whether to raise or return structured error output. + :return: Full evaluation result dictionary. + + Example:: + In: evaluate_images_with_clip(image_folder, device, model_names, beta_sigmoid, order, weights, prompts) + Out: function result returned for provided inputs + """ + log.info("Starting CLIP evaluation pipeline.") + + try: + # Validate configuration before processing + error_messages, warning_messages = validate_config( + image_folder=image_folder, + device=device, + model_names=model_names, + beta_sigmoid=beta_sigmoid, + order=order, + weights=weights, + prompts=prompts, + raise_on_error=raise_on_validation_error, + ) + except ValidationError as validation_exception: + log.error("Validation failed with %s errors.", len(validation_exception.errors)) + # Return structured error result instead of raising + if raise_on_validation_error: + raise + log.warning("Returning structured error response (raise_on_validation_error=False).") + return make_evaluation_result( + model_name=None, + order=order, + weights=dict(weights), + beta_sigmoid=beta_sigmoid, + images=[], + average_overall_pct=None, + warnings=list(validation_exception.warnings), + errors=list(validation_exception.errors), + skipped_images=[], + ) + + # Load input images + image_filenames = _get_image_files(image_folder) + + if not image_filenames: + log.warning("No images found in '%s'. Aborting evaluation.", image_folder) + else: + log.info("Found %s images to evaluate in '%s'.", len(image_filenames), image_folder) + + # Load CLIP model + model, preprocess, loaded_model_name = load_model(model_names, device) + + # Compute semantic direction vectors for categories + direction_vectors = prepare_directions(prompts, model, device) + + # Build shared configuration for scoring function + configuration_for_scoring = { + "device": device, + "beta_sigmoid": beta_sigmoid, + "order": order, + "weights": weights, + } + + formatted_image_results = [] + overall_scores = [] + + # Evaluate each image + for filename in image_filenames: + if log.isEnabledFor(logging.DEBUG): + log.debug("Scoring image: %s", filename) + + image_path = os.path.join(image_folder, filename) + + # Compute raw score + raw_score_result = score_image(image_path, model, preprocess, direction_vectors, configuration_for_scoring) + + # Format final clean result for this image + formatted_result = _format_single_result(filename, image_path, raw_score_result, order) + formatted_image_results.append(formatted_result) + + # Collect overall score for averaging + overall_scores.append(raw_score_result["overall"]) + + # Compute mean overall score + average_overall_score = sum(overall_scores) / len(overall_scores) if overall_scores else None + + log.info( + "Evaluation finished. Average overall score: %s (for %s images).", + average_overall_score, + len(overall_scores), + ) + + return make_evaluation_result( + model_name=loaded_model_name, + order=order, + weights=dict(weights), + beta_sigmoid=beta_sigmoid, + images=formatted_image_results, + average_overall_pct=(round(average_overall_score, 1) if average_overall_score is not None else None), + warnings=warning_messages, + errors=None, + skipped_images=[], + ) diff --git a/src/uq_desktop_processor/evaluation/clip_evaluator/scoring.py b/src/uq_desktop_processor/evaluation/clip_evaluator/scoring.py new file mode 100644 index 0000000..130e7d5 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_evaluator/scoring.py @@ -0,0 +1,155 @@ +""" +Per-image CLIP scoring: sigmoid probabilities, categories, and optional weighting. +""" + +import logging +import math +from collections.abc import Callable +from typing import Any, cast + +import torch +from PIL import Image +from torch import Tensor, nn + +from uq_desktop_processor.evaluation.clip_common.model import sigmoid_probability + +log = logging.getLogger(__name__) + + +def _load_and_encode_image( + path: str, preprocess: Callable[[Image.Image], Tensor], model: nn.Module, device: torch.device +) -> Tensor: + """ + Load an image from disk, preprocess it, and return its normalized + CLIP feature embedding. + + :param path: Path to the image file. + :param preprocess: Preprocessing function associated with the CLIP model. + :param model: CLIP model used for encoding images. + :param device: Torch device on which computation is performed. + :return: L2-normalized image embedding tensor (1D). + + Example:: + In: _load_and_encode_image(path, preprocess, model, device) + Out: function result returned for provided inputs + """ + log.debug("Loading and encoding image from: %s", path) + + try: + # Load the image and convert to RGB + image = Image.open(path).convert("RGB") + + # Apply CLIP preprocessing and move to device + tensor = preprocess(image).unsqueeze(0).to(device) + + # Extract feature embedding + model_any = cast(Any, model) + features = model_any.encode_image(tensor) + + # Normalize the feature vector + features = features / features.norm(dim=-1, keepdim=True) + + return features.squeeze() + + except Exception as error: + log.error("Failed to process image '%s': %s", path, error) + raise + + +def _compute_weighted_score( + results: dict[str, dict[str, float]], categories: list[str], weights: dict[str, float] +) -> float: + """ + Compute the overall score using a weighted geometric mean of probabilities. + + :param results: Dict of per-category scores, each containing: + { "delta": float, "probability": float } + :param categories: List of category names in scoring order. + :param weights: Mapping category → weight. + :return: Weighted overall score as a percentage. + + Example:: + In: _compute_weighted_score(results, categories, weights) + Out: function result returned for provided inputs + """ + min_prob: float = 1e-6 # Avoid log(0) by clamping very small values + weighted_log_sum: float = 0.0 + total_weight: float = 0.0 + + for category in categories: + # Probability is stored as percentage; convert to [0, 1] range + prob_percent: float = results[category]["probability"] + prob: float = max(prob_percent / 100.0, min_prob) + + weight: float = weights[category] + + # Use weighted geometric mean = exp(weighted average of log-probabilities) + weighted_log_sum += weight * math.log(prob) + total_weight += weight + + # Convert weighted log-mean back to percentage probability space. + score: float = math.exp(weighted_log_sum / total_weight) * 100.0 + return score + + +@torch.no_grad() +def score_image( + path: str, + model: nn.Module, + preprocess: Callable[[Image.Image], Tensor], + directions: dict[str, Tensor], + config: dict[str, Any], +) -> dict[str, Any]: + """ + Score a single image using CLIP by comparing it with category direction vectors. + + :param path: Path to the image file. + :param model: CLIP model for encoding. + :param preprocess: CLIP-specific preprocessing function. + :param directions: Precomputed direction vectors per category. + :param config: Scoring configuration dictionary containing: + - "device": torch.device + - "beta_sigmoid": float + - "order": list/tuple of categories + - "weights": weight mapping + :return: Structured scoring result: + { + "category": { "delta": float, "probability": float }, + ... + "overall": float + } + + Example:: + In: score_image(path, model, preprocess, directions, config) + Out: function result returned for provided inputs + """ + device: torch.device = config["device"] + beta: float = config["beta_sigmoid"] + + try: + # Load and encode the image into CLIP's embedding space + features: Tensor = _load_and_encode_image(path, preprocess, model, device) + + results: dict[str, Any] = {} + + # Compute per-category delta and probability + for category in config["order"]: + # Dot product measures similarity to direction vector + delta: float = torch.dot(features, directions[category]).item() + # Convert similarity into a probability using a sigmoid curve + probability: float = sigmoid_probability(delta, beta) + results[category] = {"delta": delta, "probability": probability} + + # Compute final overall score via weighted geometric mean + overall: float = _compute_weighted_score(results, config["order"], config["weights"]) + + results["overall"] = overall + + if log.isEnabledFor(logging.DEBUG): + log.debug("Scored '%s' -> Overall: %.2f", path, overall) + + return results + + except Exception as error: + log.error("Error while scoring image '%s': %s", path, error) + raise diff --git a/src/uq_desktop_processor/evaluation/clip_evaluator/utils.py b/src/uq_desktop_processor/evaluation/clip_evaluator/utils.py new file mode 100644 index 0000000..218c112 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_evaluator/utils.py @@ -0,0 +1,25 @@ +""" +Filesystem and formatting helpers used by the CLIP evaluator CLI run. +""" + +from typing import Any + +from uq_desktop_processor.evaluation.clip_common import print_results as _print_common_results + + +def print_results(result: dict[str, Any]) -> None: + """ + Wrapper around the shared print_results function. + + This exists to provide a local entry point for printing evaluation + results, while delegating the actual formatting and output logic + to the shared printer utility. + + :param result: Result dictionary produced by the evaluation pipeline. + :return: Whatever the underlying printer function returns. + + Example:: + In: print_results(result) + Out: function result returned for provided inputs + """ + return _print_common_results(result) diff --git a/src/uq_desktop_processor/evaluation/clip_prefilter/__init__.py b/src/uq_desktop_processor/evaluation/clip_prefilter/__init__.py new file mode 100644 index 0000000..4919cc6 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_prefilter/__init__.py @@ -0,0 +1,7 @@ +""" +Evaluation module: init . +""" + +from .run import prefilter_folder + +__all__ = ["prefilter_folder"] diff --git a/src/uq_desktop_processor/evaluation/clip_prefilter/defaults.py b/src/uq_desktop_processor/evaluation/clip_prefilter/defaults.py new file mode 100644 index 0000000..b46d86e --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_prefilter/defaults.py @@ -0,0 +1,41 @@ +""" +Default prompts, thresholds, and folder names for CLIP-based image prefiltering. +""" + +from types import MappingProxyType + +DEFAULT_IMAGE_FOLDER = "images/to_filter" +DEFAULT_DEVICE = "cuda" +DEFAULT_MODEL_NAMES = ("ViT-L/14@336px",) + +DEFAULT_BETA_SIGMOID = 30.0 +DEFAULT_FILTER_THRESHOLD = 40.0 + +DEFAULT_REJECTED_FOLDER = "rejected" + +FILTER_PROMPTS = MappingProxyType( + { + "pos": ( + "sunny day", + "clear sky", + "blue sky", + "bright daylight", + "good weather", + "sunlit street", + "dry road surface", + "high visibility", + ), + "neg": ( + "rain", + "rainy weather", + "heavy rain", + "drizzle", + "wet road surface", + "puddles on the road", + "overcast sky", + "dark cloudy sky", + "fog", + "snow", + ), + } +) diff --git a/src/uq_desktop_processor/evaluation/clip_prefilter/run.py b/src/uq_desktop_processor/evaluation/clip_prefilter/run.py new file mode 100644 index 0000000..1b378d5 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_prefilter/run.py @@ -0,0 +1,179 @@ +""" +Moves images into kept or rejected folders using a CLIP semantic accept/reject filter. +""" + +import logging +import os +from collections.abc import Iterable, Mapping +from typing import Any + +import torch +from PIL import Image +from torch import nn + +from uq_desktop_processor.evaluation.clip_common import encode_image, encode_texts, load_model, sigmoid_probability + +from . import defaults +from .utils import _list_images, _move, _validate_dirs, resolve_rejected_folder_path +from .validators import _validate_prefilter_config + +log = logging.getLogger(__name__) + + +@torch.no_grad() +def _build_filter_direction(model: nn.Module, device: str, filter_prompts: Mapping[str, Iterable[str]]) -> torch.Tensor: + """ + Build a direction vector representing the semantic difference + between positive and negative filtering prompts. + + :param model: CLIP model. + :param device: Torch device. + :param filter_prompts: Mapping with 'pos' and 'neg' prompt lists. + :return: Normalized direction vector in embedding space. + + Example:: + In: _build_filter_direction(model, device, filter_prompts) + Out: function result returned for provided inputs + """ + log.debug("Building semantic filter direction vector from prompts...") + positive_embeddings = encode_texts(filter_prompts["pos"], model, device).mean(0) + negative_embeddings = encode_texts(filter_prompts["neg"], model, device).mean(0) + + direction_vector = positive_embeddings - negative_embeddings + direction_norm = direction_vector.norm(p=2) + + return direction_vector / direction_norm if direction_norm > 0 else direction_vector + + +@torch.no_grad() +def prefilter_folder( + image_folder: str = defaults.DEFAULT_IMAGE_FOLDER, + device: str = defaults.DEFAULT_DEVICE, + model_names: tuple[str, ...] = defaults.DEFAULT_MODEL_NAMES, + beta_sigmoid: float = defaults.DEFAULT_BETA_SIGMOID, + filter_threshold: float = defaults.DEFAULT_FILTER_THRESHOLD, + filter_prompts: Mapping[str, Iterable[str]] = defaults.FILTER_PROMPTS, + rejected_folder: str = defaults.DEFAULT_REJECTED_FOLDER, + dry_run: bool = False, +) -> dict[str, Any]: + """ + Filter images in a folder based on similarity to prompt directions. + Images below a threshold are moved to a rejected folder (unless dry_run=True). + + :param image_folder: Folder containing image files. + :param device: PyTorch device string ("cpu" or "cuda"). + :param model_names: Models to attempt loading. + :param beta_sigmoid: Sigmoid scaling factor. + :param filter_threshold: The cutoff percentage [0, 100] for the filter. + :param filter_prompts: A mapping containing 'pos' (positive) and 'neg' (negative) prompt lists. + :param rejected_folder: Sub-folder name for rejected images. + :param dry_run: If True, no files are moved. + :return: Summary dictionary of kept/rejected images. + + Example:: + In: prefilter_folder(image_folder, device, model_names, beta_sigmoid, filter_threshold, filter_prompts, rejected_folder, dry_run) + Out: function result returned for provided inputs + """ + log.info( + "Starting pre-filter in '%s' (Threshold: %s%%).", + image_folder, + filter_threshold, + ) + + if dry_run: + log.warning("Dry run enabled: No files will actually be moved.") + + # 1. Validate inputs + error_messages, warning_messages = _validate_prefilter_config( + image_folder=image_folder, + device=device, + model_names=model_names, + beta_sigmoid=beta_sigmoid, + filter_threshold=filter_threshold, + filter_prompts=filter_prompts, + raise_on_error=True, + ) + + # 2. Prepare directories + image_filenames = _list_images(image_folder) + + if not image_filenames: + log.warning("No images found in '%s'. Aborting pre-filter.", image_folder) + else: + log.info("Found %s images to process.", len(image_filenames)) + + absolute_rejected_folder = resolve_rejected_folder_path(image_folder, rejected_folder) + _validate_dirs(image_folder, absolute_rejected_folder) + + # 3. Load Model and Build Filter Direction + model, preprocess_function, loaded_model_name = load_model(model_names, device) + filter_direction_vector = _build_filter_direction(model, device, filter_prompts) + + kept_images: list[dict[str, Any]] = [] + rejected_images: list[dict[str, Any]] = [] + + # 4. Process Images + for filename in image_filenames: + image_path = os.path.join(image_folder, filename) + try: + with Image.open(image_path) as opened_image: + image_features = encode_image(opened_image, preprocess_function, model, device) + except Exception as open_exception: + log.error("Failed to open/process image '%s': %s", filename, open_exception) + rejected_images.append({"filename": filename, "reason": f"open_error: {open_exception}"}) + continue + + # Project image features onto direction vector + delta_value = torch.dot(image_features, filter_direction_vector).item() + + # Convert to probability using sigmoid + probability_value = float(sigmoid_probability(delta_value, beta_sigmoid)) + + # Apply threshold filtering + if probability_value < filter_threshold: + if log.isEnabledFor(logging.DEBUG): + log.debug( + "REJECT: '%s' (%.1f%% < %s%%)", + filename, + probability_value, + filter_threshold, + ) + + rejected_images.append({"filename": filename, "filter_pct": round(probability_value, 1)}) + if not dry_run: + # Keep file move side effect explicit and easy to spot. + _move(image_path, absolute_rejected_folder) + else: + if log.isEnabledFor(logging.DEBUG): + log.debug( + "KEEP: '%s' (%.1f%% >= %s%%)", + filename, + probability_value, + filter_threshold, + ) + + kept_images.append({"filename": filename, "filter_pct": round(probability_value, 1)}) + + log.info( + "Pre-filtering complete. Kept: %s, Rejected: %s.", + len(kept_images), + len(rejected_images), + ) + + # 5. Return Summary + return { + "model_name": loaded_model_name, + "image_folder": image_folder, + "rejected_folder": absolute_rejected_folder, + "beta_sigmoid": beta_sigmoid, + "filter_threshold": filter_threshold, + "kept": kept_images, + "rejected": rejected_images, + "summary": { + "total": len(image_filenames), + "kept": len(kept_images), + "rejected": len(rejected_images), + }, + "warnings": warning_messages, + "dry_run": dry_run, + } diff --git a/src/uq_desktop_processor/evaluation/clip_prefilter/utils.py b/src/uq_desktop_processor/evaluation/clip_prefilter/utils.py new file mode 100644 index 0000000..63b5894 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_prefilter/utils.py @@ -0,0 +1,138 @@ +""" +Image discovery, directory checks, and file moves for the prefilter workflow. +""" + +import logging +import os +import shutil +import time + +log = logging.getLogger(__name__) + + +def _list_images(folder: str) -> list[str]: + """ + Return list of image filenames (png/jpg/jpeg) from a folder. + + :param folder: Path to folder. + :return: Sorted list of image filenames. + + Example:: + In: _list_images(folder) + Out: function result returned for provided inputs + """ + log.debug("Scanning folder for images: %s", folder) + + return sorted(filename for filename in os.listdir(folder) if filename.lower().endswith((".png", ".jpg", ".jpeg"))) + + +def _unique_path(destination_directory: str, filename: str) -> str: + """ + Generate a unique destination path to avoid overwriting existing files. + Appends timestamp and a counter until a free filename is found. + + :param destination_directory: Target folder. + :param filename: Original filename. + :return: Unique full file path. + + Example:: + In: _unique_path(destination_directory, filename) + Out: function result returned for provided inputs + """ + file_base, file_extension = os.path.splitext(filename) + candidate_path = os.path.join(destination_directory, filename) + counter = 1 + + while os.path.exists(candidate_path): + candidate_path = os.path.join( + destination_directory, f"{file_base}__{int(time.time())}_{counter}{file_extension}" + ) + counter += 1 + + if counter > 1: + log.debug( + "File collision detected. Renamed to: %s", + os.path.basename(candidate_path), + ) + + return candidate_path + + +def _ensure_dir(directory_path: str) -> None: + """ + Ensure that a directory exists, creating it if needed. + + :param directory_path: Directory path to ensure. + + Example:: + In: _ensure_dir(directory_path) + Out: function result returned for provided inputs + """ + if not os.path.exists(directory_path): + log.debug("Creating directory: %s", directory_path) + os.makedirs(directory_path, exist_ok=True) + + +def _move(source_path: str, destination_directory: str) -> None: + """ + Move a file to another directory using a unique destination filename. + + :param source_path: Source file path. + :param destination_directory: Target folder. + + Example:: + In: _move(source_path, destination_directory) + Out: function result returned for provided inputs + """ + _ensure_dir(destination_directory) + unique_destination = _unique_path(destination_directory, os.path.basename(source_path)) + + if log.isEnabledFor(logging.DEBUG): + log.debug("Moving file '%s' to '%s'", source_path, unique_destination) + + shutil.move(source_path, unique_destination) + + +def resolve_rejected_folder_path(image_folder: str, rejected_folder: str) -> str: + """ + Resolve where rejected images are stored. + + If ``rejected_folder`` is an absolute path, it is used as-is (expanded). + Otherwise it is resolved relative to the parent directory of ``image_folder`` + (same behaviour as a sibling folder name such as ``rejected``). + + :param image_folder: Folder being filtered (absolute or relative). + :param rejected_folder: Absolute path, or relative name/path under the image folder's parent. + :return: Absolute path to the rejected-images directory. + + Example:: + In: resolve_rejected_folder_path(image_folder, rejected_folder) + Out: function result returned for provided inputs + """ + image_abs = os.path.abspath(os.path.expanduser(image_folder)) + rejected_folder_input = (rejected_folder or "").strip() or "rejected" + rejected_folder_expanded = os.path.expanduser(rejected_folder_input) + if os.path.isabs(rejected_folder_expanded): + return os.path.abspath(rejected_folder_expanded) + return os.path.abspath(os.path.join(os.path.dirname(image_abs), rejected_folder_expanded)) + + +def _validate_dirs(image_folder: str, rejected_absolute_path: str) -> None: + """ + Ensure the source image folder and the destination rejected folder are distinct. + + This prevents operations where files might be moved into the same directory + they currently reside in. + + :param image_folder: Path to the source directory containing images. + :param rejected_absolute_path: Absolute path to the directory for rejected items. + :raises ValueError: If both paths resolve to the same directory. + + Example:: + In: _validate_dirs(image_folder, rejected_absolute_path) + Out: function result returned for provided inputs + """ + if os.path.abspath(image_folder) == os.path.abspath(rejected_absolute_path): + error_message = "rejected_folder must be different from image_folder." + log.error(error_message) + raise ValueError(error_message) diff --git a/src/uq_desktop_processor/evaluation/clip_prefilter/validators.py b/src/uq_desktop_processor/evaluation/clip_prefilter/validators.py new file mode 100644 index 0000000..bbe3d7f --- /dev/null +++ b/src/uq_desktop_processor/evaluation/clip_prefilter/validators.py @@ -0,0 +1,167 @@ +""" +Validates prefilter configuration dicts before loading models or scanning images. +""" + +import logging +from collections.abc import Iterable, Mapping +from typing import Any + +from uq_desktop_processor.evaluation.clip_common import ( + ValidationError, + check_beta_sigmoid, + check_device, + check_image_folder, + check_model_names, +) + +log = logging.getLogger(__name__) + + +def _is_percentage(value: Any) -> bool: + """ + Check whether a given value represents a valid percentage (0–100). + + :param value: Value to test. + :return: True if value is numeric and in range [0, 100]. + + Example:: + In: _is_percentage(value) + Out: function result returned for provided inputs + """ + try: + numeric_value = float(value) + except (ValueError, TypeError): + return False + return 0.0 <= numeric_value <= 100.0 + + +def _check_filter_threshold(threshold_value: float) -> tuple[list[str], list[str]]: + """ + Validate the numeric threshold used for filtering. + Ensures the value is a number and within [0, 100]. + + :param threshold_value: Threshold percentage. + :return: (errors, warnings) + + Example:: + In: _check_filter_threshold(threshold_value) + Out: function result returned for provided inputs + """ + error_messages: list[str] = [] + warning_messages: list[str] = [] + + if not isinstance(threshold_value, int | float): + error_message = "filter_threshold: must be a number." + log.error(error_message) + error_messages.append(error_message) + return error_messages, warning_messages + + if not _is_percentage(threshold_value): + error_message = "filter_threshold: allowed range is [0, 100]." + log.error(error_message) + error_messages.append(error_message) + + return error_messages, warning_messages + + +def _check_filter_prompts(filter_prompt_mapping: Mapping[str, Iterable[str]]) -> tuple[list[str], list[str]]: + """ + Validate the structure and content of filter prompts. + Ensures presence of 'pos' and 'neg' keys and that all prompts are non-empty strings. + + :param filter_prompt_mapping: Mapping with keys 'pos' and 'neg' pointing to lists of prompts. + :return: (errors, warnings) + + Example:: + In: _check_filter_prompts(filter_prompt_mapping) + Out: function result returned for provided inputs + """ + error_messages: list[str] = [] + warning_messages: list[str] = [] + + if not isinstance(filter_prompt_mapping, Mapping): + error_message = "filter_prompts: must be a Mapping." + log.error(error_message) + error_messages.append(error_message) + return error_messages, warning_messages + + for prompt_side_name in ("pos", "neg"): + if prompt_side_name not in filter_prompt_mapping: + error_message = f"filter_prompts: missing key '{prompt_side_name}'." + log.error(error_message) + error_messages.append(error_message) + continue + + prompt_values = list(filter_prompt_mapping[prompt_side_name] or []) + if not prompt_values: + error_message = f"filter_prompts['{prompt_side_name}']: list cannot be empty." + log.error(error_message) + error_messages.append(error_message) + elif not all(isinstance(prompt_text, str) and prompt_text.strip() for prompt_text in prompt_values): + error_message = f"filter_prompts['{prompt_side_name}']: all elements must be non-empty strings." + log.error(error_message) + error_messages.append(error_message) + + return error_messages, warning_messages + + +def _validate_prefilter_config( + *, + image_folder: str, + device: str, + model_names: Iterable[str], + beta_sigmoid: float, + filter_threshold: float, + filter_prompts: Mapping[str, Iterable[str]], + raise_on_error: bool = True, +) -> tuple[list[str], list[str]]: + """ + Validate the configuration parameters for the pre-filtering process. + + This function aggregates various validation checks for image sources, compute devices, + CLIP model specifications, and filtering parameters (thresholds and prompts). + + :param image_folder: Path to the directory containing images to be processed. + :param device: The compute device identifier (e.g., 'cpu', 'cuda'). + :param model_names: An iterable of CLIP model names to be utilized. + :param beta_sigmoid: The beta coefficient used in the sigmoid scaling function. + :param filter_threshold: The cutoff percentage [0, 100] for the filter. + :param filter_prompts: A mapping containing 'pos' (positive) and 'neg' (negative) prompt lists. + :param raise_on_error: If True, raises a ValidationError when errors occur. Defaults to True. + :return: A tuple containing a list of error messages and a list of warning messages. + :raises ValidationError: If errors are found and raise_on_error is True. + + Example:: + In: _validate_prefilter_config() + Out: function result returned for provided inputs + """ + log.debug("Validating pre-filter configuration...") + error_messages: list[str] = [] + warning_messages: list[str] = [] + + validation_checks = [ + lambda: check_image_folder(image_folder), + lambda: check_device(device), + lambda: check_model_names(model_names), + lambda: check_beta_sigmoid(beta_sigmoid), + lambda: _check_filter_threshold(filter_threshold), + lambda: _check_filter_prompts(filter_prompts), + ] + + for validation_function in validation_checks: + function_errors, function_warnings = validation_function() + error_messages += function_errors + warning_messages += function_warnings + + # Remove duplicates while preserving order + error_messages = list(dict.fromkeys(error_messages)) + warning_messages = list(dict.fromkeys(warning_messages)) + + if error_messages: + log.error("Pre-filter validation failed with %s errors.", len(error_messages)) + if raise_on_error: + raise ValidationError(error_messages, warning_messages) + else: + log.debug("Pre-filter configuration valid.") + + return error_messages, warning_messages diff --git a/src/uq_desktop_processor/evaluation/evaluation_utils/__init__.py b/src/uq_desktop_processor/evaluation/evaluation_utils/__init__.py new file mode 100644 index 0000000..d018ee2 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/evaluation_utils/__init__.py @@ -0,0 +1,21 @@ +""" +Evaluation module: init . +""" + +from .schema import ( + CategoryScore, + EvaluationResult, + ImageResult, + make_category_score, + make_evaluation_result, + make_image_result, +) + +__all__ = [ + "CategoryScore", + "ImageResult", + "EvaluationResult", + "make_category_score", + "make_image_result", + "make_evaluation_result", +] diff --git a/src/uq_desktop_processor/evaluation/evaluation_utils/schema.py b/src/uq_desktop_processor/evaluation/evaluation_utils/schema.py new file mode 100644 index 0000000..c9033bd --- /dev/null +++ b/src/uq_desktop_processor/evaluation/evaluation_utils/schema.py @@ -0,0 +1,147 @@ +""" +TypedDict schemas and helpers for structured CLIP (and related) evaluation JSON output. +""" + +from collections.abc import Mapping, Sequence +from typing import Any, NotRequired, TypedDict + + +class CategoryScore(TypedDict): + """ + CategoryScore evaluation helper class. + + Example:: + In: CategoryScore(...) + Out: initialized instance ready for use + """ + + probability_pct: float + delta: float | None + + +class ImageResult(TypedDict): + """ + ImageResult evaluation helper class. + + Example:: + In: ImageResult(...) + Out: initialized instance ready for use + """ + + filename: str + image_path: NotRequired[str] + overall_pct: float + categories: Mapping[str, CategoryScore] + + +class EvaluationResult(TypedDict): + """ + EvaluationResult evaluation helper class. + + Example:: + In: EvaluationResult(...) + Out: initialized instance ready for use + """ + + model_name: str | None + order: Sequence[str] + weights: NotRequired[Mapping[str, float] | None] + beta_sigmoid: float | None + images: list[ImageResult] + average_overall_pct: float | None + skipped_images: NotRequired[list[str]] + warnings: list[str] + errors: list[str] | None + # Optional extension point for future pipelines + extra: NotRequired[Mapping[str, Any]] + + +def make_category_score(*, probability_pct: float, delta: float | None) -> CategoryScore: + """ + Build one category score entry. + + :param probability_pct: Category probability in percent. + :param delta: Raw directional score, if available. + :return: Typed category score dictionary. + + Example:: + In: make_category_score(probability_pct=62.5, delta=0.12) + Out: {"probability_pct": 62.5, "delta": 0.12} + """ + return {"probability_pct": float(probability_pct), "delta": None if delta is None else float(delta)} + + +def make_image_result( + *, + filename: str, + image_path: str | None = None, + overall_pct: float, + categories: Mapping[str, CategoryScore], +) -> ImageResult: + """ + Build one image-level result record. + + :param filename: Source image path or name. + :param image_path: Optional absolute/relative path to source image. + :param overall_pct: Overall score in percent. + :param categories: Per-category score mapping. + :return: Typed image result dictionary. + + Example:: + In: make_image_result(filename="img.jpg", overall_pct=57.0, categories={"wealthier": {"probability_pct": 60.0, "delta": 0.2}}) + Out: {"filename": "img.jpg", "overall_pct": 57.0, "categories": {...}} + """ + out: ImageResult = {"filename": str(filename), "overall_pct": float(overall_pct), "categories": categories} + if image_path is not None: + out["image_path"] = str(image_path) + return out + + +def make_evaluation_result( + *, + model_name: str | None, + order: Sequence[str], + beta_sigmoid: float | None, + images: list[ImageResult], + average_overall_pct: float | None, + warnings: list[str] | None = None, + errors: list[str] | None = None, + weights: Mapping[str, float] | None = None, + skipped_images: list[str] | None = None, + extra: Mapping[str, Any] | None = None, +) -> EvaluationResult: + """ + Assemble full evaluation payload in a consistent schema. + + :param model_name: Model identifier. + :param order: Category output order. + :param beta_sigmoid: Beta value used by sigmoid scoring (if applicable). + :param images: Per-image results. + :param average_overall_pct: Dataset average score. + :param warnings: Non-fatal warnings. + :param errors: Fatal or aggregated errors. + :param weights: Optional category weights. + :param skipped_images: Optional skipped-image messages. + :param extra: Optional extension block. + :return: Typed evaluation result dictionary. + + Example:: + In: make_evaluation_result(model_name="ViT-L/14", order=("wealthier",), beta_sigmoid=30.0, images=[], average_overall_pct=None) + Out: {"model_name": "ViT-L/14", "order": ("wealthier",), "images": [], ...} + """ + out: EvaluationResult = { + "model_name": model_name, + "order": tuple(order), + "beta_sigmoid": beta_sigmoid, + "images": images, + "average_overall_pct": average_overall_pct, + "warnings": list(warnings or []), + "errors": errors, + } + if weights is not None: + out["weights"] = dict(weights) + if skipped_images is not None: + out["skipped_images"] = list(skipped_images) + if extra is not None: + out["extra"] = dict(extra) + return out diff --git a/src/uq_desktop_processor/evaluation/finetuned_evaluator/__init__.py b/src/uq_desktop_processor/evaluation/finetuned_evaluator/__init__.py new file mode 100644 index 0000000..4f870d7 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/finetuned_evaluator/__init__.py @@ -0,0 +1,7 @@ +""" +Evaluation module: init . +""" + +from .run import evaluate_images_with_finetuned + +__all__ = ["evaluate_images_with_finetuned"] diff --git a/src/uq_desktop_processor/evaluation/finetuned_evaluator/defaults.py b/src/uq_desktop_processor/evaluation/finetuned_evaluator/defaults.py new file mode 100644 index 0000000..2e6aa80 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/finetuned_evaluator/defaults.py @@ -0,0 +1,14 @@ +""" +Default checkpoint paths and inference settings for fine-tuned ViT scoring. +""" + +from collections.abc import Sequence + +DEFAULT_CATEGORY_ORDER: Sequence[str] = ( + "safer", + "wealthier", + "more beautiful", + "livelier", + "less depressing", + "less boring", +) diff --git a/src/uq_desktop_processor/evaluation/finetuned_evaluator/model.py b/src/uq_desktop_processor/evaluation/finetuned_evaluator/model.py new file mode 100644 index 0000000..f74e0a7 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/finetuned_evaluator/model.py @@ -0,0 +1,82 @@ +""" +Fine-tuned vision model construction and forward pass utilities for image scoring. +""" + +import logging +from typing import Any, cast + +import torch +import torch.nn as nn + +log = logging.getLogger(__name__) + +try: + import timm +except Exception as import_error: + raise RuntimeError("Install timm: pip install timm") from import_error + + +class ViTMultiHead(nn.Module): + """ + ViT backbone with a multi-output prediction head. + + Example:: + In: ViTMultiHead(...) + Out: initialized instance ready for use + """ + + def __init__(self, model_name: str, num_outputs: int = 6, image_size: int = 224) -> None: + """ + Create model backbone and MLP head for fine-tuned scoring. + + :param model_name: timm model name for the backbone. + :param num_outputs: Number of output targets. + :param image_size: Input image size used by the backbone. + + Example:: + In: ViTMultiHead("vit_base_patch14_dinov2.lvd142m", num_outputs=6, image_size=224) + Out: initialized module ready for forward passes + """ + super().__init__() + log.info("Initializing ViTMultiHead. Backbone: '%s'", model_name) + + self.backbone = timm.create_model( + model_name, pretrained=True, num_classes=0, dynamic_img_size=True, img_size=image_size + ) + backbone_any = cast(Any, self.backbone) + + if hasattr(backbone_any, "num_features"): + feat_dim = int(backbone_any.num_features) + else: + feat_dim = int(backbone_any.embed_dim) + + self.head = nn.Sequential( + nn.LayerNorm(feat_dim * 2), + nn.Linear(feat_dim * 2, 512), + nn.GELU(), + nn.Dropout(0.2), + nn.Linear(512, 256), + nn.GELU(), + nn.Linear(256, num_outputs), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Run one forward pass. + + :param x: Input batch tensor ``(N, C, H, W)``. + :return: Predicted outputs ``(N, num_outputs)``. + + Example:: + In: model.forward(torch.randn(2, 3, 224, 224)) + Out: tensor with shape (2, num_outputs) + """ + # Combine global CLS token with pooled patch context before the MLP head. + backbone_any = cast(Any, self.backbone) + features = backbone_any.forward_features(x) + cls_token = features[:, 0] + patch_tokens = features[:, 1:] + pooled_patches = torch.mean(patch_tokens, dim=1) + + combined = torch.cat([cls_token, pooled_patches], dim=1) + return self.head(combined) diff --git a/src/uq_desktop_processor/evaluation/finetuned_evaluator/run.py b/src/uq_desktop_processor/evaluation/finetuned_evaluator/run.py new file mode 100644 index 0000000..8000378 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/finetuned_evaluator/run.py @@ -0,0 +1,342 @@ +""" +Runs fine-tuned ViT scoring over a folder of images and aggregates JSON results. +""" + +import logging +from collections.abc import Sequence +from pathlib import Path + +import numpy as np +import torch +from PIL import Image +from tqdm.auto import tqdm + +from uq_desktop_processor.evaluation.evaluation_utils import make_evaluation_result + +from . import defaults +from .utils import ( + _apply_calibration, + _build_transform, + _iter_image_paths, + _load_calibrators, + _load_checkpoint_and_model, +) + +log = logging.getLogger(__name__) + + +def _resolve_paths( + images_dir: str | Path, + model_path: str | Path, + calibrators_dir: str | Path | None, +) -> tuple[Path, Path, Path | None]: + """ + Resolve and validate input paths. + Raises FileNotFoundError or NotADirectoryError if paths are invalid. + + Example:: + In: _resolve_paths(images_dir, model_path, calibrators_dir) + Out: function result returned for provided inputs + """ + images_dir = Path(images_dir) + model_path = Path(model_path) + calibrators_dir_path = Path(calibrators_dir) if calibrators_dir else None + + log.debug( + "Resolving paths: images='%s', model='%s', calibrators='%s'", + images_dir, + model_path, + calibrators_dir_path, + ) + + # Validate images directory + if not images_dir.exists(): + error_message = f"Images directory not found: {images_dir}" + log.error(error_message) + raise FileNotFoundError(error_message) + if not images_dir.is_dir(): + error_message = f"Images path is not a directory: {images_dir}" + log.error(error_message) + raise NotADirectoryError(error_message) + + # Validate model file + if not model_path.exists(): + error_message = f"Model file not found: {model_path}" + log.error(error_message) + raise FileNotFoundError(error_message) + if not model_path.is_file(): + error_message = f"Model path must be a file: {model_path}" + log.error(error_message) + raise IsADirectoryError(error_message) + + # Validate calibrators directory + if calibrators_dir_path is not None: + if not calibrators_dir_path.exists(): + error_message = f"Calibrators directory not found: {calibrators_dir_path}" + log.error(error_message) + raise FileNotFoundError(error_message) + if not calibrators_dir_path.is_dir(): + error_message = f"Calibrators path is not a directory: {calibrators_dir_path}" + log.error(error_message) + raise NotADirectoryError(error_message) + + return images_dir, model_path, calibrators_dir_path + + +def _get_calibrators(calibrators_dir: Path | None, category_order: Sequence[str]) -> dict: + """ + Load per-category calibration metadata if provided; otherwise fall back. + + If ``calibrators_dir`` is None, calibration is effectively OFF. + + Example:: + In: _get_calibrators(calibrators_dir, category_order) + Out: function result returned for provided inputs + """ + if calibrators_dir is None: + log.info("No calibrators directory provided. Calibration is OFF (fallback scaling).") + return {category_name: {"type": "fallback"} for category_name in category_order} + + log.info("Loading calibrators from: %s", calibrators_dir) + calibrators = _load_calibrators(calibrators_dir, category_order) + loaded_calibrator_types = {category_name: spec["type"] for category_name, spec in calibrators.items()} + log.info("Loaded calibrator types: %s", loaded_calibrator_types) + return calibrators + + +def _predict_batch(model: torch.nn.Module, batch_tensors: list[torch.Tensor], device: torch.device) -> np.ndarray: + """ + Run a batch of images through the fine-tuned model and return raw predictions. + + :param model: Fine-tuned model used for inference. + :param batch_tensors: List of transformed image tensors. + :param device: Device used for evaluation. + :return: Numpy array of model outputs with shape (batch_size, num_outputs). + + Example:: + In: _predict_batch(model, batch_tensors, device) + Out: function result returned for provided inputs + """ + if log.isEnabledFor(logging.DEBUG): + log.debug( + "Predicting batch of size %s on device %s", + len(batch_tensors), + device, + ) + + batch_tensor = torch.stack(batch_tensors, dim=0).to(device) + use_mixed_precision = torch.cuda.is_available() + + with torch.amp.autocast("cuda", enabled=use_mixed_precision): + return model(batch_tensor).detach().cpu().numpy().astype(np.float32) + + +def _format_single_result( + image_path: Path, + raw_probs: np.ndarray, + calibrators: dict, + category_order: Sequence[str], +) -> dict: + """ + Format one model output entry into a standardized result dictionary. + + :param image_path: Path to the evaluated image. + :param raw_probs: Raw model output (one vector per image). + :param calibrators: Calibration metadata. + :return: Dictionary formatted for downstream tooling (similar to CLIP pipeline outputs). + + Example:: + In: _format_single_result(image_path, raw_probs, calibrators, category_order) + Out: function result returned for provided inputs + """ + calibrated_probs = _apply_calibration(raw_probs, calibrators, category_order) + + # Overall score = mean of calibrated per-category probabilities + overall_score = float(np.mean([calibrated_probs[cat] for cat in category_order])) + + # CLIP pipeline expects "delta" values, but fine-tuned models do not provide them + category_block = { + category_name: {"probability_pct": float(calibrated_probs[category_name]), "delta": None} + for category_name in category_order + } + + return { + "filename": image_path.name, + "image_path": str(image_path), + "overall_pct": overall_score, + "categories": category_block, + } + + +def _process_batch( + model: torch.nn.Module, + batch_tensors: list[torch.Tensor], + batch_paths: list[Path], + calibrators: dict, + device: torch.device, + category_order: Sequence[str], +) -> list[dict]: + """ + Evaluate one batch and convert its outputs into result dictionaries. + + :param model: Fine-tuned model. + :param batch_tensors: List of tensors in the current batch. + :param batch_paths: Corresponding list of image paths. + :param calibrators: Calibration metadata. + :param device: Torch device. + :return: List of results for each image in the batch. + + Example:: + In: _process_batch(model, batch_tensors, batch_paths, calibrators, device, category_order) + Out: function result returned for provided inputs + """ + raw_output = _predict_batch(model, batch_tensors, device) + results = [] + + for index in range(raw_output.shape[0]): + # Keep path and prediction aligned by shared batch index. + result = _format_single_result(batch_paths[index], raw_output[index], calibrators, category_order) + results.append(result) + + return results + + +def _calculate_final_stats(results_images: list[dict]) -> float | None: + """ + Compute the mean overall score across all evaluated images. + + :param results_images: List of result dictionaries. + :return: Average score or None if no images were processed. + + Example:: + In: _calculate_final_stats(results_images) + Out: function result returned for provided inputs + """ + if not results_images: + return None + + return float(np.mean([res["overall_pct"] for res in results_images])) + + +@torch.no_grad() +def evaluate_images_with_finetuned( + images_dir: str | Path, + model_path: str | Path, + calibrators_dir: str | Path | None = None, + model_name: str = "vit_base_patch14_dinov2.lvd142m", + image_size: int = 224, + batch_size: int = 32, + torch_device: str = "auto", + category_order: Sequence[str] = defaults.DEFAULT_CATEGORY_ORDER, +) -> dict: + """ + Evaluate a set of images using a fine-tuned ViT-like model (loaded from checkpoint). + + This pipeline: + - resolves paths and loads the model + - builds transforms + - optionally loads calibrators + - iterates through images in batches + - computes calibrated probabilities + - formats results into a CLIP-compatible schema + + :return: Structured result dictionary similar to CLIP scoring outputs. + + Example:: + In: evaluate_images_with_finetuned(images_dir, model_path, calibrators_dir, model_name, image_size, batch_size, torch_device, category_order) + Out: function result returned for provided inputs + """ + log.info("Starting fine-tuned model evaluation pipeline.") + + # Resolve filesystem paths + images_dir, model_path, calibrators_dir_path = _resolve_paths( + images_dir=images_dir, + model_path=model_path, + calibrators_dir=calibrators_dir, + ) + + num_outputs = len(category_order) + + # Load model and preprocessing + torch_device_name = (torch_device or "auto").lower().strip() + if torch_device_name not in ("auto", "cpu", "cuda"): + raise ValueError("torch_device must be one of: auto, cpu, cuda") + if torch_device_name == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("CUDA requested but not available in this environment.") + if torch_device_name == "cuda": + device = torch.device("cuda") + elif torch_device_name == "cpu": + device = torch.device("cpu") + else: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + log.info("Loading model weights from: %s (Model: %s)", model_path, model_name) + log.info("Using device: %s", device) + + model = _load_checkpoint_and_model(model_path, model_name, image_size, device, num_outputs) + transform_function = _build_transform(model_name, image_size) + calibrators = _get_calibrators(calibrators_dir_path, category_order) + + # Gather image paths + image_paths = _iter_image_paths(images_dir) + if not image_paths: + error_message = f"No images found in: {images_dir}" + log.error(error_message) + raise FileNotFoundError(error_message) + + log.info("Found %s images to evaluate.", len(image_paths)) + + results_images: list[dict] = [] + skipped: list[str] = [] + warnings: list[str] = [] + + batch_tensors: list[torch.Tensor] = [] + batch_paths: list[Path] = [] + + iterator = tqdm(image_paths, desc="Scoring images", dynamic_ncols=True) + + # Process images batch-by-batch + for image_path in iterator: + try: + image = Image.open(image_path).convert("RGB") + except Exception as open_error: + warning_message = f"Failed to open image '{image_path.name}': {open_error}" + log.warning(warning_message) + skipped.append(f"{image_path.name}: {open_error}") + continue + + batch_tensors.append(transform_function(image)) + batch_paths.append(image_path) + + # Full batch > process it + if len(batch_tensors) >= batch_size: + batch_results = _process_batch(model, batch_tensors, batch_paths, calibrators, device, category_order) + results_images.extend(batch_results) + batch_tensors, batch_paths = [], [] + + # Process leftovers + if batch_tensors: + batch_results = _process_batch(model, batch_tensors, batch_paths, calibrators, device, category_order) + results_images.extend(batch_results) + + # Compute overall stats + average_overall_score = _calculate_final_stats(results_images) + + log.info( + "Evaluation complete. Average score: %s (Processed: %s, Skipped: %s)", + average_overall_score, + len(results_images), + len(skipped), + ) + + return make_evaluation_result( + model_name=f"{model_name} (fine-tuned)", + order=category_order, + weights=None, + beta_sigmoid=None, + images=results_images, + average_overall_pct=average_overall_score, + warnings=warnings, + errors=None, + skipped_images=skipped, + extra={"calibration": "on" if calibrators_dir_path is not None else "off"}, + ) diff --git a/src/uq_desktop_processor/evaluation/finetuned_evaluator/utils.py b/src/uq_desktop_processor/evaluation/finetuned_evaluator/utils.py new file mode 100644 index 0000000..662e4a3 --- /dev/null +++ b/src/uq_desktop_processor/evaluation/finetuned_evaluator/utils.py @@ -0,0 +1,354 @@ +""" +Loads checkpoints, image transforms, and calibration for fine-tuned evaluation. +""" + +import json +import logging +import math +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any, cast + +import joblib +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .model import ViTMultiHead + +log = logging.getLogger(__name__) + +try: + import timm + from timm.data import resolve_data_config + from timm.data.transforms_factory import create_transform +except Exception as import_error: + # Provide a clear installation message if timm is missing + raise RuntimeError("Install timm: pip install timm") from import_error + + +def _build_transform(model_name: str, image_size: int) -> Callable[[Any], torch.Tensor]: + """ + Build the preprocessing transform used for the fine-tuned model. + + Uses TIMM utilities to resolve the correct mean/std and transform pipeline + based on the model's pretrained configuration. + + :param model_name: Name of the backbone model. + :param image_size: Input image size expected by the model. + :return: A torchvision-compatible transform function. + + Example:: + In: _build_transform(model_name, image_size) + Out: function result returned for provided inputs + """ + log.debug("Building transform for model '%s' (size: %s)", model_name, image_size) + pretrained_config = timm.get_pretrained_cfg(model_name) + pretrained_cfg_dict: dict[str, Any] = pretrained_config.to_dict() if pretrained_config is not None else {} + base_config = resolve_data_config(pretrained_cfg=pretrained_cfg_dict) + + transform = create_transform( + input_size=(3, image_size, image_size), + is_training=False, + mean=base_config.get("mean"), + std=base_config.get("std"), + ) + return transform + + +def _load_checkpoint_and_model( + checkpoint_path: Path, + model_name: str, + image_size: int, + device: torch.device, + num_outputs: int, +) -> nn.Module: + """ + Load a fine-tuned ViTMultiHead model and restore weights from checkpoint. + + :param checkpoint_path: Path to a .pt checkpoint containing the saved state_dict. + :param model_name: Name of the TIMM backbone. + :param image_size: Input resolution. + :param device: Torch device for model instantiation. + :return: Loaded and ready-to-evaluate model instance. + + Example:: + In: _load_checkpoint_and_model(checkpoint_path, model_name, image_size, device, num_outputs) + Out: function result returned for provided inputs + """ + log.debug("Loading checkpoint from: %s", checkpoint_path) + + try: + checkpoint = torch.load(checkpoint_path, map_location=device) + except Exception as error: + log.error("Failed to load checkpoint file: %s", error) + raise + + # Create the classifier head with the correct shape + model = ViTMultiHead(model_name, num_outputs=num_outputs, image_size=image_size).to(device) + + def _resize_backbone_pos_embed_if_needed(state_dict_in: dict[str, Any], model_in: nn.Module) -> dict[str, Any]: + """ + Resize checkpoint positional embeddings to the current model's image size if needed. + + This function handles the spatial interpolation of ViT positional embeddings + when the inference resolution differs from the training resolution. + + :param state_dict_in: The source state dictionary from the loaded checkpoint. + :param model_in: The target model instance to match the shapes against. + :return: A state dictionary with adjusted (resized) positional embeddings. + + Example:: + In: _resize_backbone_pos_embed_if_needed(state_dict_in, model_in) + Out: function result returned for provided inputs + """ + pos_key = "backbone.pos_embed" + + # Check if the positional embedding key exists in both the checkpoint and the model + if pos_key not in state_dict_in: + return state_dict_in + + model_state = model_in.state_dict() + if pos_key not in model_state: + return state_dict_in + + checkpoint_pos_embed = state_dict_in[pos_key] + target_pos_embed = model_state[pos_key] + + # Ensure we are dealing with tensors + if not isinstance(checkpoint_pos_embed, torch.Tensor) or not isinstance(target_pos_embed, torch.Tensor): + return state_dict_in + + # If shapes already match, no interpolation is required + if checkpoint_pos_embed.shape == target_pos_embed.shape: + return state_dict_in + + # Validate dimensions before proceeding + if checkpoint_pos_embed.ndim != 3 or target_pos_embed.ndim != 3: + log.warning("Unexpected pos_embed shape. Falling back to model defaults for pos_embed.") + state_dict_copy = dict(state_dict_in) + state_dict_copy.pop(pos_key, None) + return state_dict_copy + + checkpoint_tokens = checkpoint_pos_embed.shape[1] + target_tokens = target_pos_embed.shape[1] + embed_dim = checkpoint_pos_embed.shape[2] + + # Check if the embedding vector size (last dimension) matches + if embed_dim != target_pos_embed.shape[2]: + log.warning("pos_embed embedding dim mismatch. Falling back to model defaults.") + state_dict_copy = dict(state_dict_in) + state_dict_copy.pop(pos_key, None) + return state_dict_copy + + # Determine the number of special tokens (e.g., [CLS]) to separate them from grid tokens + backbone_any = cast(Any, model_in.backbone) + num_prefix_tokens = int(getattr(backbone_any, "num_prefix_tokens", 1)) + + source_grid_tokens = checkpoint_tokens - num_prefix_tokens + target_grid_tokens = target_tokens - num_prefix_tokens + source_size = int(math.sqrt(source_grid_tokens)) + target_size = int(math.sqrt(target_grid_tokens)) + + # Ensure the spatial tokens form a perfect square + if ( + source_size * source_size != source_grid_tokens + or target_size * target_size != target_grid_tokens + or source_grid_tokens <= 0 + or target_grid_tokens <= 0 + ): + log.warning("Cannot infer square token grid. Falling back to model defaults.") + state_dict_copy = dict(state_dict_in) + state_dict_copy.pop(pos_key, None) + return state_dict_copy + + # Separate prefix tokens from patch tokens + cls_tokens = checkpoint_pos_embed[:, :num_prefix_tokens, :] + patch_tokens = checkpoint_pos_embed[:, num_prefix_tokens:, :] + + # Reshape patch tokens to a spatial grid for bicubic interpolation + patch_tokens = patch_tokens.reshape(1, source_size, source_size, embed_dim).permute(0, 3, 1, 2) + patch_tokens = F.interpolate(patch_tokens, size=(target_size, target_size), mode="bicubic", align_corners=False) + + # Flatten back to the sequence format + patch_tokens = patch_tokens.permute(0, 2, 3, 1).reshape(1, target_size * target_size, embed_dim) + + # Combine prefix tokens with the newly resized patch tokens + resized_pos_embed = torch.cat([cls_tokens, patch_tokens], dim=1).to(dtype=target_pos_embed.dtype) + + state_dict_copy = dict(state_dict_in) + state_dict_copy[pos_key] = resized_pos_embed + + log.info( + "Resized checkpoint pos_embed from %s to %s to match GUI image size.", + tuple(checkpoint_pos_embed.shape), + tuple(target_pos_embed.shape), + ) + return state_dict_copy + + # Load weights and configure for inference + try: + if isinstance(checkpoint, dict) and "model" in checkpoint: + state_dict = checkpoint["model"] + log.info("Detected dictionary checkpoint format (key 'model' found).") + else: + state_dict = checkpoint + log.info("Detected direct state_dict checkpoint format.") + + typed_state_dict = cast(dict[str, Any], state_dict) + adjusted_state_dict = _resize_backbone_pos_embed_if_needed(typed_state_dict, model) + missing_keys, unexpected_keys = model.load_state_dict(adjusted_state_dict, strict=False) + + filtered_missing_keys = [key for key in missing_keys if key != "backbone.pos_embed"] + if filtered_missing_keys or unexpected_keys: + log.warning( + "Checkpoint loaded with missing/unexpected keys. missing=%s unexpected=%s", + filtered_missing_keys, + unexpected_keys, + ) + + except Exception as error: + log.error("Error loading state_dict: %s", error) + raise + + model.eval() + torch.set_grad_enabled(False) + + log.info("Model loaded and ready for inference.") + return model + + +def _load_calibrators(calibrators_dir: Path, category_order: Sequence[str]) -> dict[str, dict[str, Any]]: + """ + Load isotonic calibrators and their metadata from the specified directory. + + This function attempts to load specific joblib models for each category + based on the mapping found in 'calibrators_meta.json'. + + :param calibrators_dir: Path to the directory containing .joblib files and metadata. + :param category_order: Sequence of category names expected by the model. + :return: A dictionary mapping each category to its calibrator model or a fallback. + + Example:: + In: _load_calibrators(calibrators_dir, category_order) + Out: function result returned for provided inputs + """ + meta_path = calibrators_dir / "calibrators_meta.json" + meta: dict[str, dict] = {} + + # Attempt to load metadata that maps categories to specific files + if meta_path.exists(): + try: + with open(meta_path, encoding="utf-8") as meta_file: + meta = json.load(meta_file) + except Exception as error: + log.warning("Failed to load calibration metadata: %s", error) + + calibrators: dict[str, dict[str, Any]] = {} + + for category_name in category_order: + entry = meta.get(category_name) + + # Retrieve filename from metadata or construct a default based on the category name + file_name_any = entry.get("file") if entry else None + file_name = ( + str(file_name_any) + if isinstance(file_name_any, str) and file_name_any.strip() + else f'calibrator_{category_name.replace(" ", "_")}.joblib' + ) + calibrator_path = calibrators_dir / file_name + + if calibrator_path.exists(): + try: + # Load the isotonic regression model using joblib + calibrators[category_name] = { + "type": "isotonic", + "model": cast(Any, joblib.load(calibrator_path)), + } + log.debug("Loaded isotonic calibrator for %s", category_name) + continue + except Exception as error: + log.warning("Could not load calibrator file %s: %s", file_name, error) + + # Set fallback if the file is missing or corrupted + calibrators[category_name] = {"type": "fallback"} + + return calibrators + + +def _apply_calibration( + raw_score_vector: np.ndarray, + calibrators: dict[str, dict[str, Any]], + category_order: Sequence[str], +) -> dict[str, float]: + """ + Apply category-specific calibration to raw model outputs. + + Supported calibration types: + • pchip – monotonic cubic Hermite spline + • isotonic – isotonic regression model + • minmax – simple linear normalization + • fallback – heuristic scaling + + :param raw_score_vector: Raw predictions for all categories (vector of length 6). + :param calibrators: Per-category calibrator configuration. + :return: Mapping category → calibrated probability in [0, 100]. + + Example:: + In: _apply_calibration(raw_score_vector, calibrators, category_order) + Out: function result returned for provided inputs + """ + calibrated_probabilities: dict[str, float] = {} + + for index, category_name in enumerate(category_order): + calibrator_spec = calibrators.get(category_name, {"type": "fallback"}) + raw_value = float(raw_score_vector[index]) + + # PCHIP interpolation + if calibrator_spec.get("type") == "pchip" and "model" in calibrator_spec: + pchip_model = calibrator_spec["model"] + calibrated_value = float(pchip_model(raw_value)) + calibrated_probabilities[category_name] = float(np.clip(calibrated_value, 0.0, 100.0)) + + # Isotonic regression + elif calibrator_spec.get("type") == "isotonic" and "model" in calibrator_spec: + calibrated_probabilities[category_name] = float(calibrator_spec["model"].predict([raw_value])[0]) + + # Min–max normalization + elif calibrator_spec.get("type") == "minmax": + lower_bound = float(calibrator_spec["x_lo"]) + upper_bound = float(calibrator_spec["x_hi"]) + + if upper_bound <= lower_bound: + # Invalid calibrator — fall back to neutral midpoint + calibrated_probabilities[category_name] = 50.0 + else: + normalized_value = (raw_value - lower_bound) / (upper_bound - lower_bound) + calibrated_probabilities[category_name] = float(np.clip(100.0 * normalized_value, 0.0, 100.0)) + + # Fallback heuristic: crude linear scaling + else: + calibrated_probabilities[category_name] = float(np.clip(50.0 + 25.0 * raw_value, 0.0, 100.0)) + + return calibrated_probabilities + + +def _iter_image_paths(images_dir: Path) -> list[Path]: + """ + Gather all valid image paths from a directory. + + Recognizes .jpg, .jpeg, .png in any capitalization. + + :param images_dir: Directory containing images. + :return: Sorted list of path objects. + + Example:: + In: _iter_image_paths(images_dir) + Out: function result returned for provided inputs + """ + valid_extensions = (".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG") + + log.debug("Scanning for images in: %s", images_dir) + + return sorted([image_path for image_path in Path(images_dir).glob("*") if image_path.suffix in valid_extensions]) diff --git a/src/uq_desktop_processor/gui/__init__.py b/src/uq_desktop_processor/gui/__init__.py new file mode 100644 index 0000000..f8eeecf --- /dev/null +++ b/src/uq_desktop_processor/gui/__init__.py @@ -0,0 +1,41 @@ +""" +GUI module: init . +""" + +import ctypes +import sys + +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + +from .paths import APP_ICON_PATH +from .platform.webengine import configure_webengine_for_deck_gl +from .shell.explorer import UrbanQualityAIExplorer + + +def main() -> int: + """ + Run main. + + :return: Result of this step or updated UI/application state. + + Example:: + In: main() + Out: UI/application state updated as intended. + """ + configure_webengine_for_deck_gl() + if sys.platform == "win32": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("uq_desktop_processor.app") + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) + app = QApplication(sys.argv) + app.setStyle("Fusion") + app_icon = QIcon(str(APP_ICON_PATH)) + app.setWindowIcon(app_icon) + gui = UrbanQualityAIExplorer() + gui.setWindowIcon(app_icon) + gui.show() + return app.exec() + + +__all__ = ["main", "UrbanQualityAIExplorer"] diff --git a/src/uq_desktop_processor/gui/map_view/__init__.py b/src/uq_desktop_processor/gui/map_view/__init__.py new file mode 100644 index 0000000..b83e8ec --- /dev/null +++ b/src/uq_desktop_processor/gui/map_view/__init__.py @@ -0,0 +1,5 @@ +""" +GUI module: init . +""" + +__all__: list[str] = [] diff --git a/src/uq_desktop_processor/gui/map_view/constants.py b/src/uq_desktop_processor/gui/map_view/constants.py new file mode 100644 index 0000000..3d569cf --- /dev/null +++ b/src/uq_desktop_processor/gui/map_view/constants.py @@ -0,0 +1,89 @@ +""" +Map layer IDs, color palettes, and display limits for PyDeck-based map views. +""" + +EULER_LINE_COLORS: list[list[int]] = [ + [0, 242, 255], + [255, 0, 170], + [0, 255, 136], + [255, 214, 0], + [136, 86, 255], + [255, 128, 0], + [120, 220, 255], + [255, 80, 80], + [180, 255, 120], + [200, 120, 255], +] + +# QListWidget: first row = drawn on top of the map (deck.gl last in stack). +MAP_LAYER_IDS_TOP_FIRST: tuple[str, ...] = ("clip", "points", "euler", "roads") +MAP_LAYER_LABELS: dict[str, str] = { + "roads": "Road network", + "points": "Sampling points", + "euler": "Euler routes (GPX)", + "clip": "Estimation results (avg)", +} + +MAP_REDRAW_DEBOUNCE_MS = 48 +ROADS_MAP_SIMPLIFY_METERS_BASE = 12.0 +ROADS_MAP_MAX_FEATURES_BASE = 25000 +POINTS_MAP_MAX_BASE = 120_000 +EULER_VERTEX_BUDGET_BASE = 250_000 + +# Read live camera before setHtml so toggling layers does not reset pan/zoom. +DECK_VIEW_STATE_JS = r""" +(function() { + try { + function normalizeVs(vs) { + if (!vs) return null; + if (vs.latitude != null && vs.longitude != null && vs.zoom != null) return vs; + return null; + } + function deepPickVs(obj, depth) { + if (!obj || depth > 6) return null; + var n = normalizeVs(obj); + if (n) return n; + if (typeof obj !== "object") return null; + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + var inner = obj[keys[i]]; + var p = deepPickVs(inner, depth + 1); + if (p) return p; + } + return null; + } + var di = (typeof window.cityLensDeck !== "undefined") ? window.cityLensDeck : null; + if (!di) return null; + var root = di.deck || di; + var vs = normalizeVs(root.viewState); + if (!vs) vs = deepPickVs(root.viewState, 0); + if (!vs && root.viewManager) { + var m = root.viewManager; + vs = normalizeVs(m.viewState) || deepPickVs(m.viewState, 0); + if (!vs && m.viewStates) vs = deepPickVs(m.viewStates, 0); + if (!vs && m._viewports && m._viewports.length) { + var vp = m._viewports[0]; + vs = normalizeVs(vp.viewState) || deepPickVs(vp.viewState, 0); + if (!vs && vp.viewport) vs = normalizeVs(vp.viewport) || deepPickVs(vp.viewport, 0); + } + } + if (!vs && typeof root.getViewports === "function") { + try { + var vps = root.getViewports(root.width && root.height ? [0, 0, root.width, root.height] : undefined); + if (vps && vps.length) { + var vp0 = vps[0]; + vs = normalizeVs(vp0) || deepPickVs(vp0, 0); + } + } catch (e2) {} + } + if (!vs || vs.latitude == null || vs.longitude == null || vs.zoom == null) return null; + return JSON.stringify({ + latitude: vs.latitude, + longitude: vs.longitude, + zoom: vs.zoom, + pitch: vs.pitch != null ? vs.pitch : 0, + bearing: vs.bearing != null ? vs.bearing : 0 + }); + } catch (e) { return null; } +})() +""" diff --git a/src/uq_desktop_processor/gui/map_view/data.py b/src/uq_desktop_processor/gui/map_view/data.py new file mode 100644 index 0000000..9a45a30 --- /dev/null +++ b/src/uq_desktop_processor/gui/map_view/data.py @@ -0,0 +1,176 @@ +""" +Builds numpy/PyDeck-friendly data from GeoJSON, sampling points, and evaluation results. +""" + +import math +from typing import Any + +import geopandas as gpd +import numpy as np +import pydeck as pdk + +from uq_desktop_processor.gui.map_view.constants import ( + EULER_VERTEX_BUDGET_BASE, + POINTS_MAP_MAX_BASE, + ROADS_MAP_MAX_FEATURES_BASE, + ROADS_MAP_SIMPLIFY_METERS_BASE, +) +from uq_desktop_processor.layer_creation.vector_layers.point_layer.utils import parse_lat_lon + + +def evaluation_scatter_data(results: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Run evaluation scatter data. + + :param results: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: evaluation_scatter_data(results) + Out: UI/application state updated as intended. + """ + xs: list[float] = [] + ys: list[float] = [] + scores: list[float] = [] + for image_result in results.get("images") or []: + image_path = image_result.get("image_path") or "" + try: + if not image_path: + raise ValueError("Missing image_path in evaluation record.") + lat, lon = parse_lat_lon(image_path) + except (ValueError, IndexError): + continue + xs.append(lon) + ys.append(lat) + scores.append(float(image_result.get("overall_pct") or 0.0)) + if not xs: + return np.array([]), np.array([]), np.array([]) + return np.array(xs), np.array(ys), np.array(scores) + + +def fit_view_state(lons: list[float], lats: list[float]) -> pdk.ViewState: + """ + Run fit view state. + + :param lons: See caller/context. + :param lats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: fit_view_state(lons, lats) + Out: UI/application state updated as intended. + """ + if not lons or not lats: + return pdk.ViewState(latitude=50.0, longitude=19.0, zoom=4, pitch=0, bearing=0) + lo, hi = min(lons), max(lons) + la_lo, la_hi = min(lats), max(lats) + span = max(hi - lo, la_hi - la_lo, 1e-6) + pad = 0.08 * span + lo, hi = lo - pad, hi + pad + la_lo, la_hi = la_lo - pad, la_hi + pad + center_lon = (lo + hi) / 2 + center_lat = (la_lo + la_hi) / 2 + lon_span = max(hi - lo, 1e-6) + zoom = math.log2(360 / lon_span) - 0.75 + zoom = max(2.0, min(17.0, zoom)) + return pdk.ViewState( + latitude=float(center_lat), + longitude=float(center_lon), + zoom=float(zoom), + pitch=0, + bearing=0, + ) + + +def red_yellow_green_rgba(scores: np.ndarray) -> list[list[int]]: + """ + Run red yellow green rgba. + + :param scores: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: red_yellow_green_rgba(scores) + Out: UI/application state updated as intended. + """ + if scores.size == 0: + return [] + vmin, vmax = float(np.min(scores)), float(np.max(scores)) + if vmax <= vmin: + vmin -= 1.0 + vmax += 1.0 + colors_rgba: list[list[int]] = [] + for score_value in scores: + normalized_score = (float(score_value) - vmin) / (vmax - vmin) + normalized_score = max(0.0, min(1.0, normalized_score)) + if normalized_score <= 0.5: + blend_factor = normalized_score / 0.5 + red_channel, green_channel, blue_channel = 255, int(255 * blend_factor), 0 + else: + blend_factor = (normalized_score - 0.5) / 0.5 + red_channel, green_channel, blue_channel = int(255 * (1.0 - blend_factor)), 255, 0 + colors_rgba.append([int(red_channel), int(green_channel), int(blue_channel), 220]) + return colors_rgba + + +def simplify_roads_gdf_for_map_display( + roads_gdf: gpd.GeoDataFrame, + *, + simplify_meters: float, + max_features: int, +) -> gpd.GeoDataFrame: + """Reduce vertex / feature count for GeoJsonLayer (display only; pipeline keeps full geometry).""" + if roads_gdf.empty: + return roads_gdf + projected_roads_gdf = roads_gdf.to_crs(3857) + simplified_geometry = projected_roads_gdf.geometry.simplify(simplify_meters, preserve_topology=True) + display_roads_gdf = gpd.GeoDataFrame(geometry=simplified_geometry, crs=projected_roads_gdf.crs) + display_roads_gdf = display_roads_gdf[~display_roads_gdf.geometry.is_empty & display_roads_gdf.geometry.notna()] + display_roads_gdf = display_roads_gdf.to_crs(4326) + feature_count = len(display_roads_gdf) + cap = max(500, int(max_features)) + if feature_count > cap: + step = max(1, math.ceil(feature_count / cap)) + display_roads_gdf = display_roads_gdf.iloc[::step].copy() + return display_roads_gdf + + +def map_display_budgets(n_visible_layers: int) -> dict[str, float | int]: + """ + When several deck.gl layers are visible, total attribute + index data grows quickly. + Qt WebEngine (often SwiftShader) can hit GPU memory or inline-JSON practical limits; scale caps with n. + """ + visible_layer_count = max(1, n_visible_layers) + scale_divisor = math.sqrt(float(visible_layer_count)) + road_max = max(4000, int(ROADS_MAP_MAX_FEATURES_BASE / scale_divisor)) + road_simplify = ROADS_MAP_SIMPLIFY_METERS_BASE * (1.0 + 0.35 * (visible_layer_count - 1)) + points_max = max(8000, int(POINTS_MAP_MAX_BASE / scale_divisor)) + euler_vertices = max(40_000, int(EULER_VERTEX_BUDGET_BASE / scale_divisor)) + return { + "road_max_features": road_max, + "road_simplify_meters": road_simplify, + "points_max": points_max, + "euler_vertex_budget": euler_vertices, + } + + +def euler_polylines_for_display( + polylines: list[list[tuple[float, float]]], + vertex_budget: int, +) -> list[list[tuple[float, float]]]: + """Same length as input so sector index / colors stay aligned with the original route list.""" + total_vertex_count = sum(len(polyline) for polyline in polylines if len(polyline) >= 2) + if total_vertex_count <= vertex_budget: + return [list(polyline) for polyline in polylines] + display_polylines: list[list[tuple[float, float]]] = [] + for polyline in polylines: + if len(polyline) < 2: + display_polylines.append(list(polyline)) + continue + vertex_share = max(2, int(vertex_budget * len(polyline) / total_vertex_count)) + if len(polyline) <= vertex_share: + display_polylines.append(list(polyline)) + else: + sampled_indices = np.linspace(0, len(polyline) - 1, vertex_share).astype(int) + display_polylines.append([polyline[int(vertex_index)] for vertex_index in sampled_indices]) + return display_polylines diff --git a/src/uq_desktop_processor/gui/map_view/geo.py b/src/uq_desktop_processor/gui/map_view/geo.py new file mode 100644 index 0000000..9bf67a8 --- /dev/null +++ b/src/uq_desktop_processor/gui/map_view/geo.py @@ -0,0 +1,20 @@ +""" +Geographic helpers: bounds fitting, coordinate transforms, and map viewport math. +""" + +from pyproj import Geod + +_GEO_WGS84 = Geod(ellps="WGS84") + + +def polyline_geodesic_length_m(poly: tuple[tuple[float, float], ...]) -> float: + """Sum of geodesic segment lengths for a (lon, lat) polyline in WGS84.""" + if len(poly) < 2: + return 0.0 + total = 0.0 + for i in range(len(poly) - 1): + lon1, lat1 = poly[i] + lon2, lat2 = poly[i + 1] + _, _, dist_m = _GEO_WGS84.inv(lon1, lat1, lon2, lat2) + total += abs(dist_m) + return total diff --git a/src/uq_desktop_processor/gui/map_view/html.py b/src/uq_desktop_processor/gui/map_view/html.py new file mode 100644 index 0000000..61c2b03 --- /dev/null +++ b/src/uq_desktop_processor/gui/map_view/html.py @@ -0,0 +1,84 @@ +""" +HTML templates and embedding glue for standalone or Qt-embedded map pages. +""" + +import json +import logging +import re + +from PySide6.QtCore import QJsonValue + + +def sanitize_pydeck_inline_json_html(deck_html: str) -> str: + """ + Escape ```` sequences inside the deck JSON block so Chromium does not + terminate the script tag early (e.g. OSM tags in string data). + """ + marker = "const jsonInput = " + if marker not in deck_html: + return deck_html + + parts = deck_html.split(marker) + if len(parts) < 2: + return deck_html + + json_and_rest = parts[1].split("", 1) + if len(json_and_rest) < 2: + return deck_html + + json_content = json_and_rest[0] + rest_of_page = json_and_rest[1] + + safe_json = re.sub(r"" + rest_of_page + + +def expose_deck_instance_on_window(deck_html: str) -> str: + """Make the deck wrapper reachable from ``runJavaScript`` (``const`` is not a window property).""" + needle = "const deckInstance = createDeck(" + if needle in deck_html: + return deck_html.replace(needle, "window.cityLensDeck = createDeck(", 1) + logging.getLogger(__name__).warning( + "pydeck HTML has no expected deckInstance line; map view may not preserve pan/zoom on layer toggles." + ) + return deck_html + + +def inject_mapbox_gl_css(deck_html: str) -> str: + """Pydeck template loads mapbox-gl.js but not mapbox-gl.css; add it to silence warnings / layout glitches.""" + if "mapbox-gl.css" in deck_html: + return deck_html + marker = "mapbox-gl-js/v1.13.0/mapbox-gl.js" + script_marker_index = deck_html.find(marker) + if script_marker_index < 0: + return deck_html + script_close_index = deck_html.find("", script_marker_index) + if script_close_index < 0: + return deck_html + script_close_index += len("") + link = '\n ' + return deck_html[:script_close_index] + link + deck_html[script_close_index:] + + +def coerce_webengine_js_json(result: object) -> object: + """QWebEnginePage.runJavaScript often delivers a QJsonValue; normalize to Python types.""" + if isinstance(result, QJsonValue): + if result.isNull() or result.isUndefined(): + return None + return result.toVariant() + return result + + +def parse_view_state_json(result: object) -> dict[str, object] | None: + """Parse JS-returned view state string/dict into a plain dict.""" + result = coerce_webengine_js_json(result) + if isinstance(result, str) and result.strip() not in ("", "null", "undefined"): + try: + parsed = json.loads(result) + return parsed if isinstance(parsed, dict) else None + except json.JSONDecodeError: + return None + if isinstance(result, dict): + return result + return None diff --git a/src/uq_desktop_processor/gui/paths.py b/src/uq_desktop_processor/gui/paths.py new file mode 100644 index 0000000..b9fce87 --- /dev/null +++ b/src/uq_desktop_processor/gui/paths.py @@ -0,0 +1,11 @@ +""" +Shared GUI filesystem paths. +""" + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +APP_ICON_PATH = PACKAGE_ROOT / "assets" / "img" / "icon.ico" + +__all__ = ["PROJECT_ROOT", "PACKAGE_ROOT", "APP_ICON_PATH"] diff --git a/src/uq_desktop_processor/gui/platform/__init__.py b/src/uq_desktop_processor/gui/platform/__init__.py new file mode 100644 index 0000000..55e0255 --- /dev/null +++ b/src/uq_desktop_processor/gui/platform/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .webengine import configure_webengine_for_deck_gl + +__all__ = ["configure_webengine_for_deck_gl"] diff --git a/src/uq_desktop_processor/gui/platform/webengine.py b/src/uq_desktop_processor/gui/platform/webengine.py new file mode 100644 index 0000000..f3d8550 --- /dev/null +++ b/src/uq_desktop_processor/gui/platform/webengine.py @@ -0,0 +1,37 @@ +""" +Qt WebEngine profile and view helpers for embedding maps and web content. +""" + +import logging +import os +import sys + + +def configure_webengine_for_deck_gl() -> None: + """ + deck.gl needs a working **WebGL** context. ``--disable-gpu`` turns WebGL off entirely + (GL_RENDERER = Disabled), so the map cannot start. + + On Windows, native ANGLE/D3D11 can hit DXGI_ERROR_DEVICE_REMOVED; the stable default is + **SwiftShader** (CPU WebGL via ``--use-angle=swiftshader``). + + If ``QTWEBENGINE_CHROMIUM_FLAGS`` is already set, it is left unchanged. + + ``CITYLENS_WEBENGINE_USE_GPU=1``: try native GPU with light sandbox mitigations only. + """ + if os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "").strip(): + return + if sys.platform != "win32": + return + log = logging.getLogger(__name__) + if os.environ.get("CITYLENS_WEBENGINE_USE_GPU", "").lower() in ("1", "true", "yes"): + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu-sandbox --ignore-gpu-blocklist" + log.info( + "Qt WebEngine: native GPU (no SwiftShader). If the map crashes, unset " + "CITYLENS_WEBENGINE_USE_GPU or set flags manually via QTWEBENGINE_CHROMIUM_FLAGS." + ) + return + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = ( + "--use-angle=swiftshader " "--enable-unsafe-swiftshader " "--disable-gpu-sandbox " "--ignore-gpu-blocklist" + ) + log.info("Qt WebEngine: SwiftShader (CPU WebGL) for deck.gl. For native GPU: " "set CITYLENS_WEBENGINE_USE_GPU=1") diff --git a/src/uq_desktop_processor/gui/shell/__init__.py b/src/uq_desktop_processor/gui/shell/__init__.py new file mode 100644 index 0000000..261d6ad --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .explorer import UrbanQualityAIExplorer + +__all__ = ["UrbanQualityAIExplorer"] diff --git a/src/uq_desktop_processor/gui/shell/constants.py b/src/uq_desktop_processor/gui/shell/constants.py new file mode 100644 index 0000000..1f2c28c --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/constants.py @@ -0,0 +1,24 @@ +""" +Shell-wide UI constants: titles, spacing keys, and shared resource identifiers. +""" + +CLIP_MODEL_CHOICES: list[tuple[str, tuple[str, ...]]] = [ + ("ViT-L/14 @336px", ("ViT-L/14@336px",)), + ("ViT-L/14", ("ViT-L/14",)), + ("ViT-B/32", ("ViT-B/32",)), + ("ViT-B/16", ("ViT-B/16",)), +] + +# Combo userData order must match stacked pages in route/planner input stacks. +SOURCE_INPUT_STACK_PAGE_ORDER: tuple[str, ...] = ("place", "region", "roads") + +EULER_SOURCE_FIELD_LABELS: dict[str, str] = { + "place": "City / place", + "region": "Region file", + "roads": "Roads file", +} +PLANNER_SOURCE_FIELD_LABELS: dict[str, str] = { + "place": "Target city / place", + "region": "Region file", + "roads": "Roads file", +} diff --git a/src/uq_desktop_processor/gui/shell/explorer/__init__.py b/src/uq_desktop_processor/gui/shell/explorer/__init__.py new file mode 100644 index 0000000..d41c744 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .window import UrbanQualityAIExplorer + +__all__ = ["UrbanQualityAIExplorer"] diff --git a/src/uq_desktop_processor/gui/shell/explorer/console.py b/src/uq_desktop_processor/gui/shell/explorer/console.py new file mode 100644 index 0000000..236bfbd --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/console.py @@ -0,0 +1,54 @@ +""" +Embedded console widget for the explorer window (command/output surface). +""" + +from typing import Protocol + +from PySide6.QtGui import QTextCursor +from PySide6.QtWidgets import QTextEdit + + +class ConsoleSupported(Protocol): + """ + ConsoleSupported UI helper class. + """ + + console: QTextEdit + metrics_text: QTextEdit + + +class ConsoleMixin: + """ + ConsoleMixin UI helper class. + """ + + def _append_console(self: ConsoleSupported, line: str) -> None: + """ + Run append console. + + :param line: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _append_console(line) + Out: UI/application state updated as intended. + """ + self.console.append(line.rstrip()) + + def _append_data_science_section(self: ConsoleSupported, title: str, lines: list[str]) -> None: + """ + Run append data science section. + + :param title: See caller/context. + :param lines: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _append_data_science_section(title, lines) + Out: UI/application state updated as intended. + """ + current = self.metrics_text.toPlainText().rstrip() + sep = "\n\n" + "─" * 44 + "\n\n" if current else "" + body = "\n".join(lines) + self.metrics_text.setPlainText(f"{current}{sep}{title}\n{body}") + self.metrics_text.moveCursor(QTextCursor.MoveOperation.End) diff --git a/src/uq_desktop_processor/gui/shell/explorer/dialogs.py b/src/uq_desktop_processor/gui/shell/explorer/dialogs.py new file mode 100644 index 0000000..1f25c83 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/dialogs.py @@ -0,0 +1,86 @@ +""" +Modal dialogs for the explorer: paths, confirmations, and configuration prompts. +""" + +from PySide6.QtWidgets import QFileDialog, QLineEdit, QWidget + + +class DialogsMixin: + """ + DialogsMixin UI helper class. + """ + + def pick_file(self, target: QLineEdit, filt: str) -> None: + """ + Run pick file. + + :param target: See caller/context. + :param filt: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: pick_file(target, filt) + Out: UI/application state updated as intended. + """ + parent_widget: QWidget | None = self if isinstance(self, QWidget) else None + path, _ = QFileDialog.getOpenFileName(parent_widget, "Select file", "", filt) + if path: + target.setText(path) + + def pick_folder(self, target: QLineEdit) -> None: + """ + Run pick folder. + + :param target: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: pick_folder(target) + Out: UI/application state updated as intended. + """ + parent_widget: QWidget | None = self if isinstance(self, QWidget) else None + path = QFileDialog.getExistingDirectory(parent_widget, "Select folder") + if path: + target.setText(path) + + def pick_save_points_layer(self, target: QLineEdit) -> None: + """ + Run pick save points layer. + + :param target: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: pick_save_points_layer(target) + Out: UI/application state updated as intended. + """ + parent_widget: QWidget | None = self if isinstance(self, QWidget) else None + path, _ = QFileDialog.getSaveFileName( + parent_widget, + "Save point layer", + target.text() or "sampling_points.geojson", + "GeoJSON (*.geojson);;GeoPackage (*.gpkg)", + ) + if path: + target.setText(path) + + def pick_save_gpkg(self, target: QLineEdit) -> None: + """ + Run pick save gpkg. + + :param target: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: pick_save_gpkg(target) + Out: UI/application state updated as intended. + """ + parent_widget: QWidget | None = self if isinstance(self, QWidget) else None + path, _ = QFileDialog.getSaveFileName( + parent_widget, + "Save GeoPackage layer", + target.text() or "vit_finetuned_scores.gpkg", + "GeoPackage (*.gpkg)", + ) + if path: + target.setText(path) diff --git a/src/uq_desktop_processor/gui/shell/explorer/modules.py b/src/uq_desktop_processor/gui/shell/explorer/modules.py new file mode 100644 index 0000000..f73eac4 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/modules.py @@ -0,0 +1,46 @@ +""" +Registers explorer tool modules and exposes metadata for the sidebar. +""" + +from PySide6.QtWidgets import QButtonGroup, QSplitter, QStackedWidget, QWidget + + +class ModulesMixin: + """ + ModulesMixin UI helper class. + """ + + tool_container: QWidget + button_group: QButtonGroup + stacked_tools: QStackedWidget + main_splitter: QSplitter + current_active_module: int + + def toggle_module_panel(self, index: int) -> None: + """ + Run toggle module panel. + + :param index: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: toggle_module_panel(index) + Out: UI/application state updated as intended. + """ + is_visible = self.tool_container.isVisible() + + if is_visible and self.current_active_module == index: + self.tool_container.setVisible(False) + self.button_group.setExclusive(False) + self.button_group.button(index).setChecked(False) + self.button_group.setExclusive(True) + self.current_active_module = -1 + else: + self.stacked_tools.setCurrentIndex(index) + self.tool_container.setVisible(True) + + sizes = self.main_splitter.sizes() + if sizes and sizes[0] < 50: + self.main_splitter.setSizes([320, 800, 320]) + + self.current_active_module = index diff --git a/src/uq_desktop_processor/gui/shell/explorer/pipeline.py b/src/uq_desktop_processor/gui/shell/explorer/pipeline.py new file mode 100644 index 0000000..c190748 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/pipeline.py @@ -0,0 +1,478 @@ +""" +Connects pipeline execution and progress signals to the explorer UI. +""" + +import logging +import os +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QLineEdit, + QPlainTextEdit, + QProgressBar, + QPushButton, + QSlider, + QSpinBox, + QWidget, +) + +from uq_desktop_processor.evaluation.clip_prefilter.defaults import FILTER_PROMPTS as CLIP_PREFILTER_DEFAULT_PROMPTS +from uq_desktop_processor.gui.shell.formatters import ( + format_download_summary, + format_euler_routes_summary, + format_prefilter_summary, + format_vit_summary, +) +from uq_desktop_processor.gui.shell.worker import StepThread +from uq_desktop_processor.street_view_analysis import EulerRoutesResult, generate_clean_routes + +if TYPE_CHECKING: + Base = QWidget +else: + Base = object + + +class PipelineMixin(Base): + """ + PipelineMixin UI helper class. + """ + + prefilter_pos_edit: QPlainTextEdit + prefilter_neg_edit: QPlainTextEdit + model_combo: QComboBox + planner_source_combo: QComboBox + place_name_edit: QLineEdit + region_path_edit: QLineEdit + road_path_edit: QLineEdit + spacing_edit: QLineEdit + min_dist_edit: QLineEdit + planner_points_out_edit: QLineEdit + token_edit: QLineEdit + mapillary_points_edit: QLineEdit + mapillary_out_edit: QLineEdit + radius_edit: QLineEdit + workers_spin: QSpinBox + device_combo: QComboBox + prefilter_image_folder_edit: QLineEdit + prefilter_rejected_edit: QLineEdit + threshold_slider: QSlider + beta_spin: QDoubleSpinBox + vit_images_dir_edit: QLineEdit + vit_model_path_edit: QLineEdit + vit_calibrators_edit: QLineEdit + vit_output_path_edit: QLineEdit + vit_output_layer_name_edit: QLineEdit + vit_model_name_edit: QLineEdit + vit_image_size_spin: QSpinBox + vit_batch_size_spin: QSpinBox + vit_device_combo: QComboBox + progress_bar: QProgressBar + run_buttons: Iterable[QPushButton] + route_source_combo: QComboBox + route_grid_cols: QSpinBox + route_grid_rows: QSpinBox + route_out_edit: QLineEdit + route_consolidate_tol: QDoubleSpinBox + route_cache_check: QCheckBox + route_city_edit: QLineEdit + route_region_path_edit: QLineEdit + route_roads_path_edit: QLineEdit + + pipeline: Any + map_canvas: Any + _worker: StepThread | None + _last_euler_result: EulerRoutesResult | None + + def _append_data_science_section(self, title: str, lines: list[str]) -> None: + """ + Run append data science section. + + :param title: See caller/context. + :param lines: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _append_data_science_section(title, lines) + Out: UI/application state updated as intended. + """ + pass + + def reset_clip_prefilter_prompts(self) -> None: + """ + Run reset clip prefilter prompts. + + :return: Result of this step or updated UI/application state. + + Example:: + In: reset_clip_prefilter_prompts() + Out: UI/application state updated as intended. + """ + self.prefilter_pos_edit.setPlainText("\n".join(CLIP_PREFILTER_DEFAULT_PROMPTS["pos"])) + self.prefilter_neg_edit.setPlainText("\n".join(CLIP_PREFILTER_DEFAULT_PROMPTS["neg"])) + + def _apply_config_from_ui(self) -> None: + """ + Run apply config from ui. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _apply_config_from_ui() + Out: UI/application state updated as intended. + """ + model_tuple = self.model_combo.currentData() + if not isinstance(model_tuple, tuple): + # Keep a valid fallback if combo-box payload is malformed. + model_tuple = ("ViT-L/14@336px",) + + # Snapshot all controls into pipeline config before launching any step. + self.pipeline.update_config( + { + "planner_source": self.planner_source_combo.currentData(), + "place_name": self.place_name_edit.text().strip(), + "region_geojson_path": self.region_path_edit.text().strip(), + "road_geojson_path": self.road_path_edit.text().strip(), + "spacing": float(self.spacing_edit.text() or "100"), + "min_distance": float(self.min_dist_edit.text() or "50"), + "points_layer_path": self.planner_points_out_edit.text().strip(), + "mapillary_token": self.token_edit.text().strip() or os.environ.get("MAPILLARY_ACCESS_TOKEN", ""), + "mapillary_points_path": self.mapillary_points_edit.text().strip(), + "mapillary_images_output_dir": self.mapillary_out_edit.text().strip(), + "search_radius": float(self.radius_edit.text() or "150"), + "mapillary_max_workers": int(self.workers_spin.value()), + "clip_model_names": model_tuple, + "torch_device": self.device_combo.currentData(), + "prefilter_image_folder": self.prefilter_image_folder_edit.text().strip(), + "prefilter_rejected_folder": self.prefilter_rejected_edit.text().strip(), + "prefilter_pos_prompts": self.prefilter_pos_edit.toPlainText(), + "prefilter_neg_prompts": self.prefilter_neg_edit.toPlainText(), + "filter_threshold": float(self.threshold_slider.value()), + "beta_sigmoid": float(self.beta_spin.value()), + "vit_images_dir": self.vit_images_dir_edit.text().strip(), + "vit_model_path": self.vit_model_path_edit.text().strip(), + "vit_calibrators_dir": self.vit_calibrators_edit.text().strip(), + "vit_output_layer_path": self.vit_output_path_edit.text().strip(), + "vit_output_layer_name": self.vit_output_layer_name_edit.text().strip(), + "vit_model_name": self.vit_model_name_edit.text().strip(), + "vit_image_size": int(self.vit_image_size_spin.value()), + "vit_batch_size": int(self.vit_batch_size_spin.value()), + "vit_torch_device": self.vit_device_combo.currentData(), + } + ) + self.pipeline.base_dir = Path(self.pipeline.config["base_dir"]) + image_output_dir = (self.pipeline.config.get("mapillary_images_output_dir") or "").strip() + if image_output_dir: + self.pipeline.raw_dir = Path(image_output_dir).expanduser().resolve() + else: + # Planner default output lives under base_dir/images/raw. + self.pipeline.raw_dir = self.pipeline.base_dir / "images" / "raw" + self.pipeline.rejected_dir = self.pipeline.base_dir / "images" / "rejected" + self.pipeline.results_dir = self.pipeline.base_dir / "results" + self.pipeline.ensure_directories() + + def _set_busy(self, busy: bool) -> None: + """ + Run set busy. + + :param busy: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _set_busy(busy) + Out: UI/application state updated as intended. + """ + self.progress_bar.setRange(0, 0 if busy else 100) + if not busy: + self.progress_bar.setValue(0) + for run_button in self.run_buttons: + run_button.setDisabled(busy) + + def _run_async(self, task_fn: Callable[[], Any], on_ok: Callable[[Any], None] | None = None) -> None: + """ + Run run async. + + :param task_fn: See caller/context. + :param on_ok: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _run_async(task_fn, on_ok) + Out: UI/application state updated as intended. + """ + if self._worker is not None and self._worker.isRunning(): + logging.getLogger(__name__).warning("An operation is already running.") + return + self._apply_config_from_ui() + self._set_busy(True) + + self._worker = StepThread(task_fn, self) + + def _done(result: Any, error_message: str) -> None: + """ + Run done. + + :param result: See caller/context. + :param error_message: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _done(result, error_message) + Out: UI/application state updated as intended. + """ + self._set_busy(False) + if error_message: + logging.getLogger(__name__).error("Worker thread: %s", error_message) + elif on_ok is not None: + # Marshal callbacks back onto the GUI event loop. + QTimer.singleShot(0, lambda: on_ok(result)) + self._worker = None + + self._worker.finished.connect(_done) + self._worker.start() + + def _refresh_map_after_points(self) -> None: + """ + Run refresh map after points. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _refresh_map_after_points() + Out: UI/application state updated as intended. + """ + self.map_canvas.update_planner_layers(self.pipeline.roads_gdf, self.pipeline.points) + + def _refresh_map_after_eval(self) -> None: + """ + Run refresh map after eval. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _refresh_map_after_eval() + Out: UI/application state updated as intended. + """ + self.map_canvas.update_eval_layer(self.pipeline.roads_gdf, self.pipeline.evaluation_results) + evaluation_results = self.pipeline.evaluation_results + if evaluation_results: + self._append_data_science_section( + "ViT scoring (fine-tuned)", + format_vit_summary( + evaluation_results, + pipeline_config=self.pipeline.config, + default_results_dir=self.pipeline.results_dir, + ), + ) + + def _on_sampling_points_done(self, count: Any) -> None: + """ + Run on sampling points done. + + :param count: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_sampling_points_done(count) + Out: UI/application state updated as intended. + """ + self._refresh_map_after_points() + out = (self.pipeline.config.get("points_layer_path") or "").strip() + path_disp = str(Path(out).resolve()) if out else "(unknown)" + generated_count = int(count) if count is not None else 0 + self._append_data_science_section( + "Sampling points", + [ + f"Generated points: {generated_count}", + f"Output path: {path_disp}", + ], + ) + + def on_run_planner(self) -> None: + """ + Run on run planner. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_planner() + Out: UI/application state updated as intended. + """ + self._run_async( + self.pipeline.step_1_generate_points, + on_ok=self._on_sampling_points_done, + ) + + def _on_download_done(self, stats: Any) -> None: + """ + Run on download done. + + :param stats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_download_done(stats) + Out: UI/application state updated as intended. + """ + self._refresh_map_after_points() + if isinstance(stats, dict): + self._append_data_science_section("Mapillary download", format_download_summary(stats)) + + def on_run_download(self) -> None: + """ + Run on run download. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_download() + Out: UI/application state updated as intended. + """ + self._run_async( + self.pipeline.step_2_download_images, + on_ok=self._on_download_done, + ) + + def _on_prefilter_done(self, stats: Any) -> None: + """ + Run on prefilter done. + + :param stats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_prefilter_done(stats) + Out: UI/application state updated as intended. + """ + self._refresh_map_after_points() + if isinstance(stats, dict): + self._append_data_science_section("CLIP prefilter", format_prefilter_summary(stats)) + + def on_run_prefilter(self) -> None: + """ + Run on run prefilter. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_prefilter() + Out: UI/application state updated as intended. + """ + self._run_async( + self.pipeline.step_3_prefilter, + on_ok=self._on_prefilter_done, + ) + + def on_run_vit_evaluate(self) -> None: + """ + Run on run vit evaluate. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_vit_evaluate() + Out: UI/application state updated as intended. + """ + self._run_async( + self.pipeline.step_6_evaluate_vit, + on_ok=lambda _result: self._refresh_map_after_eval(), + ) + + @staticmethod + def on_run_export(_checked: bool = False) -> None: + """ + Run on run export. + + :param _checked: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_export(_checked) + Out: UI/application state updated as intended. + """ + logging.getLogger(__name__).warning("Export UI is not enabled yet. This tab is intentionally empty for now.") + + def on_run_euler_routes(self) -> None: + """ + Run on run euler routes. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_run_euler_routes() + Out: UI/application state updated as intended. + """ + route_source = self.route_source_combo.currentData() + kwargs: dict[str, Any] = { + "grid_size": (int(self.route_grid_cols.value()), int(self.route_grid_rows.value())), + "output_dir": self.route_out_edit.text().strip() or "routes_chinese_postman_gpx", + "consolidate_tolerance_m": float(self.route_consolidate_tol.value()), + "use_cache": self.route_cache_check.isChecked(), + } + if route_source == "place": + city = self.route_city_edit.text().strip() + if not city: + logging.getLogger(__name__).warning("Enter a city / place name.") + return + kwargs["city_name"] = city + kwargs["region_geojson_path"] = None + kwargs["road_geojson_path"] = None + elif route_source == "region": + region_path = self.route_region_path_edit.text().strip() + if not region_path: + logging.getLogger(__name__).warning("Select a region polygon GeoJSON file.") + return + kwargs["region_geojson_path"] = region_path + kwargs["city_name"] = None + kwargs["road_geojson_path"] = None + elif route_source == "roads": + roads_path = self.route_roads_path_edit.text().strip() + if not roads_path: + logging.getLogger(__name__).warning("Select a road network GeoJSON file.") + return + kwargs["road_geojson_path"] = roads_path + kwargs["city_name"] = None + kwargs["region_geojson_path"] = None + else: + logging.getLogger(__name__).error("Unknown route source: %s", route_source) + return + + def task() -> EulerRoutesResult: + """ + Run task. + + :return: Result of this step or updated UI/application state. + + Example:: + In: task() + Out: UI/application state updated as intended. + """ + return generate_clean_routes(**kwargs) + + self._run_async(task, on_ok=self._on_euler_routes_done) + + def _on_euler_routes_done(self, result: Any) -> None: + """ + Run on euler routes done. + + :param result: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_euler_routes_done(result) + Out: UI/application state updated as intended. + """ + if not isinstance(result, EulerRoutesResult): + return + self._last_euler_result = result + polylines = [list(polyline) for polyline in result.polylines_wgs84] + self.map_canvas.update_euler_layer(polylines) + self._append_data_science_section( + "Chinese postman / GPX drive routes", + format_euler_routes_summary(result), + ) diff --git a/src/uq_desktop_processor/gui/shell/explorer/sources.py b/src/uq_desktop_processor/gui/shell/explorer/sources.py new file mode 100644 index 0000000..26ade4a --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/sources.py @@ -0,0 +1,44 @@ +""" +Defines map layer sources and GeoJSON loading for the explorer map. +""" + +from PySide6.QtWidgets import QComboBox, QLabel + +from uq_desktop_processor.gui.shell.constants import SOURCE_INPUT_STACK_PAGE_ORDER +from uq_desktop_processor.gui.widgets import CompactStackedWidget + + +class SourceInputsMixin: + """ + SourceInputsMixin UI helper class. + """ + + @staticmethod + def _apply_source_input_page( + combo: QComboBox, + stack: CompactStackedWidget, + field_label: QLabel, + labels: dict[str, str], + ) -> None: + """ + Run apply source input page. + + :param combo: See caller/context. + :param stack: See caller/context. + :param field_label: See caller/context. + :param labels: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _apply_source_input_page(combo, stack, field_label, labels) + Out: UI/application state updated as intended. + """ + selected_source = combo.currentData() + source_key = str(selected_source) if selected_source is not None else "place" + try: + page_index = SOURCE_INPUT_STACK_PAGE_ORDER.index(source_key) + except ValueError: + page_index = 0 + source_key = "place" + stack.setCurrentIndex(page_index) + field_label.setText(labels[source_key]) diff --git a/src/uq_desktop_processor/gui/shell/explorer/window.py b/src/uq_desktop_processor/gui/shell/explorer/window.py new file mode 100644 index 0000000..8e14025 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/explorer/window.py @@ -0,0 +1,176 @@ +""" +Main explorer window: layout, menus, and coordination of map and tools. +""" + +import logging + +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFrame, + QLabel, + QLineEdit, + QMainWindow, + QPlainTextEdit, + QProgressBar, + QPushButton, + QSlider, + QSpinBox, + QSplitter, + QStackedWidget, + QTextEdit, + QVBoxLayout, +) + +from uq_desktop_processor.gui.paths import APP_ICON_PATH +from uq_desktop_processor.gui.shell.constants import EULER_SOURCE_FIELD_LABELS, PLANNER_SOURCE_FIELD_LABELS +from uq_desktop_processor.gui.shell.explorer.console import ConsoleMixin +from uq_desktop_processor.gui.shell.explorer.dialogs import DialogsMixin +from uq_desktop_processor.gui.shell.explorer.modules import ModulesMixin +from uq_desktop_processor.gui.shell.explorer.pipeline import PipelineMixin +from uq_desktop_processor.gui.shell.explorer.sources import SourceInputsMixin +from uq_desktop_processor.gui.shell.logging_bridge import LogBridge, QtLogHandler +from uq_desktop_processor.gui.shell.ui.main_frame import build_main_frame +from uq_desktop_processor.gui.shell.worker import StepThread +from uq_desktop_processor.gui.styles.theme import NEON_STYLE +from uq_desktop_processor.gui.widgets import CompactStackedWidget, DeckMapWidget, NeonPanel +from uq_desktop_processor.pipeline import UrbanQualityAIPipeline +from uq_desktop_processor.street_view_analysis import EulerRoutesResult + + +class UrbanQualityAIExplorer( + QMainWindow, + ConsoleMixin, + DialogsMixin, + SourceInputsMixin, + PipelineMixin, + ModulesMixin, +): + """Main window; shell UI is built by ``urban_quality_ai.gui.shell.ui``.""" + + sidebar: QFrame + button_group: QButtonGroup + main_splitter: QSplitter + tool_container: QFrame + tool_layout: QVBoxLayout + stacked_tools: QStackedWidget + map_container: QFrame + map_canvas: DeckMapWidget + right_panel: QFrame + ai_vision_box: NeonPanel + metrics_text: QTextEdit + logs_box: NeonPanel + console: QTextEdit + progress_bar: QProgressBar + route_source_combo: QComboBox + _route_input_stack: CompactStackedWidget + route_city_edit: QLineEdit + route_region_path_edit: QLineEdit + route_roads_path_edit: QLineEdit + _route_input_field_label: QLabel + route_grid_cols: QSpinBox + route_grid_rows: QSpinBox + route_out_edit: QLineEdit + route_consolidate_tol: QDoubleSpinBox + route_cache_check: QCheckBox + planner_source_combo: QComboBox + _planner_input_stack: CompactStackedWidget + place_name_edit: QLineEdit + region_path_edit: QLineEdit + road_path_edit: QLineEdit + _planner_input_field_label: QLabel + spacing_edit: QLineEdit + min_dist_edit: QLineEdit + planner_points_out_edit: QLineEdit + token_edit: QLineEdit + mapillary_points_edit: QLineEdit + mapillary_out_edit: QLineEdit + radius_edit: QLineEdit + workers_spin: QSpinBox + prefilter_image_folder_edit: QLineEdit + prefilter_rejected_edit: QLineEdit + model_combo: QComboBox + device_combo: QComboBox + threshold_slider: QSlider + threshold_label: QLabel + beta_spin: QDoubleSpinBox + prefilter_pos_edit: QPlainTextEdit + prefilter_neg_edit: QPlainTextEdit + vit_images_dir_edit: QLineEdit + vit_model_path_edit: QLineEdit + vit_calibrators_edit: QLineEdit + vit_output_path_edit: QLineEdit + vit_output_layer_name_edit: QLineEdit + vit_model_name_edit: QLineEdit + vit_image_size_spin: QSpinBox + vit_batch_size_spin: QSpinBox + vit_device_combo: QComboBox + run_buttons: list[QPushButton] + + def __init__(self) -> None: + """ + Run init . + + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__() + Out: UI/application state updated as intended. + """ + super().__init__() + self.setWindowTitle("UrbanQuality-AI 2026") + self.setWindowIcon(QIcon(str(APP_ICON_PATH))) + self.resize(1400, 850) + self.setStyleSheet(NEON_STYLE) + + self.pipeline = UrbanQualityAIPipeline() + self.current_active_module = -1 + self._worker: StepThread | None = None + self.run_buttons = [] + self._last_euler_result: EulerRoutesResult | None = None + + self._log_bridge = LogBridge() + self._log_bridge.append_text.connect(self._append_console) + self._qt_log_handler = QtLogHandler(self._log_bridge) + self._qt_log_handler.setLevel(logging.INFO) + self._qt_log_handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s")) + logging.getLogger().addHandler(self._qt_log_handler) + + build_main_frame(self) + + def on_route_source_changed(self) -> None: + """ + Run on route source changed. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_route_source_changed() + Out: UI/application state updated as intended. + """ + self._apply_source_input_page( + self.route_source_combo, + self._route_input_stack, + self._route_input_field_label, + EULER_SOURCE_FIELD_LABELS, + ) + + def on_planner_source_changed(self) -> None: + """ + Run on planner source changed. + + :return: Result of this step or updated UI/application state. + + Example:: + In: on_planner_source_changed() + Out: UI/application state updated as intended. + """ + self._apply_source_input_page( + self.planner_source_combo, + self._planner_input_stack, + self._planner_input_field_label, + PLANNER_SOURCE_FIELD_LABELS, + ) diff --git a/src/uq_desktop_processor/gui/shell/formatters.py b/src/uq_desktop_processor/gui/shell/formatters.py new file mode 100644 index 0000000..322f5d1 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/formatters.py @@ -0,0 +1,164 @@ +""" +Small string formatting helpers for pipeline status and shell UI labels. +""" + +from pathlib import Path +from typing import Any + +import numpy as np + +from uq_desktop_processor.gui.map_view.geo import polyline_geodesic_length_m +from uq_desktop_processor.street_view_analysis import EulerRoutesResult + + +def format_euler_routes_summary(result: EulerRoutesResult) -> list[str]: + """ + Run format euler routes summary. + + :param result: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: format_euler_routes_summary(result) + Out: UI/application state updated as intended. + """ + file_count = len(result.gpx_paths) + polylines = result.polylines_wgs84 + segment_lengths_m = [polyline_geodesic_length_m(polyline) for polyline in polylines] + total_length_m = sum(segment_lengths_m) + lines: list[str] = [ + f"Output directory: {result.output_dir}", + f"GPX files written: {file_count}", + f"Route segments (polylines): {len(polylines)}", + ] + for segment_index, segment_length_m in enumerate(segment_lengths_m, start=1): + lines.append(f" Segment {segment_index}: {segment_length_m / 1000.0:.3f} km") + lines.append(f"Total length (all segments): {total_length_m / 1000.0:.3f} km") + return lines + + +def format_download_summary(stats: dict[str, Any]) -> list[str]: + """ + Run format download summary. + + :param stats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: format_download_summary(stats) + Out: UI/application state updated as intended. + """ + point_count = int(stats.get("point_count") or 0) + downloaded_count = int(stats.get("downloaded") or 0) + elapsed = float(stats.get("elapsed_s") or 0.0) + output_folder = str(stats.get("output_folder") or "") + images_per_second = float(stats.get("images_per_second") or 0.0) + failed_messages: list[str] = list(stats.get("failed_messages") or []) + lines = [ + f"Points processed: {point_count}", + f"Images saved: {downloaded_count}", + f"Output path: {output_folder}", + f"Elapsed: {elapsed:.1f} s", + f"Mean throughput (saved images / s): {images_per_second:.2f}", + ] + if failed_messages: + max_show = 12 + lines.append(f"Not downloaded / failed ({len(failed_messages)}):") + for failed_message in failed_messages[:max_show]: + lines.append(f" • {failed_message}") + if len(failed_messages) > max_show: + lines.append(f" … and {len(failed_messages) - max_show} more") + else: + lines.append("Failures: none") + return lines + + +def format_prefilter_summary(stats: dict[str, Any]) -> list[str]: + """ + Run format prefilter summary. + + :param stats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: format_prefilter_summary(stats) + Out: UI/application state updated as intended. + """ + summary = stats.get("summary") or {} + rejected_folder = stats.get("rejected_folder") or "" + rejected_count = int(summary.get("rejected") or 0) + kept_count = int(summary.get("kept") or 0) + model_name = stats.get("model_name") or "" + return [ + f"CLIP model: {model_name}", + f"Rejected (moved): {rejected_count}", + f"Kept: {kept_count}", + f"Rejected folder: {rejected_folder}", + ] + + +def format_vit_summary( + results: dict[str, Any], + *, + pipeline_config: dict[str, Any], + default_results_dir: Path, +) -> list[str]: + """ + Run format vit summary. + + :param results: See caller/context. + :param pipeline_config: See caller/context. + :param default_results_dir: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: format_vit_summary(results) + Out: UI/application state updated as intended. + """ + out_spec = (pipeline_config.get("vit_output_layer_path") or "").strip() + if out_spec: + out_path = str(Path(out_spec).expanduser().resolve()) + else: + out_path = str((default_results_dir / "vit_finetuned_scores.gpkg").resolve()) + model = str(results.get("model_name") or "") + images = results.get("images") or [] + order = list(results.get("order") or []) + checkpoint_path = (pipeline_config.get("vit_model_path") or "").strip() + checkpoint_line = str(Path(checkpoint_path).expanduser().resolve()) if checkpoint_path else "(not set)" + calibrators_spec = (pipeline_config.get("vit_calibrators_dir") or "").strip() + calibrators_line = str(Path(calibrators_spec).expanduser().resolve()) if calibrators_spec else "(calibration off)" + arch = (pipeline_config.get("vit_model_name") or "").strip() + image_size = int(pipeline_config.get("vit_image_size") or 224) + batch_size = int(pipeline_config.get("vit_batch_size") or 32) + torch_device = str(pipeline_config.get("vit_torch_device") or "auto") + lines: list[str] = [ + f"Output path: {out_path}", + f"Model (architecture): {arch}", + f"Weights file: {checkpoint_line}", + f"Calibrators: {calibrators_line}", + f"Inference: image_size={image_size}, batch_size={batch_size}, device={torch_device}", + f"Readout model label: {model}", + f"Images scored: {len(images)}", + ] + for cat in order: + vals: list[float] = [] + for im in images: + block = (im.get("categories") or {}).get(cat) or {} + if "probability_pct" in block: + vals.append(float(block["probability_pct"])) + if not vals: + continue + arr = np.array(vals, dtype=np.float64) + lo = float(arr.min()) + hi = float(arr.max()) + lines.append(f" {cat}: avg={float(arr.mean()):.2f}%, min={lo:.2f}%, max={hi:.2f}%, range={hi - lo:.2f}%") + average_overall_pct = results.get("average_overall_pct") + if average_overall_pct is not None: + lines.append(f"Overall (mean of per-image means): {float(average_overall_pct):.2f}%") + skipped_images = results.get("skipped_images") or [] + if skipped_images: + lines.append(f"Skipped images: {len(skipped_images)}") + warnings = results.get("warnings") or [] + if warnings: + lines.append("Warnings: " + "; ".join(str(warning_message) for warning_message in warnings)) + return lines diff --git a/src/uq_desktop_processor/gui/shell/logging_bridge.py b/src/uq_desktop_processor/gui/shell/logging_bridge.py new file mode 100644 index 0000000..ceb48ca --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/logging_bridge.py @@ -0,0 +1,54 @@ +""" +Forwards Python logging records into Qt text widgets for in-app log panes. +""" + +import logging + +from PySide6.QtCore import QObject, Signal + + +class LogBridge(QObject): + """ + LogBridge UI helper class. + """ + + append_text = Signal(str) + + +class QtLogHandler(logging.Handler): + """ + QtLogHandler UI helper class. + """ + + def __init__(self, bridge: LogBridge) -> None: + """ + Run init . + + :param bridge: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(bridge) + Out: UI/application state updated as intended. + """ + super().__init__() + self._bridge = bridge + + def emit(self, record: logging.LogRecord) -> None: + """ + Run emit. + + :param record: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: emit(record) + Out: UI/application state updated as intended. + """ + try: + formatted_message = self.format(record) + self._bridge.append_text.emit(formatted_message) + except (KeyboardInterrupt, SystemExit): + raise + except (RuntimeError, ValueError, TypeError, AttributeError): + self.handleError(record) diff --git a/src/uq_desktop_processor/gui/shell/ui/__init__.py b/src/uq_desktop_processor/gui/shell/ui/__init__.py new file mode 100644 index 0000000..46a4359 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .main_frame import build_main_frame + +__all__ = ["build_main_frame"] diff --git a/src/uq_desktop_processor/gui/shell/ui/main_frame.py b/src/uq_desktop_processor/gui/shell/ui/main_frame.py new file mode 100644 index 0000000..f1c8595 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/main_frame.py @@ -0,0 +1,142 @@ +""" +Primary application frame hosting the stacked tool pages and central splitter. +""" + +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QButtonGroup, + QFrame, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QSplitter, + QStackedWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from uq_desktop_processor.gui.shell.ui.module_pages import setup_module_pages +from uq_desktop_processor.gui.widgets import DeckMapWidget, NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def build_main_frame(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run build main frame. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: build_main_frame(explorer) + Out: UI/application state updated as intended. + """ + central_widget = QWidget() + explorer.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + top_layout = QHBoxLayout() + top_layout.setSpacing(0) + top_layout.setContentsMargins(0, 0, 0, 0) + + explorer.sidebar = QFrame() + explorer.sidebar.setObjectName("Sidebar") + explorer.sidebar.setFixedWidth(70) + sidebar_layout = QVBoxLayout(explorer.sidebar) + sidebar_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + explorer.button_group = QButtonGroup(explorer) + explorer.button_group.setExclusive(True) + + for panel_index, (icon, name) in enumerate( + [ + ("1", "Drive route"), + ("2", "Sampling points"), + ("3", "Mapillary download"), + ("4", "Panoramas → views"), + ("5", "CLIP prefilter"), + ("6", "ViT scoring"), + ("7", "Export results"), + ("8", "Validation"), + ] + ): + sidebar_button = QPushButton(icon) + sidebar_button.setObjectName("SidebarBtn") + sidebar_button.setCheckable(True) + sidebar_button.setToolTip(name) + sidebar_button.setFixedSize(70, 70) + sidebar_button.clicked.connect( + lambda _checked=False, panel_idx=panel_index: explorer.toggle_module_panel(panel_idx) + ) + explorer.button_group.addButton(sidebar_button, panel_index) + sidebar_layout.addWidget(sidebar_button) + + top_layout.addWidget(explorer.sidebar) + + explorer.main_splitter = QSplitter(Qt.Orientation.Horizontal) + explorer.main_splitter.setHandleWidth(6) + + explorer.tool_container = QFrame() + explorer.tool_container.setObjectName("ToolPanelContainer") + explorer.tool_container.setMinimumWidth(320) + explorer.tool_layout = QVBoxLayout(explorer.tool_container) + + explorer.stacked_tools = QStackedWidget() + explorer.tool_layout.addWidget(explorer.stacked_tools) + + setup_module_pages(explorer) + explorer.tool_container.setVisible(False) + + explorer.map_container = QFrame() + explorer.map_container.setObjectName("PanelBackground") + map_layout = QVBoxLayout(explorer.map_container) + map_header = QLabel("Live Map View") + map_layout.addWidget(map_header) + + explorer.map_canvas = DeckMapWidget() + map_layout.addWidget(explorer.map_canvas, 1) + + explorer.right_panel = QFrame() + explorer.right_panel.setMinimumWidth(300) + right_layout = QVBoxLayout(explorer.right_panel) + + explorer.ai_vision_box = NeonPanel("Data Science Console") + explorer.metrics_text = QTextEdit() + explorer.metrics_text.setReadOnly(True) + explorer.metrics_text.setObjectName("Console") + explorer.metrics_text.setMaximumHeight(160) + explorer.metrics_text.setPlaceholderText( + "Pipeline summaries will appear here. Use the tools on the left to run a step; each " + "completed run appends metrics and output paths below." + ) + explorer.ai_vision_box.main_layout.addWidget(explorer.metrics_text) + explorer.ai_vision_box.main_layout.addStretch() + right_layout.addWidget(explorer.ai_vision_box, 1) + + explorer.logs_box = NeonPanel("System Logs") + explorer.console = QTextEdit() + explorer.console.setObjectName("Console") + explorer.console.setReadOnly(True) + explorer.console.append("[INFO] System Ready...") + explorer.logs_box.main_layout.addWidget(explorer.console) + right_layout.addWidget(explorer.logs_box, 2) + + explorer.main_splitter.addWidget(explorer.tool_container) + explorer.main_splitter.addWidget(explorer.map_container) + explorer.main_splitter.addWidget(explorer.right_panel) + + explorer.main_splitter.setStretchFactor(1, 1) + + top_layout.addWidget(explorer.main_splitter) + main_layout.addLayout(top_layout, 1) + + explorer.progress_bar = QProgressBar() + main_layout.addWidget(explorer.progress_bar) diff --git a/src/uq_desktop_processor/gui/shell/ui/module_pages.py b/src/uq_desktop_processor/gui/shell/ui/module_pages.py new file mode 100644 index 0000000..ea506df --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/module_pages.py @@ -0,0 +1,47 @@ +""" +Builds or wires stacked module pages into the shell navigation model. +""" + +from typing import TYPE_CHECKING + +from uq_desktop_processor.gui.shell.ui.pages import clip_prefilter, drive_route, mapillary, sampling_points, vit_scoring +from uq_desktop_processor.gui.shell.ui.placeholders import add_placeholder_tool_page + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def setup_module_pages(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run setup module pages. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: setup_module_pages(explorer) + Out: UI/application state updated as intended. + """ + drive_route.add_drive_route_page(explorer) + sampling_points.add_sampling_points_page(explorer) + mapillary.add_mapillary_page(explorer) + add_placeholder_tool_page( + explorer.stacked_tools, + explorer.run_buttons, + title="Panoramas → views", + description="This module is a placeholder and will be implemented later.", + ) + clip_prefilter.add_clip_prefilter_page(explorer) + vit_scoring.add_vit_scoring_page(explorer) + add_placeholder_tool_page( + explorer.stacked_tools, + explorer.run_buttons, + title="Export results", + description="This module is a placeholder and will be implemented later.", + ) + add_placeholder_tool_page( + explorer.stacked_tools, + explorer.run_buttons, + title="Validation", + description="This module is a placeholder and will be implemented later.", + ) diff --git a/src/uq_desktop_processor/gui/shell/ui/osm_source_block.py b/src/uq_desktop_processor/gui/shell/ui/osm_source_block.py new file mode 100644 index 0000000..78060d9 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/osm_source_block.py @@ -0,0 +1,90 @@ +""" +UI block for selecting and validating OSM / road-graph input sources. +""" + +from collections.abc import Callable +from dataclasses import dataclass + +from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget + +from uq_desktop_processor.gui.widgets import CompactStackedWidget + +GEOJSON_FILTER = "GeoJSON (*.geojson *.json)" + + +@dataclass(frozen=True) +class OsmTripleSourceWidgets: + """ + OsmTripleSourceWidgets UI helper class. + """ + + combo: QComboBox + stack: CompactStackedWidget + field_label: QLabel + place_edit: QLineEdit + region_edit: QLineEdit + roads_edit: QLineEdit + + +def build_osm_triple_source_block( + pick_file: Callable[[QLineEdit, str], None], + field_labels: dict[str, str], + on_changed: Callable[[], None], + initial_place: str, + *, + roads_item_label: str, +) -> OsmTripleSourceWidgets: + """ + Run build osm triple source block. + + :param pick_file: See caller/context. + :param field_labels: See caller/context. + :param on_changed: See caller/context. + :param initial_place: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: build_osm_triple_source_block(pick_file, field_labels, on_changed, initial_place) + Out: UI/application state updated as intended. + """ + combo = QComboBox() + combo.addItem("Place name (OSM)", "place") + combo.addItem("Region polygon (GeoJSON)", "region") + combo.addItem(roads_item_label, "roads") + combo.currentIndexChanged.connect(on_changed) + + stack = CompactStackedWidget() + + page_place = QWidget() + lay_place = QHBoxLayout(page_place) + lay_place.setContentsMargins(0, 0, 0, 0) + place_edit = QLineEdit(initial_place) + lay_place.addWidget(place_edit) + stack.addWidget(page_place) + + page_region = QWidget() + lay_region = QHBoxLayout(page_region) + lay_region.setContentsMargins(0, 0, 0, 0) + region_edit = QLineEdit() + region_edit.setPlaceholderText("polygon.geojson …") + btn_region = QPushButton("…") + btn_region.setFixedWidth(36) + btn_region.clicked.connect(lambda: pick_file(region_edit, GEOJSON_FILTER)) + lay_region.addWidget(region_edit) + lay_region.addWidget(btn_region) + stack.addWidget(page_region) + + page_roads = QWidget() + lay_roads = QHBoxLayout(page_roads) + lay_roads.setContentsMargins(0, 0, 0, 0) + roads_edit = QLineEdit() + roads_edit.setPlaceholderText("roads.geojson …") + btn_roads = QPushButton("…") + btn_roads.setFixedWidth(36) + btn_roads.clicked.connect(lambda: pick_file(roads_edit, GEOJSON_FILTER)) + lay_roads.addWidget(roads_edit) + lay_roads.addWidget(btn_roads) + stack.addWidget(page_roads) + + field_label = QLabel(field_labels["place"]) + return OsmTripleSourceWidgets(combo, stack, field_label, place_edit, region_edit, roads_edit) diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/__init__.py b/src/uq_desktop_processor/gui/shell/ui/pages/__init__.py new file mode 100644 index 0000000..f434173 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/__init__.py @@ -0,0 +1 @@ +"""Individual stacked tool pages.""" diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/clip_prefilter.py b/src/uq_desktop_processor/gui/shell/ui/pages/clip_prefilter.py new file mode 100644 index 0000000..6b1469a --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/clip_prefilter.py @@ -0,0 +1,131 @@ +""" +Stacked tool page: CLIP prefilter (configure and run image filtering). +""" + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) + +from uq_desktop_processor.evaluation.clip_prefilter.defaults import FILTER_PROMPTS as CLIP_PREFILTER_DEFAULT_PROMPTS +from uq_desktop_processor.gui.shell.constants import CLIP_MODEL_CHOICES +from uq_desktop_processor.gui.widgets import NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def add_clip_prefilter_page(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run add clip prefilter page. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_clip_prefilter_page(explorer) + Out: UI/application state updated as intended. + """ + prefilter_page = QWidget() + page_layout = QVBoxLayout(prefilter_page) + prefilter_panel = NeonPanel( + "CLIP prefilter", + "Compare images to two phrase lists and move low-score matches to the rejected folder.", + ) + form_layout = QFormLayout() + + pre_in_row = QHBoxLayout() + explorer.prefilter_image_folder_edit = QLineEdit(str(Path("data") / "images" / "raw")) + explorer.prefilter_image_folder_edit.setPlaceholderText("Folder with .jpg / .png to filter") + btn_pf_in = QPushButton("…") + btn_pf_in.setFixedWidth(36) + btn_pf_in.clicked.connect(lambda: explorer.pick_folder(explorer.prefilter_image_folder_edit)) + pre_in_row.addWidget(explorer.prefilter_image_folder_edit) + pre_in_row.addWidget(btn_pf_in) + form_layout.addRow("Input: images folder", pre_in_row) + + pre_rej_row = QHBoxLayout() + explorer.prefilter_rejected_edit = QLineEdit("rejected") + explorer.prefilter_rejected_edit.setPlaceholderText("Sibling name (e.g. rejected) or full folder path") + btn_pf_rej = QPushButton("…") + btn_pf_rej.setFixedWidth(36) + btn_pf_rej.clicked.connect(lambda: explorer.pick_folder(explorer.prefilter_rejected_edit)) + pre_rej_row.addWidget(explorer.prefilter_rejected_edit) + pre_rej_row.addWidget(btn_pf_rej) + form_layout.addRow("Output: rejected folder", pre_rej_row) + + explorer.model_combo = QComboBox() + for label, names in CLIP_MODEL_CHOICES: + explorer.model_combo.addItem(label, names) + form_layout.addRow("CLIP model", explorer.model_combo) + + explorer.device_combo = QComboBox() + explorer.device_combo.addItem("Auto (CUDA if FORCE_CUDA=1)", "auto") + explorer.device_combo.addItem("CPU", "cpu") + explorer.device_combo.addItem("CUDA", "cuda") + form_layout.addRow("Torch device", explorer.device_combo) + + explorer.threshold_slider = QSlider(Qt.Orientation.Horizontal) + explorer.threshold_slider.setRange(0, 100) + explorer.threshold_slider.setValue(40) + explorer.threshold_label = QLabel("40 %") + explorer.threshold_slider.valueChanged.connect( + lambda slider_value: explorer.threshold_label.setText(f"{slider_value} %") + ) + th_row = QHBoxLayout() + th_row.addWidget(explorer.threshold_slider) + th_row.addWidget(explorer.threshold_label) + form_layout.addRow("Threshold", th_row) + + explorer.beta_spin = QDoubleSpinBox() + explorer.beta_spin.setRange(1.0, 100.0) + explorer.beta_spin.setValue(30.0) + explorer.beta_spin.setDecimals(1) + form_layout.addRow("Sigmoid β", explorer.beta_spin) + + pos_lbl = QLabel("Phrases: direction to KEEP (higher match)") + pos_lbl.setStyleSheet("font-weight: normal; text-transform: none; color: #afa;") + form_layout.addRow(pos_lbl) + explorer.prefilter_pos_edit = QPlainTextEdit() + explorer.prefilter_pos_edit.setPlaceholderText("One phrase per line…") + explorer.prefilter_pos_edit.setMinimumHeight(88) + explorer.prefilter_pos_edit.setPlainText("\n".join(CLIP_PREFILTER_DEFAULT_PROMPTS["pos"])) + form_layout.addRow(explorer.prefilter_pos_edit) + + neg_lbl = QLabel("Phrases: contrast / away from (lower → reject)") + neg_lbl.setStyleSheet("font-weight: normal; text-transform: none; color: #faa;") + form_layout.addRow(neg_lbl) + explorer.prefilter_neg_edit = QPlainTextEdit() + explorer.prefilter_neg_edit.setPlaceholderText("One phrase per line…") + explorer.prefilter_neg_edit.setMinimumHeight(88) + explorer.prefilter_neg_edit.setPlainText("\n".join(CLIP_PREFILTER_DEFAULT_PROMPTS["neg"])) + form_layout.addRow(explorer.prefilter_neg_edit) + + prefilter_panel.main_layout.addLayout(form_layout) + reset_prompts_row = QHBoxLayout() + btn_reset_pf_prompts = QPushButton("Reset phrases to defaults") + btn_reset_pf_prompts.clicked.connect(explorer.reset_clip_prefilter_prompts) + reset_prompts_row.addWidget(btn_reset_pf_prompts) + reset_prompts_row.addStretch() + prefilter_panel.main_layout.addLayout(reset_prompts_row) + prefilter_panel.main_layout.addStretch() + btn4 = QPushButton("PREFILTER (CLIP) ◆") + btn4.setObjectName("RunBtn") + btn4.clicked.connect(explorer.on_run_prefilter) + prefilter_panel.main_layout.addWidget(btn4) + page_layout.addWidget(prefilter_panel) + explorer.stacked_tools.addWidget(prefilter_page) + explorer.run_buttons.append(btn4) diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/drive_route.py b/src/uq_desktop_processor/gui/shell/ui/pages/drive_route.py new file mode 100644 index 0000000..38665b0 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/drive_route.py @@ -0,0 +1,99 @@ +""" +Stacked tool page: Chinese postman routes and GPX export for drive coverage. +""" + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QCheckBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from uq_desktop_processor.gui.shell.constants import EULER_SOURCE_FIELD_LABELS +from uq_desktop_processor.gui.shell.ui.osm_source_block import build_osm_triple_source_block +from uq_desktop_processor.gui.widgets import NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def add_drive_route_page(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run add drive route page. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_drive_route_page(explorer) + Out: UI/application state updated as intended. + """ + p0 = QWidget() + l0 = QVBoxLayout(p0) + pan0 = NeonPanel( + "Drive route (Chinese postman/GPX)", + "Generate a drivable GPX route over the road network.", + ) + f0 = QFormLayout() + + euler = build_osm_triple_source_block( + explorer.pick_file, + EULER_SOURCE_FIELD_LABELS, + explorer.on_route_source_changed, + "Katowice, Poland", + roads_item_label="Road network (GeoJSON)", + ) + explorer.route_source_combo = euler.combo + explorer._route_input_stack = euler.stack + explorer.route_city_edit = euler.place_edit + explorer.route_region_path_edit = euler.region_edit + explorer.route_roads_path_edit = euler.roads_edit + explorer._route_input_field_label = euler.field_label + f0.addRow("Data source", explorer.route_source_combo) + f0.addRow(explorer._route_input_field_label, explorer._route_input_stack) + + explorer.route_grid_cols = QSpinBox() + explorer.route_grid_cols.setRange(1, 30) + explorer.route_grid_cols.setValue(3) + f0.addRow("Grid columns", explorer.route_grid_cols) + explorer.route_grid_rows = QSpinBox() + explorer.route_grid_rows.setRange(1, 30) + explorer.route_grid_rows.setValue(3) + f0.addRow("Grid rows", explorer.route_grid_rows) + + out_row = QHBoxLayout() + explorer.route_out_edit = QLineEdit("routes_chinese_postman_gpx") + explorer.route_out_edit.setPlaceholderText("Output folder for .gpx files") + btn_out = QPushButton("…") + btn_out.setFixedWidth(36) + btn_out.clicked.connect(lambda: explorer.pick_folder(explorer.route_out_edit)) + out_row.addWidget(explorer.route_out_edit) + out_row.addWidget(btn_out) + f0.addRow("Output folder", out_row) + + explorer.route_consolidate_tol = QDoubleSpinBox() + explorer.route_consolidate_tol.setRange(5.0, 80.0) + explorer.route_consolidate_tol.setValue(15.0) + explorer.route_consolidate_tol.setSuffix(" m") + f0.addRow("Consolidate tolerance", explorer.route_consolidate_tol) + explorer.route_cache_check = QCheckBox("Use OSMnx cache") + explorer.route_cache_check.setChecked(True) + f0.addRow("", explorer.route_cache_check) + + pan0.main_layout.addLayout(f0) + pan0.main_layout.addStretch() + btn0 = QPushButton("GENERATE ROUTE ▶") + btn0.setObjectName("RunBtn") + btn0.clicked.connect(explorer.on_run_euler_routes) + pan0.main_layout.addWidget(btn0) + l0.addWidget(pan0) + explorer.stacked_tools.addWidget(p0) + explorer.run_buttons.append(btn0) + explorer.on_route_source_changed() diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/mapillary.py b/src/uq_desktop_processor/gui/shell/ui/pages/mapillary.py new file mode 100644 index 0000000..5ddefd1 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/mapillary.py @@ -0,0 +1,77 @@ +""" +Stacked tool page: Mapillary image search and download around sampling points. +""" + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QFormLayout, QHBoxLayout, QLineEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget + +from uq_desktop_processor.gui.widgets import NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def add_mapillary_page(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run add mapillary page. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_mapillary_page(explorer) + Out: UI/application state updated as intended. + """ + p2 = QWidget() + l2 = QVBoxLayout(p2) + pan2 = NeonPanel( + "Mapillary download", + "Download street-level images for the generated sampling points.", + ) + f2 = QFormLayout() + explorer.token_edit = QLineEdit() + explorer.token_edit.setPlaceholderText("Paste token or set MAPILLARY_ACCESS_TOKEN") + f2.addRow("Mapillary token", explorer.token_edit) + + pts_in_row = QHBoxLayout() + explorer.mapillary_points_edit = QLineEdit(str(Path("data") / "results" / "sampling_points.geojson")) + explorer.mapillary_points_edit.setPlaceholderText(".geojson / .gpkg from point generation step") + btn_mapillary_pts = QPushButton("…") + btn_mapillary_pts.setFixedWidth(36) + btn_mapillary_pts.clicked.connect( + lambda: explorer.pick_file( + explorer.mapillary_points_edit, + "GeoJSON (*.geojson);;GeoPackage (*.gpkg)", + ) + ) + pts_in_row.addWidget(explorer.mapillary_points_edit) + pts_in_row.addWidget(btn_mapillary_pts) + f2.addRow("Input: point layer", pts_in_row) + + img_out_row = QHBoxLayout() + explorer.mapillary_out_edit = QLineEdit(str(Path("data") / "images" / "raw")) + explorer.mapillary_out_edit.setPlaceholderText("Folder for downloaded JPEGs (used by later CLIP steps)") + btn_mapillary_out = QPushButton("…") + btn_mapillary_out.setFixedWidth(36) + btn_mapillary_out.clicked.connect(lambda: explorer.pick_folder(explorer.mapillary_out_edit)) + img_out_row.addWidget(explorer.mapillary_out_edit) + img_out_row.addWidget(btn_mapillary_out) + f2.addRow("Output: image folder", img_out_row) + + explorer.radius_edit = QLineEdit("150") + f2.addRow("Search radius (m)", explorer.radius_edit) + explorer.workers_spin = QSpinBox() + explorer.workers_spin.setRange(1, 64) + explorer.workers_spin.setValue(20) + f2.addRow("Parallel workers", explorer.workers_spin) + pan2.main_layout.addLayout(f2) + pan2.main_layout.addStretch() + btn2 = QPushButton("DOWNLOAD IMAGES ⬇") + btn2.setObjectName("RunBtn") + btn2.clicked.connect(explorer.on_run_download) + pan2.main_layout.addWidget(btn2) + l2.addWidget(pan2) + explorer.stacked_tools.addWidget(p2) + explorer.run_buttons.append(btn2) diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/sampling_points.py b/src/uq_desktop_processor/gui/shell/ui/pages/sampling_points.py new file mode 100644 index 0000000..d4065e7 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/sampling_points.py @@ -0,0 +1,75 @@ +""" +Stacked tool page: generate and visualize road sampling points (GeoJSON output). +""" + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QFormLayout, QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget + +from uq_desktop_processor.gui.shell.constants import PLANNER_SOURCE_FIELD_LABELS +from uq_desktop_processor.gui.shell.ui.osm_source_block import build_osm_triple_source_block +from uq_desktop_processor.gui.widgets import NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def add_sampling_points_page(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run add sampling points page. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_sampling_points_page(explorer) + Out: UI/application state updated as intended. + """ + p1 = QWidget() + l1 = QVBoxLayout(p1) + pan1 = NeonPanel( + "Sampling points", + "Generate a point layer along roads for image sampling.", + ) + f1 = QFormLayout() + + planner = build_osm_triple_source_block( + explorer.pick_file, + PLANNER_SOURCE_FIELD_LABELS, + explorer.on_planner_source_changed, + "Katowice, Poland", + roads_item_label="Road network file (GeoJSON)", + ) + explorer.planner_source_combo = planner.combo + explorer._planner_input_stack = planner.stack + explorer.place_name_edit = planner.place_edit + explorer.region_path_edit = planner.region_edit + explorer.road_path_edit = planner.roads_edit + explorer._planner_input_field_label = planner.field_label + f1.addRow("Data source", explorer.planner_source_combo) + f1.addRow(explorer._planner_input_field_label, explorer._planner_input_stack) + + explorer.spacing_edit = QLineEdit("100") + f1.addRow("Distance between points (m)", explorer.spacing_edit) + explorer.min_dist_edit = QLineEdit("50") + f1.addRow("Min distance (m)", explorer.min_dist_edit) + points_out_row = QHBoxLayout() + explorer.planner_points_out_edit = QLineEdit("data/results/sampling_points.geojson") + explorer.planner_points_out_edit.setPlaceholderText("path and filename (.geojson / .gpkg)") + btn_points_out = QPushButton("…") + btn_points_out.setFixedWidth(36) + btn_points_out.clicked.connect(lambda: explorer.pick_save_points_layer(explorer.planner_points_out_edit)) + points_out_row.addWidget(explorer.planner_points_out_edit) + points_out_row.addWidget(btn_points_out) + f1.addRow("Output: point layer", points_out_row) + + pan1.main_layout.addLayout(f1) + pan1.main_layout.addStretch() + btn1 = QPushButton("GENERATE POINTS ▶") + btn1.setObjectName("RunBtn") + btn1.clicked.connect(explorer.on_run_planner) + pan1.main_layout.addWidget(btn1) + l1.addWidget(pan1) + explorer.stacked_tools.addWidget(p1) + explorer.run_buttons.append(btn1) + explorer.on_planner_source_changed() diff --git a/src/uq_desktop_processor/gui/shell/ui/pages/vit_scoring.py b/src/uq_desktop_processor/gui/shell/ui/pages/vit_scoring.py new file mode 100644 index 0000000..0c66d3f --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/pages/vit_scoring.py @@ -0,0 +1,113 @@ +""" +Stacked tool page: fine-tuned ViT scoring configuration and batch runs. +""" + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QHBoxLayout, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from uq_desktop_processor.gui.widgets import NeonPanel + +if TYPE_CHECKING: + from uq_desktop_processor.gui.shell.explorer import UrbanQualityAIExplorer + + +def add_vit_scoring_page(explorer: "UrbanQualityAIExplorer") -> None: + """ + Run add vit scoring page. + + :param explorer: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_vit_scoring_page(explorer) + Out: UI/application state updated as intended. + """ + p5 = QWidget() + l5 = QVBoxLayout(p5) + pan5 = NeonPanel( + "ViT scoring (fine-tuned)", + "Score images with a fine-tuned ViT model and write a GeoPackage layer.", + ) + f5 = QFormLayout() + + imgs_row = QHBoxLayout() + explorer.vit_images_dir_edit = QLineEdit("") + explorer.vit_images_dir_edit.setPlaceholderText("folder with images (required)") + btn_imgs = QPushButton("…") + btn_imgs.setFixedWidth(36) + btn_imgs.clicked.connect(lambda: explorer.pick_folder(explorer.vit_images_dir_edit)) + imgs_row.addWidget(explorer.vit_images_dir_edit) + imgs_row.addWidget(btn_imgs) + f5.addRow("Images folder", imgs_row) + + model_row = QHBoxLayout() + explorer.vit_model_path_edit = QLineEdit("") + explorer.vit_model_path_edit.setPlaceholderText("model weights file (.pt/.pth) (required)") + btn_model = QPushButton("…") + btn_model.setFixedWidth(36) + btn_model.clicked.connect(lambda: explorer.pick_file(explorer.vit_model_path_edit, "Model (*.pt *.pth)")) + model_row.addWidget(explorer.vit_model_path_edit) + model_row.addWidget(btn_model) + f5.addRow("Model path", model_row) + + cal_row = QHBoxLayout() + explorer.vit_calibrators_edit = QLineEdit("") + explorer.vit_calibrators_edit.setPlaceholderText("(optional) folder with calibrators; empty = calibration OFF") + btn_cal = QPushButton("…") + btn_cal.setFixedWidth(36) + btn_cal.clicked.connect(lambda: explorer.pick_folder(explorer.vit_calibrators_edit)) + cal_row.addWidget(explorer.vit_calibrators_edit) + cal_row.addWidget(btn_cal) + f5.addRow("Calibrators dir", cal_row) + + vit_out_row = QHBoxLayout() + explorer.vit_output_path_edit = QLineEdit("") + explorer.vit_output_path_edit.setPlaceholderText("e.g. results/vit_scores.gpkg (include .gpkg)") + btn_vit_out = QPushButton("…") + btn_vit_out.setFixedWidth(36) + btn_vit_out.clicked.connect(lambda: explorer.pick_save_gpkg(explorer.vit_output_path_edit)) + vit_out_row.addWidget(explorer.vit_output_path_edit) + vit_out_row.addWidget(btn_vit_out) + f5.addRow("Output layer (.gpkg)", vit_out_row) + + explorer.vit_output_layer_name_edit = QLineEdit("vit_finetuned_scores") + f5.addRow("Layer name", explorer.vit_output_layer_name_edit) + + explorer.vit_model_name_edit = QLineEdit("vit_base_patch14_dinov2.lvd142m") + f5.addRow("Model name", explorer.vit_model_name_edit) + + explorer.vit_image_size_spin = QSpinBox() + explorer.vit_image_size_spin.setRange(128, 1024) + explorer.vit_image_size_spin.setValue(224) + f5.addRow("Image size", explorer.vit_image_size_spin) + + explorer.vit_batch_size_spin = QSpinBox() + explorer.vit_batch_size_spin.setRange(1, 256) + explorer.vit_batch_size_spin.setValue(32) + f5.addRow("Batch size", explorer.vit_batch_size_spin) + + explorer.vit_device_combo = QComboBox() + explorer.vit_device_combo.addItem("Auto (CUDA if FORCE_CUDA=1)", "auto") + explorer.vit_device_combo.addItem("CPU", "cpu") + explorer.vit_device_combo.addItem("CUDA", "cuda") + f5.addRow("Torch device", explorer.vit_device_combo) + + pan5.main_layout.addLayout(f5) + pan5.main_layout.addStretch() + btn5 = QPushButton("EVALUATE (ViT) ⚙") + btn5.setObjectName("RunBtn") + btn5.clicked.connect(explorer.on_run_vit_evaluate) + pan5.main_layout.addWidget(btn5) + l5.addWidget(pan5) + explorer.stacked_tools.addWidget(p5) + explorer.run_buttons.append(btn5) diff --git a/src/uq_desktop_processor/gui/shell/ui/placeholders.py b/src/uq_desktop_processor/gui/shell/ui/placeholders.py new file mode 100644 index 0000000..b42004b --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/ui/placeholders.py @@ -0,0 +1,38 @@ +""" +Placeholder pages and stub widgets for shell sections not yet implemented. +""" + +from PySide6.QtWidgets import QPushButton, QStackedWidget, QVBoxLayout, QWidget + +from uq_desktop_processor.gui.widgets import NeonPanel + + +def add_placeholder_tool_page( + stacked_tools: QStackedWidget, + run_buttons: list[QPushButton], + *, + title: str, + description: str, +) -> None: + """ + Run add placeholder tool page. + + :param stacked_tools: See caller/context. + :param run_buttons: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: add_placeholder_tool_page(stacked_tools, run_buttons) + Out: UI/application state updated as intended. + """ + page = QWidget() + layout = QVBoxLayout(page) + panel = NeonPanel(title, description) + panel.main_layout.addStretch() + btn = QPushButton("COMING SOON") + btn.setObjectName("RunBtn") + btn.setEnabled(False) + panel.main_layout.addWidget(btn) + layout.addWidget(panel) + stacked_tools.addWidget(page) + run_buttons.append(btn) diff --git a/src/uq_desktop_processor/gui/shell/worker.py b/src/uq_desktop_processor/gui/shell/worker.py new file mode 100644 index 0000000..79ee920 --- /dev/null +++ b/src/uq_desktop_processor/gui/shell/worker.py @@ -0,0 +1,47 @@ +""" +QThread-based workers for running pipeline steps without blocking the GUI. +""" + +from collections.abc import Callable +from typing import Any + +from PySide6.QtCore import QObject, QThread, Signal + + +class StepThread(QThread): + """ + StepThread UI helper class. + """ + + finished = Signal(object, str) + + def __init__(self, func: Callable[[], Any], parent: QObject | None = None) -> None: + """ + Run init . + + :param func: See caller/context. + :param parent: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(func, parent) + Out: UI/application state updated as intended. + """ + super().__init__(parent) + self._func = func + + def run(self) -> None: + """ + Run run. + + :return: Result of this step or updated UI/application state. + + Example:: + In: run() + Out: UI/application state updated as intended. + """ + try: + result = self._func() + self.finished.emit(result, "") + except Exception as error: + self.finished.emit(None, str(error)) diff --git a/src/uq_desktop_processor/gui/styles/__init__.py b/src/uq_desktop_processor/gui/styles/__init__.py new file mode 100644 index 0000000..133aeb7 --- /dev/null +++ b/src/uq_desktop_processor/gui/styles/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .theme import NEON_STYLE + +__all__ = ["NEON_STYLE"] diff --git a/src/uq_desktop_processor/gui/styles/theme.py b/src/uq_desktop_processor/gui/styles/theme.py new file mode 100644 index 0000000..3b0172b --- /dev/null +++ b/src/uq_desktop_processor/gui/styles/theme.py @@ -0,0 +1,225 @@ +""" +Neon-style Qt stylesheet (QSS) strings and palette for the UrbanQuality-AI GUI. +""" + +NEON_STYLE = """ +QMainWindow { + background-color: #050505; +} + +QFrame#Sidebar { + background-color: #0a0a0a; + border-right: 1px solid #00f2ff; +} + +QFrame#PanelBackground { + background-color: #0d0d0d; + border: 1px solid #1a1a1a; + border-radius: 10px; +} + +QSplitter::handle { + background-color: #1a1a1a; +} + +QSplitter::handle:horizontal { + width: 4px; +} + +QSplitter::handle:hover { + background-color: #00f2ff; +} + +QLabel { + color: #00f2ff; + font-family: 'Segoe UI', sans-serif; + text-transform: uppercase; + font-weight: bold; + font-size: 10px; +} + +QLabel#PanelTitle { + font-size: 14px; + letter-spacing: 2px; + margin-bottom: 8px; + color: #fff; + border-bottom: 1px solid #00f2ff; + padding-bottom: 5px; +} + +QLabel#PanelSubtitle { + font-size: 11px; + font-weight: normal; + text-transform: none; + letter-spacing: 0px; + color: #9cf; + margin-top: 0px; + margin-bottom: 12px; +} + +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit { + background-color: #111; + border: 1px solid #333; + color: #fff; + padding: 8px; + border-radius: 2px; +} + +QAbstractSpinBox { + /* Reserve space for the right-side buttons so clicks don't land on the editor area. */ + padding-right: 24px; +} + +QAbstractSpinBox::up-button, QAbstractSpinBox::down-button { + subcontrol-origin: padding; + width: 18px; + border-left: 1px solid #333; +} + +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: none; + width: 10px; + height: 10px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 7px solid #0b7a2a; +} + +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: none; + width: 10px; + height: 10px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 7px solid #8a1f1f; +} + +QAbstractSpinBox::up-button { + subcontrol-position: top right; + height: 14px; + background-color: #151515; + border-bottom: 1px solid #262626; +} + +QAbstractSpinBox::down-button { + subcontrol-position: bottom right; + height: 14px; + background-color: #151515; + border-top: 1px solid #262626; +} + +QAbstractSpinBox::up-button:hover, QAbstractSpinBox::down-button:hover { + border-left: 1px solid #00f2ff; + background-color: #1f1f1f; +} + +QLineEdit:focus, QPlainTextEdit:focus { + border: 1px solid #00f2ff; +} + +QPushButton#SidebarBtn { + background-color: transparent; + border: none; + color: #444; + padding: 15px; + font-size: 20px; +} + +QPushButton#SidebarBtn:hover { + color: #00f2ff; +} + +QPushButton#SidebarBtn:checked { + color: #00f2ff; + background-color: #001a1a; + border-left: 3px solid #00f2ff; +} + +QPushButton#RunBtn { + background-color: #00f2ff; + color: #000; + font-weight: bold; + font-size: 11px; + padding: 12px; + border-radius: 5px; + margin-top: 10px; +} + +QPushButton#RunBtn:hover { + background-color: #55faff; +} + +QPushButton#RunBtn:disabled { + background-color: #333; + color: #666; +} + +QTextEdit#Console { + background-color: #050505; + border: 1px solid #1a1a1a; + color: #00ff41; + font-family: 'Consolas', monospace; + font-size: 11px; +} + +QProgressBar { + border: 1px solid #1a1a1a; + background-color: #0a0a0a; + text-align: center; + color: #fff; + height: 10px; +} + +QProgressBar::chunk { + background-color: #00f2ff; +} + +QFrame#MapLayerPanel { + background-color: rgba(10, 10, 10, 220); + border: 1px solid rgba(0, 242, 255, 0.28); + border-radius: 8px; + min-width: 168px; +} + +QLabel#MapLayerTitle { + color: #fff; + font-size: 11px; + letter-spacing: 1px; + padding: 6px 8px 2px 8px; +} + +QListWidget#MapLayerList { + background-color: #050505; + border: none; + color: #ccc; + font-size: 11px; + outline: none; +} + +QListWidget#MapLayerList::item { + padding: 6px 4px; + border-radius: 4px; +} + +QListWidget#MapLayerList::item:selected { + background-color: #001a1a; + color: #00f2ff; +} + +QListWidget#MapLayerList::item:hover:!active { + background-color: #111; +} + +QPushButton#LayerOrderUp, QPushButton#LayerOrderDown { + background-color: #111; + border: 1px solid #333; + color: #00f2ff; + font-size: 10px; + padding: 4px 6px; + border-radius: 3px; +} + +QPushButton#LayerOrderUp:hover, QPushButton#LayerOrderDown:hover { + border: 1px solid #00f2ff; +} +""" diff --git a/src/uq_desktop_processor/gui/widgets/__init__.py b/src/uq_desktop_processor/gui/widgets/__init__.py new file mode 100644 index 0000000..a3b3e76 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/__init__.py @@ -0,0 +1,16 @@ +""" +GUI module: init . +""" + +from .deck_map import DeckMapWidget +from .map_layer_list import MapLayerListWidget +from .map_web_stack import MapWebStack +from .stacked import CompactStackedWidget, NeonPanel + +__all__ = [ + "CompactStackedWidget", + "DeckMapWidget", + "MapLayerListWidget", + "MapWebStack", + "NeonPanel", +] diff --git a/src/uq_desktop_processor/gui/widgets/deck_map/__init__.py b/src/uq_desktop_processor/gui/widgets/deck_map/__init__.py new file mode 100644 index 0000000..98e2669 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/deck_map/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module: init . +""" + +from .widget import DeckMapWidget + +__all__ = ["DeckMapWidget"] diff --git a/src/uq_desktop_processor/gui/widgets/deck_map/layer_panel.py b/src/uq_desktop_processor/gui/widgets/deck_map/layer_panel.py new file mode 100644 index 0000000..1784353 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/deck_map/layer_panel.py @@ -0,0 +1,186 @@ +""" +Side panel UI for toggling and ordering PyDeck map layers. +""" + +from collections.abc import Callable +from typing import Any + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, + QFrame, + QHBoxLayout, + QLabel, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) + +from uq_desktop_processor.gui.widgets.map_layer_list import MapLayerListWidget + + +class LayerPanel: + """ + LayerPanel UI helper class. + """ + + _LAYER_ID_ROLE: int = int(Qt.ItemDataRole.UserRole) + + def __init__( + self, + parent: QWidget, + *, + layer_ids_top_first: list[str], + layer_labels: dict[str, str], + on_changed: Callable[[], None], + on_reordered: Callable[[], None], + on_move_up: Callable[[], None], + on_move_down: Callable[[], None], + ) -> None: + """ + Run init . + + :param parent: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(parent) + Out: UI/application state updated as intended. + """ + self.layer_ids_top_first = layer_ids_top_first + self._layer_labels = layer_labels + self._on_changed = on_changed + self._on_reordered = on_reordered + + panel = QFrame(parent) + panel.setObjectName("MapLayerPanel") + panel_layout = QVBoxLayout(panel) + panel_layout.setContentsMargins(6, 4, 6, 6) + panel_layout.setSpacing(4) + + title = QLabel("Layers") + title.setObjectName("MapLayerTitle") + panel_layout.addWidget(title) + + self.list = MapLayerListWidget(panel) + self.list.setObjectName("MapLayerList") + self.list.setMaximumHeight(200) + self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.list.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.list.setDefaultDropAction(Qt.DropAction.MoveAction) + self.list.setDragDropOverwriteMode(False) + self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.list.setSpacing(2) + + for lid in layer_ids_top_first: + item = QListWidgetItem(layer_labels[lid]) + item.setData(self._LAYER_ID_ROLE, lid) + item.setFlags(Qt.ItemFlag.NoItemFlags) + self.list.addItem(item) + + self.list.itemChanged.connect(lambda _it: self._on_changed()) + self.list.model().rowsMoved.connect(lambda *_a: self._on_reordered()) + self.list.reordered.connect(lambda *_a: self._on_reordered()) + panel_layout.addWidget(self.list, 1) + + order_row = QHBoxLayout() + order_row.setSpacing(6) + btn_up = QPushButton("▲ Up") + btn_up.setObjectName("LayerOrderUp") + btn_up.setToolTip("Higher in the list = drawn above lower layers") + btn_up.clicked.connect(on_move_up) + btn_down = QPushButton("▼ Down") + btn_down.setObjectName("LayerOrderDown") + btn_down.clicked.connect(on_move_down) + order_row.addWidget(btn_up) + order_row.addWidget(btn_down) + panel_layout.addLayout(order_row) + + self.widget = panel + + def item_for_layer(self, lid: str) -> QListWidgetItem | None: + """ + Run item for layer. + + :param lid: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: item_for_layer(lid) + Out: UI/application state updated as intended. + """ + for row in range(self.list.count()): + it = self.list.item(row) + if it is not None and it.data(self._LAYER_ID_ROLE) == lid: + return it + return None + + def layer_order_top_to_bottom(self) -> list[str]: + """ + Run layer order top to bottom. + + :return: Result of this step or updated UI/application state. + + Example:: + In: layer_order_top_to_bottom() + Out: UI/application state updated as intended. + """ + out: list[str] = [] + for row in range(self.list.count()): + it = self.list.item(row) + if it is None: + continue + lid = it.data(self._LAYER_ID_ROLE) + if isinstance(lid, str): + out.append(lid) + return out + + def layer_checked(self, lid: str) -> bool: + """ + Run layer checked. + + :param lid: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: layer_checked(lid) + Out: UI/application state updated as intended. + """ + item = self.item_for_layer(lid) + if item is None: + return False + return item.checkState() == Qt.CheckState.Checked + + @staticmethod + def active_item_flags() -> Any: + """ + Run active item flags. + + :return: Result of this step or updated UI/application state. + + Example:: + In: active_item_flags() + Out: UI/application state updated as intended. + """ + return ( + Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + ) + + @staticmethod + def inactive_item_flags() -> Any: + """ + Run inactive item flags. + + :return: Result of this step or updated UI/application state. + + Example:: + In: inactive_item_flags() + Out: UI/application state updated as intended. + """ + return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled diff --git a/src/uq_desktop_processor/gui/widgets/deck_map/layers.py b/src/uq_desktop_processor/gui/widgets/deck_map/layers.py new file mode 100644 index 0000000..0fcffa8 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/deck_map/layers.py @@ -0,0 +1,273 @@ +""" +Constructs PyDeck layers from geodata and evaluation overlays with tooltips. +""" + +import json +import math +from typing import Any + +import geopandas as gpd +import pydeck as pdk + +from uq_desktop_processor.gui.map_view.constants import EULER_LINE_COLORS +from uq_desktop_processor.gui.map_view.data import ( + euler_polylines_for_display, + evaluation_scatter_data, + map_display_budgets, + red_yellow_green_rgba, + simplify_roads_gdf_for_map_display, +) + + +def roads_geojson_layer( + roads_gdf: gpd.GeoDataFrame | None, + *, + simplify_meters: float, + max_features: int, + line_rgba: tuple[int, int, int, int] = (68, 68, 68, 235), +) -> Any: + """ + Run roads geojson layer. + + :param roads_gdf: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: roads_geojson_layer(roads_gdf) + Out: UI/application state updated as intended. + """ + if roads_gdf is None or roads_gdf.empty: + return None + display_roads_gdf = simplify_roads_gdf_for_map_display( + roads_gdf.to_crs(4326), + simplify_meters=simplify_meters, + max_features=max_features, + ) + if display_roads_gdf.empty: + return None + data = json.loads(display_roads_gdf.to_json()) + return pdk.Layer( + "GeoJsonLayer", + id="roads-layer", + data=data, + stroked=True, + filled=False, + line_width_min_pixels=1, + get_line_color=list(line_rgba), + pickable=False, + opacity=0.8, + parameters={"depthTest": False}, + ) + + +def append_bounds_roads(roads_gdf: gpd.GeoDataFrame | None, lons: list[float], lats: list[float]) -> None: + """ + Run append bounds roads. + + :param roads_gdf: See caller/context. + :param lons: See caller/context. + :param lats: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: append_bounds_roads(roads_gdf, lons, lats) + Out: UI/application state updated as intended. + """ + if roads_gdf is None or roads_gdf.empty: + return + bounds = roads_gdf.to_crs(4326).total_bounds + lons.extend([float(bounds[0]), float(bounds[2])]) + lats.extend([float(bounds[1]), float(bounds[3])]) + + +def bounds_for_all_data( + *, + planner_roads: gpd.GeoDataFrame | None, + planner_points: list[tuple[float, float]], + euler_polylines: list[list[tuple[float, float]]], + eval_results: dict[str, Any] | None, +) -> tuple[list[float], list[float]]: + """ + Run bounds for all data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: bounds_for_all_data() + Out: UI/application state updated as intended. + """ + lons: list[float] = [] + lats: list[float] = [] + append_bounds_roads(planner_roads, lons, lats) + for lat, lon in planner_points: + lons.append(float(lon)) + lats.append(float(lat)) + for poly in euler_polylines: + for lon, la in poly: + lons.append(float(lon)) + lats.append(float(la)) + if eval_results: + lon, la, _s = evaluation_scatter_data(eval_results) + for i in range(len(lon)): + lons.append(float(lon[i])) + lats.append(float(la[i])) + return lons, lats + + +def build_deck_layers( + *, + layer_order_top_to_bottom: list[str], + layer_checked: dict[str, bool], + layer_has_data: dict[str, bool], + planner_roads: gpd.GeoDataFrame | None, + planner_points: list[tuple[float, float]], + euler_polylines: list[list[tuple[float, float]]], + eval_results: dict[str, Any] | None, +) -> list[Any]: + """ + Run build deck layers. + + :return: Result of this step or updated UI/application state. + + Example:: + In: build_deck_layers() + Out: UI/application state updated as intended. + """ + order_bottom_first = list(reversed(layer_order_top_to_bottom)) + layers: list[Any] = [] + visible = sum(1 for lid, on in layer_checked.items() if on and layer_has_data.get(lid, False)) + budgets = map_display_budgets(visible) + common_params = {"depthTest": False} + + for lid in order_bottom_first: + if not layer_checked.get(lid, False) or not layer_has_data.get(lid, False): + continue + if lid == "roads": + layer = roads_geojson_layer( + planner_roads, + simplify_meters=float(budgets["road_simplify_meters"]), + max_features=int(budgets["road_max_features"]), + ) + if layer is not None: + layers.append(layer) + elif lid == "points": + rows = [{"lon": float(lon), "lat": float(lat)} for lat, lon in planner_points] + cap = int(budgets["points_max"]) + if len(rows) > cap: + step = max(1, math.ceil(len(rows) / cap)) + rows = rows[::step] + layers.append( + pdk.Layer( + "ScatterplotLayer", + id="points-layer", + data=rows, + get_position="[lon, lat]", + get_fill_color=[0, 242, 255, 195], + get_line_color=[0, 242, 255, 255], + stroked=True, + line_width_min_pixels=1, + get_radius=22, + radius_min_pixels=4, + radius_max_pixels=28, + pickable=True, + parameters=common_params, + ) + ) + elif lid == "euler": + paths: list[dict[str, Any]] = [] + euler_polys = euler_polylines_for_display( + euler_polylines, + int(budgets["euler_vertex_budget"]), + ) + for polyline_index, polyline in enumerate(euler_polys): + if len(polyline) < 2: + continue + rgb = EULER_LINE_COLORS[polyline_index % len(EULER_LINE_COLORS)] + path = [[float(lon), float(lat)] for lon, lat in polyline] + paths.append({"path": path, "name": f"Sector {polyline_index + 1}", "color": [*rgb, 235]}) + if paths: + layers.append( + pdk.Layer( + "PathLayer", + id="euler-layer", + data=paths, + get_path="path", + get_color="color", + width_min_pixels=3, + cap_rounded=True, + joint_rounded=True, + pickable=True, + parameters=common_params, + ) + ) + elif lid == "clip" and eval_results is not None: + lon_values, lat_values, scores = evaluation_scatter_data(eval_results) + if lon_values.size == 0: + continue + colors = red_yellow_green_rgba(scores) + rows = [ + { + "lon": float(lon_values[row_index]), + "lat": float(lat_values[row_index]), + "overall": round(float(scores[row_index]), 1), + "color": colors[row_index], + } + for row_index in range(len(lon_values)) + ] + cap = int(budgets["points_max"]) + if len(rows) > cap: + step = max(1, math.ceil(len(rows) / cap)) + rows = rows[::step] + layers.append( + pdk.Layer( + "ScatterplotLayer", + id="clip-layer", + data=rows, + get_position="[lon, lat]", + get_fill_color="color", + get_line_color=[0, 242, 255, 200], + stroked=True, + line_width_min_pixels=1, + get_radius=26, + radius_min_pixels=5, + radius_max_pixels=32, + pickable=True, + parameters=common_params, + ) + ) + return layers + + +def pick_tooltip(*, layer_checked: dict[str, bool], layer_has_data: dict[str, bool]) -> dict[str, Any]: + """ + Run pick tooltip. + + :return: Result of this step or updated UI/application state. + + Example:: + In: pick_tooltip() + Out: UI/application state updated as intended. + """ + pickable_on = sum( + 1 + for layer_id in ("clip", "euler", "points") + if layer_checked.get(layer_id, False) and layer_has_data.get(layer_id, False) + ) + if pickable_on > 1: + return { + "html": "Layer: {layer}
Configure the view in the panel", + "style": {"backgroundColor": "#111", "color": "#fff", "fontSize": "10px"}, + } + if layer_checked.get("clip", False) and layer_has_data.get("clip", False): + return { + "html": "Avg estimation
{overall}%", + "style": {"backgroundColor": "#111", "color": "#00ff41", "fontSize": "12px"}, + } + if layer_checked.get("euler", False) and layer_has_data.get("euler", False): + return { + "html": "{name}
Euler / GPX", + "style": {"backgroundColor": "#111", "color": "#00f2ff"}, + } + if layer_checked.get("points", False) and layer_has_data.get("points", False): + return {"html": "Point
{lat}, {lon}", "style": {"color": "#00f2ff"}} + return {} diff --git a/src/uq_desktop_processor/gui/widgets/deck_map/web.py b/src/uq_desktop_processor/gui/widgets/deck_map/web.py new file mode 100644 index 0000000..05f896f --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/deck_map/web.py @@ -0,0 +1,167 @@ +""" +Bridges PyDeck HTML output to Qt WebEngine (inject data, reload, callbacks). +""" + +import logging +from collections import deque +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import pydeck as pdk +from PySide6.QtCore import QTemporaryDir, QUrl +from PySide6.QtWebEngineWidgets import QWebEngineView + +from uq_desktop_processor.gui.map_view.constants import DECK_VIEW_STATE_JS +from uq_desktop_processor.gui.map_view.html import ( + expose_deck_instance_on_window, + inject_mapbox_gl_css, + parse_view_state_json, + sanitize_pydeck_inline_json_html, +) + + +class DeckWebController: + """ + DeckWebController UI helper class. + """ + + def __init__(self, web: QWebEngineView) -> None: + """ + Run init . + + :param web: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(web) + Out: UI/application state updated as intended. + """ + self._web = web + self._deck_html_dir = QTemporaryDir() + if not self._deck_html_dir.isValid(): + logging.getLogger(__name__).warning("QTemporaryDir for map HTML is invalid; using setHtml.") + + self.map_commit_generation = 0 + self.deck_surface_ready = False + self._deck_expecting_html_load = False + self._deck_html_gen = 0 + self._deck_finish_queue: deque[int] = deque() + + self._web.loadFinished.connect(self._on_deck_html_load_finished) + + def _on_deck_html_load_finished(self, ok: bool) -> None: + """ + Run on deck html load finished. + + :param ok: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_deck_html_load_finished(ok) + Out: UI/application state updated as intended. + """ + if not self._deck_expecting_html_load or not self._deck_finish_queue: + return + finished_gen = self._deck_finish_queue.popleft() + if finished_gen != self._deck_html_gen: + return + self._deck_expecting_html_load = False + self.deck_surface_ready = bool(ok) + if not ok: + logging.getLogger(__name__).warning("WebEngine load failed.") + + def set_deck_page_html(self, deck: pdk.Deck) -> None: + """ + Run set deck page html. + + :param deck: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: set_deck_page_html(deck) + Out: UI/application state updated as intended. + """ + self._deck_html_gen += 1 + self._deck_finish_queue.append(self._deck_html_gen) + self._deck_expecting_html_load = True + self.deck_surface_ready = False + + raw = deck.to_html(as_string=True, css_background_color="#0d0d0d") + raw = sanitize_pydeck_inline_json_html(raw) + html = expose_deck_instance_on_window(inject_mapbox_gl_css(raw)) + if self._deck_html_dir.isValid(): + path = Path(self._deck_html_dir.path()) / "deck.html" + path.write_text(html, encoding="utf-8") + self._web.load(QUrl.fromLocalFile(str(path.resolve()))) + else: + self._web.setHtml(html, QUrl("https://cdn.jsdelivr.net/")) + + def clear_pending(self) -> None: + """ + Run clear pending. + + :return: Result of this step or updated UI/application state. + + Example:: + In: clear_pending() + Out: UI/application state updated as intended. + """ + self.deck_surface_ready = False + self._deck_expecting_html_load = False + self._deck_finish_queue.clear() + + def schedule_reload_preserving_view( + self, + *, + on_view: Callable[[pdk.ViewState | None, int], None], + ) -> None: + """ + Run schedule reload preserving view. + + :return: Result of this step or updated UI/application state. + + Example:: + In: schedule_reload_preserving_view() + Out: UI/application state updated as intended. + """ + self.map_commit_generation += 1 + gen = self.map_commit_generation + self._web.page().runJavaScript( + DECK_VIEW_STATE_JS, + lambda res, g=gen: self._on_deck_view_captured(res, g, on_view), + ) + + @staticmethod + def _on_deck_view_captured( + result: Any, + scheduled_gen: int, + on_view: Callable[[pdk.ViewState | None, int], None], + ) -> None: + """ + Run on deck view captured. + + :param result: See caller/context. + :param scheduled_gen: See caller/context. + :param on_view: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_deck_view_captured(result, scheduled_gen, on_view) + Out: UI/application state updated as intended. + """ + view: pdk.ViewState | None = None + if isinstance(result, dict): + parsed = parse_view_state_json(result) + if parsed is not None: + try: + view = pdk.ViewState( + latitude=float(cast(Any, parsed["latitude"])), + longitude=float(cast(Any, parsed["longitude"])), + zoom=float(cast(Any, parsed["zoom"])), + pitch=float(cast(Any, parsed.get("pitch", 0))), + bearing=float(cast(Any, parsed.get("bearing", 0))), + ) + except (KeyError, TypeError, ValueError): + view = None + on_view(view, scheduled_gen) diff --git a/src/uq_desktop_processor/gui/widgets/deck_map/widget.py b/src/uq_desktop_processor/gui/widgets/deck_map/widget.py new file mode 100644 index 0000000..1410902 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/deck_map/widget.py @@ -0,0 +1,468 @@ +""" +Composite PyDeck map widget: web view, layer panel, and redraw orchestration. +""" + +from typing import Any + +import geopandas as gpd +import pydeck as pdk +from PySide6.QtCore import Qt, QTimer, QUrl +from PySide6.QtGui import QColor +from PySide6.QtWebEngineCore import QWebEngineSettings +from PySide6.QtWebEngineWidgets import QWebEngineView +from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QVBoxLayout, QWidget + +from uq_desktop_processor.gui.map_view.constants import ( + MAP_LAYER_IDS_TOP_FIRST, + MAP_LAYER_LABELS, + MAP_REDRAW_DEBOUNCE_MS, +) +from uq_desktop_processor.gui.map_view.data import evaluation_scatter_data, fit_view_state +from uq_desktop_processor.gui.widgets.deck_map.layer_panel import LayerPanel +from uq_desktop_processor.gui.widgets.deck_map.layers import bounds_for_all_data, build_deck_layers, pick_tooltip +from uq_desktop_processor.gui.widgets.deck_map.web import DeckWebController +from uq_desktop_processor.gui.widgets.map_web_stack import MapWebStack + + +class DeckMapWidget(QWidget): + """ + DeckMapWidget UI helper class. + """ + + def __init__(self) -> None: + """ + Run init . + + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__() + Out: UI/application state updated as intended. + """ + super().__init__() + self._planner_roads: gpd.GeoDataFrame | None = None + self._planner_points: list[tuple[float, float]] = [] + self._euler_polylines: list[list[tuple[float, float]]] = [] + self._eval_results: dict[str, Any] | None = None + + self._stored_view: Any = None + self._layers_first_auto_check: set[str] = set() + + self._map_redraw_timer = QTimer(self) + self._map_redraw_timer.setSingleShot(True) + self._map_redraw_timer.setInterval(MAP_REDRAW_DEBOUNCE_MS) + self._map_redraw_timer.timeout.connect(self._flush_map_redraw) + self._map_redraw_wants_refit = False + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + self._web = QWebEngineView(self) + self._web.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + wes = self._web.settings() + wes.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True) + wes.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True) + self._web.setStyleSheet("background-color: #0d0d0d;") + self._web.page().setBackgroundColor(QColor(0x0D, 0x0D, 0x0D)) + + self._deck_web = DeckWebController(self._web) + + self._map_idle_overlay = QFrame(self) + self._map_idle_overlay.setObjectName("MapIdleOverlay") + self._map_idle_overlay.setStyleSheet( + "QFrame#MapIdleOverlay { background-color: rgba(13, 13, 13, 0.96); border: 1px solid #2a2a2a; }" + ) + idle_layout = QVBoxLayout(self._map_idle_overlay) + idle_layout.addStretch(1) + idle_msg = QLabel( + "The map becomes active after generating the first preview layer\n" + "(roads, sampling points, Euler/GPX routes, or CLIP results)." + ) + idle_msg.setWordWrap(True) + idle_msg.setAlignment(Qt.AlignmentFlag.AlignCenter) + idle_msg.setStyleSheet("color: #777; font-size: 12px; padding: 28px;") + idle_layout.addWidget(idle_msg) + idle_layout.addStretch(1) + + self._layer_panel = LayerPanel( + self, + layer_ids_top_first=list(MAP_LAYER_IDS_TOP_FIRST), + layer_labels=MAP_LAYER_LABELS, + on_changed=lambda: self._request_map_redraw(refit=False), + on_reordered=lambda: self._request_map_redraw(refit=False), + on_move_up=self._move_layer_up, + on_move_down=self._move_layer_down, + ) + + self._map_web_host = MapWebStack(self._web, self._map_idle_overlay, self._layer_panel.widget) + self._map_web_host.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + root.addWidget(self._map_web_host, 1) + + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._sync_layer_list_items() + self._apply_map_redraw(refit=True) + + def _has_roads_data(self) -> bool: + """ + Run has roads data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _has_roads_data() + Out: UI/application state updated as intended. + """ + return self._planner_roads is not None and not self._planner_roads.empty + + def _has_points_data(self) -> bool: + """ + Run has points data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _has_points_data() + Out: UI/application state updated as intended. + """ + return bool(self._planner_points) + + def _has_euler_data(self) -> bool: + """ + Run has euler data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _has_euler_data() + Out: UI/application state updated as intended. + """ + return any(len(polyline) >= 2 for polyline in self._euler_polylines) + + def _has_clip_data(self) -> bool: + """ + Run has clip data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _has_clip_data() + Out: UI/application state updated as intended. + """ + if not self._eval_results: + return False + lon_values, _lat_values, _score_values = evaluation_scatter_data(self._eval_results) + return lon_values.size > 0 + + def _layer_has_data(self, layer_id: str) -> bool: + """ + Run layer has data. + + :param layer_id: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _layer_has_data(layer_id) + Out: UI/application state updated as intended. + """ + return { + "roads": self._has_roads_data(), + "points": self._has_points_data(), + "euler": self._has_euler_data(), + "clip": self._has_clip_data(), + }[layer_id] + + def _has_any_map_data(self) -> bool: + """ + Run has any map data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _has_any_map_data() + Out: UI/application state updated as intended. + """ + return any(self._layer_has_data(lid) for lid in MAP_LAYER_IDS_TOP_FIRST) + + def _sync_layer_list_items(self) -> None: + """ + Run sync layer list items. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _sync_layer_list_items() + Out: UI/application state updated as intended. + """ + self._layer_panel.list.blockSignals(True) + try: + for layer_id in MAP_LAYER_IDS_TOP_FIRST: + item = self._layer_panel.item_for_layer(layer_id) + if item is None: + continue + has_data = self._layer_has_data(layer_id) + if has_data: + item.setFlags(self._layer_panel.active_item_flags()) + if layer_id not in self._layers_first_auto_check: + # Auto-enable a layer once when its data appears for the first time. + item.setCheckState(Qt.CheckState.Checked) + self._layers_first_auto_check.add(layer_id) + else: + # Data disappeared (or was reset), so disable the item again. + self._layers_first_auto_check.discard(layer_id) + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(self._layer_panel.inactive_item_flags()) + finally: + self._layer_panel.list.blockSignals(False) + + def _move_layer_up(self) -> None: + """ + Run move layer up. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _move_layer_up() + Out: UI/application state updated as intended. + """ + row = self._layer_panel.list.currentRow() + if row <= 0: + return + self._layer_panel.list.blockSignals(True) + try: + item = self._layer_panel.list.takeItem(row) + if item: + self._layer_panel.list.insertItem(row - 1, item) + self._layer_panel.list.setCurrentRow(row - 1) + finally: + self._layer_panel.list.blockSignals(False) + self._request_map_redraw(refit=False) + + def _move_layer_down(self) -> None: + """ + Run move layer down. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _move_layer_down() + Out: UI/application state updated as intended. + """ + row = self._layer_panel.list.currentRow() + # Nothing to move when no selection or already at the last row. + if row < 0 or row >= self._layer_panel.list.count() - 1: + return + self._layer_panel.list.blockSignals(True) + try: + item = self._layer_panel.list.takeItem(row) + if item: + self._layer_panel.list.insertItem(row + 1, item) + self._layer_panel.list.setCurrentRow(row + 1) + finally: + self._layer_panel.list.blockSignals(False) + self._request_map_redraw(refit=False) + + def _bounds_for_all_data(self) -> tuple[list[float], list[float]]: + """ + Run bounds for all data. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _bounds_for_all_data() + Out: UI/application state updated as intended. + """ + return bounds_for_all_data( + planner_roads=self._planner_roads, + planner_points=self._planner_points, + euler_polylines=self._euler_polylines, + eval_results=self._eval_results, + ) + + def _build_deck_layers(self) -> list[Any]: + """ + Run build deck layers. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _build_deck_layers() + Out: UI/application state updated as intended. + """ + order_top_first = self._layer_panel.layer_order_top_to_bottom() + checked = {layer_id: self._layer_panel.layer_checked(layer_id) for layer_id in MAP_LAYER_IDS_TOP_FIRST} + has_data = {layer_id: self._layer_has_data(layer_id) for layer_id in MAP_LAYER_IDS_TOP_FIRST} + return build_deck_layers( + layer_order_top_to_bottom=order_top_first, + layer_checked=checked, + layer_has_data=has_data, + planner_roads=self._planner_roads, + planner_points=self._planner_points, + euler_polylines=self._euler_polylines, + eval_results=self._eval_results, + ) + + def _pick_tooltip(self) -> dict[str, Any]: + """ + Run pick tooltip. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _pick_tooltip() + Out: UI/application state updated as intended. + """ + checked = {layer_id: self._layer_panel.layer_checked(layer_id) for layer_id in MAP_LAYER_IDS_TOP_FIRST} + has_data = {layer_id: self._layer_has_data(layer_id) for layer_id in MAP_LAYER_IDS_TOP_FIRST} + return pick_tooltip(layer_checked=checked, layer_has_data=has_data) + + def _request_map_redraw(self, refit: bool) -> None: + """ + Run request map redraw. + + :param refit: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _request_map_redraw(refit) + Out: UI/application state updated as intended. + """ + self._map_redraw_wants_refit = self._map_redraw_wants_refit or refit + self._map_redraw_timer.start() + + def _flush_map_redraw(self) -> None: + """ + Run flush map redraw. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _flush_map_redraw() + Out: UI/application state updated as intended. + """ + refit = self._map_redraw_wants_refit + self._map_redraw_wants_refit = False + self._apply_map_redraw(refit=refit) + + def _apply_map_redraw(self, *, refit: bool) -> None: + """ + Run apply map redraw. + + :return: Result of this step or updated UI/application state. + + Example:: + In: _apply_map_redraw() + Out: UI/application state updated as intended. + """ + if not self._has_any_map_data(): + self._deck_web.clear_pending() + self._stored_view = None + self._map_idle_overlay.setVisible(True) + self._web.setEnabled(False) + self._web.setHtml( + '' + "", + QUrl("about:blank"), + ) + return + + self._map_idle_overlay.setVisible(False) + self._web.setEnabled(True) + + longitudes, latitudes = self._bounds_for_all_data() + + if refit or self._stored_view is None or not self._deck_web.deck_surface_ready: + self._deck_web.map_commit_generation += 1 + self._stored_view = fit_view_state(longitudes, latitudes) + deck = pdk.Deck( + layers=self._build_deck_layers(), + initial_view_state=self._stored_view, + map_style=pdk.map_styles.CARTO_DARK, + tooltip=self._pick_tooltip(), + ) + self._deck_web.set_deck_page_html(deck) + return + + def _on_view(view: pdk.ViewState | None, generation: int) -> None: + """ + Run on view. + + :param view: See caller/context. + :param generation: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_view(view, generation) + Out: UI/application state updated as intended. + """ + if generation != self._deck_web.map_commit_generation: + return + if view is not None: + self._stored_view = view + elif self._stored_view is None: + self._stored_view = fit_view_state(longitudes, latitudes) + deck = pdk.Deck( + layers=self._build_deck_layers(), + initial_view_state=self._stored_view, + map_style=pdk.map_styles.CARTO_DARK, + tooltip=self._pick_tooltip(), + ) + self._deck_web.set_deck_page_html(deck) + + self._deck_web.schedule_reload_preserving_view(on_view=_on_view) + + def update_planner_layers( + self, + roads_gdf: gpd.GeoDataFrame | None, + points: list[tuple[float, float]] | None, + ) -> None: + """ + Run update planner layers. + + :param roads_gdf: See caller/context. + :param points: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: update_planner_layers(roads_gdf, points) + Out: UI/application state updated as intended. + """ + self._planner_roads = roads_gdf + self._planner_points = list(points) if points else [] + self._sync_layer_list_items() + self._request_map_redraw(refit=True) + + def update_euler_layer(self, polylines: list[list[tuple[float, float]]]) -> None: + """ + Run update euler layer. + + :param polylines: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: update_euler_layer(polylines) + Out: UI/application state updated as intended. + """ + self._euler_polylines = [list(polyline) for polyline in polylines] + self._sync_layer_list_items() + self._request_map_redraw(refit=True) + + def update_eval_layer(self, _roads_gdf: gpd.GeoDataFrame | None, results: dict[str, Any] | None) -> None: + """ + Run update eval layer. + + :param _roads_gdf: See caller/context. + :param results: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: update_eval_layer(_roads_gdf, results) + Out: UI/application state updated as intended. + """ + if results: + lon_values, _lat_values, _score_values = evaluation_scatter_data(results) + self._eval_results = results if lon_values.size != 0 else None + else: + self._eval_results = None + self._sync_layer_list_items() + self._request_map_redraw(refit=True) diff --git a/src/uq_desktop_processor/gui/widgets/map_layer_list.py b/src/uq_desktop_processor/gui/widgets/map_layer_list.py new file mode 100644 index 0000000..ca36f23 --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/map_layer_list.py @@ -0,0 +1,100 @@ +""" +QListWidget-based layer list with reordering and visibility for map stacks. +""" + +import logging + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QDropEvent +from PySide6.QtWidgets import QListWidget, QWidget + +log = logging.getLogger(__name__) + + +class MapLayerListWidget(QListWidget): + """ + MapLayerListWidget UI helper class. + """ + + reordered = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + """ + Run init . + + :param parent: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(parent) + Out: UI/application state updated as intended. + """ + super().__init__(parent) + self._drag_src_row: int = -1 + + def startDrag(self, supported_actions: Qt.DropAction) -> None: + """ + Run startDrag. + + :param supported_actions: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: startDrag(supported_actions) + Out: UI/application state updated as intended. + """ + self._drag_src_row = self.currentRow() + super().startDrag(supported_actions) + + def dropEvent(self, event: QDropEvent) -> None: + """ + Run dropEvent. + + :param event: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: dropEvent(event) + Out: UI/application state updated as intended. + """ + try: + src_row = self._drag_src_row if self._drag_src_row >= 0 else self.currentRow() + if src_row < 0: + super().dropEvent(event) + return + + drop_position = event.position().toPoint() + drop_item = self.itemAt(drop_position) + + if drop_item is None: + dst_row = self.count() + else: + drop_item_rect = self.visualItemRect(drop_item) + y_mid = drop_item_rect.y() + drop_item_rect.height() / 2.0 + dst_row = self.row(drop_item) + if drop_position.y() >= y_mid: + dst_row += 1 + + if dst_row > src_row: + dst_row -= 1 + + if dst_row == src_row: + event.acceptProposedAction() + return + + moved_item = self.takeItem(src_row) + if moved_item is None: + return + + self.insertItem(dst_row, moved_item) + self.setCurrentRow(dst_row) + self.reordered.emit() + + event.setDropAction(Qt.DropAction.MoveAction) + event.accept() + + except (AttributeError, TypeError, RuntimeError) as error: + log.error("Drop error: %s", error) + super().dropEvent(event) + finally: + self._drag_src_row = -1 diff --git a/src/uq_desktop_processor/gui/widgets/map_web_stack.py b/src/uq_desktop_processor/gui/widgets/map_web_stack.py new file mode 100644 index 0000000..97b27aa --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/map_web_stack.py @@ -0,0 +1,73 @@ +""" +Stacked Qt web views for switching between multiple embedded map instances. +""" + +from PySide6.QtGui import QResizeEvent +from PySide6.QtWidgets import QVBoxLayout, QWidget + + +class MapWebStack(QWidget): + """ + MapWebStack UI helper class. + """ + + _LAYER_CORNER_MARGIN = 10 + _LAYER_PANEL_MAX_WIDTH = 210 + + def __init__( + self, + web: QWidget, + overlay: QWidget, + layer_panel: QWidget | None = None, + ) -> None: + """ + Run init . + + :param web: See caller/context. + :param overlay: See caller/context. + :param layer_panel: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(web, overlay, layer_panel) + Out: UI/application state updated as intended. + """ + super().__init__() + self._web = web + self._overlay = overlay + self._layer_panel = layer_panel + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + lay.addWidget(web) + overlay.setParent(self) + if layer_panel is not None: + layer_panel.setParent(self) + + def resizeEvent(self, event: QResizeEvent) -> None: + """ + Run resizeEvent. + + :param event: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: resizeEvent(event) + Out: UI/application state updated as intended. + """ + super().resizeEvent(event) + r = self.rect() + self._overlay.setGeometry(r) + if self._layer_panel is not None: + lp = self._layer_panel + m = self._LAYER_CORNER_MARGIN + lp.adjustSize() + hint = lp.sizeHint() + w = max(lp.minimumWidth(), min(hint.width(), self._LAYER_PANEL_MAX_WIDTH)) + max_h = max(100, r.height() - 2 * m) + h = min(hint.height(), max_h) + x = r.x() + r.width() - w - m + y = r.y() + m + lp.setGeometry(x, y, w, h) + lp.raise_() + self._overlay.raise_() diff --git a/src/uq_desktop_processor/gui/widgets/stacked.py b/src/uq_desktop_processor/gui/widgets/stacked.py new file mode 100644 index 0000000..7c18dfe --- /dev/null +++ b/src/uq_desktop_processor/gui/widgets/stacked.py @@ -0,0 +1,107 @@ +""" +Generic stacked page container with a sidebar list and content widget swapper. +""" + +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QFrame, QLabel, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget + + +class CompactStackedWidget(QStackedWidget): + """ + ``QStackedWidget`` that sizes vertically to the *current* page only. + + The default stacked widget reserves the height of the *tallest* page, which + leaves a large empty gap when a shorter page (e.g. single line edit) is shown. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + """ + Run init . + + :param parent: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(parent) + Out: UI/application state updated as intended. + """ + super().__init__(parent) + self.currentChanged.connect(self._on_page_changed) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + + def _on_page_changed(self, _index: int) -> None: + """ + Run on page changed. + + :param _index: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: _on_page_changed(_index) + Out: UI/application state updated as intended. + """ + self.updateGeometry() + parent_widget = self.parentWidget() + if parent_widget is not None: + parent_widget.updateGeometry() + + def sizeHint(self) -> QSize: # noqa: N802 (Qt naming) + """ + Run sizeHint. + + :return: Result of this step or updated UI/application state. + + Example:: + In: sizeHint() + Out: UI/application state updated as intended. + """ + w = self.currentWidget() + if w is not None: + cw = w.sizeHint() + base_w = super().sizeHint().width() + return QSize(max(base_w, cw.width()), cw.height()) + return super().sizeHint() + + def minimumSizeHint(self) -> QSize: # noqa: N802 + """ + Run minimumSizeHint. + + :return: Result of this step or updated UI/application state. + + Example:: + In: minimumSizeHint() + Out: UI/application state updated as intended. + """ + w = self.currentWidget() + if w is not None: + cm = w.minimumSizeHint() + base_w = super().minimumSizeHint().width() + return QSize(max(base_w, cm.width()), cm.height()) + return super().minimumSizeHint() + + +class NeonPanel(QFrame): + """Titled panel with a vertical layout.""" + + def __init__(self, title: str, subtitle: str | None = None) -> None: + """ + Run init . + + :param title: See caller/context. + :param subtitle: See caller/context. + :return: Result of this step or updated UI/application state. + + Example:: + In: __init__(title, subtitle) + Out: UI/application state updated as intended. + """ + super().__init__() + self.main_layout = QVBoxLayout(self) + self.title_label = QLabel(title) + self.title_label.setObjectName("PanelTitle") + self.main_layout.addWidget(self.title_label) + if subtitle: + self.subtitle_label = QLabel(subtitle) + self.subtitle_label.setObjectName("PanelSubtitle") + self.subtitle_label.setWordWrap(True) + self.main_layout.addWidget(self.subtitle_label) diff --git a/src/uq_desktop_processor/layer_creation/__init__.py b/src/uq_desktop_processor/layer_creation/__init__.py new file mode 100644 index 0000000..72779bf --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/__init__.py @@ -0,0 +1,7 @@ +""" +Layer-creation public API for exporting scored points to vector formats. +""" + +from .vector_layers import export_point_layer + +__all__ = ["export_point_layer"] diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/__init__.py b/src/uq_desktop_processor/layer_creation/vector_layers/__init__.py new file mode 100644 index 0000000..a78ec13 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/__init__.py @@ -0,0 +1,7 @@ +""" +Vector-layer export API. +""" + +from .point_layer import export_point_layer + +__all__ = ["export_point_layer"] diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/__init__.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/__init__.py new file mode 100644 index 0000000..fa3b3c7 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/__init__.py @@ -0,0 +1,7 @@ +""" +Point-layer exporter entrypoint. +""" + +from .exporter import export_point_layer + +__all__ = ["export_point_layer"] diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/converters.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/converters.py new file mode 100644 index 0000000..b00ba48 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/converters.py @@ -0,0 +1,195 @@ +""" +Converts evaluation result dicts into GeoJSON-like feature collections for export. +""" + +import logging +from typing import Any + +from . import defaults +from .utils import parse_lat_lon + +log = logging.getLogger(__name__) + +DEFAULT_CATEGORIES = defaults.DEFAULT_CATEGORIES + + +def _get_value_from_categories(categories_data: dict[str, Any], key: str) -> dict[str, Any]: + """ + Safely retrieve a category sub-dictionary from the categories mapping. + + :param categories_data: Mapping of model category keys to dictionaries. + :param key: Key to retrieve from the mapping. + :return: The corresponding dict if present and of correct type, otherwise {}. + + Example:: + In: _get_value_from_categories({"wealthier": {"delta": 0.3}}, "wealthier") + Out: {"delta": 0.3} + """ + value = categories_data.get(key) + if isinstance(value, dict): + return value + return {} + + +def _resolve_weight(global_weights: dict[str, Any], key: str, alias: str) -> float | None: + """ + Resolve a category weight using either the canonical category name or its alias. + + :param global_weights: Mapping of category names or aliases to weight values. + :param key: Canonical category name (e.g. "greenery"). + :param alias: Alternate key used in the model output, if different. + :return: The resolved weight, or None if no matching key was found. + + Example:: + In: _resolve_weight({"wealthier": 0.4}, "wealthier", "wealth") + Out: 0.4 + """ + if not global_weights: + return None + canonical_weight = global_weights.get(key) + if canonical_weight is not None: + return canonical_weight + return global_weights.get(alias) + + +def _build_feature( + image_data: dict[str, Any], + global_metadata: dict[str, Any], + categories: list[str], + aliases: dict[str, str], +) -> dict[str, Any]: + """ + Convert a single scoring result into a GeoJSON Feature. + + This function: + - extracts lat/lon coordinates from the filename, + - merges global metadata (model name, beta, weights), + - attaches per-category probability and delta values, + - builds a valid GeoJSON Point feature. + + :param image_data: Single entry from results["images"] with per-image metrics. + :param global_metadata: Metadata shared across all images + (model_name, beta_sigmoid, weights, etc.). + :param categories: List of logical category names to export. + :param aliases: Mapping from logical category names to model keys. + :return: A GeoJSON Feature dictionary. + + Example:: + In: _build_feature(image_data, global_metadata, ["wealthier"], {"wealthier": "wealthier"}) + Out: {"type": "Feature", "geometry": {"type": "Point", ...}, "properties": {...}} + """ + # Location comes strictly from image metadata (EXIF GPS). + filename = image_data.get("filename", "") + image_path = image_data.get("image_path") + if not image_path: + raise ValueError("Missing image_path; cannot read EXIF GPS metadata.") + lat, lon = parse_lat_lon(image_path) + + # Per-category data provided by the scoring pipeline + categories_data = image_data.get("categories", {}) or {} + + # Base properties (apply to the entire image/result) + properties = { + "filename": filename, + "image_path": image_path, + "overall_pct": image_data.get("overall_pct"), + "model_name": global_metadata.get("model_name"), + "beta_sigmoid": global_metadata.get("beta_sigmoid"), + } + + global_weights = global_metadata.get("weights") or {} + + # Add per-category values. + for category_key in categories: + category_values = _get_value_from_categories(categories_data, category_key) + + # Property names should not contain spaces + clean_key = category_key.replace(" ", "_") + + # Probability and delta per category + properties[f"{clean_key}_prob_pct"] = category_values.get("probability_pct") + properties[f"{clean_key}_delta"] = category_values.get("delta") + + # Optional weight information for this category. + category_alias = aliases.get(category_key, category_key) + weight = _resolve_weight(global_weights, category_key, category_alias) + if weight is not None: + properties[f"weights_{clean_key}"] = weight + + feature = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + "properties": properties, + } + return feature + + +def _results_to_feature_collection( + results: dict[str, Any], + categories: list[str] | None = None, + aliases: dict[str, str] | None = None, +) -> dict[str, Any]: + """ + Convert the full evaluation output into a GeoJSON FeatureCollection. + + :param results: Dictionary returned by the scoring pipeline. + :param categories: Optional list of categories to export. If None, uses + defaults.DEFAULT_CATEGORIES. + :param aliases: Optional mapping from category names to model keys. + If None, uses defaults.DEFAULT_ALIASES. + :return: A GeoJSON FeatureCollection dictionary including extra metadata + under the "x_meta" key. + + Example:: + In: _results_to_feature_collection({"images": [], "model_name": "clip"}) + Out: {"type": "FeatureCollection", "features": [], "x_meta": {...}} + """ + target_categories = categories if categories is not None else DEFAULT_CATEGORIES + target_aliases = aliases if aliases is not None else getattr(defaults, "DEFAULT_ALIASES", {}) + + images_list = results.get("images", []) or [] + + log.debug("Converting %s results to GeoJSON features.", len(images_list)) + + # Data shared between all features; also emitted in x_meta. + global_metadata = { + "model_name": results.get("model_name"), + "beta_sigmoid": results.get("beta_sigmoid"), + "weights": results.get("weights"), + "order": results.get("order"), + "average_overall_pct": results.get("average_overall_pct"), + } + + features_list: list[dict[str, Any]] = [] + + for image_entry in images_list: + try: + feature = _build_feature( + image_entry, + global_metadata, + target_categories, + target_aliases, + ) + features_list.append(feature) + except Exception as exception: + # Do not fail the entire export on a single bad filename or record. + filename = image_entry.get("filename", "unknown") + log.warning("Skipping '%s': %s", filename, exception) + + if not features_list: + log.warning("No features were generated during conversion (all input images failed or list was empty).") + + feature_collection = { + "type": "FeatureCollection", + "name": results.get("model_name") or "model_points", + "crs": {"type": "name", "properties": {"name": "EPSG:4326"}}, + "features": features_list, + "x_meta": { + **global_metadata, + "exported_categories": target_categories, + "skipped_images": results.get("skipped_images", []), + "warnings": results.get("warnings", []), + "errors": results.get("errors"), + }, + } + return feature_collection diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/defaults.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/defaults.py new file mode 100644 index 0000000..ca7e5a1 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/defaults.py @@ -0,0 +1,6 @@ +""" +Default filenames and options for point-layer GeoJSON/GPKG export. +""" + +# Display order used when writing per-category attributes. +DEFAULT_CATEGORIES = ["wealthier", "safer", "more beautiful", "livelier", "less depressing", "less boring"] diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/exporter.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/exporter.py new file mode 100644 index 0000000..68bd6d0 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/exporter.py @@ -0,0 +1,71 @@ +""" +Exports scoring results to GeoJSON or GeoPandas-backed spatial files. +""" + +import logging +import os +from typing import Any + +from .converters import _results_to_feature_collection +from .utils import _is_geopandas_available +from .writers import _write_geojson, _write_with_geopandas + +log = logging.getLogger(__name__) + + +def export_point_layer( + results: dict[str, Any], output_path: str, layer: str | None = None, categories: list[str] | None = None +) -> str: + """ + Export scoring results to a geospatial file. + + The output format is inferred from the file extension: + + - .geojson / .json -> plain GeoJSON, written with the standard json module + - .gpkg / .shp / .parquet / others supported by GeoPandas -> written using GeoPandas + + :param results: Dictionary returned by the evaluation pipeline. + :param output_path: Destination file path (extension determines format). + :param layer: Optional layer name (for formats that support multiple layers). + :param categories: Optional subset of categories to include (default uses + defaults.DEFAULT_CATEGORIES). + :return: Absolute path to the saved file. + + Example:: + In: export_point_layer(results, "data/results/urban_quality_ai_output.geojson") + Out: "C:/.../data/results/urban_quality_ai_output.geojson" + """ + log.info("Preparing to export data to: %s", output_path) + + try: + # Convert raw results into a structured FeatureCollection. + feature_collection = _results_to_feature_collection(results, categories=categories) + + feature_count = len(feature_collection.get("features", [])) + log.debug("Converted results to FeatureCollection with %s features.", feature_count) + + absolute_output_path = os.path.abspath(output_path) + file_extension = os.path.splitext(output_path.lower())[1] + + # JSON-based formats do not require GeoPandas. + if file_extension in (".geojson", ".json"): + _write_geojson(feature_collection, absolute_output_path) + else: + # Other formats require GeoPandas. + if not _is_geopandas_available(): + msg = ( + "GeoPandas is required to save to binary formats (GPKG, SHP, Parquet).\n" + "Install: pip install geopandas pyproj fiona shapely\n" + "Or use the .geojson extension." + ) + log.error(msg) + raise RuntimeError(msg) + + _write_with_geopandas(feature_collection, absolute_output_path, layer=layer) + + log.info("Export successful. File saved at: %s", absolute_output_path) + return absolute_output_path + + except Exception as error: + log.error("Failed to export layer to '%s': %s", output_path, error) + raise diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/utils.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/utils.py new file mode 100644 index 0000000..46a952c --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/utils.py @@ -0,0 +1,129 @@ +""" +Parses lat/lon from image metadata and misc helpers for point layer creation. +""" + +import logging +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + + +def _exif_gps_to_decimal(exif_gps: dict[int, Any]) -> tuple[float, float] | None: + """ + Convert EXIF GPSInfo dict to (lat, lon) in decimal degrees. + + Works with EXIF dicts returned by PIL.Image.getexif() where GPSInfo is a mapping + of numeric keys to values (rationals, tuples). + + :param exif_gps: GPSInfo mapping from EXIF. + :return: ``(lat, lon)`` if GPS fields are complete and valid, otherwise None. + + Example:: + In: _exif_gps_to_decimal({1: "N", 2: ((50,1),(3,1),(0,1)), 3: "E", 4: ((19,1),(56,1),(0,1))}) + Out: (50.05, 19.933333333333334) + """ + + def _ratio_to_float(ratio_value: Any) -> float: + # PIL can return IFDRational or tuple(num, den) + try: + return float(ratio_value) + except (TypeError, ValueError): + pass + if isinstance(ratio_value, tuple | list) and len(ratio_value) == 2: + numerator, denominator = ratio_value + return float(numerator) / float(denominator) + raise ValueError(f"Unsupported rational type: {type(ratio_value)}") + + def _dms_to_deg(dms_value: Any, reference: str) -> float: + if not isinstance(dms_value, tuple | list) or len(dms_value) != 3: + raise ValueError("GPS DMS must be a 3-tuple") + degrees = _ratio_to_float(dms_value[0]) + minutes = _ratio_to_float(dms_value[1]) + seconds = _ratio_to_float(dms_value[2]) + decimal_degrees = degrees + minutes / 60.0 + seconds / 3600.0 + normalized_reference = (reference or "").upper() + if normalized_reference in ("S", "W"): + decimal_degrees = -decimal_degrees + return float(decimal_degrees) + + try: + # EXIF GPS tag ids: 1/2 latitude ref+value, 3/4 longitude ref+value. + lat_ref = exif_gps.get(1) # GPSLatitudeRef + lat_dms = exif_gps.get(2) # GPSLatitude + lon_ref = exif_gps.get(3) # GPSLongitudeRef + lon_dms = exif_gps.get(4) # GPSLongitude + if not lat_ref or not lon_ref or not lat_dms or not lon_dms: + return None + lat = _dms_to_deg(lat_dms, str(lat_ref)) + lon = _dms_to_deg(lon_dms, str(lon_ref)) + return lat, lon + except (ValueError, TypeError, ZeroDivisionError): + return None + + +def parse_lat_lon_from_image_metadata(image_path: str | Path) -> tuple[float, float]: + """ + Extract (lat, lon) from image EXIF GPS metadata. + + :param image_path: Path to an image file. + :return: ``(latitude, longitude)`` from EXIF GPS tags. + :raises ValueError: if GPS metadata is missing or invalid. + + Example:: + In: parse_lat_lon_from_image_metadata("data/images/raw/123.jpg") + Out: (50.0612, 19.9377) + """ + from PIL import Image + + image_path_obj = Path(image_path) + try: + with Image.open(image_path_obj) as im: + exif = im.getexif() + if not exif: + raise ValueError("missing EXIF") + gps_info = exif.get_ifd(0x8825) # GPSInfo IFD + if not isinstance(gps_info, dict): + raise ValueError("missing GPSInfo") + latlon_coordinates = _exif_gps_to_decimal(gps_info) + if latlon_coordinates is None: + raise ValueError("missing GPS lat/lon") + return latlon_coordinates + except ValueError: + raise + except Exception as error: + raise ValueError(f"cannot read EXIF GPS from image: {image_path_obj}") from error + + +def parse_lat_lon(image_path: str | Path) -> tuple[float, float]: + """ + Parse coordinates strictly from EXIF GPS metadata. + + :param image_path: Path to an image file. + :return: ``(latitude, longitude)``. + + Example:: + In: parse_lat_lon("data/images/raw/img_0001.jpg") + Out: (50.0612, 19.9377) + """ + return parse_lat_lon_from_image_metadata(image_path) + + +def _is_geopandas_available() -> bool: + """ + Check whether GeoPandas and Shapely are available in the environment. + + :return: True if both packages can be imported, False otherwise. + + Example:: + In: _is_geopandas_available() + Out: True + """ + try: + import geopandas # noqa: F401 + import shapely # noqa: F401 + + return True + except ImportError as error: + log.debug("GeoPandas availability check failed: %s", error) + return False diff --git a/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/writers.py b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/writers.py new file mode 100644 index 0000000..9360502 --- /dev/null +++ b/src/uq_desktop_processor/layer_creation/vector_layers/point_layer/writers.py @@ -0,0 +1,94 @@ +""" +Low-level writers for GeoJSON and GeoPandas outputs used by the exporter. +""" + +import json +import logging +import os +from typing import Any + +log = logging.getLogger(__name__) + + +def _write_geojson(feature_collection: dict[str, Any], output_path: str) -> None: + """ + Save a GeoJSON FeatureCollection to a .geojson or .json file. + + :param feature_collection: GeoJSON FeatureCollection dictionary. + :param output_path: Destination path for the JSON/GeoJSON file. + + Example:: + In: _write_geojson(feature_collection, "out.geojson") + Out: "out.geojson" created on disk + """ + with open(output_path, "w", encoding="utf-8") as file_handle: + json.dump(feature_collection, file_handle, ensure_ascii=False, indent=2) + + feature_count = len(feature_collection.get("features", [])) + log.info("Saved GeoJSON: %s (object count: %s)", output_path, feature_count) + + +def _write_with_geopandas(feature_collection: dict[str, Any], output_path: str, layer: str | None = None) -> None: + """ + Save a FeatureCollection to a binary geospatial format using GeoPandas. + + Supported formats (inferred from file extension): + - .gpkg (GeoPackage) + - .shp (ESRI Shapefile) + - .parquet + - other formats supported by GeoPandas drivers + + :param feature_collection: GeoJSON-like structure to be converted. + :param output_path: Output file path. + :param layer: Optional layer name for multi-layer formats (e.g. GeoPackage). + + Example:: + In: _write_with_geopandas(feature_collection, "out.gpkg", layer="scores") + Out: "out.gpkg" created on disk with layer "scores" + """ + import geopandas as gpd + from shapely.geometry import Point + + if not feature_collection["features"]: + log.warning("No objects to save; feature collection is empty.") + return + + rows: list[dict[str, Any]] = [] + + # Convert each GeoJSON feature into a GeoPandas row. + for feature_item in feature_collection["features"]: + lon, lat = feature_item["geometry"]["coordinates"] + properties = feature_item["properties"].copy() + rows.append({**properties, "geometry": Point(lon, lat)}) + + log.debug("Converted %s features to GeoDataFrame rows.", len(rows)) + + geo_data_frame = gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326") + + file_extension = os.path.splitext(output_path.lower())[1] + + try: + if file_extension == ".gpkg": + # Use provided layer name or fall back to collection name. + layer_name = layer or (feature_collection.get("name") or "data").replace("/", "_") + geo_data_frame.to_file(output_path, layer=layer_name, driver="GPKG") # type: ignore[assignment] + + elif file_extension == ".shp": + geo_data_frame.to_file(output_path, driver="ESRI Shapefile") # type: ignore[assignment] + + elif file_extension == ".parquet": + geo_data_frame.to_parquet(output_path, index=False) # type: ignore[assignment] + + else: + # Default driver chosen by GeoPandas based on extension. + geo_data_frame.to_file(output_path) # type: ignore[assignment] + + log.info( + "Saved %s: %s (object count: %s)", + file_extension.upper(), + output_path, + len(geo_data_frame), + ) + + except Exception as error: + log.error("GeoPandas save error: %s", error) diff --git a/src/uq_desktop_processor/logging_config.py b/src/uq_desktop_processor/logging_config.py new file mode 100644 index 0000000..272bdb6 --- /dev/null +++ b/src/uq_desktop_processor/logging_config.py @@ -0,0 +1,445 @@ +""" +Central logging setup: UTC timestamps, optional colors, and file handlers. +Call `configure_logging` once at startup; use `getLogger(__name__)` afterward. +""" + +import logging +import os +import sys +import time +from dataclasses import dataclass +from typing import IO + +__all__ = ["configure_logging", "add_file_logging"] + + +COLORS = { + "DEBUG": "\033[90m", + "INFO": "\033[32m", + "WARNING": "\033[93m", + "ERROR": "\033[91m", + "CRITICAL": "\033[97;41m", + "RESET": "\033[0m", +} + + +@dataclass(frozen=True) +class ColorSupport: + """ + Represents whether ANSI color output is supported in the current environment. + + Attributes: + supported (bool): True if ANSI colors are supported, False otherwise. + reason (Optional[str]): Explanation why colors are not supported (if applicable). + env_hint (Optional[str]): Environment hint to provide user-specific tips (e.g. 'vscode', 'pycharm'). + """ + + supported: bool + reason: str | None = None + env_hint: str | None = None + + +class ColorEnv: + """ + Detects environment characteristics related to color/ANSI support + and provides methods to enable or disable ANSI colors in different terminals. + """ + + _WINDOWS_ANSI_ENABLED = False + _COLOR_HINT_SHOWN = False + + @staticmethod + def is_ci() -> bool: + """ + Detects if the current process is running inside a CI environment. + + :return: True if running in a known CI system, False otherwise. + """ + return os.environ.get("CI") == "1" or any( + os.environ.get(env_var) + for env_var in ( + "GITHUB_ACTIONS", + "GITLAB_CI", + "TF_BUILD", + "TEAMCITY_VERSION", + "BUILDKITE", + "TRAVIS", + "CIRCLECI", + "APPVEYOR", + "DRONE", + "JENKINS_URL", + ) + ) + + @staticmethod + def is_vscode() -> bool: + """ + Detects if the process is running inside VS Code's Integrated Terminal or Debug Console. + + :return: True if running in VS Code, False otherwise. + """ + return bool(os.environ.get("TERM_PROGRAM") == "vscode" or os.environ.get("VSCODE_PID")) + + @staticmethod + def is_pycharm() -> bool: + """ + Detects if the process is running inside the PyCharm IDE. + + :return: True if running in PyCharm, False otherwise. + """ + return bool(os.environ.get("PYCHARM_HOSTED")) + + @staticmethod + def is_windows_terminal() -> bool: + """ + Detects if the process is running in Windows Terminal or ConEmu. + + :return: True if running in Windows Terminal or ConEmu, False otherwise. + """ + return bool(os.environ.get("WT_SESSION") or os.environ.get("ConEmuPID")) + + @staticmethod + def is_jupyter() -> bool: + """ + Detects if the process is running inside a Jupyter or Spyder kernel. + + :return: True if in Jupyter, False otherwise. + """ + try: + import ipykernel # noqa: F401 + + return True + except ImportError: + return False + + @classmethod + def ensure_windows_ansi(cls) -> None: + """ + Ensures that ANSI escape sequences are enabled in Windows consoles. + Requires the `colorama` package. + + :return: None + """ + if cls._WINDOWS_ANSI_ENABLED or os.name != "nt": + return + + try: + import colorama # noqa E402 + except ImportError: + return + + colorama.just_fix_windows_console() + cls._WINDOWS_ANSI_ENABLED = True + + @staticmethod + def stream_isatty(stream: IO[str]) -> bool: + """ + Checks whether a given stream is connected to a TTY (interactive terminal). + + :param stream: The stream to check. + :return: True if the stream is a TTY, False otherwise. + """ + try: + return hasattr(stream, "isatty") and callable(stream.isatty) and stream.isatty() + except (OSError, ValueError): + return False + + @classmethod + def color_support_with_reason(cls, stream: IO[str]) -> ColorSupport: + """ + Determines whether colors are supported in the current environment, + including reasons and environment hints if disabled. + + :param stream: The output stream (e.g. sys.stderr). + :return: A ColorSupport instance with the decision and explanation. + """ + if os.environ.get("FORCE_COLOR"): + cls.ensure_windows_ansi() + return ColorSupport(True) + + if os.environ.get("NO_COLOR"): + return ColorSupport(False, "NO_COLOR is set.", None) + + if cls.is_pycharm(): + cls.ensure_windows_ansi() + return ColorSupport(True, env_hint="pycharm") + + if cls.is_vscode(): + cls.ensure_windows_ansi() + return ColorSupport(True, env_hint="vscode") + + if cls.is_windows_terminal(): + cls.ensure_windows_ansi() + + if cls.is_jupyter(): + return ColorSupport(True, env_hint="jupyter") + + if not cls.stream_isatty(stream): + return ColorSupport(False, "Output is not a TTY (file/pipe/IDE wo/ emulation).", None) + + if os.name == "nt": + cls.ensure_windows_ansi() + return ( + ColorSupport(True) + if cls._WINDOWS_ANSI_ENABLED + else ColorSupport(False, "Windows without 'colorama'.", "windows") + ) + + terminal_type = os.environ.get("TERM", "") + if terminal_type in ("", "dumb"): + return ColorSupport(False, f"TERM={terminal_type!r} does not support ANSI.", "unix") + + return ColorSupport(True) + + @classmethod + def maybe_show_color_hint(cls, reason: str | None, env_hint: str | None) -> None: + """ + Optionally prints a hint to stderr explaining why colors are disabled + and how to enable them, depending on the environment. + + :param reason: Reason why colors are disabled. + :param env_hint: Environment hint (if available). + :return: None + """ + if cls._COLOR_HINT_SHOWN or cls.is_ci() or os.environ.get("LOG_COLOR_HINT") != "1": + return + + if not reason: + return + + cls._COLOR_HINT_SHOWN = True + + base_tips = [ + "- If redirecting to a file/pipeline: colors are intentionally disabled.", + "- Remove the `NO_COLOR` variable if it's set.", + "- You can force colors: `FORCE_COLOR=1`.", + ] + + env_tips = { + "pycharm": ["- PyCharm: enable **Run → Emulate terminal in output console**."], + "vscode": [ + "- VS Code: use the **Integrated Terminal** (View → Terminal),", + " or set `FORCE_COLOR=1` in your Debug Configuration.", + ], + "windows": ["- Windows: `pip install colorama` or run in Windows Terminal."], + "unix": [ + "- Linux/macOS: ensure `TERM` is something like `xterm-256color`.", + "- In Docker, run with a TTY: `docker run -it ...`.", + ], + "jupyter": ["- Jupyter: colors usually work; to disable set `NO_COLOR=1`."], + None: [ + "- Windows: consider **Windows Terminal** or `pip install colorama`.", + "- VS Code/PyCharm: run via the **Integrated/Emulated Terminal**.", + "- Docker: add `-t` (pseudo-TTY).", + ], + } + + tips = env_tips.get(env_hint, env_tips[None]) + + message = ( + "[log-color] Colors are disabled: " + f"{reason} How to enable them:\n " + + "\n ".join(tips + base_tips) + + "\n (Silence tips: LOG_COLOR_HINT=0 / off by default)" + ) + + try: + print(message, file=sys.stderr) + except (OSError, ValueError): + pass + + +class UtcFormatter(logging.Formatter): + """ + Formatter that enforces UTC timestamps for log records. + """ + + @staticmethod + def _converter(secs: float | None) -> time.struct_time: + return time.gmtime(0 if secs is None else secs) + + converter = _converter + + +class ColoredFormatter(UtcFormatter): + """ + A log formatter that applies ANSI color codes to messages based on log level. + + Extends UtcFormatter to add colorization when enabled. + """ + + def __init__(self, format_string: str, date_format: str | None, use_color: bool) -> None: + super().__init__(fmt=format_string, datefmt=date_format) + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + message = super().format(record) + + if not self.use_color: + return message + + color = COLORS.get(record.levelname, COLORS["RESET"]) + return f"{color}{message}{COLORS['RESET']}" + + +class HandlerFactory: + """ + Factory for creating logging handlers with appropriate formatters + (stream handlers with optional colors, or file handlers without colors). + """ + + @staticmethod + def stream_handler(stream: IO[str], fmt: str, date_format: str) -> logging.Handler: + handler = logging.StreamHandler(stream) + handler.setLevel(logging.NOTSET) + + support = ColorEnv.color_support_with_reason(stream) + if not support.supported: + ColorEnv.maybe_show_color_hint(support.reason, support.env_hint) + + handler.setFormatter(ColoredFormatter(format_string=fmt, date_format=date_format, use_color=support.supported)) + return handler + + @staticmethod + def file_handler(path: str, fmt: str, date_format: str, encoding: str = "utf-8") -> logging.Handler: + absolute_path = os.path.abspath(path) + handler = logging.FileHandler(absolute_path, encoding=encoding) + handler.setLevel(logging.NOTSET) + handler.setFormatter(UtcFormatter(fmt=fmt, datefmt=date_format)) + return handler + + +class LoggingConfigurator: + """ + Provides high-level configuration methods for setting up logging + with optional color support for console and file handlers. + """ + + @staticmethod + def configure( + level: int = logging.DEBUG, + stream: IO[str] = sys.stderr, + format_string: str = "%(asctime)s.%(msecs)03dZ [%(levelname)s] %(name)s: %(message)s", + date_format: str = "%Y-%m-%dT%H:%M:%S", + *, + replace_handlers: bool = False, + capture_warnings: bool = True, + ) -> None: + """ + Configures global logging with colorized console output. + + :param level: Minimum logging level (default DEBUG). + :param stream: Stream to log to (default sys.stderr). + :param format_string: Log message format string. + :param date_format: Date/time format string. + :param replace_handlers: If True, removes existing handlers first. + :param capture_warnings: If True, redirects warnings to logging. + :return: None + """ + root = logging.getLogger() + + if replace_handlers: + for handler in root.handlers[:]: + root.removeHandler(handler) + if hasattr(root, "_colored_logging_configured"): + delattr(root, "_colored_logging_configured") + + if getattr(root, "_colored_logging_configured", False) and not replace_handlers: + if capture_warnings: + logging.captureWarnings(True) + return + + root.setLevel(level) + root.addHandler(HandlerFactory.stream_handler(stream, format_string, date_format)) + root._colored_logging_configured = True # type: ignore[attr-defined] + + if capture_warnings: + logging.captureWarnings(True) + + @staticmethod + def add_file_logging( + path: str, + level: int = logging.DEBUG, + format_string: str = "%(asctime)s.%(msecs)03dZ [%(levelname)s] %(name)s: %(message)s", + date_format: str = "%Y-%m-%dT%H:%M:%S", + encoding: str = "utf-8", + ) -> None: + """ + Adds a file handler to the global logger, writing logs in UTC. + + :param path: Path to the log file. + :param level: Minimum logging level (default DEBUG). + :param format_string: Log message format string. + :param date_format: Date/time format string. + :param encoding: File encoding (default UTF-8). + :return: None + """ + root = logging.getLogger() + absolute_path = os.path.abspath(path) + + for handler in root.handlers: + if isinstance(handler, logging.FileHandler) and getattr(handler, "baseFilename", None) == absolute_path: + if root.level > level: + root.setLevel(level) + return + + root.addHandler(HandlerFactory.file_handler(absolute_path, format_string, date_format, encoding)) + + if root.level > level: + root.setLevel(level) + + +def configure_logging( + level: int = logging.DEBUG, + stream: IO[str] = sys.stderr, + format_string: str = "%(asctime)s.%(msecs)03dZ [%(levelname)s] %(name)s: %(message)s", + date_format: str = "%Y-%m-%dT%H:%M:%S", + *, + replace_handlers: bool = False, + capture_warnings: bool = True, +) -> None: + """ + Public API to configure global logging with colorized console output. + + :param level: Minimum logging level (default DEBUG). + :param stream: Stream to log to (default sys.stderr). + :param format_string: Log message format string. + :param date_format: Date/time format string. + :param replace_handlers: If True, removes existing handlers first. + :param capture_warnings: If True, redirects warnings to logging. + :return: None + """ + LoggingConfigurator.configure( + level=level, + stream=stream, + format_string=format_string, + date_format=date_format, + replace_handlers=replace_handlers, + capture_warnings=capture_warnings, + ) + + +def add_file_logging( + path: str, + level: int = logging.DEBUG, + format_string: str = "%(asctime)s.%(msecs)03dZ [%(levelname)s] %(name)s: %(message)s", # Default - ISO-8601 + date_format: str = "%Y-%m-%dT%H:%M:%S", + encoding: str = "utf-8", +) -> None: + """ + Public API to add file logging to the global logger. + + :param path: Path to the log file. + :param level: Minimum logging level (default DEBUG). + :param format_string: Log message format string. + :param date_format: Date/time format string. + :param encoding: File encoding (default UTF-8). + :return: None + """ + LoggingConfigurator.add_file_logging( + path=path, + level=level, + format_string=format_string, + date_format=date_format, + encoding=encoding, + ) diff --git a/src/uq_desktop_processor/pipeline/__init__.py b/src/uq_desktop_processor/pipeline/__init__.py new file mode 100644 index 0000000..863a8f0 --- /dev/null +++ b/src/uq_desktop_processor/pipeline/__init__.py @@ -0,0 +1,17 @@ +""" +Public pipeline API: defaults, orchestrator, CLI entrypoint, and point/prefilter helpers. +""" + +from uq_desktop_processor.pipeline.cli import run_pipeline +from uq_desktop_processor.pipeline.defaults import DEFAULT_CONFIG +from uq_desktop_processor.pipeline.pipeline import UrbanQualityAIPipeline +from uq_desktop_processor.pipeline.points_io import load_sampling_points_latlon +from uq_desktop_processor.pipeline.prefilter_config import prefilter_prompts_from_config + +__all__ = [ + "DEFAULT_CONFIG", + "UrbanQualityAIPipeline", + "load_sampling_points_latlon", + "prefilter_prompts_from_config", + "run_pipeline", +] diff --git a/src/uq_desktop_processor/pipeline/cli.py b/src/uq_desktop_processor/pipeline/cli.py new file mode 100644 index 0000000..7e34a51 --- /dev/null +++ b/src/uq_desktop_processor/pipeline/cli.py @@ -0,0 +1,39 @@ +""" +CLI entry: runs the full UrbanQuality-AI pipeline (points, download, prefilter, evaluate, export). +""" + +import logging + +from uq_desktop_processor.pipeline.pipeline import UrbanQualityAIPipeline + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def run_pipeline() -> int: + """ + Run the default end-to-end pipeline from the command line. + + :return: Process-like exit code (0 success, 1 operational error, 130 interrupted). + + Example:: + In: run_pipeline() + Out: 0 + """ + pipeline = UrbanQualityAIPipeline() + try: + pipeline.step_1_generate_points() + pipeline.step_2_download_images() + + prefilter_stats = pipeline.step_3_prefilter() + + # Skip scoring/export when prefilter removed all images. + if prefilter_stats.get("summary", {}).get("kept", 0) > 0: + pipeline.step_4_evaluate() + pipeline.step_5_export() + return 0 + except KeyboardInterrupt: + return 130 + except (RuntimeError, OSError, ValueError) as error: + logger.error("Pipeline failed due to operational error: %s", error) + return 1 diff --git a/src/uq_desktop_processor/pipeline/defaults.py b/src/uq_desktop_processor/pipeline/defaults.py new file mode 100644 index 0000000..29a9f00 --- /dev/null +++ b/src/uq_desktop_processor/pipeline/defaults.py @@ -0,0 +1,44 @@ +""" +Default directories and parameters for each pipeline step. +""" + +import os +from pathlib import Path +from typing import Any + +DEFAULT_CONFIG: dict[str, Any] = { + "place_name": "Katowice, Poland", + # Source for sampling-point planning: "place" | "region" | "roads". + "planner_source": "place", + "region_geojson_path": "", + "road_geojson_path": "", + # Distances are interpreted in meters. + "spacing": 100, + "min_distance": 50, + "search_radius": 100, + "mapillary_max_workers": 20, + "mapillary_fov_deg": 90, + "mapillary_points_path": "", + "mapillary_images_output_dir": "", + "base_dir": "data", + "points_layer_path": str(Path("data") / "results" / "sampling_points.geojson"), + "mapillary_token": os.environ.get("MAPILLARY_ACCESS_TOKEN", ""), + "clip_model_names": ("ViT-L/14@336px",), + "prefilter_image_folder": "", + "prefilter_rejected_folder": "", + "prefilter_pos_prompts": "", + "prefilter_neg_prompts": "", + "filter_threshold": 40.0, + "beta_sigmoid": 30.0, + "export_path": "", + "torch_device": "auto", + "vit_images_dir": "", + "vit_model_path": "", + "vit_calibrators_dir": "", + "vit_model_name": "vit_base_patch14_dinov2.lvd142m", + "vit_image_size": 224, + "vit_batch_size": 32, + "vit_torch_device": "auto", + "vit_output_layer_path": "", + "vit_output_layer_name": "vit_finetuned_scores", +} diff --git a/src/uq_desktop_processor/pipeline/pipeline.py b/src/uq_desktop_processor/pipeline/pipeline.py new file mode 100644 index 0000000..632fb64 --- /dev/null +++ b/src/uq_desktop_processor/pipeline/pipeline.py @@ -0,0 +1,368 @@ +""" +Orchestrates end-to-end processing: points, images, prefilter, CLIP eval, vector export. +""" + +import logging +import os +from pathlib import Path +from typing import Any + +from uq_desktop_processor.evaluation import ( + evaluate_images_with_clip, + prefilter_folder, +) +from uq_desktop_processor.evaluation.finetuned_evaluator.run import evaluate_images_with_finetuned +from uq_desktop_processor.layer_creation import export_point_layer +from uq_desktop_processor.pipeline.defaults import DEFAULT_CONFIG +from uq_desktop_processor.pipeline.points_io import load_sampling_points_latlon +from uq_desktop_processor.pipeline.prefilter_config import prefilter_prompts_from_config +from uq_desktop_processor.street_view_analysis import build_points_pipeline, download_mapillary_images + + +class UrbanQualityAIPipeline: + """ + Application pipeline controller. + + Holds in-memory state (roads, points, evaluation) and exposes steps to run sequentially. + """ + + def __init__(self, config: dict[str, Any] | None = None): + """ + Initialize pipeline state and merge runtime overrides with defaults. + + :param config: Optional dictionary with config overrides. + + Example:: + In: UrbanQualityAIPipeline({"base_dir": "data_dev"}) + Out: pipeline object with directories initialized under "data_dev" + """ + self.log = logging.getLogger("UrbanQualityAI") + self.log.setLevel(logging.DEBUG) + + self.config = {**DEFAULT_CONFIG, **(config or {})} + + self.base_dir = Path(self.config["base_dir"]) + self.raw_dir = self.base_dir / "images" / "raw" + self.rejected_dir = self.base_dir / "images" / "rejected" + self.results_dir = self.base_dir / "results" + + self.roads_gdf = None + self.points: list[tuple[float, float]] = [] + self.evaluation_results = None + + self._ensure_directories() + + def _resolve_torch_device(self) -> str: + """ + Resolve torch device from config and environment fallback rules. + + :return: ``"cpu"`` or ``"cuda"``. + + Example:: + In: pipeline._resolve_torch_device() + Out: "cpu" + """ + configured_device = self.config.get("torch_device", "auto") + if configured_device == "cpu": + return "cpu" + if configured_device == "cuda": + return "cuda" + return "cuda" if os.environ.get("FORCE_CUDA") == "1" else "cpu" + + def update_config(self, new_config: dict[str, Any]) -> None: + """ + Merge new settings into active runtime config. + + :param new_config: Partial config dictionary to apply. + + Example:: + In: pipeline.update_config({"spacing": 80}) + Out: pipeline.config["spacing"] == 80 + """ + self.config.update(new_config) + self.log.info("Configuration updated: %s", new_config) + + def _ensure_directories(self) -> None: + """Create raw/rejected/results directories if they are missing.""" + for directory_path in [self.raw_dir, self.rejected_dir, self.results_dir]: + directory_path.mkdir(parents=True, exist_ok=True) + + def ensure_directories(self) -> None: + """ + Public wrapper for directory bootstrap. + + Example:: + In: pipeline.ensure_directories() + Out: required directories exist on disk + """ + self._ensure_directories() + + def step_1_generate_points(self) -> int: + """ + Step 1: generate sampling points and save them to the configured layer path. + + :return: Number of generated points. + + Example:: + In: pipeline.step_1_generate_points() + Out: 1248 + """ + planner_source = self.config.get("planner_source", "place") + spacing = float(self.config["spacing"]) + min_distance = float(self.config["min_distance"]) + points_output_path = (self.config.get("points_layer_path") or "").strip() + if not points_output_path: + raise ValueError("Set ``points_layer_path`` to a .geojson / .gpkg output path.") + + self.log.info("=== STEP 1: Point generation (source=%s, spacing=%sm) ===", planner_source, spacing) + + try: + # Choose exactly one planner source mode. + if planner_source == "place": + place = self.config["place_name"] + self.roads_gdf, self.points = build_points_pipeline( + output_points_path=points_output_path, + place_name=place, + spacing=spacing, + min_distance_m=min_distance, + ) + elif planner_source == "region": + region_path = (self.config.get("region_geojson_path") or "").strip() + if not region_path: + raise ValueError("Region GeoJSON path is empty.") + self.roads_gdf, self.points = build_points_pipeline( + output_points_path=points_output_path, + region_geojson_path=region_path, + spacing=spacing, + min_distance_m=min_distance, + ) + elif planner_source == "roads": + roads_path = (self.config.get("road_geojson_path") or "").strip() + if not roads_path: + raise ValueError("Road network GeoJSON path is empty.") + self.roads_gdf, self.points = build_points_pipeline( + output_points_path=points_output_path, + road_geojson_path=roads_path, + spacing=spacing, + min_distance_m=min_distance, + ) + else: + raise ValueError(f"Unknown planner source: {planner_source}") + + count = len(self.points) + self.log.info("Success. Generated %s points; saved to %s", count, Path(points_output_path).resolve()) + return count + except Exception as error: + self.log.error("Step 1 failed: %s", error) + raise + + def step_2_download_images(self) -> dict[str, Any]: + """ + Step 2: download Mapillary images for loaded sampling points. + + :return: Download summary dictionary. + + Example:: + In: pipeline.step_2_download_images()["downloaded"] + Out: 915 + """ + self.log.info("=== STEP 2: Mapillary download ===") + + points_path = (self.config.get("mapillary_points_path") or "").strip() + if not points_path: + points_path = (self.config.get("points_layer_path") or "").strip() + if not points_path: + raise ValueError("Set a point layer path (Mapillary tab or planner output path).") + try: + # Reload points from disk to keep GUI/CLI behavior consistent. + self.points = load_sampling_points_latlon(points_path) + except FileNotFoundError as error: + self.log.error("%s", error) + raise RuntimeError("Sampling points file missing. Run step 1 (generate points) first.") from error + if not self.points: + raise RuntimeError(f"Point layer is empty: {points_path}") + + token = self.config["mapillary_token"] + if not token: + self.log.error("Mapillary token is missing.") + raise ValueError("A Mapillary access token is required.") + + image_output_dir = (self.config.get("mapillary_images_output_dir") or "").strip() + if image_output_dir: + self.raw_dir = Path(image_output_dir).expanduser().resolve() + else: + self.raw_dir = self.base_dir / "images" / "raw" + self._ensure_directories() + + # Downloader returns per-point outcomes plus aggregate timing/count metrics. + stats = download_mapillary_images( + points=self.points, + output_folder=str(self.raw_dir), + mapillary_token=token, + search_radius_m=float(self.config["search_radius"]), + fov_deg=float(self.config.get("mapillary_fov_deg", 90)), + max_workers=int(self.config.get("mapillary_max_workers", 20)), + ) + self.log.info("Download finished.") + return stats + + def step_3_prefilter(self) -> dict[str, Any]: + """ + Step 3: run CLIP prefilter and split images into kept/rejected groups. + + :return: Prefilter statistics dictionary. + + Example:: + ``pipeline.step_3_prefilter()["summary"]["kept"] -> 640`` + """ + self.log.info("=== STEP 3: CLIP prefilter ===") + + device = self._resolve_torch_device() + + configured_image_folder = (self.config.get("prefilter_image_folder") or "").strip() + if not configured_image_folder: + image_folder = str(self.raw_dir.resolve()) + else: + image_folder = str(Path(configured_image_folder).expanduser().resolve()) + + rejected_spec = (self.config.get("prefilter_rejected_folder") or "").strip() + if not rejected_spec: + # Relative name means "inside image_folder". + rejected_spec = "rejected" + + model_names = tuple(self.config.get("clip_model_names") or ("ViT-L/14@336px",)) + stats = prefilter_folder( + image_folder=image_folder, + rejected_folder=rejected_spec, + device=device, + model_names=model_names, + beta_sigmoid=float(self.config.get("beta_sigmoid", 30.0)), + filter_threshold=float(self.config.get("filter_threshold", 40.0)), + filter_prompts=prefilter_prompts_from_config(self.config), + ) + + kept_count = stats["summary"]["kept"] + rejected_count = stats["summary"]["rejected"] + self.log.info("Prefilter done. Kept: %s, rejected: %s", kept_count, rejected_count) + return stats + + def step_4_evaluate(self) -> dict[str, Any]: + """ + Step 4: run CLIP scoring for configured dimensions. + + :return: Evaluation results dictionary. + + Example:: + In: pipeline.step_4_evaluate() + Out: {"results": [...], "summary": {...}, ...} + """ + self.log.info("=== STEP 4: CLIP image scoring ===") + + device = self._resolve_torch_device() + + model_names = tuple(self.config.get("clip_model_names") or ("ViT-L/14@336px",)) + evaluation_results = evaluate_images_with_clip( + image_folder=str(self.raw_dir), + device=device, + model_names=model_names, + beta_sigmoid=float(self.config.get("beta_sigmoid", 30.0)), + raise_on_validation_error=True, + ) + self.evaluation_results = evaluation_results + + self.log.info("Scoring finished successfully.") + return evaluation_results + + def step_5_export(self) -> str: + """ + Step 5: export scored points to a vector file. + + :return: Path to saved output file. + + Example:: + In: pipeline.step_5_export() + Out: "data/results/urban_quality_ai_output.geojson" + """ + self.log.info("=== STEP 5: Export results ===") + + if not self.evaluation_results: + self.log.warning("No evaluation results. Run step 4 first.") + raise RuntimeError("Nothing to export.") + + custom = (self.config.get("export_path") or "").strip() + if custom: + output_path = Path(custom) + if not output_path.is_absolute(): + # Keep relative paths anchored to current working directory. + output_path = Path.cwd() / output_path + else: + output_path = self.results_dir / "urban_quality_ai_output.geojson" + output_path.parent.mkdir(parents=True, exist_ok=True) + saved_path = export_point_layer( + results=self.evaluation_results, + output_path=str(output_path), + ) + self.log.info("Written to %s", saved_path) + return str(saved_path) + + def step_6_evaluate_vit(self) -> dict[str, Any]: + """ + Step 6: run fine-tuned ViT scoring and export result layer. + + :return: Evaluation results dictionary. + + Example:: + In: pipeline.step_6_evaluate_vit() + Out: {"results": [...], "summary": {...}, ...} + """ + self.log.info("=== STEP 6: Fine-tuned ViT scoring ===") + + images_dir_spec = (self.config.get("vit_images_dir") or "").strip() + if not images_dir_spec: + raise ValueError("Set 'vit_images_dir' (folder with images).") + images_dir = str(Path(images_dir_spec).expanduser().resolve()) + + model_path_spec = (self.config.get("vit_model_path") or "").strip() + if not model_path_spec: + # Backward-compatible alias used by some configs. + model_path_spec = (self.config.get("vit_checkpoint_path") or "").strip() + if not model_path_spec: + raise ValueError("Set 'vit_model_path' (model weights file).") + model_path = Path(model_path_spec) + + calibrators_dir_spec = (self.config.get("vit_calibrators_dir") or "").strip() + calibrators_dir = Path(calibrators_dir_spec) if calibrators_dir_spec else None + + output_layer_spec = (self.config.get("vit_output_layer_path") or "").strip() + output_layer_path = ( + Path(output_layer_spec) if output_layer_spec else (self.results_dir / "vit_finetuned_scores.gpkg") + ) + output_layer_name = (self.config.get("vit_output_layer_name") or "").strip() or None + + model_name = str(self.config.get("vit_model_name") or "vit_base_patch14_dinov2.lvd142m") + image_size = int(self.config.get("vit_image_size") or 224) + batch_size = int(self.config.get("vit_batch_size") or 32) + vit_torch_device = str(self.config.get("vit_torch_device") or "auto") + + evaluation_results = evaluate_images_with_finetuned( + images_dir=images_dir, + model_path=model_path, + calibrators_dir=calibrators_dir, + model_name=model_name, + image_size=image_size, + batch_size=batch_size, + torch_device=vit_torch_device, + ) + self.evaluation_results = evaluation_results + + # Export is intentionally done at the pipeline layer (avoid evaluation -> layer_creation coupling). + output_layer_path.parent.mkdir(parents=True, exist_ok=True) + export_point_layer( + results=evaluation_results, + output_path=str(output_layer_path), + layer=output_layer_name, + ) + self.log.info("Exported fine-tuned results to: %s", output_layer_path) + + self.log.info("ViT scoring finished successfully.") + return evaluation_results diff --git a/src/uq_desktop_processor/pipeline/points_io.py b/src/uq_desktop_processor/pipeline/points_io.py new file mode 100644 index 0000000..f572cdb --- /dev/null +++ b/src/uq_desktop_processor/pipeline/points_io.py @@ -0,0 +1,45 @@ +""" +Loads and saves sampling-point GeoJSON used between pipeline stages. +""" + +from pathlib import Path + +import geopandas as gpd + + +def load_sampling_points_latlon(path: str | Path) -> list[tuple[float, float]]: + """ + Load a point layer (Point geometries, WGS84) into ``(latitude, longitude)`` tuples + for Mapillary download and map display. + + Non-point geometries are converted to centroids. + + :param path: Path to a vector layer containing sampling geometries. + :return: Sampling points as ``(latitude, longitude)`` pairs. + :raises FileNotFoundError: If the point layer file does not exist. + + Example:: + In: load_sampling_points_latlon("data/results/sampling_points.geojson") + Out: [(50.0612, 19.9377), (50.0620, 19.9402), ...] + """ + point_layer_path = Path(path) + if not point_layer_path.is_file(): + raise FileNotFoundError(f"Point layer file not found: {point_layer_path}") + gdf = gpd.read_file(point_layer_path) + if gdf.empty: + return [] + if gdf.crs is None: + gdf = gdf.set_crs("EPSG:4326") + else: + gdf = gdf.to_crs("EPSG:4326") + latlon_points: list[tuple[float, float]] = [] + for geom in gdf.geometry: + if geom is None or geom.is_empty: + continue + if geom.geom_type == "Point": + latlon_points.append((float(geom.y), float(geom.x))) + else: + # Keep a single representative coordinate for non-point input. + centroid = geom.centroid + latlon_points.append((float(centroid.y), float(centroid.x))) + return latlon_points diff --git a/src/uq_desktop_processor/pipeline/prefilter_config.py b/src/uq_desktop_processor/pipeline/prefilter_config.py new file mode 100644 index 0000000..472a86c --- /dev/null +++ b/src/uq_desktop_processor/pipeline/prefilter_config.py @@ -0,0 +1,36 @@ +""" +Loads YAML/JSON prefilter settings and merges them with pipeline defaults. +""" + +from typing import Any + +from uq_desktop_processor.evaluation.clip_prefilter import defaults as clip_prefilter_defaults + + +def prefilter_prompts_from_config(config: dict[str, Any]) -> dict[str, tuple[str, ...]]: + """ + Parse multiline ``prefilter_*_prompts`` strings into positive/negative prompt tuples. + + Falls back to bundled defaults when any side is missing. + + :param config: Pipeline config dictionary with optional ``prefilter_pos_prompts`` / ``prefilter_neg_prompts``. + :return: Dict with ``"pos"`` and ``"neg"`` tuple values. + + Example:: + In: prefilter_prompts_from_config({"prefilter_pos_prompts": "green\\npark", "prefilter_neg_prompts": ""}) + Out: {"pos": ("green", "park"), "neg": (...defaults...)} + """ + + def lines(text: Any) -> tuple[str, ...]: + # Accept only non-empty strings; ignore blank lines. + if not text or not isinstance(text, str): + return () + return tuple(line.strip() for line in text.splitlines() if line.strip()) + + positive_prompts = lines(config.get("prefilter_pos_prompts")) + negative_prompts = lines(config.get("prefilter_neg_prompts")) + if not positive_prompts: + positive_prompts = tuple(clip_prefilter_defaults.FILTER_PROMPTS["pos"]) + if not negative_prompts: + negative_prompts = tuple(clip_prefilter_defaults.FILTER_PROMPTS["neg"]) + return {"pos": positive_prompts, "neg": negative_prompts} diff --git a/src/uq_desktop_processor/street_view_analysis/__init__.py b/src/uq_desktop_processor/street_view_analysis/__init__.py new file mode 100644 index 0000000..08db078 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/__init__.py @@ -0,0 +1,14 @@ +""" +Street-view analysis: road sampling, Mapillary download, and Euler route generation. +""" + +from .chinese_postman_routes import EulerRoutesResult, generate_clean_routes +from .images_downloader import download_mapillary_images +from .road_points_generator import build_points_pipeline + +__all__ = [ + "EulerRoutesResult", + "build_points_pipeline", + "download_mapillary_images", + "generate_clean_routes", +] diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/__init__.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/__init__.py new file mode 100644 index 0000000..a306e6a --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/__init__.py @@ -0,0 +1,9 @@ +""" +Road-network coverage routes (Chinese postman / Eulerian circuits) split on a grid; +export to GPX in WGS84. +""" + +from .generate import generate_clean_routes +from .result import EulerRoutesResult + +__all__ = ["EulerRoutesResult", "generate_clean_routes"] diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/generate.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/generate.py new file mode 100644 index 0000000..83b0788 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/generate.py @@ -0,0 +1,166 @@ +""" +Grid-sector Euler routes: truncate graph per tile, Chinese postman per component, write GPX. +""" + +import logging +from pathlib import Path +from typing import cast + +import networkx as nx +import numpy as np +import osmnx as ox +from osmnx.truncate import truncate_graph_bbox + +from uq_desktop_processor.street_view_analysis.road_graph_prepare import load_route_aligned_graph_wgs84 + +from .gpx_export import build_gpx, write_gpx +from .postman import chinese_postman_polyline +from .result import EulerRoutesResult + +log = logging.getLogger(__name__) + + +def generate_clean_routes( + city_name: str = "Katowice, Poland", + grid_size: tuple[int, int] = (3, 3), + output_dir: str | Path = "routes_chinese_postman_gpx", + *, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, + region_geojson_path: str | Path | None = None, + road_geojson_path: str | Path | None = None, +) -> EulerRoutesResult: + """ + Split the study area into a lat/lon grid, run Chinese postman on each sector, emit GPX files. + + :param city_name: Default place when no region or road file is passed. + :param grid_size: ``(columns, rows)`` of bounding-box sectors. + :param output_dir: Directory for ``trasa_*.gpx`` outputs. + :param consolidate_tolerance_m: Same as :func:`load_route_aligned_graph_wgs84`. + :param use_cache: OSMnx HTTP cache. + :param region_geojson_path: Optional polygon AOI instead of ``city_name``. + :param road_geojson_path: Optional local roads instead of downloading. + :return: Resolved paths and polylines for all successful sectors. + + Example:: + In: generate_clean_routes(city_name="Katowice, Poland", grid_size=(2, 2)).gpx_paths + Out: tuple of saved GPX paths, e.g. (Path(".../trasa_1.gpx"), Path(".../trasa_2.gpx"), ...) + """ + output_directory = Path(output_dir) + output_directory.mkdir(parents=True, exist_ok=True) + + geographic_graph, city_gdf = load_route_aligned_graph_wgs84( + city_name=city_name, + region_geojson_path=region_geojson_path, + road_geojson_path=road_geojson_path, + consolidate_tolerance_m=consolidate_tolerance_m, + use_cache=use_cache, + ) + + # OSMnx truncate_* expects a MultiDiGraph (successors/predecessors); load_route_aligned_graph_wgs84 returns MultiGraph. + directed_geographic_graph = nx.MultiDiGraph(geographic_graph) + directed_geographic_graph.graph.update(geographic_graph.graph) + + w_bound, s_bound, e_bound, n_bound = city_gdf.total_bounds + column_count, row_count = grid_size[0], grid_size[1] + # Grid lines in WGS84; each cell becomes one sector + lon_grid_boundaries = np.linspace(w_bound, e_bound, column_count + 1) + lat_grid_boundaries = np.linspace(s_bound, n_bound, row_count + 1) + + sectors: list[dict[str, float]] = [] + for lat_index in range(len(lat_grid_boundaries) - 1): + for lon_index in range(len(lon_grid_boundaries) - 1): + sectors.append( + { + "s": float(lat_grid_boundaries[lat_index]), + "n": float(lat_grid_boundaries[lat_index + 1]), + "w": float(lon_grid_boundaries[lon_index]), + "e": float(lon_grid_boundaries[lon_index + 1]), + } + ) + + gpx_paths: list[Path] = [] + polylines: list[tuple[tuple[float, float], ...]] = [] + + for sector_index, sector_bounds in enumerate(sectors): + tile_number = sector_index + 1 + log.info("Sector %s / %s", tile_number, len(sectors)) + + try: + # OSMnx bbox order: north, south, east, west + bounding_box = (sector_bounds["n"], sector_bounds["s"], sector_bounds["e"], sector_bounds["w"]) + sector_graph = truncate_graph_bbox(directed_geographic_graph, bbox=bounding_box, truncate_by_edge=True) + + if sector_graph is None or len(sector_graph.nodes) < 2: + log.warning("Sector %s: empty graph, skipping.", tile_number) + continue + + undirected_sector_graph = ox.get_undirected(cast(nx.MultiDiGraph, sector_graph)) + connected_components = list(nx.connected_components(undirected_sector_graph)) + connected_components.sort(key=len, reverse=True) # Larger components first (typical main roads) + + sector_polylines: list[list[tuple[float, float]]] = [] + + for component_index, component_nodes in enumerate(connected_components, start=1): + component_subgraph = undirected_sector_graph.subgraph(component_nodes).copy() + if component_subgraph.number_of_edges() < 1: + continue + try: + route_polyline = chinese_postman_polyline(cast(nx.MultiGraph, component_subgraph)) + except Exception as component_error: + log.exception( + "Sector %s subgraph %s / %s: Chinese postman failed: %s", + tile_number, + component_index, + len(connected_components), + component_error, + ) + continue + if len(route_polyline) < 2: + continue + sector_polylines.append(route_polyline) + + if not sector_polylines: + log.warning("Sector %s: no routable subgraphs, skipping file.", tile_number) + continue + + # Inform when one tile produced multiple disjoint walks + # Count only components that actually contain edges (ignore isolated-node components). + component_count_with_edges = sum( + 1 + for component_nodes in connected_components + if undirected_sector_graph.subgraph(component_nodes).number_of_edges() >= 1 + ) + if component_count_with_edges > 1: + log.info( + "Sector %s: %s route(s) covering %s disconnected subgraph(s) with edges.", + tile_number, + len(sector_polylines), + component_count_with_edges, + ) + + gpx_document = build_gpx(sector_polylines, sector_name=f"Sector {tile_number}") + gpx_file_path = output_directory / f"route_{tile_number}.gpx" + write_gpx(gpx_file_path, gpx_document) + log.info("Wrote %s", gpx_file_path) + + gpx_paths.append(gpx_file_path) + polylines.extend(tuple(polyline) for polyline in sector_polylines) + + except ValueError as error: + # Empty bbox / polygon (e.g. sector outside drivable network after simplify) + if "no graph nodes" in str(error).lower(): + log.warning("Sector %s: no graph nodes in sector bounds, skipping.", tile_number) + continue + raise + except Exception as error: + log.exception("Error in sector %s: %s", tile_number, error) + + return EulerRoutesResult( + output_dir=output_directory.resolve(), + gpx_paths=tuple(gpx_paths), + polylines_wgs84=tuple(polylines), + ) + + +__all__ = ["generate_clean_routes"] diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/gpx_export.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/gpx_export.py new file mode 100644 index 0000000..4212043 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/gpx_export.py @@ -0,0 +1,52 @@ +""" +Build GPX documents from WGS84 polylines and write them to disk. +""" + +from pathlib import Path + +import gpxpy.gpx + + +def build_gpx(polylines_wgs84: list[list[tuple[float, float]]], *, sector_name: str) -> gpxpy.gpx.GPX: + """ + Create one GPX with one track per polyline; split tracks are named with a part suffix. + + :param polylines_wgs84: Each inner list is ``(longitude, latitude)`` vertices. + :param sector_name: Base name for track metadata. + :return: In-memory GPX object. + + Example:: + In: build_gpx([[(19.94, 50.06), (19.95, 50.06)]], sector_name="Sector 1") + Out: GPX with 1 track, 1 segment, 2 points, track name "Sector 1" + """ + gpx = gpxpy.gpx.GPX() + for polyline_coordinates in polylines_wgs84: + # One track + one segment per closed/open walk + track = gpxpy.gpx.GPXTrack() + gpx.tracks.append(track) + track_segment = gpxpy.gpx.GPXTrackSegment() + track.segments.append(track_segment) + for longitude, latitude in polyline_coordinates: + track_segment.points.append(gpxpy.gpx.GPXTrackPoint(latitude=latitude, longitude=longitude)) + + # Multiple subgraphs -> numbered track names + for track_index, track in enumerate(gpx.tracks): + if len(gpx.tracks) == 1: + track.name = sector_name + else: + track.name = f"{sector_name} part {track_index + 1}" + return gpx + + +def write_gpx(output_path: Path, gpx_document: gpxpy.gpx.GPX) -> None: + """ + Serialize a GPX object to UTF-8 text. + + :param output_path: Destination ``.gpx`` path. + :param gpx_document: Object from :func:`build_gpx`. + + Example:: + In: write_gpx(Path("routes/trasa_1.gpx"), gpx) + Out: "routes/trasa_1.gpx" created on disk (UTF-8 GPX XML) + """ + output_path.write_text(gpx_document.to_xml(), encoding="utf-8") diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/polyline.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/polyline.py new file mode 100644 index 0000000..dbf6b04 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/polyline.py @@ -0,0 +1,57 @@ +""" +Order edge geometries along an Eulerian circuit into a single WGS84 polyline. +""" + +from typing import Any + +import networkx as nx + + +def polyline_from_eulerian_circuit( + eulerian_graph: nx.MultiGraph, + circuit: list[tuple[Any, Any, Any]], +) -> list[tuple[float, float]]: + """ + Walk the circuit and concatenate edge ``geometry`` coords (or node x/y) in order. + + Coordinates are ``(longitude, latitude)``. Duplicate consecutive vertices are skipped. + + :param eulerian_graph: Graph whose edges may carry Shapely line geometries (OSMnx-style). + :param circuit: Eulerian circuit from ``nx.eulerian_circuit(..., keys=True)``. + :return: Ordered vertex list along the closed walk. + + Example:: + In: polyline_from_eulerian_circuit(eulerian_graph, circuit) + Out: [(19.94, 50.06), (19.95, 50.06), ...] + """ + ordered_polyline: list[tuple[float, float]] = [] + for start_node, end_node, edge_key in circuit: + edge_data = eulerian_graph.get_edge_data(start_node, end_node, edge_key) + start_node_data = eulerian_graph.nodes[start_node] + + if edge_data is not None and "geometry" in edge_data: + coords = list(edge_data["geometry"].coords) + # Orient LineString to start near circuit start_node + start_distance_squared = (coords[0][0] - start_node_data["x"]) ** 2 + ( + coords[0][1] - start_node_data["y"] + ) ** 2 + end_distance_squared = (coords[-1][0] - start_node_data["x"]) ** 2 + ( + coords[-1][1] - start_node_data["y"] + ) ** 2 + if end_distance_squared < start_distance_squared: + coords = coords[::-1] + for lon, lat in coords: + # Append only when this vertex differs from the previous one. + # This avoids duplicate consecutive points at segment joins. + if not ordered_polyline or (ordered_polyline[-1][1] != lat or ordered_polyline[-1][0] != lon): + ordered_polyline.append((float(lon), float(lat))) + else: + # Straight edge: use node coordinates only + for node_id in (start_node, end_node): + node_data = eulerian_graph.nodes[node_id] + node_lon = float(node_data["x"]) + node_lat = float(node_data["y"]) + # Same dedup rule for fallback node-based coordinates. + if not ordered_polyline or (ordered_polyline[-1][1] != node_lat or ordered_polyline[-1][0] != node_lon): + ordered_polyline.append((node_lon, node_lat)) + return ordered_polyline diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/postman.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/postman.py new file mode 100644 index 0000000..59ec343 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/postman.py @@ -0,0 +1,53 @@ +""" +Chinese postman: duplicate edges to balance odd degrees, then extract an Euler tour polyline. +""" + +from typing import Any + +import networkx as nx + +from .polyline import polyline_from_eulerian_circuit + + +def chinese_postman_polyline(undirected_graph: nx.MultiGraph) -> list[tuple[float, float]]: + """ + Minimum-length closed walk covering every edge at least once (undirected, connected, with edges). + + Odd-degree nodes are paired by min-weight matching on shortest-path distances; duplicated + edges are added, then an Euler circuit is converted to a ``(lon, lat)`` polyline. + + :param undirected_graph: Road subgraph; edge attribute ``length`` weights paths and matching. + :return: Closed polyline in WGS84 node order. + + Example:: + In: chinese_postman_polyline(undirected_graph) + Out: [(19.94, 50.06), (19.95, 50.06), ...] + """ + odd_degree_nodes = [node_id for node_id, degree in undirected_graph.degree() if degree % 2 == 1] + odd_node_matching: Any = [] + if odd_degree_nodes: + # Pair odd vertices by shortest-path length; min matching yields min duplicate mileage + odd_complete_graph = nx.Graph() + for odd_node_index, source_node in enumerate(odd_degree_nodes): + distance_by_node = nx.single_source_dijkstra_path_length(undirected_graph, source_node, weight="length") + for target_node in odd_degree_nodes[odd_node_index + 1 :]: + if target_node in distance_by_node: + odd_complete_graph.add_edge(source_node, target_node, weight=distance_by_node[target_node]) + odd_node_matching = nx.algorithms.matching.min_weight_matching(odd_complete_graph, weight="weight") + + # Start from original edges; duplicate along shortest paths to make all degrees even + eulerian_graph = nx.MultiGraph(undirected_graph) + for source_node, target_node in odd_node_matching: + shortest_path_nodes = nx.shortest_path(undirected_graph, source_node, target_node, weight="length") + for path_index in range(len(shortest_path_nodes) - 1): + path_start_node, path_end_node = shortest_path_nodes[path_index], shortest_path_nodes[path_index + 1] + edge_data = undirected_graph.get_edge_data(path_start_node, path_end_node) + # MultiGraph: pick first parallel edge's attrs when duplicating + edge_attributes = edge_data[0] if isinstance(edge_data, dict) and 0 in edge_data else edge_data + if isinstance(edge_attributes, dict): + eulerian_graph.add_edge(path_start_node, path_end_node, **edge_attributes) + else: + eulerian_graph.add_edge(path_start_node, path_end_node) + + eulerian_circuit_edges = list(nx.eulerian_circuit(eulerian_graph, keys=True)) + return polyline_from_eulerian_circuit(eulerian_graph, eulerian_circuit_edges) diff --git a/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/result.py b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/result.py new file mode 100644 index 0000000..6173367 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/chinese_postman_routes/result.py @@ -0,0 +1,19 @@ +""" +Container for GPX paths and polylines returned by grid-sector route generation. +""" + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class EulerRoutesResult: + """ + Output directory, list of GPX paths, and WGS84 polylines (one sequence per route). + + ``polylines_wgs84`` has multiple entries when a sector contained several disconnected subgraphs. + """ + + output_dir: Path + gpx_paths: tuple[Path, ...] = field(default_factory=tuple) + polylines_wgs84: tuple[tuple[tuple[float, float], ...], ...] = field(default_factory=tuple) diff --git a/src/uq_desktop_processor/street_view_analysis/images_downloader/__init__.py b/src/uq_desktop_processor/street_view_analysis/images_downloader/__init__.py new file mode 100644 index 0000000..c580e3a --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/images_downloader/__init__.py @@ -0,0 +1,19 @@ +""" +Mapillary image download, API helpers, and panorama processing. +""" + +from .api import query_mapillary_image +from .downloader import download_mapillary_images +from .helpers import lat_lon_from_geometry, lat_lon_from_mapillary_record, mapillary_image_filename +from .image_processing import download_image, process_panorama, save_jpeg_with_exif + +__all__ = [ + "download_image", + "download_mapillary_images", + "lat_lon_from_geometry", + "lat_lon_from_mapillary_record", + "mapillary_image_filename", + "process_panorama", + "query_mapillary_image", + "save_jpeg_with_exif", +] diff --git a/src/uq_desktop_processor/street_view_analysis/images_downloader/api.py b/src/uq_desktop_processor/street_view_analysis/images_downloader/api.py new file mode 100644 index 0000000..4ba6122 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/images_downloader/api.py @@ -0,0 +1,185 @@ +""" +Mapillary Graph API client: image search by bbox and metadata retrieval. +""" + +import logging + +import numpy as np +import requests +from requests.exceptions import ConnectionError, HTTPError, Timeout + +log = logging.getLogger(__name__) + +MAPILLARY_API_URL = "https://graph.mapillary.com/images" + + +def _calculate_bounding_box(lat: float, lon: float, radius_m: float) -> tuple[float, float, float, float]: + """ + Build a geographic bounding box from a center point and radius in meters. + + :param lat: Latitude of the center. + :param lon: Longitude of the center. + :param radius_m: Half-edge size in meters (approximate). + :return: ``(min_lon, min_lat, max_lon, max_lat)``. + + Example:: + In: _calculate_bounding_box(50.05, 19.94, 100) + Out: tuple (min_lon, min_lat, max_lon, max_lat) around the input point + """ + # ~111.32 km per degree latitude; longitude spacing scales with cos(lat) + delta_lat = radius_m / 111320 + delta_lon = delta_lat / np.cos(np.radians(lat)) + return lon - delta_lon, lat - delta_lat, lon + delta_lon, lat + delta_lat + + +def _build_mapillary_query_params(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict: + """ + Build query parameters for a single-image Mapillary ``/images`` request. + + :param min_lon: Bounding box minimum longitude. + :param min_lat: Bounding box minimum latitude. + :param max_lon: Bounding box maximum longitude. + :param max_lat: Bounding box maximum latitude. + :return: Query parameter mapping for ``requests.get``. + + Example:: + In: _build_mapillary_query_params(19.9, 50.0, 20.0, 50.1)["limit"] + Out: 1 + """ + return { + "fields": "id,thumb_1024_url,is_pano,captured_at,geometry,computed_geometry", + "bbox": f"{min_lon},{min_lat},{max_lon},{max_lat}", + "limit": 1, + } + + +def _is_radius_timeout_error(response: requests.Response) -> bool: + """ + Detect Mapillary error 3404014 (bbox / radius too large for the request). + + :param response: HTTP response with error JSON body. + :return: True if the error subcode indicates radius timeout. + """ + try: + error_info = response.json().get("error") + # Mapillary: bbox too large / request timed out + return error_info and error_info.get("error_subcode") == 3404014 + + except ValueError: + return False + + +def _fetch_mapillary_data(params: dict, headers: dict, timeout: float) -> dict: + """ + GET the Mapillary images endpoint and return parsed JSON. + + :param params: Query string parameters. + :param headers: Request headers (e.g. OAuth). + :param timeout: Socket read timeout in seconds. + :return: Parsed JSON body. + :raises HTTPError: If the status code indicates failure. + """ + log.debug("Requesting Mapillary API with params: %s", params) + response = requests.get(MAPILLARY_API_URL, params=params, headers=headers, timeout=timeout) + response.raise_for_status() + return response.json() + + +def _get_single_image_from_response(data: dict) -> dict | None: + """ + Return the first image record from a Mapillary list response. + + :param data: Parsed JSON from ``/images``. + :return: One image dict, or None if the list is empty. + + Example:: + In: _get_single_image_from_response({"data": [{"id": "abc"}]}) + Out: {"id": "abc"} + """ + images = data.get("data", []) + if not images: + log.debug("No images found in API response.") + return images[0] if images else None + + +def query_mapillary_image( + lat: float, lon: float, token: str, radius: float, timeout: float, retries: int = 2 +) -> dict | None: + """ + Find one image near ``(lat, lon)``, shrinking the radius or retrying on certain errors. + + :param lat: Search latitude. + :param lon: Search longitude. + :param token: OAuth access token. + :param radius: Initial search radius in meters. + :param timeout: HTTP timeout in seconds. + :param retries: Extra attempts after the first request (radius shrink and HTTP retries). + :return: Image metadata dict, or None if no image or unrecoverable error. + + Example:: + In: query_mapillary_image(50.06, 19.94, token="...", radius=150, timeout=10.0) + Out: {"id": "...", ...} | None + """ + headers = {"Authorization": f"OAuth {token}"} + + # First attempt + retries (radius shrink, 5xx, or network) + for attempt_index in range(retries + 1): + min_lon, min_lat, max_lon, max_lat = _calculate_bounding_box(lat, lon, radius) + params = _build_mapillary_query_params(min_lon, min_lat, max_lon, max_lat) + + try: + data = _fetch_mapillary_data(params, headers, timeout) + result = _get_single_image_from_response(data) + if result: + log.debug( + "Found image for (%s, %s) at attempt %s.", + lat, + lon, + attempt_index + 1, + ) + return result + + except HTTPError as http_error: + # Shrink search area and retry + if http_error.response.status_code == 400 and _is_radius_timeout_error(http_error.response): + old_radius = radius + radius = max(50, radius // 2) + log.warning( + "Radius %sm too large for (%s, %s). Reducing to %sm and retrying.", + old_radius, + lat, + lon, + radius, + ) + continue + + # Transient server errors: retry same bbox + if 500 <= http_error.response.status_code < 600: + log.warning( + "Mapillary server error %s for (%s, %s). Retrying...", + http_error.response.status_code, + lat, + lon, + ) + continue + + # Auth, bad request, etc.: do not loop forever + log.error("HTTP error querying Mapillary for (%s, %s): %s", lat, lon, http_error) + break + + except (Timeout, ConnectionError) as network_error: + log.warning( + "Network error querying Mapillary for (%s, %s): %s. Retrying...", + lat, + lon, + network_error, + ) + continue + + log.info( + "Failed to retrieve image for (%s, %s) after %s attempts.", + lat, + lon, + retries + 1, + ) + return None diff --git a/src/uq_desktop_processor/street_view_analysis/images_downloader/downloader.py b/src/uq_desktop_processor/street_view_analysis/images_downloader/downloader.py new file mode 100644 index 0000000..1b7a7b9 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/images_downloader/downloader.py @@ -0,0 +1,218 @@ +""" +Downloads street-view images for sampling points with concurrency and retries. +""" + +import logging +import os +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +from .api import query_mapillary_image +from .helpers import lat_lon_from_mapillary_record, mapillary_image_filename +from .image_processing import download_image, process_panorama, save_jpeg_with_exif + +log = logging.getLogger(__name__) + + +def _handle_single_point( + lat_lon: tuple[float, float], + output_folder: str, + mapillary_token: str, + search_radius_m: float, + fov_deg: float, + output_hw: tuple[int, int], + api_timeout_s: float, + retries: int = 3, +) -> str: + """ + Fetch and save a Mapillary image for one point, with retries on failure. + + :param lat_lon: ``(latitude, longitude)`` of the target location. + :param output_folder: Directory for the saved JPEG. + :param mapillary_token: OAuth token for the Mapillary API. + :param search_radius_m: Search radius in meters around the point. + :param fov_deg: Horizontal field of view in degrees for panorama cropping. + :param output_hw: Output size as ``(height, width)``. + :param api_timeout_s: Timeout for API and HTTP download requests. + :param retries: Attempt count including the first try. + :return: Status message (success or failure reason). + + Example:: + In: _handle_single_point((50.06, 19.94), "data/images", token, 150, 90, (512, 512), 10.0) + Out: status string, e.g. "Success (50.06000, 19.94000): .jpg" or "(50.06000, 19.94000) - no image found" + """ + latitude, longitude = lat_lon + for attempt_index in range(retries): + try: + # One full fetch+save path; outer loop handles retries + return _try_fetch_and_save_image( + latitude, longitude, output_folder, mapillary_token, search_radius_m, fov_deg, output_hw, api_timeout_s + ) + except Exception as error: + if attempt_index < retries - 1: + log.warning( + "(%.5f, %.5f) Attempt %s failed: %s. Retrying...", + latitude, + longitude, + attempt_index + 1, + error, + ) + time.sleep(1) + else: + return f"({latitude:.5f}, {longitude:.5f}) Critical error: {error}" + + # e.g. retries == 0 + return f"({latitude:.5f}, {longitude:.5f}) - failed after {retries} attempts" + + +def _try_fetch_and_save_image( + lat: float, + lon: float, + output_folder: str, + mapillary_token: str, + search_radius_m: float, + fov_deg: float, + output_hw: tuple[int, int], + api_timeout_s: float, +) -> str: + """ + Query Mapillary, download the image, optionally unwrap panorama, write JPEG with EXIF. + + :param lat: Latitude of the target point. + :param lon: Longitude of the target point. + :param output_folder: Directory for the output file. + :param mapillary_token: OAuth token for the Mapillary API. + :param search_radius_m: Search radius in meters. + :param fov_deg: Field of view in degrees for panorama cropping. + :param output_hw: Output resolution ``(height, width)``. + :param api_timeout_s: Timeout for API and download. + :return: Status message indicating success or failure. + + Example:: + In: _try_fetch_and_save_image(50.06, 19.94, "data/images", token, 150, 90, (512, 512), 10.0) + Out: status string, e.g. "Success (...)" or "(...) - missing image URL in API response" + """ + # Nearest image metadata in bbox (or None) + data = query_mapillary_image(lat, lon, mapillary_token, search_radius_m, api_timeout_s) + if not data: + return f"({lat:.5f}, {lon:.5f}) - no image found" + + image_url = data.get("thumb_1024_url") + if not image_url: + return f"({lat:.5f}, {lon:.5f}) - missing image URL in API response" + + image_array = download_image(image_url, api_timeout_s) + if image_array is None: + return f"({lat:.5f}, {lon:.5f}) - download failed" + + # Panoramas: equirectangular -> perspective crop + if data.get("is_pano", False): + image_array = process_panorama(image_array, fov_deg, output_hw) + + image_id = data.get("id") + if not image_id: + return f"({lat:.5f}, {lon:.5f}) - missing image id in API response" + + image_coordinates = lat_lon_from_mapillary_record(data) + # Fall back to query point if the record has no usable geometry + image_latitude, image_longitude = image_coordinates if image_coordinates is not None else (lat, lon) + + filename = mapillary_image_filename(str(image_id)) + path = os.path.join(output_folder, filename) + captured_at = data.get("captured_at") + save_jpeg_with_exif( + image_array, + path, + lat=image_latitude, + lon=image_longitude, + captured_at_ms=captured_at, + ) + + return f"Success ({lat:.5f}, {lon:.5f}): {filename}" + + +def download_mapillary_images( + points: list[tuple[float, float]], + output_folder: str, + mapillary_token: str, + *, + search_radius_m: float = 150, + fov_deg: float = 90, + output_hw: tuple[int, int] = (512, 512), + max_workers: int = 20, + api_timeout_s: float = 10.0, +) -> dict[str, Any]: + """ + Download Mapillary images for many points using a thread pool. + + :param points: ``(latitude, longitude)`` tuples to query. + :param output_folder: Folder for saved JPEGs (created if missing). + :param mapillary_token: OAuth token for the Mapillary API. + :param search_radius_m: Maximum search radius in meters. + :param fov_deg: Field of view for panoramic crops. + :param output_hw: Output image ``(height, width)``. + :param max_workers: Parallel worker threads. + :param api_timeout_s: Per-request timeout in seconds. + :return: Summary with counts, timing, failure messages, and resolved output path. + + Example:: + In: download_mapillary_images([(50.06, 19.94)], "data/images", token)["downloaded"] + Out: summary dict, e.g. {"point_count": 1, "downloaded": 1, "failed_messages": [], ...} + """ + os.makedirs(output_folder, exist_ok=True) + out_resolved = str(Path(output_folder).resolve()) + + total_point_count = len(points) + log.info( + "Start downloading images for %s points using %s threads...", + total_point_count, + max_workers, + ) + + start_time = time.time() + status_messages: list[str] = [] + + # I/O-bound: threads overlap API + download latency + with ThreadPoolExecutor(max_workers=max_workers) as pool: + for status_message in pool.map( + lambda point_coordinates: _handle_single_point( + point_coordinates, output_folder, mapillary_token, search_radius_m, fov_deg, output_hw, api_timeout_s + ), + points, + ): + status_messages.append(status_message) + # Quiet expected outcomes; surface real failures + if status_message.startswith("Success") or "no image found" in status_message: + log.debug(status_message) + else: + log.warning(status_message) + + elapsed_seconds = time.time() - start_time + # Success is encoded in the message prefix from _handle_single_point. + downloaded_image_count = sum(1 for status_message in status_messages if status_message.startswith("Success")) + # Keep all non-success messages for caller inspection/reporting. + failure_messages = [ + status_message for status_message in status_messages if not status_message.startswith("Success") + ] + if total_point_count > 0: + log.info( + "Download complete in %.1f s (avg %.2f s/point); saved %s / %s images.", + elapsed_seconds, + elapsed_seconds / total_point_count, + downloaded_image_count, + total_point_count, + ) + else: + log.info("Download complete: no points to process.") + + images_per_second = (downloaded_image_count / elapsed_seconds) if elapsed_seconds > 0 else 0.0 + return { + "point_count": total_point_count, + "downloaded": downloaded_image_count, + "failed_messages": failure_messages, + "elapsed_s": elapsed_seconds, + "output_folder": out_resolved, + "images_per_second": images_per_second, + } diff --git a/src/uq_desktop_processor/street_view_analysis/images_downloader/helpers.py b/src/uq_desktop_processor/street_view_analysis/images_downloader/helpers.py new file mode 100644 index 0000000..6497efc --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/images_downloader/helpers.py @@ -0,0 +1,69 @@ +""" +Shared helpers for paths, HTTP sessions, and image naming in the downloader. +""" + +import re + + +def mapillary_image_filename(image_id: str) -> str: + """ + Build a filesystem-safe JPEG filename from a Mapillary image id. + + :param image_id: Mapillary image identifier (as returned by the Graph API). + :return: Filename such as ``{id}.jpg``. + + Example:: + In: mapillary_image_filename("123/abc") + Out: "123_abc.jpg" + """ + # Strip unsafe path characters for cross-platform filenames + sanitized_image_id = re.sub(r"[^\w.\-]", "_", str(image_id).strip()) + if not sanitized_image_id: + raise ValueError("image_id must be non-empty after sanitization") + return f"{sanitized_image_id}.jpg" + + +def lat_lon_from_mapillary_record(data: dict) -> tuple[float, float] | None: + """ + Read image coordinates from a Mapillary image record. + + Prefers ``computed_geometry`` over raw ``geometry``. + + :param data: Image object from the Mapillary API. + :return: ``(latitude, longitude)`` or None if no valid Point geometry. + + Example:: + In: lat_lon_from_mapillary_record({"geometry": {"type": "Point", "coordinates": [19.94, 50.06]}}) + Out: (50.06, 19.94) + """ + for key in ("computed_geometry", "geometry"): + latitude_longitude = lat_lon_from_geometry(data.get(key)) + if latitude_longitude is not None: + return latitude_longitude + return None + + +def lat_lon_from_geometry(geometry: dict | None) -> tuple[float, float] | None: + """ + Parse GeoJSON Point coordinates from a Mapillary ``geometry`` field. + + Coordinates are ``[longitude, latitude]`` per GeoJSON. + + :param geometry: The ``geometry`` object from a Mapillary image record, or None. + :return: ``(latitude, longitude)`` or None if missing or invalid. + + Example:: + In: lat_lon_from_geometry({"type": "Point", "coordinates": [19.94, 50.06]}) + Out: (50.06, 19.94) + """ + if not geometry or geometry.get("type") != "Point": + return None + coords = geometry.get("coordinates") + if not isinstance(coords, list | tuple) or len(coords) < 2: + return None + try: + lon, lat = float(coords[0]), float(coords[1]) + except (TypeError, ValueError): + return None + # API is GeoJSON order; callers use (lat, lon) + return lat, lon diff --git a/src/uq_desktop_processor/street_view_analysis/images_downloader/image_processing.py b/src/uq_desktop_processor/street_view_analysis/images_downloader/image_processing.py new file mode 100644 index 0000000..efed2d9 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/images_downloader/image_processing.py @@ -0,0 +1,164 @@ +""" +Resize, EXIF, and panorama-to-perspective conversion for downloaded images. +""" + +import logging +from datetime import UTC, datetime + +import cv2 +import numpy as np +import piexif +import py360convert +import requests +from PIL import Image + +log = logging.getLogger(__name__) + + +def _decimal_deg_to_exif_rationals(decimal_deg: float) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]: + """ + Convert signed decimal degrees to EXIF GPS DMS rationals (absolute value; Ref gives hemisphere). + + :param decimal_deg: Latitude or longitude in degrees. + :return: Three rationals ``(degrees, minutes, seconds)`` for piexif. + + Example:: + In: _decimal_deg_to_exif_rationals(50.5) + Out: ((50, 1), (30, 1), (0, 1000000)) + """ + absolute = abs(decimal_deg) + deg = int(absolute) + minutes_float = (absolute - deg) * 60.0 + minutes = int(minutes_float) + seconds = (minutes_float - minutes) * 60.0 + sec_num = int(round(seconds * 1_000_000)) + # Keep sub-second rational within one minute + if sec_num >= 60 * 1_000_000: + sec_num = 59_999_999 + return (deg, 1), (minutes, 1), (sec_num, 1_000_000) + + +def save_jpeg_with_exif( + bgr: np.ndarray, + path: str, + *, + lat: float, + lon: float, + captured_at_ms: int | float | None = None, + jpeg_quality: int = 95, +) -> None: + """ + Save a BGR image as JPEG with GPS and optional capture time in EXIF. + + :param bgr: Image in OpenCV BGR layout. + :param path: Output ``.jpg`` path. + :param lat: Latitude in WGS84 (degrees). + :param lon: Longitude in WGS84 (degrees). + :param captured_at_ms: Unix time in milliseconds (e.g. Mapillary ``captured_at``), or None. + :param jpeg_quality: JPEG quality 1-100. + + Example:: + In: save_jpeg_with_exif(img, "out.jpg", lat=50.06, lon=19.94, captured_at_ms=1710000000000) + Out: "out.jpg" saved with GPS EXIF and capture timestamp metadata + """ + rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(rgb) + + # piexif expects these IFD buckets even when mostly empty + exif_dict: dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} + + if captured_at_ms is not None: + try: + ts = float(captured_at_ms) / 1000.0 + dt = datetime.fromtimestamp(ts, tz=UTC) + dt_str = dt.strftime("%Y:%m:%d %H:%M:%S") + except (OSError, OverflowError, TypeError, ValueError): + dt_str = None + if dt_str: + exif_dict["0th"][piexif.ImageIFD.DateTime] = dt_str + exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = dt_str + exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = dt_str + + # GPS refs encode hemisphere; rationals use absolute values + lat_ref = b"N" if lat >= 0 else b"S" + lon_ref = b"E" if lon >= 0 else b"W" + exif_dict["GPS"][piexif.GPSIFD.GPSVersionID] = (2, 0, 0, 0) + exif_dict["GPS"][piexif.GPSIFD.GPSLatitudeRef] = lat_ref + exif_dict["GPS"][piexif.GPSIFD.GPSLatitude] = _decimal_deg_to_exif_rationals(lat) + exif_dict["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lon_ref + exif_dict["GPS"][piexif.GPSIFD.GPSLongitude] = _decimal_deg_to_exif_rationals(lon) + + exif_bytes = piexif.dump(exif_dict) + pil_image.save(path, format="JPEG", quality=jpeg_quality, exif=exif_bytes) + + +def download_image(url: str, timeout: float) -> np.ndarray | None: + """ + Download an image URL and decode it to a BGR ``ndarray``. + + :param url: Image URL. + :param timeout: HTTP timeout in seconds. + :return: BGR image, or None on network/decode failure. + + Example:: + In: download_image("https://example.com/photo.jpg", timeout=10.0) + Out: decoded BGR image array (H, W, 3) or None if download/decode fails + """ + try: + if log.isEnabledFor(logging.DEBUG): + log.debug("Downloading image from: %s", url) + + response = requests.get(url, stream=True, timeout=timeout) + response.raise_for_status() + + # OpenCV expects a 1-D uint8 buffer for imdecode + image_data = np.asarray(bytearray(response.content), dtype=np.uint8) + image = cv2.imdecode(image_data, cv2.IMREAD_COLOR) + + if image is None: + log.warning("OpenCV failed to decode image from: %s", url) + return None + + return image + + except (requests.RequestException, ValueError) as download_error: + log.warning("Download/decode error for %s: %s", url, download_error) + return None + + +def process_panorama(img: np.ndarray, fov_deg: float, out_hw: tuple[int, int]) -> np.ndarray: + """ + Convert a 360° equirectangular image to a perspective crop. + + Scalar ``fov_deg`` is expanded to ``(h_fov, v_fov)`` so py360convert uses a stable code path. + + :param img: Equirectangular panorama (BGR). + :param fov_deg: Field of view in degrees, or ``(h, v)`` if a pair is needed. + :param out_hw: Output ``(height, width)``. + :return: Perspective image as BGR ``ndarray``. + + Example:: + In: process_panorama(pano_bgr, fov_deg=90, out_hw=(512, 512)) + Out: perspective BGR image array with shape (512, 512, 3) + """ + if log.isEnabledFor(logging.DEBUG): + log.debug( + "Converting panorama (Input shape: %s, FOV: %s)", + img.shape, + fov_deg, + ) + + # Tuple FOV avoids a buggy scalar branch in some py360convert versions + fov_hv = ( + (float(fov_deg[0]), float(fov_deg[1])) + if isinstance(fov_deg, tuple | list) and len(fov_deg) >= 2 + else (float(fov_deg), float(fov_deg)) + ) + + return py360convert.e2p( + img, + fov_hv, + 0, + 0, + out_hw, + ) diff --git a/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/__init__.py b/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/__init__.py new file mode 100644 index 0000000..645a73e --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/__init__.py @@ -0,0 +1,11 @@ +""" +OSMnx road graph loading with highway filtering and metric edge extraction. +""" + +from .prepare_graph import ( + EXCLUDED_HIGHWAY_TYPES, + load_route_aligned_graph_wgs84, + route_aligned_edges_web_mercator, +) + +__all__ = ["EXCLUDED_HIGHWAY_TYPES", "load_route_aligned_graph_wgs84", "route_aligned_edges_web_mercator"] diff --git a/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/prepare_graph.py b/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/prepare_graph.py new file mode 100644 index 0000000..1958bd2 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_graph_prepare/prepare_graph.py @@ -0,0 +1,162 @@ +""" +Load a drive network from OSM or files, filter highways, consolidate intersections, project to WGS84. +""" + +import logging +from pathlib import Path +from typing import Any, cast + +import geopandas as gpd +import networkx as nx +import osmnx as ox + +log = logging.getLogger(__name__) + +EXCLUDED_HIGHWAY_TYPES = frozenset( + { + "motorway", + "motorway_link", + "trunk", + "trunk_link", + } +) + + +def _edge_has_excluded_highway(edge_data: dict[str, Any]) -> bool: + """ + Return True if an edge's ``highway`` tag is in :data:`EXCLUDED_HIGHWAY_TYPES`. + + :param edge_data: OSMnx edge attribute dict. + :return: Whether the edge should be dropped before routing. + + Example:: + In: _edge_has_excluded_highway({"highway": "motorway"}) + Out: True + """ + highway_value = edge_data.get("highway") + if highway_value is None: + return False + if isinstance(highway_value, str): + return highway_value in EXCLUDED_HIGHWAY_TYPES + if isinstance(highway_value, list | tuple): + return any( + isinstance(highway_entry, str) and highway_entry in EXCLUDED_HIGHWAY_TYPES + for highway_entry in highway_value + ) + return False + + +def load_route_aligned_graph_wgs84( + *, + city_name: str | None = None, + region_geojson_path: str | Path | None = None, + road_geojson_path: str | Path | None = None, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, +) -> tuple[nx.MultiGraph, gpd.GeoDataFrame]: + """ + Build an undirected multigraph in EPSG:4326 for routing and point sampling. + + Exactly one of ``city_name``, ``region_geojson_path``, or ``road_geojson_path`` must be set. + Freeway/trunk edges are removed, intersections are merged, then the graph is reprojected to WGS84. + + :param city_name: OSM place query. + :param region_geojson_path: Polygon file to download a network inside. + :param road_geojson_path: Existing road lines to turn into a graph. + :param consolidate_tolerance_m: Meters for ``ox.consolidate_intersections``. + :param use_cache: OSMnx HTTP cache toggle. + :return: ``(graph_wgs84, area_or_edges_gdf)`` for bounds or bookkeeping. + :raises ValueError: If not exactly one source is provided. + + Example:: + In: load_route_aligned_graph_wgs84(city_name="Katowice, Poland") + Out: (nx.MultiGraph with WGS84 node coords, GeoDataFrame describing area/edges bounds) + """ + ox.settings.use_cache = use_cache # type: ignore + + sources = [ + city_name is not None, + region_geojson_path is not None, + road_geojson_path is not None, + ] + if sum(sources) != 1: + msg = "Provide exactly one of: city_name, region_geojson_path, road_geojson_path." + raise ValueError(msg) + + # Branch 1: user-supplied line geometries -> graph + if road_geojson_path is not None: + road_file_path = Path(road_geojson_path) + log.info("Loading road network from file: %s", road_file_path) + road_edges_gdf = gpd.read_file(road_file_path) + if road_edges_gdf.crs is None: + road_edges_gdf = road_edges_gdf.set_crs("EPSG:4326") + + raw_graph = ox.graph_from_gdfs(gdf_nodes=cast(Any, None), gdf_edges=road_edges_gdf) + city_gdf = road_edges_gdf.to_crs("EPSG:4326") + # Branch 2: polygon AOI -> download inside + elif region_geojson_path is not None: + region_file_path = Path(region_geojson_path) + log.info("Loading region polygon from file: %s", region_file_path) + region_gdf = gpd.read_file(region_file_path) + region_polygon_union = region_gdf.unary_union + raw_graph = ox.graph_from_polygon(region_polygon_union, network_type="drive", simplify=True) + city_gdf = region_gdf.to_crs("EPSG:4326") + # Branch 3: named place -> download + else: + assert city_name is not None + log.info("Downloading and building graph for %s", city_name) + raw_graph = ox.graph_from_place(city_name, network_type="drive", simplify=True) + city_gdf = ox.geocode_to_gdf(city_name) + + # Drop motorways/trunk and any nodes left isolated + edges_to_remove = [ + (start_node, end_node, edge_key) + for start_node, end_node, edge_key, edge_attributes in raw_graph.edges(data=True, keys=True) # type: ignore + if _edge_has_excluded_highway(edge_attributes) + ] + raw_graph.remove_edges_from(edges_to_remove) + isolated_nodes = list(nx.isolates(raw_graph)) + raw_graph.remove_nodes_from(isolated_nodes) + log.info("Removed %s edges and %s isolated nodes.", len(edges_to_remove), len(isolated_nodes)) + + # Consolidation runs in projected (metric) CRS + projected_graph = ox.project_graph(raw_graph) + log.info("Consolidating intersections (tolerance=%s m)", consolidate_tolerance_m) + consolidated_graph = ox.consolidate_intersections( + projected_graph, + rebuild_graph=True, + tolerance=consolidate_tolerance_m, + dead_ends=False, + ) + + geographic_directed_graph = ox.project_graph(consolidated_graph, to_crs="EPSG:4326") + + # Undirected multigraph for downstream algorithms + geographic_undirected_graph = nx.MultiGraph(geographic_directed_graph) + geographic_undirected_graph.graph.update(geographic_directed_graph.graph) # type: ignore + + return geographic_undirected_graph, city_gdf + + +def route_aligned_edges_web_mercator(geographic_graph: nx.MultiGraph) -> gpd.GeoDataFrame: + """ + Export non-duplicate road edges as a Web Mercator GeoDataFrame for metric sampling. + + :param geographic_graph: Output of :func:`load_route_aligned_graph_wgs84`. + :return: Edge table with unique geometries in EPSG:3857. + + Example:: + In: route_aligned_edges_web_mercator(geographic_graph).crs.to_string() + Out: "EPSG:3857" + """ + edges = ox.graph_to_gdfs(cast(nx.MultiDiGraph, geographic_graph), nodes=False, edges=True) + edges = edges.to_crs(epsg=3857) + # Same physical street can appear as two directed rows + return edges.drop_duplicates(subset="geometry") + + +__all__ = [ + "EXCLUDED_HIGHWAY_TYPES", + "load_route_aligned_graph_wgs84", + "route_aligned_edges_web_mercator", +] diff --git a/src/uq_desktop_processor/street_view_analysis/road_points_generator/__init__.py b/src/uq_desktop_processor/street_view_analysis/road_points_generator/__init__.py new file mode 100644 index 0000000..244ad8b --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_points_generator/__init__.py @@ -0,0 +1,16 @@ +""" +Road-network sampling points: load graph, generate points, optional filtering. +""" + +from .filtering import filter_close_points +from .generator import generate_points_along_roads +from .io import download_road_network, read_road_geojson +from .run import build_points_pipeline + +__all__ = [ + "build_points_pipeline", + "download_road_network", + "filter_close_points", + "generate_points_along_roads", + "read_road_geojson", +] diff --git a/src/uq_desktop_processor/street_view_analysis/road_points_generator/filtering.py b/src/uq_desktop_processor/street_view_analysis/road_points_generator/filtering.py new file mode 100644 index 0000000..6d729f6 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_points_generator/filtering.py @@ -0,0 +1,133 @@ +""" +Filters and deduplicates candidate road sampling coordinates. +""" + +import logging + +import geopandas as gpd +import numpy as np +import numpy.typing as npt + +log = logging.getLogger(__name__) + + +def _compute_projected_coords( + points: list[tuple[float, float]], crs_from: str = "EPSG:4326", crs_to: str = "EPSG:3857" +) -> npt.NDArray[np.float64]: + """ + Project geographic ``(lat, lon)`` points to metric coordinates for distance checks. + + :param points: Non-empty list of ``(latitude, longitude)`` tuples. + :param crs_from: Source CRS (default WGS84). + :param crs_to: Target CRS in meters (default Web Mercator). + :return: Array of shape ``(n, 2)`` with ``(x, y)`` in meters. + :raises ValueError: If ``points`` is empty or invalid. + :raises RuntimeError: If GeoPandas projection fails. + + Example:: + In: _compute_projected_coords([(50.06, 19.94)]).shape + Out: (1, 2) + """ + if log.isEnabledFor(logging.DEBUG): + log.debug( + "Projecting %s points from %s to %s.", + len(points), + crs_from, + crs_to, + ) + + try: + latitudes, longitudes = zip(*points, strict=False) + except ValueError as error: + log.error("Input `points` list is invalid or empty.") + raise ValueError("Expected `points` to be a non-empty list of (lat, lon) tuples.") from error + + try: + # points_from_xy expects (x=lon, y=lat) in geographic CRS + projected_gdf = gpd.GeoDataFrame(geometry=gpd.points_from_xy(longitudes, latitudes), crs=crs_from).to_crs( + crs_to + ) + except Exception as projection_error: + log.error("Coordinate projection failed: %s", projection_error) + raise RuntimeError("Coordinate projection failed.") from projection_error + + x_coords = projected_gdf.geometry.x + y_coords = projected_gdf.geometry.y + return np.column_stack((x_coords, y_coords)) + + +def _find_sparse_indices(points: npt.NDArray[np.float64], min_distance_m: float) -> list[int]: + """ + Greedy index selection so kept points are at least ``min_distance_m`` apart (planar). + + Uses a grid hash to limit neighbor checks. + + :param points: Projected ``(x, y)`` coordinates in meters. + :param min_distance_m: Minimum separation between any two kept points. + :return: Indices into ``points`` of the kept samples (in traversal order). + + Example:: + In: _find_sparse_indices(np.array([[0.0, 0.0], [1.0, 1.0], [50.0, 50.0]]), 20) + Out: [0, 2] + """ + # Cell side ~ min_distance_m -> only compare within a 3x3 neighborhood + grid_coords = np.floor(points / min_distance_m).astype(int) + + occupied_cells: dict[tuple[int, int], int] = {} + kept_indices: list[int] = [] + + # Self + 8 neighbors in grid space + neighbor_offsets = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 0), (0, 1), (1, -1), (1, 0), (1, 1)] + + for point_index, (grid_x, grid_y) in enumerate(grid_coords): + too_close = False + for delta_x, delta_y in neighbor_offsets: + neighbor_cell = (grid_x + delta_x, grid_y + delta_y) + if neighbor_cell in occupied_cells: + neighbor_idx = occupied_cells[neighbor_cell] + distance = np.linalg.norm(points[point_index] - points[neighbor_idx]) + if distance < min_distance_m: + too_close = True + break + + if not too_close: + kept_indices.append(point_index) + occupied_cells[(grid_x, grid_y)] = point_index + + return kept_indices + + +def filter_close_points(points: list[tuple[float, float]], min_distance_m: float = 20) -> list[tuple[float, float]]: + """ + Drop points that lie closer than ``min_distance_m`` to an already kept point. + + :param points: ``(latitude, longitude)`` candidates. + :param min_distance_m: Minimum spacing in meters (Web Mercator plane). + :return: Subset of ``points`` in original order, thinned by the rule above. + + Example:: + In: filter_close_points([(50.06, 19.94), (50.06001, 19.94001)], min_distance_m=20) + Out: [(50.06, 19.94)] + """ + if not points: + log.debug("No points provided for filtering.") + return [] + + if min_distance_m <= 0: + msg = "`min_distance_m` must be a positive number." + log.error(msg) + raise ValueError(msg) + + # Planar distances in meters (Web Mercator) + coords = _compute_projected_coords(points) + kept_indices = _find_sparse_indices(coords, min_distance_m) + filtered_points = [points[kept_point_index] for kept_point_index in kept_indices] + + log.info( + "Point filtering complete: %s -> %s points kept (min_dist=%sm).", + len(points), + len(filtered_points), + min_distance_m, + ) + + return filtered_points diff --git a/src/uq_desktop_processor/street_view_analysis/road_points_generator/generator.py b/src/uq_desktop_processor/street_view_analysis/road_points_generator/generator.py new file mode 100644 index 0000000..ba603a8 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_points_generator/generator.py @@ -0,0 +1,102 @@ +""" +Generates evenly spaced sampling points along road network edges. +""" + +import logging + +import geopandas as gpd +from shapely.geometry import LineString, MultiLineString, Point +from shapely.geometry.base import BaseGeometry +from tqdm import tqdm + +from .filtering import filter_close_points + +log = logging.getLogger(__name__) + + +def _get_lines_from_geometry(geometry: BaseGeometry) -> list[LineString]: + """ + Normalize geometry to a list of ``LineString`` parts. + + :param geometry: ``LineString`` or ``MultiLineString``. + :return: Non-empty list of lines, or empty if the type is unsupported. + + Example:: + In: _get_lines_from_geometry(LineString([(0, 0), (1, 1)])) + Out: [LineString(...)] + """ + if isinstance(geometry, LineString): + return [geometry] + elif isinstance(geometry, MultiLineString): + return list(geometry.geoms) + return [] + + +def _generate_points_from_lines(lines: list[LineString], spacing: float) -> list[Point]: + """ + Place points every ``spacing`` meters along each line (including endpoints where applicable). + + :param lines: Road segments in a metric CRS. + :param spacing: Step length in meters. + :return: Shapely ``Point`` geometries along the lines. + + Example:: + In: _generate_points_from_lines([LineString([(0, 0), (100, 0)])], spacing=50) + Out: [Point(...), Point(...), Point(...)] + """ + if spacing <= 0: + msg = "Spacing must be a positive number greater than zero." + log.error(msg) + raise ValueError(msg) + + points = [] + for line in lines: + length = line.length + num_points = int(length // spacing) + # Include start and regular steps along the segment (metric CRS) + points.extend([line.interpolate(step_index * spacing) for step_index in range(num_points + 1)]) + return points + + +def generate_points_along_roads( + roads_gdf: gpd.GeoDataFrame, spacing: float = 100, min_distance_m: float = 20 +) -> list[tuple[float, float]]: + """ + Sample ``(latitude, longitude)`` along road line geometries, then thin by minimum distance. + + :param roads_gdf: Roads as ``LineString`` / ``MultiLineString``; reprojected to metric CRS if geographic. + :param spacing: Along-line spacing in meters. + :param min_distance_m: Post-process minimum separation between returned points. + :return: List of ``(lat, lon)`` in WGS84 after :func:`filter_close_points`. + + Example:: + In: generate_points_along_roads(roads_gdf, spacing=100, min_distance_m=20) + Out: [(50.06, 19.94), ...] + """ + log.info( + "Generating road points for %s geometry objects (spacing=%sm).", + len(roads_gdf), + spacing, + ) + + if roads_gdf.crs is None or roads_gdf.crs.is_geographic: + log.debug("Reprojecting input GeoDataFrame to EPSG:3857.") + roads_gdf = roads_gdf.to_crs(epsg=3857) + + generated_points = [] # Shapely Points in roads_gdf.crs (meters) + + for _, row in tqdm(roads_gdf.iterrows(), total=len(roads_gdf), desc="Generating points"): + lines = _get_lines_from_geometry(row.geometry) + points = _generate_points_from_lines(lines, spacing) + generated_points.extend(points) + + log.debug( + "Generated %s raw points. Converting to WGS84...", + len(generated_points), + ) + + geo_series = gpd.GeoSeries(generated_points, crs=roads_gdf.crs).to_crs(epsg=4326) + # Pipeline convention: (lat, lon) + latlon_points = [(point.y, point.x) for point in geo_series] + + return filter_close_points(latlon_points, min_distance_m) diff --git a/src/uq_desktop_processor/street_view_analysis/road_points_generator/io.py b/src/uq_desktop_processor/street_view_analysis/road_points_generator/io.py new file mode 100644 index 0000000..8fb5fa5 --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_points_generator/io.py @@ -0,0 +1,192 @@ +""" +Reads/writes sampling-point layers and loads route-aligned road networks. +""" + +import logging + +import geopandas as gpd +import requests +from fiona.errors import DriverError, FionaValueError +from pyogrio.errors import DataSourceError +from yaspin import yaspin + +from uq_desktop_processor.street_view_analysis.road_graph_prepare import ( + load_route_aligned_graph_wgs84, + route_aligned_edges_web_mercator, +) + +log = logging.getLogger(__name__) + + +def load_roads_route_aligned( + *, + place_name: str | None = None, + region_geojson_path: str | None = None, + road_geojson_path: str | None = None, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, +) -> gpd.GeoDataFrame: + """ + Road edges in EPSG:3857 from the same graph pipeline as Euler routes (drive, no trunk/motorway). + + :param place_name: OSM place query, if used alone. + :param region_geojson_path: Region polygon file, if used alone. + :param road_geojson_path: Local road lines file, if used alone. + :param consolidate_tolerance_m: ``ox.consolidate_intersections`` tolerance in meters. + :param use_cache: Whether OSMnx may use its HTTP cache. + :return: Edge GeoDataFrame in Web Mercator. + + Example:: + In: load_roads_route_aligned(place_name="Katowice, Poland").crs.to_string() + Out: "EPSG:3857" + """ + # Shared graph build with Euler / sampling; then edge geometries only + geographic_graph, _ = load_route_aligned_graph_wgs84( + city_name=place_name, + region_geojson_path=region_geojson_path, + road_geojson_path=road_geojson_path, + consolidate_tolerance_m=consolidate_tolerance_m, + use_cache=use_cache, + ) + return route_aligned_edges_web_mercator(geographic_graph) + + +def download_road_network( + place_name: str, + *, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, +) -> gpd.GeoDataFrame: + """ + Load route-aligned roads for a named place (same settings as GPX route generation). + + :param place_name: OSM geocodable place string. + :param consolidate_tolerance_m: Intersection merge tolerance in meters. + :param use_cache: OSMnx cache flag. + :return: Metric road edges. + :raises ValueError: If no roads are found for the place. + :raises ConnectionError: On Overpass/network failure. + :raises RuntimeError: On other load failures. + + Example:: + In: download_road_network("Katowice, Poland") + Out: GeoDataFrame with LineString/MultiLineString road edges in EPSG:3857 + """ + log.info("Initiating route-aligned road network download for: '%s'", place_name) + + with yaspin(text=f"Downloading road network for: {place_name}..."): + try: + edges = load_roads_route_aligned( + place_name=place_name, + consolidate_tolerance_m=consolidate_tolerance_m, + use_cache=use_cache, + ) + log.info( + "Successfully loaded %s road segments for '%s' (route-aligned).", + len(edges), + place_name, + ) + return edges + + except ValueError as error: + log.error("No road data or invalid location: %s: %s", place_name, error) + raise ValueError(f"No roads found for: {place_name}") from error + + except requests.exceptions.RequestException as error: + log.error("Network error when downloading data for '%s': %s", place_name, error) + raise ConnectionError("Connection problem with Overpass API") from error + + except Exception as error: + log.error("Unexpected error when downloading road network for '%s': %s", place_name, error) + raise RuntimeError(f"Error while downloading road network for: {place_name}") from error + + +def read_road_geojson(input_path: str) -> gpd.GeoDataFrame: + """ + Load line geometries from a local GeoJSON (no route-aligned graph build). + + For Euler-aligned sampling, prefer :func:`load_roads_route_aligned` with ``road_geojson_path``. + + :param input_path: Path to a vector file readable by GeoPandas. + :return: Subset of features that are ``LineString`` or ``MultiLineString``. + :raises ValueError: If the file is invalid. + :raises FileNotFoundError: If the path does not exist. + :raises RuntimeError: On unexpected read errors. + + Example:: + In: read_road_geojson("roads.geojson").geometry.type.isin(["LineString", "MultiLineString"]).all() + Out: True + """ + log.info("Reading local road network from: %s", input_path) + + try: + roads_geodataframe = gpd.read_file(input_path) + # Ignore points/polygons if the file is mixed + line_geometries = roads_geodataframe[roads_geodataframe.geometry.type.isin(["LineString", "MultiLineString"])] + + log.debug("Loaded %s valid road geometries from file.", len(line_geometries)) + return line_geometries + + except (FionaValueError, DriverError, OSError) as error: + log.error("Error reading GeoJSON: %s", error) + raise ValueError(f"Invalid GeoJSON file: {input_path}") from error + + except DataSourceError as error: + if "No such file or directory" in str(error): + log.error("File does not exist: %s", input_path) + raise FileNotFoundError(f"File not found: {input_path}") from error + log.error("Error reading GeoJSON: %s", error) + raise ValueError(f"Invalid GeoJSON file: {input_path}") from error + + except Exception as error: + log.error("Unexpected error while loading file: %s", error) + raise RuntimeError(f"Error while loading GeoJSON file: {input_path}") from error + + +def download_roads_from_region( + region_path: str, + *, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, +) -> gpd.GeoDataFrame: + """ + Load route-aligned roads clipped to the first polygon in a region file. + + :param region_path: GeoJSON (or similar) with a polygon geometry. + :param consolidate_tolerance_m: Intersection merge tolerance in meters. + :param use_cache: OSMnx cache flag. + :return: Metric road edges for the region. + :raises RuntimeError: If the region file cannot be processed. + + Example:: + In: download_roads_from_region("region.geojson") + Out: GeoDataFrame with route-aligned road edges for the region (EPSG:3857) + """ + log.info("Downloading route-aligned road network for region: %s", region_path) + + try: + region_geodataframe = gpd.read_file(region_path) + + if region_geodataframe.empty or region_geodataframe.geometry.iloc[0] is None: + raise ValueError(f"The provided region file is empty or invalid: {region_path}") + + # First feature defines the AOI (WGS84 for OSMnx) + polygon = region_geodataframe.to_crs(epsg=4326).geometry.iloc[0] + + if polygon.geom_type not in ("Polygon", "MultiPolygon"): + raise ValueError(f"Expected a Polygon geometry in {region_path}, but found {polygon.geom_type}.") + + with yaspin(text=f"Downloading road network for region: {region_path}..."): + road_edges = load_roads_route_aligned( + region_geojson_path=region_path, + consolidate_tolerance_m=consolidate_tolerance_m, + use_cache=use_cache, + ) + log.info("Successfully loaded %s road segments for custom region.", len(road_edges)) + return road_edges + + except ValueError: + raise + except Exception as error: + log.error("Error processing region file '%s': %s", region_path, error) + raise RuntimeError(f"Failed to download roads for region: {region_path}") from error diff --git a/src/uq_desktop_processor/street_view_analysis/road_points_generator/run.py b/src/uq_desktop_processor/street_view_analysis/road_points_generator/run.py new file mode 100644 index 0000000..7c69e4d --- /dev/null +++ b/src/uq_desktop_processor/street_view_analysis/road_points_generator/run.py @@ -0,0 +1,121 @@ +""" +CLI-oriented pipeline: load route-aligned roads, sample points, write a layer file. +""" + +import logging +from pathlib import Path + +import geopandas as gpd + +from .generator import generate_points_along_roads +from .io import load_roads_route_aligned + +log = logging.getLogger(__name__) + + +def _save_sampling_points_layer( + points: list[tuple[float, float]], + output_path: str | Path, +) -> Path: + """ + Write sampling points to a vector file in EPSG:4326. + + Coordinates follow :func:`generate_points_along_roads`: each tuple is ``(latitude, longitude)``. + + :param points: Sample locations. + :param output_path: GeoPackage, GeoJSON, or other OGR path. + :return: Resolved path that was written. + + Example:: + In: _save_sampling_points_layer([(50.06, 19.94)], "data/results/points.geojson") + Out: Path(".../points.geojson") + """ + output_path_obj = Path(output_path) + output_path_obj.parent.mkdir(parents=True, exist_ok=True) + if not points: + points_gdf = gpd.GeoDataFrame({"id": []}, geometry=gpd.GeoSeries([], crs="EPSG:4326")) + else: + latitudes, longitudes = zip(*points, strict=False) + # points_from_xy: x=lon, y=lat (EPSG:4326) + points_gdf = gpd.GeoDataFrame( + {"id": range(len(points))}, + geometry=gpd.points_from_xy(longitudes, latitudes), + crs="EPSG:4326", + ) + points_gdf.to_file(output_path_obj) + return output_path_obj.resolve() + + +def build_points_pipeline( + *, + output_points_path: str | Path, + place_name: str | None = None, + region_geojson_path: str | None = None, + road_geojson_path: str | None = None, + spacing: float = 100, + min_distance_m: float = 20, + consolidate_tolerance_m: float = 15.0, + use_cache: bool = True, +) -> tuple[gpd.GeoDataFrame, list[tuple[float, float]]]: + """ + End-to-end sampling: exactly one of place, region file, or road file; then generate and save points. + + :param output_points_path: Output vector path for point features. + :param place_name: OSM place name (mutually exclusive with region/road paths). + :param region_geojson_path: Polygon file for a custom AOI. + :param road_geojson_path: Predefined road lines file. + :param spacing: Along-road spacing in meters. + :param min_distance_m: Minimum spacing after global thinning. + :param consolidate_tolerance_m: Same as Euler route graph preparation. + :param use_cache: OSMnx HTTP cache. + :return: ``(roads_gdf, list of (lat, lon))``. + :raises ValueError: If zero or more than one source argument is given. + + Example:: + In: build_points_pipeline(place_name="Katowice, Poland", output_points_path="out/points.geojson") + Out: (road_edges_gdf, [(50.06, 19.94), (50.0612, 19.9415), ...]) + """ + log.info( + "Starting points generation pipeline (spacing=%sm, min_dist=%sm, consolidate=%sm).", + spacing, + min_distance_m, + consolidate_tolerance_m, + ) + + # Exactly one OSM / file source for load_route_aligned_graph_wgs84 + sources_provided = sum( + source_value is not None for source_value in [place_name, region_geojson_path, road_geojson_path] + ) + + if sources_provided != 1: + msg = ( + "You must provide exactly one data source: " "`place_name`, `region_geojson_path`, OR `road_geojson_path`." + ) + log.error(msg) + raise ValueError(msg) + + if place_name is not None: + log.info("Source selected: place name '%s'", place_name) + elif region_geojson_path is not None: + log.info("Source selected: region polygon '%s'", region_geojson_path) + else: + log.info("Source selected: road GeoJSON '%s'", road_geojson_path) + + roads = load_roads_route_aligned( + place_name=place_name, + region_geojson_path=region_geojson_path, + road_geojson_path=road_geojson_path, + consolidate_tolerance_m=consolidate_tolerance_m, + use_cache=use_cache, + ) + + points = generate_points_along_roads( + roads, + spacing=spacing, + min_distance_m=min_distance_m, + ) + + saved_output_path = _save_sampling_points_layer(points, output_points_path) + log.info("Pipeline finished. Total points generated: %s. Saved to %s", len(points), saved_output_path) + + return roads, points